diff --git a/.env.example b/.env.example index 7279e4d1df842ddbe29c0ae080c2187163e3f570..1594a7286865ed9c5ff630be8c07cbd1e5ac4d87 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ HOME_PATH=/spd/home/index TEST_USER= TEST_PASS= +# 第二测试账号(可选):与主账号按 worker 轮换登录态,并行时减轻同账号互踢 +# TEST_USER_2=playwright +# TEST_PASS_2= + # 设为 1 时每次运行强制重新登录(默认复用 auth/user.json,token 过期或失效才登录) # FORCE_AUTH_LOGIN=0 # token 剩余有效期 ≥ AUTH_FRESH_REMAINING_MS 时跳过首页探活(默认 20 分钟);设 0 强制探活 @@ -23,6 +27,8 @@ TEST_PASS= # 操作层瞬时重试(click/fill/goto),默认 3 次;断言失败不会走这里 # ACTION_RETRIES=3 # ACTION_RETRY_DELAY_MS=250 +# 单次操作重试总预算(毫秒,含首次与退避);到点即停,避免 次数×超时 叠加;0=不限 +# ACTION_RETRY_BUDGET_MS=45000 # 整条用例重试默认 0(不要全局打开);仅排查时可显式设 PW_RETRIES # PW_RETRIES=0 # 已知瞬时 flaky 用 testFlaky,额外重跑次数(不含首次) @@ -32,6 +38,11 @@ TEST_PASS= # RUN_QUARANTINE=1 # FLAKE_MIN_RUNS=4 # FLAKE_RATE=0.3 +# 失败频次统计(跨构建累计):失败次数与失败率双达标才算「经常失败」 +# FREQ_FAIL_MIN=2 +# FREQ_FAIL_RATE=0.2 +# 累计文件路径(默认 reports/flake/failure-counts.json) +# PW_FAILURE_COUNTS= # PW_WORKERS=3 # 分片并行(npm run test:warn:shard / test:spd:shard):SHARDS=3 WORKERS=2 # 排序全列等 @full 用例:RUN_FULL=1 @@ -40,7 +51,14 @@ TEST_PASS= # 分片:SHARDS=2 或 PW_SHARD=1/2(多 Agent) # 默认按文件并行;不要开 PW_FULLY_PARALLEL=1(同账号会互踩) # 冒烟快速失败:PW_MAX_FAILURES=8 -# 关闭 Allure:ALLURE=0 +# Allure 默认关(与 HTML 重复保存 trace/截图);ALLURE=1 才额外生成 +# ALLURE=1 +# JUnit 默认紧凑(只留 message);需要完整堆栈时设 0 +# PW_JUNIT_COMPACT=0 +# 归档内只留体积最大的 N 个 trace/截图附件(默认全留;0=不要) +# PW_KEEP_TRACES=0 +# 本机只保留最近 N 份 reports/ci-*(默认全留;0=跑完即删本次归档) +# PW_KEEP_ARCHIVES=1 # 纳入调试 spec:INCLUDE_DEBUG_SPECS=1 # 跳过站点探活:SKIP_PREFLIGHT=1 diff --git a/Jenkinsfile b/Jenkinsfile index ae9c130c50d68c64496c2eb9eb187769b7e61a3f..096515c1d7cdc327e713f6f52a754e3dede6cc59 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -112,7 +112,7 @@ pipeline { parameters { choice( name: 'SUITE', - choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'extended', 'quarantine'], + choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'vendor', 'admit', 'basic', 'surgery', 'warehouse', 'flow', 'extended', 'quarantine'], description: '执行方式(默认 full 全量回归,含 @full 全列排序)' ) string(name: 'WORKERS', defaultValue: '3', description: '单进程内并行文件数(同一 spec 不拆开)') @@ -120,6 +120,9 @@ pipeline { string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开') booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)') string(name: 'GREP', defaultValue: '', description: '只跑标题匹配这些用例(Playwright grep)。空则按 SUITE 全跑') + // Allure 默认关(与 HTML 重复保存 trace/截图);需要时勾选 + booleanParam(name: 'ALLURE', defaultValue: false, description: '额外生成 Allure(体积约翻倍,默认关闭)') + string(name: 'KEEP_ARCHIVES', defaultValue: '1', description: '本节点保留最近 N 份 reports/ci-*;-1=全留(占盘最快),0=跑完即删本次归档') string( name: 'REPORT_EMAIL_TO', defaultValue: '', @@ -137,6 +140,9 @@ pipeline { RUN_FULL = "${params.RUN_FULL ? '1' : '0'}" PW_GREP = "${params.GREP ?: ''}" PLAYWRIGHT_BROWSERS_PATH = "${WORKSPACE}/.playwright-browsers" + // 存储:Allure 默认关(与 HTML 重复保存 trace/截图);归档只留本次,latest/ 始终可用 + ALLURE = "${params.ALLURE ? '1' : '0'}" + PW_KEEP_ARCHIVES = "${params.KEEP_ARCHIVES ?: '1'}" SPD_CREDENTIALS_ID = "${env.SPD_CREDENTIALS_ID ?: 'spd-ui-test-account'}" JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}" } diff --git a/README.md b/README.md index 4bcad548d36ca5c1b43acfee2437ade7082d5614..d571075ebcad28090e1c90b40028060e969bab00 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 快速开始 ```bash -cp .env.example .env # 填写 TEST_USER / TEST_PASS +cp .env.example .env # 填写 TEST_USER / TEST_PASS(可选 TEST_USER_2 / TEST_PASS_2 双账号并行) npm ci # postinstall 安装 chromium;prepare 安装 git 门禁 hook npm run typecheck # CI 同样会跑 tsc --noEmit npm run gate # 未执行通过的用例脚本不能 commit/push,见 docs/git-gate.md @@ -17,9 +17,17 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p 默认站点:`http://spdtest.cmic.com.cn:8080`,登录页 `/spd/login`。账号只放 `.env`(已 gitignore),不要写入源码。 -`login` project 不带登录态,测登录页本身;`chromium` 依赖 `auth.setup` 复用 `auth/user.json`。 +`login` project 不带登录态,测登录页本身;`chromium` 依赖 `auth.setup` 写入并复用 `auth/user.json`。 -**登录态复用**:`utils/auth-session.ts` 统一编排。`auth.setup` 与同页 `describeWithReuseOpen` 共用:token 未过期则复用 `auth/user.json`,过期或被踢回登录页则重新登录并重建上下文。需强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 工作区残留旧会话。 +### 登录态复用(storageState,大幅提速) + +- **单独 setup**:`tests/setup/auth.setup.ts` 为主/辅账号各登录一次,写入 `auth/user.json` 与 `auth/user-playwright.json`。 +- **业务用例加载文件**:`fixtures/index.ts` 按 worker 注入对应 `storageState`;每条用例由 Playwright **新建独立 BrowserContext**,再注入 cookie / localStorage,直接进入已登录态。 +- **隔离第一原则**:复用的是**登录态文件**,不是同一个 context。禁止全局共享 `BrowserContext`/`Page`;厂商 / 准入 / 医院资料等新模块每条用例各自 `open`,互不污染。未登录反例用局部 `test.use({ storageState: { cookies: [], origins: [] } })`。 +- token 未过期可跳过重复登录与探活;过期或被踢则 setup 重新登录。强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 残留旧会话。 +- 存量报表里的 `describeWithReuseOpen`(组内同页加速)**不要**扩到新模块。 + +`utils/auth-session.ts` 统一编排登录与探活。 ## 套件与 Jenkins @@ -27,13 +35,13 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p | 套件 | 命令 | 用途 | |------|------|------| -| `smoke` | `npm run test:ci:smoke` | 全部报表模块中已打 `@smoke` 的用例(每页重置 / 列表或查询 / 列设置 / 导出) | -| `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 与调试 `*.stat-*.spec.ts` | +| `smoke` | `npm run test:ci:smoke` | 报表与基础设置中已打 `@smoke` 的用例(含厂商、准入、基础管理、手术、库房、流程) | +| `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 全列排序 | | `full` | `npm run test:ci:full` | 含全列排序,建议周末 | -| 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `warn` / `recon` 等 | +| 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `vendor` / `admit` / `basic` / `surgery` / `warehouse` / `flow` 等 | | 夜间分片 | `npm run test:spd:shard` | 先登录一次,再 2 进程分片并合并报告 | -并行默认按 **文件**(`PW_WORKERS`)。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后同一账号会互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。 +并行默认按 **文件**(`PW_WORKERS`)。配置 `TEST_USER_2` / `TEST_PASS_2` 后 worker 在双账号间轮换,可在不加大单账号并发的前提下提高并行度。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后仍易互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。 产物在 `reports/latest/`:Playwright HTML、`junit.xml`、Allure results、`summary.json`。定时跑完会发 **HTML 摘要邮件**(测试范围、执行数、成功/失败、通过率、报告链接),不要把完整报告当附件。默认收件人在 `config/report-email-recipients.txt`,SMTP 与任务拆分见 [docs/jenkins.md](docs/jenkins.md)。 @@ -65,25 +73,31 @@ scripts/run-ci.mjs Jenkins / 定时执行入口 | 层 | 允许 | 禁止 | |----|------|------| | `config/` | 环境、超时、套件 | 业务操作 | -| `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill | +| `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill、密码 | | `locators/` | `getByRole` / `getByPlaceholder` / `getByTestId` | click、fill、断言 | | `core/` | 等待、操作重试、诊断、健康检查、BasePage | 具体报表选择器 | -| `pages/` | 业务操作、调用 core 等待 | 用例流程编排、裸选择器 | -| `tests/` | 调用 Page 方法 + 断言业务结果 | `page.locator(...)`、访问 `locators` | +| `pages/` | 业务操作、取数/快照(如 `snapshotList`) | 用例主断言、流程编排、裸密码 | +| `tests/` | 调用 Page 方法 + **`expect` 断言业务结果** | `page.locator(...)`(反例探测除外)、访问 `locators` | + +厂商管理样板:`tests/spd/vendor-query.spec.ts` + `tests/spd/helpers/vendor-assert.helper.ts`(断言在用例;PO 无 `expect`)。 **智能等待**:查询按钮 loading、Ant Spin、表体有行/空态,统一走 `core/smart-wait.ts`。动画沉降用 `waitUiSettle`(双 rAF),不要在 Page 里写 `waitForTimeout`。 **重试策略**: - **操作层**(默认开):`BasePage` 的 `goto` / `click` / `fill` / `getText` 对点击被挡、节点卸载、导航超时等瞬时失败重试(`ACTION_RETRIES`,默认 3 次)。`expect`、健康检查、`guardedSkip` **不重试**。 +- **写操作安全**(默认防重复触发):导出、提交、删除、打印等 `{ operation: 'write' }` 的点击**只尝试 1 次**、且不试 fallback;仅当业务确认幂等时才加 `idempotent: true`。 +- **超时总预算**:`retryAction` 的 `deadlineMs`(默认 `ACTION_RETRY_BUDGET_MS=45_000`)限制单次操作总耗时,避免 `次数 × 单次超时` 叠成几十秒慢失败。 - **用例层**(默认关):全局 `retries=0`,Jenkins / GitHub 也不自动打开。已知瞬时 flaky 用 `testFlaky`(带 `@flaky`),断言失败仍立即失败。不要设 `PW_RETRIES` 掩盖断言失败。 -**Flake 治理**:失败自动分为产品/脚本/环境/数据/瞬时,写入 `failure-context.md`。CI 累计 `reports/flake/history.jsonl`,`npm run flake:report` 看趋势与隔离候选。已知瞬时问题进 `config/quarantine.json`,主门禁默认隔离;`npm run test:quarantine` 单独验证。细则见 [docs/flake.md](docs/flake.md)。 +**Flake 治理**:失败自动分为产品/脚本/环境/数据/瞬时,写入 `failure-context.md`。CI 累计 `reports/flake/history.jsonl`,`npm run flake:report` 看趋势与隔离候选;`npm run quarantine:sync`(预览)/ `quarantine:apply`(落库)把候选写入 `config/quarantine.json`——只隔离环境/瞬时/脚本类,**不自动隔离产品缺陷**。主门禁默认隔离;`npm run test:quarantine` 单独验证。细则见 [docs/flake.md](docs/flake.md)。 + +**失败频次标记**:跨构建按 `STAT-` 编号累计每个用例的 `失败/执行`,失败次数与失败率双达标即标为「经常失败」。`npm run failures:report` 查榜单(`reports/flake/frequent-failures.md`),CI 邮件摘要自动附该小节;用例报告里带 `frequentFailure` / `failureRuns` / `failureRate` annotation,可直接筛选。 **数据驱动**:每个报表一份 `data/{page}.data.ts`(`CenterReportPageData`),Page 构造时注入;用例标题带 `STAT-xxx`,报告里自动带 `caseId`。 **失败诊断**:失败时附件 `failure-context.md`(URL、iframe、接口/页面报错),HTML / Allure 可直接打开。通过用例仍做运行时健康收尾,避免接口 5xx 被当成通过。 -`seed.spec.ts` 默认不执行。需要时:`npm run test:seed`。CI 默认不跑 `*.stat-*.spec.ts` 调试用例;本地按文件跑不受影响,强制纳入:`INCLUDE_DEBUG_SPECS=1`。 +`seed.spec.ts` 默认不执行。需要时:`npm run test:seed`。单条用例本地单步用 `PWDEBUG=1 npx playwright test --project=chromium`。 ## 新增页面清单 diff --git a/config/credentials.config.ts b/config/credentials.config.ts index fad909ff9ff7f06968cd3c17bec56f11a5c5ed13..bff275fb8b8d51f0266a6a5c7b15789eedbfaabd 100644 --- a/config/credentials.config.ts +++ b/config/credentials.config.ts @@ -14,6 +14,16 @@ export const credentials: Credentials = { password: process.env.TEST_PASS ?? '', }; +/** 第二测试账号,与主账号并行跑用例时按 worker 分配登录态。 */ +export const credentialsSecondary: Credentials = { + username: process.env.TEST_USER_2 ?? '', + password: process.env.TEST_PASS_2 ?? '', +}; + export function hasCredentials(): boolean { return Boolean(credentials.username && credentials.password); } + +export function hasSecondaryCredentials(): boolean { + return Boolean(credentialsSecondary.username && credentialsSecondary.password); +} diff --git a/config/suites.config.mjs b/config/suites.config.mjs index 9d2906c3007c2df4be51685c6dbe67a56c58d302..839b53e642fcc0236b91e2374d4e8e8f7a0f66c2 100644 --- a/config/suites.config.mjs +++ b/config/suites.config.mjs @@ -94,6 +94,45 @@ export const PAGE_GROUPS = { ops: ['tests/spd/ops-check.spec.ts'], reag: ['tests/spd/reag-entry.spec.ts'], auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'], + vendor: [ + 'tests/spd/vendor-query.spec.ts', + 'tests/spd/vendor-qual.spec.ts', + 'tests/spd/vendor-audit.spec.ts', + 'tests/spd/vendor-cert-diff.spec.ts', + 'tests/spd/vendor-eval-scheme.spec.ts', + 'tests/spd/vendor-eval-indicator.spec.ts', + 'tests/spd/vendor-holiday.spec.ts', + ], + admit: [ + 'tests/spd/admit-material.spec.ts', + 'tests/spd/admit-material-list.spec.ts', + 'tests/spd/admit-product-apply.spec.ts', + 'tests/spd/admit-product-list.spec.ts', + 'tests/spd/admit-supply.spec.ts', + 'tests/spd/admit-task.spec.ts', + ], + basic: [ + 'tests/spd/basic-user.spec.ts', + 'tests/spd/basic-user-union.spec.ts', + 'tests/spd/basic-role.spec.ts', + 'tests/spd/basic-branch.spec.ts', + 'tests/spd/basic-notice.spec.ts', + 'tests/spd/basic-hospital-info.spec.ts', + 'tests/spd/basic-finance-month.spec.ts', + ], + surgery: [ + 'tests/spd/surgery-base-pkg.spec.ts', + 'tests/spd/surgery-oper-type.spec.ts', + 'tests/spd/surgery-std-pkg.spec.ts', + ], + warehouse: [ + 'tests/spd/warehouse-zone.spec.ts', + 'tests/spd/warehouse-probe.spec.ts', + ], + flow: [ + 'tests/spd/flow-def.spec.ts', + 'tests/spd/flow-inst.spec.ts', + ], }; /** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */ @@ -109,7 +148,7 @@ export const SUITES = { retries: 0, }, smoke: { - description: '冒烟:全部报表模块中已打 @smoke 的用例(登录/首页、成本、开票、科室、中心库、消耗、追溯、对账、预警、政策、异常、统计分析、运维、试剂)', + description: '冒烟:报表与基础设置中已打 @smoke 的用例(登录/首页、报表、厂商、准入、基础管理、手术、库房、流程)', files: [ ...PAGE_GROUPS.auth, ...PAGE_GROUPS.cost, @@ -125,6 +164,12 @@ export const SUITES = { ...PAGE_GROUPS.ana, ...PAGE_GROUPS.ops, ...PAGE_GROUPS.reag, + ...PAGE_GROUPS.vendor, + ...PAGE_GROUPS.admit, + ...PAGE_GROUPS.basic, + ...PAGE_GROUPS.surgery, + ...PAGE_GROUPS.warehouse, + ...PAGE_GROUPS.flow, ], grep: '@smoke', workers: 3, @@ -215,6 +260,42 @@ export const SUITES = { workers: 2, retries: 0, }, + vendor: { + description: '厂商管理', + files: PAGE_GROUPS.vendor, + workers: 1, + retries: 0, + }, + admit: { + description: '准入管理', + files: PAGE_GROUPS.admit, + workers: 1, + retries: 0, + }, + basic: { + description: '基础管理(用户/角色/院区等)', + files: PAGE_GROUPS.basic, + workers: 1, + retries: 0, + }, + surgery: { + description: '手术包管理', + files: PAGE_GROUPS.surgery, + workers: 1, + retries: 0, + }, + warehouse: { + description: '库区与探针', + files: PAGE_GROUPS.warehouse, + workers: 1, + retries: 0, + }, + flow: { + description: '流程定义与实例', + files: PAGE_GROUPS.flow, + workers: 1, + retries: 0, + }, extended: { description: '扩展报表:预警/运维/异常/政策/财务分析/试剂', files: [ diff --git a/config/timeouts.config.ts b/config/timeouts.config.ts index d4c4173fc4f6a094683fe7b58d56e208bc27fa66..87637e861cea428ebbfaf2c15c2c7100bdae5a85 100644 --- a/config/timeouts.config.ts +++ b/config/timeouts.config.ts @@ -24,12 +24,23 @@ export const timeouts = { actionRetries: num('ACTION_RETRIES', 3), /** 操作重试间隔基数(毫秒),第 n 次等待 n * 该值 */ actionRetryDelayMs: num('ACTION_RETRY_DELAY_MS', 250), + /** + * 单次操作重试的总时间预算(毫秒,含首次尝试与退避)。 + * 到点即停,避免 次数 × 单次超时 叠加成几十秒慢失败;0 表示不限。 + * 默认 45_000:约为 action(12s) × 3 的 1.25 倍上限,慢页面下也能及时收敛。 + */ + actionRetryBudgetMs: num('ACTION_RETRY_BUDGET_MS', 45_000), /** iframe 挂载、查询按钮可见 */ pageReady: num('PAGE_READY_TIMEOUT', 20_000), /** 表体 attached / spin hidden */ tableAttached: num('TABLE_ATTACHED_TIMEOUT', 15_000), /** waitForTableReady 轮询上限(原 45s) */ tableReady: num('TABLE_READY_TIMEOUT', 20_000), + /** + * 查询后尚无行、无空态、也无 spin 时,留给 loading 出现的短窗口(毫秒)。 + * 超过即视为空闲,避免空表卡满整段 tableReady。 + */ + tableIdleGrace: num('TABLE_IDLE_GRACE_MS', 400), /** 查询按钮 loading 消失 */ searchLoading: num('SEARCH_LOADING_TIMEOUT', 15_000), /** 导出接口 waitForResponse */ @@ -37,6 +48,8 @@ export const timeouts = { /** auth setup 探活 */ authProbeNav: num('AUTH_PROBE_NAV_TIMEOUT', 12_000), authProbeMenu: num('AUTH_PROBE_MENU_TIMEOUT', 8_000), + /** auth.setup 写登录态(含锁等待 + 登录跳转) */ + authSetup: num('AUTH_SETUP_TIMEOUT', 120_000), /** * token 剩余有效期大于该值则跳过服务端探活(默认 20 分钟)。 * 设 AUTH_SKIP_PROBE_IF_FRESH=0 可强制每次探活。 diff --git a/core/action-retry.ts b/core/action-retry.ts index d4c7d5af7f460cc5ab0b891d313e5711bdbceff4..316b8bcdb29c823d194d53094bf701d54a175930 100644 --- a/core/action-retry.ts +++ b/core/action-retry.ts @@ -1,12 +1,33 @@ import { timeouts } from '../config/timeouts.config'; import { logger } from './logger'; +/** + * 操作语义: + * - `read`(默认):查询、导航、读取、打开面板等可安全重复执行的操作,瞬时失败可重试。 + * - `write`:导出、提交、删除、审批等会产生副作用的操作。超时后无法确认服务端是否已执行, + * 盲目重试会重复触发,因此默认只尝试 1 次(除非调用方显式声明 `idempotent: true`)。 + */ +export type RetryOperation = 'read' | 'write'; + export type RetryActionOptions = { - /** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3。 */ + /** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3;写操作默认 1。 */ attempts?: number; delayMs?: number; label?: string; onRetry?: (error: unknown, attempt: number) => Promise | void; + /** 操作语义,默认 `read`。写操作默认不重试,防重复触发。 */ + operation?: RetryOperation; + /** + * 写操作是否幂等。仅当业务确认「重复执行安全」(如按固定单据号覆盖式保存)时设 true, + * 才会按 `attempts` 重试;否则即使设了更大 attempts 也只尝试 1 次。 + */ + idempotent?: boolean; + /** + * 本次重试的总时间预算(毫秒,含首次尝试与退避等待)。 + * 到点即停并抛出最后一次错误,避免 `次数 × 单次超时` 叠加成几十秒慢失败。 + * 默认 `ACTION_RETRY_BUDGET_MS`;未设且时间为 0 表示不限。 + */ + deadlineMs?: number; }; type ErrorLike = { @@ -68,30 +89,55 @@ function sleep(ms: number): Promise { /** * 底层操作细粒度重试。只重试瞬时 UI/导航错误;断言与健康检查失败立即抛出。 + * + * 安全边界: + * - 写操作(`operation: 'write'`)默认只尝试 1 次,避免副作用被重复触发; + * - 设了 `deadlineMs` 时,退避等待不会让总耗时突破预算。 */ export async function retryAction( action: () => Promise, options: RetryActionOptions = {}, ): Promise { - const attempts = Math.max(1, options.attempts ?? timeouts.actionRetries); + const operation = options.operation ?? 'read'; + const writesAreRisky = operation === 'write' && !options.idempotent; + const requestedAttempts = Math.max(1, options.attempts ?? timeouts.actionRetries); + // 非幂等写操作:无论传了多少次,都只执行一次。 + const attempts = writesAreRisky ? 1 : requestedAttempts; const delayMs = options.delayMs ?? timeouts.actionRetryDelayMs; const label = options.label ?? '操作'; + const deadlineMs = Math.max(0, options.deadlineMs ?? timeouts.actionRetryBudgetMs); + const startedAt = Date.now(); + const budgetExhausted = (): boolean => deadlineMs > 0 && Date.now() - startedAt >= deadlineMs; let lastError: unknown; + if (writesAreRisky && requestedAttempts > 1) { + logger.warn( + `${label} 属写操作,已禁用重试(防重复触发);如确认幂等请传 idempotent: true`, + ); + } + for (let attempt = 1; attempt <= attempts; attempt += 1) { try { return await action(); } catch (error) { lastError = error; const retryable = shouldRetryAction(error); - if (!retryable || attempt === attempts) { + const isLast = attempt === attempts; + if (!retryable || isLast) { + throw error; + } + if (budgetExhausted()) { + logger.warn(`${label} 重试预算 ${deadlineMs}ms 已用完,放弃重试:${errorText(error).slice(0, 200)}`); throw error; } logger.warn( `${label} 瞬时失败,重试 ${attempt}/${attempts - 1}:${errorText(error).slice(0, 240)}`, ); await options.onRetry?.(error, attempt); - await sleep(delayMs * attempt); + // 退避等待不越界:预算不足时按剩余时间收敛,至少不等待。 + const backoff = delayMs * attempt; + const remaining = deadlineMs > 0 ? deadlineMs - (Date.now() - startedAt) : backoff; + await sleep(Math.max(0, Math.min(backoff, remaining))); } } diff --git a/core/base.page.ts b/core/base.page.ts index 9e22bedd1136ddd54bd702d0c57aab3453d72f43..8020225da33faed3ee6919c5131ae01f3ce9032d 100644 --- a/core/base.page.ts +++ b/core/base.page.ts @@ -1,7 +1,7 @@ import { expect, type Locator, type Page, test } from '@playwright/test'; import { timeouts } from '../config/timeouts.config'; -import { retryAction } from './action-retry'; +import { retryAction, type RetryOperation } from './action-retry'; import { runWithLocatorHeal } from './heal-locator'; import { waitAntSpinGone, @@ -16,6 +16,9 @@ import { import { type PageHealthScope } from './page-health.util'; import { logger } from './logger'; +/** 列表就绪后的健康断言:进页仅 ui;查询/导出等业务动作完成后用 business。 */ +export type TableHealthMode = 'ui' | 'business'; + /** 所有 Page Object 的基类:导航、智能等待、通用操作。选择器不写在这里。 */ export class BasePage { constructor(protected readonly page: Page) {} @@ -30,12 +33,41 @@ export class BasePage { return { page: this.page }; } - /** 系统挂掉、接口报错或页面报错时 fail,避免被 skip 掩盖。 */ + /** 进页/菜单导航后:只校验页面可交互(无业务错误提示),不校验静态资源与被动加载接口。 */ + protected async assertUiReady(context: string): Promise { + await assertRuntimeHealthy( + this.pageHealthScope(), + context, + getRuntimeHealthGuard(this.page), + 'ui', + ); + } + + /** + * 业务动作(查询/保存/导出等)执行前:清空接口窗口并确认当前可交互。 + * 框架聚焦 UI 交互与布局,不承担字体/图片等资源完整性检查。 + */ + protected async assertBeforeBusinessAction(actionLabel: string): Promise { + getRuntimeHealthGuard(this.page)?.beginBusinessActionWindow(); + await this.assertUiReady(`${actionLabel}前`); + } + + /** 等表/壳就绪后:按场景选择是否校验业务接口。 */ + protected async assertAfterTableWait( + context: string, + mode: TableHealthMode = 'business', + ): Promise { + if (mode === 'ui') await this.assertUiReady(context); + else await this.assertPageHealthy(context); + } + + /** 业务动作完成后:UI + 本动作窗口内业务数据接口。 */ protected async assertPageHealthy(context: string): Promise { await assertRuntimeHealthy( this.pageHealthScope(), context, getRuntimeHealthGuard(this.page), + 'business', ); } @@ -88,7 +120,7 @@ export class BasePage { async click( locator: Locator, stepName?: string, - options?: { timeout?: number; fallbacks?: Locator[] }, + options?: { timeout?: number; fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean }, ): Promise { const timeout = options?.timeout ?? timeouts.action; const action = async (): Promise => { @@ -114,6 +146,9 @@ export class BasePage { }, { label: stepName ?? 'click', + // 导出/提交/删除类写操作默认不重试,防副作用重复触发 + operation: options?.operation ?? 'read', + idempotent: options?.idempotent, onRetry: async () => { await waitAntSpinGone(this.page); await waitUiSettle(this.page); @@ -132,7 +167,7 @@ export class BasePage { locator: Locator, value: string, stepName?: string, - options?: { fallbacks?: Locator[] }, + options?: { fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean }, ): Promise { const action = async (): Promise => { if (stepName) { @@ -147,6 +182,8 @@ export class BasePage { }, { label: stepName ?? 'fill', + operation: options?.operation ?? 'read', + idempotent: options?.idempotent, onRetry: async () => { await waitAntSpinGone(this.page); await waitUiSettle(this.page); @@ -164,4 +201,164 @@ export class BasePage { async getText(locator: Locator): Promise { return retryAction(async () => (await locator.innerText()).trim(), { label: '读取文本' }); } + + /** 搜索条作用域:有 iframe 用首个 iframe,否则用主文档。 */ + async queryScope(): Promise { + const frames = this.page.locator('iframe'); + if ((await frames.count().catch(() => 0)) > 0) return this.page.frameLocator('iframe').first(); + return this.page; + } + + /** + * 通用多条件筛选:按字段名定位搜索条,一次性填多个条件后查询。 + * 用于「组合查询」类用例;单字段用例仍可用各页原有专用方法。 + */ + /** + * 通用多条件筛选:按字段名定位搜索条,一次性填多个条件后查询。 + * 用于「组合查询」类用例;单字段用例仍可用各页原有专用方法。 + */ + async applyFilterCombination( + fields: FilterField[], + options: { + /** 搜索条根节点;iframe 页传入 frameLocator */ + scope?: FilterScope; + /** 触发查询的按钮;默认按「查 询」文本找 */ + queryButton?: Locator; + /** 字段不可见时:true=跳过并记录,false=直接失败 */ + skipMissing?: boolean; + } = {}, + ): Promise<{ applied: string[]; missing: string[] }> { + const scope = options.scope ?? this.page; + const applied: string[] = []; + const missing: string[] = []; + + // 筛选区默认可能折叠(如差异对比页),先展开再填,否则后半截字段不可见 + await this.expandFiltersIfCollapsed(scope); + + for (const field of fields) { + const item = await this.resolveFilterItem(scope, field.label); + if (!item) { + missing.push(field.label); + if (options.skipMissing === false) { + throw new Error(`筛选字段「${field.label}」不可见,组合查询无法执行`); + } + continue; + } + if (field.kind === 'select') { + await this.pickFirstOption(scope, item, field.label); + } else if (field.kind === 'dateRange') { + await this.fillDateRange(scope, item, field.label); + } else { + const input = item.locator('input').first(); + await this.fill(input, field.value ?? '', `组合查询:${field.label}`, { + fallbacks: [scope.getByPlaceholder(new RegExp(field.label)).first()], + }); + } + applied.push(field.label); + } + + logger.step(`组合查询:已填 ${applied.length}/${fields.length} 项,未找到 ${missing.join('、') || '无'}`); + expect( + applied.length, + `组合查询至少要有 2 个条件生效(已生效:${applied.join('、') || '无'})`, + ).toBeGreaterThanOrEqual(Math.min(2, fields.length)); + + const queryBtn = options.queryButton ?? scope.getByRole('button', { name: /查\s*询/ }).first(); + await this.click(queryBtn.first(), '组合查询:点击查询'); + await this.waitForTableShellVisible(); + return { applied, missing }; + } + + /** 搜索条「展开」:折叠时点开,保证所有筛选字段可填(如差异对比页默认只露 3 项)。 */ + protected async expandFiltersIfCollapsed(scope: FilterScope): Promise { + const expand = scope.getByRole('button', { name: /^展\s*开$/ }).first(); + const expandByClass = scope.locator('button._btn.collapse').filter({ hasText: '展开' }).first(); + const target = (await expand.isVisible().catch(() => false)) ? expand : expandByClass; + if (await target.isVisible().catch(() => false)) { + await this.click(target, '组合查询:展开筛选区'); + await this.waitSettle(600); + } + } + + /** + * 搜索条中的单个字段:按 label[for] 精确定位到同级控件容器。 + * antd 的 label for / 控件 id 一一对应,比 hasText 更可靠(避免「物料名称」误命中含同名文字的外层)。 + */ + protected async resolveFilterItem(scope: FilterScope, label: string): Promise { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const exactLabel = new RegExp(`^\\s*${escaped}\\s*$`); + const labels = scope.locator('.ant-form-item label').filter({ hasText: exactLabel }); + const count = await labels.count().catch(() => 0); + for (let i = 0; i < count; i += 1) { + const lab = labels.nth(i); + const id = await lab.getAttribute('for').catch(() => null); + if (!id) continue; + const item = scope.locator('.ant-form-item').filter({ has: scope.locator(`#${CSS_ESCAPE(id)}`) }).first(); + if (await item.isVisible().catch(() => false)) return item; + } + // 退化:label 所在表单项(仅当 label 文本完全相等时) + const fallback = scope.locator('.ant-form-item').filter({ has: scope.locator('label', { hasText: exactLabel }) }).first(); + if (await fallback.isVisible().catch(() => false)) return fallback; + return null; + } + + /** + * 打开下拉并取第一个可选项。浮层挂在**当前 scope 的 document.body**—— + * iframe 页面的浮层在 iframe 内,主页面查不到,因此 scope 必须传对。 + */ + protected async pickFirstOption(scope: FilterScope, item: Locator, label: string): Promise { + const dropdown = scope.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last(); + await this.click(item.locator('.ant-select-selector').first(), `组合查询:展开「${label}」`); + const option = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)').first(); + await option.waitFor({ state: 'visible', timeout: timeouts.expect }); + const text = ((await option.innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + await this.click(option, `组合查询:选择「${label}」`); + await this.waitSettle(300); + return text; + } + + /** + * 日期区间:antd range picker 输入框是 readonly,必须走浮层「今天」选择, + * 起止同一天既保证区间合法,又不依赖业务日期数据。浮层同样跟随 scope。 + */ + protected async fillDateRange(scope: FilterScope, item: Locator, label: string): Promise { + await this.click(item.locator('input').first(), `组合查询:打开「${label}」`); + const panel = scope.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').last(); + const today = panel.getByText(/今\s*天/).first(); + const todayVisible = await today.isVisible().catch(() => false); + if (todayVisible) { + await this.click(today, `组合查询:「${label}」选择今天`); + await this.click(today, `组合查询:「${label}」结束选择今天`).catch(() => undefined); + } else { + // 无「今天」快捷项时退化为键盘回车,避免写死日期格式 + await item.locator('input').first().press('Enter').catch(() => undefined); + } + await this.page.keyboard.press('Escape').catch(() => undefined); + await this.waitSettle(300); + } + + /** 列表壳就绪(表头或空态出现),不含业务接口健康断言。 */ + async waitForTableShellVisible(timeout = timeouts.expect): Promise { + await this.page + .locator('.ant-table-thead th, .ant-table-header th, .ant-empty-normal') + .first() + .waitFor({ state: 'visible', timeout }) + .catch(() => undefined); + await this.waitSettle(400); + } } + +/** 搜索条作用域:Page、Locator 或 FrameLocator 均可。 */ +export type FilterScope = Pick; + +/** CSS id 选择器转义(antd 的 id 形如 table_search_form_keyword,安全,但统一走转义)。 */ +const CSS_ESCAPE = (id: string) => id.replace(/([^\w-])/g, '\\$1'); + +/** 组合查询的单个筛选条件。 */ +export type FilterField = { + /** 搜索条上的字段名,需与页面 label 文本一致 */ + label: string; + kind: 'text' | 'select' | 'dateRange'; + /** kind=text 时的输入值;其余可省略 */ + value?: string; +}; diff --git a/core/diagnostics.ts b/core/diagnostics.ts index 1788cf2ffe40fd6f30f0b19cf5f3492730b90627..d7acabc2371ec73d8559c4ce90bd7bbe24725a69 100644 --- a/core/diagnostics.ts +++ b/core/diagnostics.ts @@ -17,6 +17,7 @@ export type FailureSnapshot = { pageTitle: string; frames: string[]; healthIssues: string[]; + staticObservations: string[]; closed: boolean; classification?: FailureClassification; healSuggestions?: string[]; @@ -33,6 +34,7 @@ export async function collectFailureSnapshot( let pageTitle = ''; const frames: string[] = []; let healthIssues: string[] = []; + let staticObservations: string[] = []; if (!closed) { url = page.url(); @@ -44,7 +46,10 @@ export async function collectFailureSnapshot( } } if (healthGuard) { - healthIssues = await healthGuard.collectAllIssues().catch(() => [...healthGuard.getApiIssues()]); + healthIssues = await healthGuard + .collectAllIssues() + .catch(() => [...healthGuard.getBusinessApiIssues()]); + staticObservations = [...healthGuard.getStaticObservations()]; } } @@ -58,6 +63,7 @@ export async function collectFailureSnapshot( pageTitle, frames: frames.slice(0, 8), healthIssues, + staticObservations, closed, classification, healSuggestions, @@ -84,6 +90,13 @@ export function formatFailureMarkdown(snapshot: FailureSnapshot): string { } else { lines.push(``, `## 运行时健康问题`, `- 无(或页面已关闭未能采集)`); } + if (snapshot.staticObservations.length) { + lines.push( + ``, + `## 静态资源观测(仅日志,不阻断用例)`, + ...snapshot.staticObservations.slice(0, 10).map((item) => `- ${item}`), + ); + } if (snapshot.healSuggestions?.length) { lines.push( ``, diff --git a/core/failure-classify.ts b/core/failure-classify.ts index afc6bf49f64a4e83a1fb519e0f7efb78c2ff4e60..723a74e954e44c9efd3f93e4e711fb93614663af 100644 --- a/core/failure-classify.ts +++ b/core/failure-classify.ts @@ -58,7 +58,7 @@ export function classifyFailure(input: { /net::ERR_|ECONNRESET|ENOTFOUND|ETIMEDOUT|ERR_CONNECTION|ERR_EMPTY_RESPONSE|ERR_INTERNET|browser has been closed|Target closed|Browser closed/i.test( raw, ) || - /接口请求失败:.*\(net::/i.test(health) + /业务接口请求失败:.*\(net::/i.test(health) ) { return { cls: 'environment', @@ -79,7 +79,11 @@ export function classifyFailure(input: { }; } - if (/检测到页面\/接口\/系统报错|检测到页面报错或系统异常|业务码=/.test(raw)) { + if ( + /检测到业务数据接口错误|检测到页面业务错误提示|检测到页面报错或系统异常|业务码=|业务接口/.test( + raw, + ) + ) { return { cls: 'product', confidence: 'high', diff --git a/core/failure-tracker.ts b/core/failure-tracker.ts new file mode 100644 index 0000000000000000000000000000000000000000..30043d5fca8ef4512f5862c70f981b5fbe5e37c2 --- /dev/null +++ b/core/failure-tracker.ts @@ -0,0 +1,304 @@ +/** + * 用例失败频次统计:跨构建累计每个用例(按 STAT- 编号聚合)的执行/失败次数, + * 用于标记「经常执行失败」的用例,便于优先修脚本或提单。 + * + * 存储:reports/flake/failure-counts.json(累计文件,非单次 history,重启不清零)。 + * 标记:失败/成功时写入 Playwright annotation(failureRuns / failureRate / frequentFailure), + * HTML / Allure / JUnit 均可检索;同时可写 markdown 榜单。 + * + * 仅统计「真实执行」的用例:skip 不增加失败,但会增加执行次数;断言与瞬时失败都计入失败。 + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { TestInfo } from '@playwright/test'; + +import { extractCaseId } from './case-meta'; +import { logger } from './logger'; + +export type CaseFailureStat = { + /** STAT-XXX-NNN */ + id: string; + /** 最近一次看到的原标题(去掉编号前缀后可能变化) */ + title: string; + /** 最近一次所在 spec(相对路径) */ + file: string; + /** 累计执行次数(passed + failed + timedOut,不含 skipped) */ + runs: number; + /** 累计通过 */ + passed: number; + /** 累计失败(failed + timedOut) */ + failed: number; + /** 首次失败时间 */ + firstFailedAt?: string; + /** 最近一次失败时间 */ + lastFailedAt?: string; + /** 最近一次失败分类 */ + lastClass?: string; + /** 连续失败次数(一旦通过则归零) */ + consecutiveFails: number; +}; + +export type FailureStore = { + version: number; + updatedAt: string; + /** 判定「经常失败」的阈值(失败次数下限) */ + frequentMinFails: number; + /** 判定「经常失败」的阈值(失败率下限) */ + frequentRate: number; + items: Record; +}; + +const DEFAULT_MIN_FAILS = 2; +const DEFAULT_RATE = 0.2; + +function num(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function storePath(): string { + return process.env.PW_FAILURE_COUNTS || path.join(process.cwd(), 'reports/flake/failure-counts.json'); +} + +export function loadFailureStore(): FailureStore { + const empty: FailureStore = { + version: 1, + updatedAt: new Date().toISOString(), + frequentMinFails: num('FREQ_FAIL_MIN', DEFAULT_MIN_FAILS), + frequentRate: num('FREQ_FAIL_RATE', DEFAULT_RATE), + items: {}, + }; + try { + const parsed = JSON.parse(readFileSync(storePath(), 'utf8')) as Partial; + return { + ...empty, + ...parsed, + // 阈值以当前环境变量为准,便于调参 + frequentMinFails: num('FREQ_FAIL_MIN', parsed.frequentMinFails ?? DEFAULT_MIN_FAILS), + frequentRate: num('FREQ_FAIL_RATE', parsed.frequentRate ?? DEFAULT_RATE), + items: parsed.items ?? {}, + }; + } catch { + return empty; + } +} + +export function saveFailureStore(store: FailureStore): void { + const file = storePath(); + store.updatedAt = new Date().toISOString(); + try { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`, 'utf8'); + } catch (error) { + logger.warn(`失败频次统计写入失败:${String(error)}`); + } +} + +/** + * 多 worker / 多分片安全落盘:先读回磁盘上最新统计,把本次进程的增量合并进去再写, + * 避免各进程用「启动时的旧快照」互相覆盖。 + * + * 合并策略:同一用例取两边的较大值。正常情况下一条用例只会落在某一个 worker/分片, + * 因此不存在同用例并发累加;取 max 既能保留先写进程的增量,也能在磁盘更新时不回退。 + */ +export function mergeAndSaveFailureStore(local: FailureStore): void { + const file = storePath(); + let onDisk: FailureStore; + try { + onDisk = JSON.parse(readFileSync(file, 'utf8')) as FailureStore; + } catch { + onDisk = { ...local, items: {} }; + } + const merged: FailureStore = { + version: onDisk.version ?? local.version, + updatedAt: new Date().toISOString(), + frequentMinFails: local.frequentMinFails, + frequentRate: local.frequentRate, + items: { ...(onDisk.items ?? {}) }, + }; + for (const [id, stat] of Object.entries(local.items)) { + const prev = merged.items[id]; + if (!prev) { + merged.items[id] = stat; + continue; + } + merged.items[id] = { + ...prev, + title: stat.title || prev.title, + file: stat.file || prev.file, + // runs/passed/failed 为跨 worker 累加量,用「本地-基线」增量不可知, + // 这里以两边的最大值 + 增量差值近似,避免重复计数导致虚高。 + runs: Math.max(prev.runs, stat.runs), + passed: Math.max(prev.passed, stat.passed), + failed: Math.max(prev.failed, stat.failed), + consecutiveFails: stat.consecutiveFails, + firstFailedAt: prev.firstFailedAt ?? stat.firstFailedAt, + lastFailedAt: laterIso(prev.lastFailedAt, stat.lastFailedAt), + lastClass: stat.lastClass ?? prev.lastClass, + }; + } + try { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); + } catch (error) { + logger.warn(`失败频次统计写入失败:${String(error)}`); + } +} + +function laterIso(a?: string, b?: string): string | undefined { + if (!a) return b; + if (!b) return a; + return a > b ? a : b; +} + +export function failureRateOf(stat: Pick): number { + return stat.runs > 0 ? stat.failed / stat.runs : 0; +} + +/** 是否「经常失败」:失败次数达标且失败率达标。 */ +export function isFrequentFailure(stat: CaseFailureStat, store: FailureStore): boolean { + return stat.failed >= store.frequentMinFails && failureRateOf(stat) >= store.frequentRate; +} + +/** + * 记录一次用例结果并更新累计统计(就地修改 store)。 + * @returns 更新后的该用例统计(无编号则返回 undefined)。 + */ +export function recordOutcome( + store: FailureStore, + input: { + title: string; + file: string; + status: TestInfo['status']; + failureClass?: string; + }, +): CaseFailureStat | undefined { + const id = extractCaseId(input.title); + if (!id) return undefined; + + const now = new Date().toISOString(); + const stat: CaseFailureStat = + store.items[id] ?? + { + id, + title: input.title, + file: input.file, + runs: 0, + passed: 0, + failed: 0, + consecutiveFails: 0, + }; + + // 跳过不计入执行;其余都算一次执行 + if (input.status !== 'skipped') { + stat.runs += 1; + } + if (input.status === 'passed') { + stat.passed += 1; + stat.consecutiveFails = 0; + } else if (input.status === 'failed' || input.status === 'timedOut') { + stat.failed += 1; + stat.consecutiveFails += 1; + stat.lastFailedAt = now; + stat.firstFailedAt ??= now; + if (input.failureClass) stat.lastClass = input.failureClass; + } + + // 标题/文件可能微调,始终记录最新 + stat.title = input.title; + stat.file = input.file; + store.items[id] = stat; + return stat; +} + +/** 把频次统计写进 annotation,报告与 JUnit 里可检索。 */ +export function annotateFailureFrequency(testInfo: TestInfo, stat: CaseFailureStat, store: FailureStore): void { + const rate = failureRateOf(stat); + const frequent = isFrequentFailure(stat, store); + const push = (type: string, description: string): void => { + if (testInfo.annotations.some((item) => item.type === type && item.description === description)) return; + testInfo.annotations.push({ type, description }); + }; + push('failureRuns', `${stat.failed}/${stat.runs}`); + push('failureRate', rate.toFixed(2)); + if (stat.consecutiveFails >= 2) push('consecutiveFails', String(stat.consecutiveFails)); + if (frequent) push('frequentFailure', `经常失败:${stat.failed}/${stat.runs} (${(rate * 100).toFixed(0)}%)`); +} + +export type FrequentFailureRow = CaseFailureStat & { rate: number }; + +/** 列出经常失败的用例,按失败次数、失败率排序。 */ +export function listFrequentFailures(store: FailureStore): FrequentFailureRow[] { + return Object.values(store.items) + .filter((stat) => isFrequentFailure(stat, store)) + .map((stat) => ({ ...stat, rate: failureRateOf(stat) })) + .sort((a, b) => b.failed - a.failed || b.rate - a.rate); +} + +/** 生成 markdown 榜单,写入 reports/flake/frequent-failures.md。 */ +export function formatFrequentFailuresMarkdown(store: FailureStore): string { + const rows = listFrequentFailures(store); + const lines = [ + '# 经常失败的用例', + '', + `- 生成: ${new Date().toISOString()}`, + `- 判定: 失败 ≥ ${store.frequentMinFails} 次且失败率 ≥ ${store.frequentRate}`, + `- 累计用例数: ${Object.keys(store.items).length}`, + `- 达标条目: ${rows.length}`, + '', + '| 编号 | 失败/执行 | 失败率 | 连续失败 | 最近分类 | 最近失败时间 | 用例 |', + '|------|-----------|--------|----------|----------|--------------|------|', + ...(rows.length + ? rows.map( + (r) => + `| ${r.id} | ${r.failed}/${r.runs} | ${(r.rate * 100).toFixed(0)}% | ${r.consecutiveFails} | ${ + r.lastClass ?? '-' + } | ${r.lastFailedAt?.slice(0, 19).replace('T', ' ') ?? '-'} | ${r.title} |`, + ) + : ['| - | - | - | - | - | - | 暂无(需多次构建累计) |']), + '', + ]; + return lines.join('\n'); +} + +export function writeFrequentFailuresReport(store: FailureStore = loadFailureStore()): string { + const file = path.join(path.dirname(storePath()), 'frequent-failures.md'); + try { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, formatFrequentFailuresMarkdown(store), 'utf8'); + } catch (error) { + logger.warn(`经常失败榜单写入失败:${String(error)}`); + } + return file; +} + +/** + * afterEach 调用:按当前用例结果**预估**统计并打 annotation(只读,不落盘)。 + * 落盘由 reporter(主进程,覆盖更全)负责;这里预估是为了让 annotation 反映本次结果, + * 供 HTML/Allure/JUnit 即时筛选,不依赖 reporter 的执行时机。 + */ +export function annotateFailureFrequencyForTest( + testInfo: TestInfo, +): CaseFailureStat | undefined { + const id = extractCaseId(testInfo.title); + if (!id) return undefined; + const store = loadFailureStore(); + // 在内存副本上叠加本次结果,仅用于标注,不写盘,避免与 reporter 重复计数。 + const projected = recordOutcome(store, { + title: testInfo.title, + file: testInfo.file, + status: testInfo.status, + failureClass: testInfo.annotations.find((item) => item.type === 'failureClass')?.description, + }); + if (!projected) return undefined; + annotateFailureFrequency(testInfo, projected, store); + return projected; +} + +/** 刷新 markdown 榜单(CI 收尾调用,或 reporter onEnd 调用)。 */ +export function refreshFrequentFailuresReport(): string { + return writeFrequentFailuresReport(loadFailureStore()); +} diff --git a/core/heal-locator.ts b/core/heal-locator.ts index a645169b6fcb1a3431b11cfea4ad8a475b9299ce..2cc591a34bff46f2d9ee362116f59c323d16efcb 100644 --- a/core/heal-locator.ts +++ b/core/heal-locator.ts @@ -41,11 +41,13 @@ export async function runWithLocatorHeal( options: RetryActionOptions & { label: string }, ): Promise { const { label, ...retry } = options; + // 非幂等写操作:主定位失败后可能已部分触发副作用,不再尝试 fallback,避免重复触发。 + const writeRisk = retry.operation === 'write' && !retry.idempotent; try { await retryAction(() => action(primary), { ...retry, label }); return; } catch (primaryError) { - if (!fallbacks?.length) throw primaryError; + if (!fallbacks?.length || writeRisk) throw primaryError; for (let i = 0; i < fallbacks.length; i += 1) { try { await retryAction(() => action(fallbacks[i]), { diff --git a/core/index.ts b/core/index.ts index 331ebb59c3cbfec3e158938b79dfb4317cb98159..de3ae23d575752a4a99f6a3e9e51fe71c424a870 100644 --- a/core/index.ts +++ b/core/index.ts @@ -4,6 +4,8 @@ export { isTransientActionError, retryAction, shouldRetryAction, + type RetryActionOptions, + type RetryOperation, } from './action-retry'; export { FLAKY_TAG, resolveFlakyRetries } from './flaky'; export { @@ -15,6 +17,21 @@ export { type FailureClassification, } from './failure-classify'; export { applyQuarantine, loadQuarantine, matchQuarantine, QUARANTINE_TAG } from './quarantine'; +export { + annotateFailureFrequency, + annotateFailureFrequencyForTest, + failureRateOf, + formatFrequentFailuresMarkdown, + isFrequentFailure, + listFrequentFailures, + loadFailureStore, + mergeAndSaveFailureStore, + recordOutcome, + refreshFrequentFailuresReport, + type CaseFailureStat, + type FailureStore, + type FrequentFailureRow, +} from './failure-tracker'; export { collectHealSuggestions, runWithLocatorHeal } from './heal-locator'; export { logger } from './logger'; export { applyCaseMeta, caseTitle, extractCaseId } from './case-meta'; diff --git a/core/quarantine.ts b/core/quarantine.ts index c3c417b1eace3fb33d3f58892d89f6781b815346..1a05cf99178c11af6d1308dc7ee873ee4041e630 100644 --- a/core/quarantine.ts +++ b/core/quarantine.ts @@ -24,6 +24,8 @@ export type QuarantineItem = { since: string; ticket?: string; category?: FailureClass; + /** 由 scripts/sync-quarantine.mjs 自动写入(区别于人工维护项)。 */ + auto?: boolean; }; export type QuarantineFile = { diff --git a/core/runtime-health.util.ts b/core/runtime-health.util.ts index 9b2417b356e39c1dc8f4b2d298605407a073497d..698d8959614a70655307e9f1d655b19c0b28eb59 100644 --- a/core/runtime-health.util.ts +++ b/core/runtime-health.util.ts @@ -1,23 +1,52 @@ import { expect, type FrameLocator, type Locator, type Page, type Response } from '@playwright/test'; +import { logger } from './logger'; import { collectPageHealthIssues, type PageHealthScope, } from './page-health.util'; const MAX_ISSUES = 30; +const MAX_STATIC_OBSERVATIONS = 50; +/** 只解析小型业务包络;大列表响应跳过 JSON.parse。 */ +const MAX_BUSINESS_ERROR_BODY = 64 * 1024; -const STATIC_ASSET_PATTERN = /\.(js|css|png|jpe?g|gif|svg|webp|woff2?|ico|map)(\?|$)/i; +/** 纯视觉/静态资源后缀(不参与阻断,仅观测日志)。 */ +const STATIC_ASSET_PATTERN = + /\.(js|css|png|jpe?g|gif|svg|webp|woff2?|ttf|otf|eot|ico|map)(\?|$)/i; -/** 需要监控的业务接口(排除静态资源)。 */ -function isTrackableApiUrl(url: string): boolean { - if (STATIC_ASSET_PATTERN.test(url)) return false; +/** 前端静态目录(字体、图标、打包资源等)。 */ +const STATIC_PATH_PATTERN = /\/(assets|fonts|static|media|img|images|iconfont)\//i; + +/** Playwright resourceType:框架 route 会 abort 的纯视觉资源,失败只记日志。 */ +const PASSIVE_VISUAL_RESOURCE_TYPES = new Set(['image', 'media', 'font']); + +const BUSINESS_ACTION_PATTERN = + /(export|query|list|search|async|save|update|delete|add|remove)/i; + +export type RuntimeHealthAssertMode = 'ui' | 'business'; + +function isStaticResourceUrl(url: string): boolean { + if (STATIC_ASSET_PATTERN.test(url)) return true; + if (STATIC_PATH_PATTERN.test(url)) return true; + return false; +} + +function isPassiveVisualResource(url: string, resourceType?: string): boolean { + if (resourceType && PASSIVE_VISUAL_RESOURCE_TYPES.has(resourceType)) return true; + return isStaticResourceUrl(url); +} + +/** 业务数据源接口(XHR/JSON);静态壳与视觉资源不纳入。 */ +function isTrackableBusinessApiUrl(url: string): boolean { + if (isStaticResourceUrl(url)) return false; if (url.startsWith('data:') || url.startsWith('blob:')) return false; - return ( - /\/api\//i.test(url) || - /\/spd\//i.test(url) || - /(export|query|list|report|search|async)/i.test(url) - ); + + if (/\/api\//i.test(url)) return true; + if (BUSINESS_ACTION_PATTERN.test(url)) return true; + if (/\/report\//i.test(url) && BUSINESS_ACTION_PATTERN.test(url)) return true; + + return false; } function trimUrl(url: string): string { @@ -27,8 +56,12 @@ function trimUrl(url: string): string { async function readBusinessError(response: Response): Promise { const contentType = response.headers()['content-type'] ?? ''; if (!contentType.includes('json')) return null; + const lengthHeader = response.headers()['content-length']; + if (lengthHeader && Number(lengthHeader) > MAX_BUSINESS_ERROR_BODY) return null; try { - const body = await response.json(); + const raw = await response.body(); + if (raw.byteLength > MAX_BUSINESS_ERROR_BODY) return null; + const body = JSON.parse(raw.toString('utf8')) as unknown; if (!body || typeof body !== 'object') return null; const record = body as Record; const code = record.code ?? record.statusCode ?? record.errCode; @@ -49,8 +82,14 @@ async function readBusinessError(response: Response): Promise { return null; } +/** + * UI 回归运行时守卫:阻断业务数据接口异常与页面业务错误提示; + * 字体/图片等视觉资源失败仅日志观测(与 fixtures 中 abort 策略一致)。 + */ export class RuntimeHealthGuard { - private readonly apiIssues: string[] = []; + private readonly businessApiIssues: string[] = []; + + private readonly staticObservations: string[] = []; private readonly allowedIssuePatterns: RegExp[] = []; @@ -64,19 +103,42 @@ export class RuntimeHealthGuard { this.attachListeners(page); } - private pushIssue(issue: string): void { - if (this.apiIssues.length >= MAX_ISSUES) return; - if (this.apiIssues.includes(issue)) return; - this.apiIssues.push(issue); + /** 业务动作开始前调用:仅统计本动作窗口内的接口问题。 */ + beginBusinessActionWindow(): void { + this.businessApiIssues.length = 0; + } + + private pushBusinessIssue(issue: string): void { + if (this.businessApiIssues.length >= MAX_ISSUES) return; + if (this.businessApiIssues.includes(issue)) return; + this.businessApiIssues.push(issue); + } + + private observeStaticResource(message: string): void { + if (this.staticObservations.includes(message)) return; + if (this.staticObservations.length >= MAX_STATIC_OBSERVATIONS) return; + this.staticObservations.push(message); + logger.warn(`[health:static] ${message}`); } private attachListeners(target: Page): void { target.on('requestfailed', (request) => { - if (!isTrackableApiUrl(request.url())) return; + const url = request.url(); const failure = request.failure()?.errorText ?? 'unknown'; - // 切换统计维度/重复查询时,浏览器会 abort 上一次 in-flight 请求,非业务失败 if (/ERR_ABORTED/i.test(failure)) return; - this.pushIssue(`接口请求失败: ${request.method()} ${trimUrl(request.url())} (${failure})`); + + const resourceType = request.resourceType(); + if (isPassiveVisualResource(url, resourceType)) { + this.observeStaticResource( + `资源加载失败(${resourceType}): ${request.method()} ${trimUrl(url)} (${failure})`, + ); + return; + } + + if (!isTrackableBusinessApiUrl(url)) return; + this.pushBusinessIssue( + `业务接口请求失败: ${request.method()} ${trimUrl(url)} (${failure})`, + ); }); target.on('response', (response) => { @@ -86,21 +148,32 @@ export class RuntimeHealthGuard { private async handleResponse(response: Response): Promise { const url = response.url(); - if (!isTrackableApiUrl(url)) return; + const resourceType = response.request().resourceType(); - const status = response.status(); - if (status >= 500) { - this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`); + if (isPassiveVisualResource(url, resourceType)) { + const status = response.status(); + if (status >= 400) { + this.observeStaticResource(`资源 HTTP ${status}: ${trimUrl(url)}`); + } return; } + + if (!isTrackableBusinessApiUrl(url)) return; + + const contentType = response.headers()['content-type'] ?? ''; + const isJsonResponse = contentType.includes('json'); + + const status = response.status(); if (status >= 400) { - this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`); + if (isJsonResponse || status >= 500) { + this.pushBusinessIssue(`业务接口 HTTP ${status}: ${trimUrl(url)}`); + } return; } const businessError = await readBusinessError(response); if (businessError) { - this.pushIssue(`接口业务报错 ${trimUrl(url)}: ${businessError}`); + this.pushBusinessIssue(`业务接口报错 ${trimUrl(url)}: ${businessError}`); } } @@ -117,11 +190,20 @@ export class RuntimeHealthGuard { return issues.filter((issue) => !this.isAllowedIssue(issue)); } + /** @deprecated 使用 getBusinessApiIssues */ getApiIssues(): readonly string[] { - return this.apiIssues; + return this.businessApiIssues; } - async collectAllIssues(contextRoots?: Array): Promise { + getBusinessApiIssues(): readonly string[] { + return this.businessApiIssues; + } + + getStaticObservations(): readonly string[] { + return this.staticObservations; + } + + private async collectUiIssues(contextRoots?: Array): Promise { const roots = contextRoots ?? [...this.extraRoots]; const scope: PageHealthScope = { page: this.page, roots }; const uiIssues = await collectPageHealthIssues(scope); @@ -145,15 +227,40 @@ export class RuntimeHealthGuard { } } - return [...new Set([...uiIssues, ...this.apiIssues])].slice(0, MAX_ISSUES); + return uiIssues; + } + + async collectBlockingIssues( + mode: RuntimeHealthAssertMode, + contextRoots?: Array, + ): Promise { + const uiIssues = await this.collectUiIssues(contextRoots); + if (mode === 'ui') { + return [...new Set(uiIssues)].slice(0, MAX_ISSUES); + } + return [...new Set([...uiIssues, ...this.businessApiIssues])].slice(0, MAX_ISSUES); + } + + /** 兼容诊断:等价于 collectBlockingIssues('business')。 */ + async collectAllIssues(contextRoots?: Array): Promise { + return this.collectBlockingIssues('business', contextRoots); + } + + async assertHealthy( + context: string, + mode: RuntimeHealthAssertMode, + contextRoots?: Array, + ): Promise { + const issues = this.filterUnexpected(await this.collectBlockingIssues(mode, contextRoots)); + const hint = + mode === 'ui' + ? `${context}:检测到页面业务错误提示,无法继续 UI 交互校验` + : `${context}:检测到业务数据接口错误或页面业务报错`; + expect(issues, hint).toEqual([]); } async assertClean(context: string, contextRoots?: Array): Promise { - const issues = this.filterUnexpected(await this.collectAllIssues(contextRoots)); - expect( - issues, - `${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`, - ).toEqual([]); + await this.assertHealthy(context, 'business', contextRoots); } } @@ -174,16 +281,25 @@ export function getRuntimeHealthGuard(page: Page): RuntimeHealthGuard | undefine return guardByPage.get(page); } -/** 页面 + 接口 + 系统统一健康断言(所有用例 skip 前/结束时调用)。 */ +/** + * UI:仅 Ant 错误提示与系统异常文案(业务动作前置,验证可交互)。 + * business:UI + 本动作窗口内业务接口问题(查询/保存等之后)。 + */ export async function assertRuntimeHealthy( scope: PageHealthScope, context: string, guard?: RuntimeHealthGuard, + mode: RuntimeHealthAssertMode = 'business', ): Promise { - const raw = guard ? await guard.collectAllIssues(scope.roots) : await collectPageHealthIssues(scope); - const issues = guard ? guard.filterUnexpected(raw) : raw; - expect( - issues, - `${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`, - ).toEqual([]); + if (guard) { + await guard.assertHealthy(context, mode, scope.roots); + return; + } + const uiIssues = await collectPageHealthIssues(scope); + const issues = mode === 'ui' ? uiIssues : uiIssues; + const hint = + mode === 'ui' + ? `${context}:检测到页面业务错误提示,无法继续 UI 交互校验` + : `${context}:检测到业务数据接口错误或页面业务报错`; + expect(issues, hint).toEqual([]); } diff --git a/core/smart-wait.ts b/core/smart-wait.ts index 518bfa11d71148490c74083d9c3517250155b652..5922caee1f84241f88d2398740e69f4dffa88930 100644 --- a/core/smart-wait.ts +++ b/core/smart-wait.ts @@ -35,13 +35,24 @@ export async function waitAntSpinGone( .catch(() => undefined); } -/** 查询按钮 loading:先给一个短窗口出现,再等到消失。 */ +/** + * 等查询按钮上的 loading 消失。 + * 当前不在 loading 且 appearTimeout 为 0 时立即返回,不再空等图标出现。 + */ export async function waitSearchButtonIdle( searchBtn: Locator, timeout = timeouts.searchLoading, + appearTimeout = 0, ): Promise { - const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR); - await loadingIcon.waitFor({ state: 'visible', timeout: 2_000 }).catch(() => undefined); + const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR).first(); + if (!(await isSearchButtonLoading(searchBtn))) { + if (appearTimeout <= 0) return; + const appeared = await loadingIcon + .waitFor({ state: 'visible', timeout: appearTimeout }) + .then(() => true) + .catch(() => false); + if (!appeared) return; + } await loadingIcon.waitFor({ state: 'hidden', timeout }).catch(() => undefined); } diff --git a/data/admit.data.ts b/data/admit.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..521f1c32ce0cdcc8d6b00d020a3491e67569fdd2 --- /dev/null +++ b/data/admit.data.ts @@ -0,0 +1,29 @@ +/** 基础管理-准入管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const admitData = { + materialPath: '/spd/basic/dataSync/dataSync/productApply/ZRlist', + productApplyPath: '/spd/basic/dataSync/dataSync/productApply/list', + materialListPath: '/spd/basic/dataSync/dataSync/productOrder/zrlist', + productListPath: '/spd/basic/dataSync/dataSync/productOrder/list', + supplyPath: '/spd/basic/dataSync/dataSync/supplyApply/list', + taskPath: '/spd/basic/dataSync/dataSync/accessKind/list', + menuBasic: '基础设置', + menuAdmit: '准入管理', + menuMaterial: '物料准入管理', + menuProductApply: '产品准入申请', + menuMaterialList: '物料准入单列表', + menuProductList: '产品准入单列表', + menuSupply: '供货目录申请', + menuTask: '准入任务分配', + mockGoods: '__NOT_EXIST_GOODS__99999', + mockCert: '__NOT_EXIST_CERT__99999', + mockOrder: '__NOT_EXIST_ORDER__99999', +} as const; + +export function uniqProductName(): string { + return `AUTO-PAP-${Date.now()}`; +} + +export function uniqRemark(): string { + return `AUTO-SUP-${Date.now()}`; +} diff --git a/data/basic-hospital.data.ts b/data/basic-hospital.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..a20afa4173441c8fc05dca78853731d8f4cc486d --- /dev/null +++ b/data/basic-hospital.data.ts @@ -0,0 +1,40 @@ +/** 基础管理-医院资料:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const basicHospitalData = { + noticePath: '/spd/basic/hospital/paltform/notice', + hospitalInfoPath: '/spd/basic/hospital/paltform/info', + financeMonthPath: '/spd/basic/hospital/balance/month', + branchPath: '/spd/basic/hospital/paltform/area', + menuBasic: '基础设置', + menuHospital: '医院资料', + menuNotice: '公告管理', + menuHospitalInfo: '医院信息登记', + menuFinanceMonth: '财务月份设置', + menuBranch: '院区管理', + mockNoticeTitle: '__NOT_EXIST_NOTICE__99999', + mockNoticeBody: '__MOCK_NOTICE_BODY__', + mockEmptyContentTitle: '__MOCK_EMPTY_CONTENT_TITLE__', + mockCampusName: '__NOT_EXIST_CAMPUS__99999', + mockCampusNoRegion: '__MOCK_CAMPUS_NO_REGION__', + mockAddr: '__MOCK_ADDR__', + mockCampusNoAddr: '__MOCK_CAMPUS_NO_ADDR__', + mockCampusId: '__MOCK_CAMPUS_ID__', + mockOutOfRangeDate: '1999-01-01', + writeDisabledReason: '环境禁止写:SKIP_BASIC_WRITE=1', +} as const; + +export function isBasicWriteAllowed(): boolean { + return process.env.SKIP_BASIC_WRITE !== '1'; +} + +export function uniqNoticeTitle(): string { + return `AUTO-NTC-${Date.now()}`; +} + +export function uniqLinkman(): string { + return `AUTO-HOS-${Date.now()}`; +} + +export function uniqCampusName(): string { + return `AUTO-BRN-${Date.now()}`; +} diff --git a/data/center-report-entry.data.ts b/data/center-report-entry.data.ts index 9c825e2afec9db3aaed1bdea18e462548ac79c08..dccec574dd376481e5a79f8722cb0b7bd4a2d302 100644 --- a/data/center-report-entry.data.ts +++ b/data/center-report-entry.data.ts @@ -67,9 +67,24 @@ export const centerReportEntrySubTableHeaders = [ /** 入库汇总查询 · 测试数据(与 testcase CSV 对齐)。 */ export const centerReportEntryData = { + reportPath: '/spd/form/centerReport/report/centerPages/entry', entryPath: '/spd/form/centerReport/report/centerPages/entry', + iframeSrc: 'centerPages/entry', + productNameSelector: '#form_item_goodsName', columns: centerReportEntryColumns, drawerFields: centerReportEntryDrawerFields, + sortColumns: [...centerReportEntrySortColumns], + hasAdvancedFilter: true, + hasExport: true, + hasExpandRow: true, + hasBatchPrint: false, + hasFilterToggle: false, + forbiddenSummaryKeywords: ['出库数量', '物流出库金额'] as const, + drawerFieldInputIds: { + batchNo: 'form_item_batchCode', + sourceUnit: 'form_item_sourceStock', + supplier: 'form_item_provName', + }, dimensions: ['区分包装', '区分批号', '区分来源单位'] as const, coreTableHeaders: [ '产品编号', diff --git a/data/center-report-sheet.data.ts b/data/center-report-sheet.data.ts index 0097eaacc335a07ed2f46f8de522040ec901fdcd..59ccc684a842e44691c9e07f8e744f65381474c1 100644 --- a/data/center-report-sheet.data.ts +++ b/data/center-report-sheet.data.ts @@ -1,3 +1,8 @@ +import { + detailSheetSummaryMatch, + type CenterReportDrawerField, +} from './center-report-shared.types'; + /** 出入库明细查询 · 列表列名与抽屉字段映射(动态取数用)。 */ export const centerReportSheetColumns = { productCode: '产品编号', @@ -120,11 +125,29 @@ function buildSortColumnCases(): Array<{ id: string; column: string }> { /** 出入库明细查询冒烟/回归数据(与 testcase CSV 对齐)。 */ export const centerReportSheetData = { + reportPath: '/spd/form/centerReport/report/centerPages/sheet', sheetPath: '/spd/form/centerReport/report/centerPages/sheet', + iframeSrc: 'centerPages/sheet', + productNameSelector: '#form_item_goodsName', columns: centerReportSheetColumns, columnAliases: centerReportSheetColumnAliases, + coreTableHeaders: ['产品编号', '产品名称', '批号', '进院供应商', '院区名称'], + summaryKeywords: detailSheetSummaryMatch.metricLabels, + summaryMatch: detailSheetSummaryMatch, drawerLabels: centerReportSheetDrawerLabels, - drawerFields: centerReportSheetDrawerFields, + drawerFields: centerReportSheetDrawerFields.map( + (field): CenterReportDrawerField => ({ + key: field.key, + type: field.type === 'dateRange' ? 'date' : field.type, + label: field.label ?? centerReportSheetColumns[field.key], + }), + ), + sortColumns: [...centerReportSheetSortColumns], + hasAdvancedFilter: true, + hasExport: true, + hasExpandRow: false, + hasBatchPrint: false, + hasFilterToggle: true, /** 动态取数:默认从列表首行 extractSampleRow() 读取,不写死业务数据 */ sampleRowIndex: 0, /** 反例 mock(仅负向用例使用) */ diff --git a/data/flow.data.ts b/data/flow.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6787e946f77bee55c3acfb333aaf71d767e05a4 --- /dev/null +++ b/data/flow.data.ts @@ -0,0 +1,17 @@ +/** 基础管理-流程设置:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const flowData = { + defPath: '/spd/basic/flowSet/workflow/flowSet', + instPath: '/spd/basic/flowSet/workflow/flowCase', + menuBasic: '基础设置', + menuGroup: '流程设置', + menuDef: '审批流程设置', + menuInst: '审批流实例', + mockFlow: '__NOT_EXIST_FLOW__99999', + prefixType: { + PLAN: '采购计划', + DB: '二级库调拨', + GC: '产品信息变更', + BUY: '请领单', + } as const, +} as const; diff --git a/data/surgery.data.ts b/data/surgery.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..6447d63189938a9ccd7f65b0a21ca164cfd61f45 --- /dev/null +++ b/data/surgery.data.ts @@ -0,0 +1,56 @@ +/** + * 基础管理-手术套包设置:路径、菜单、URL 片段与 CSV 反例 mock。 + * 正例登录走 auth.setup 写入的 storageState,用例内不重复登录、不写明文凭据。 + * 严格对齐 testcase/基础管理-手术套包设置-*-UI自动化测试用例-01.csv。 + */ + +export const surgeryData = { + basePkgPath: '/spd/basic/operationSetting/baseMaintenance', + operTypePath: '/spd/basic/operationSetting/typeMaintenance', + stdPkgPath: '/spd/basic/operationSetting/standardMaintenance', + + basePkgPathPart: '/spd/basic/operationSetting/baseMaintenance', + operTypePathPart: '/spd/basic/operationSetting/typeMaintenance', + stdPkgPathPart: '/spd/basic/operationSetting/standardMaintenance', + + menuBasic: '基础设置', + menuSurgery: '手术套包设置', + menuBasePkg: '基础包维护', + menuOperType: '术种维护', + menuStdPkg: '标准套包维护', + + // CSV 反例 mock(不依赖真实业务数据) + mockBaseKeyword: '__NOT_EXIST_BASE__99999', + mockOperKeyword: '__NOT_EXIST_OPER__99999', + mockStdKeyword: '__NOT_EXIST_STD__99999', + + /** 术种维护状态 Tab(CSV SURG-TYPE-011/024/025) */ + statusAll: '全部', + statusDisabled: '停用', + statusUnclassified: '未建分类', +} as const; + +/** 唯一名称/编码,避免用例间数据污染(CSV 前置 ${uniqPkgName} 等)。 */ +export function uniqStamp(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; +} + +export function uniqBasePkgName(): string { + return uniqStamp('AUTO-BASE'); +} + +export function uniqBasePkgCode(): string { + return uniqStamp('AB'); +} + +export function uniqOperName(): string { + return uniqStamp('AUTO-OPER'); +} + +export function uniqStdPkgName(): string { + return uniqStamp('AUTO-STD'); +} + +export function uniqCategoryName(): string { + return uniqStamp('AUTO-CATE'); +} diff --git a/data/user.data.ts b/data/user.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..26ae49e79bfdb802bc606a775576310a7b6c930b --- /dev/null +++ b/data/user.data.ts @@ -0,0 +1,21 @@ +/** 基础管理-用户管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const userData = { + userPath: '/spd/basic/user/platform/person', + unionPath: '/spd/basic/user/platform/personCom', + rolePath: '/spd/basic/user/platform/role', + menuBasic: '基础设置', + menuUserGroup: '用户管理', + menuUser: '用户管理', + menuUnion: '医共体用户管理', + menuRole: '角色管理', + positiveUser: 'test_1', + mockUser: '__NOT_EXIST_USER__99999', + mockRole: '__NOT_EXIST_ROLE__99999', + /** 列设置 toggle 用非核心列(须当前表头可见)。 */ + columnSettingsToggleCandidates: ['联系方式', '医院员工编号', '科室任职数'], +} as const; + +export function uniqLogin(): string { + return `auto.usr.${Date.now()}`; +} diff --git a/data/vendor.data.ts b/data/vendor.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..b951c064e006c12939b5dd7cd9f45bd7047f2cfb --- /dev/null +++ b/data/vendor.data.ts @@ -0,0 +1,29 @@ +/** 基础管理-厂商管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const vendorData = { + queryPath: '/spd/basic/mfrs/paltform/mfrsName/factory', + qualPath: '/spd/basic/mfrs/basic/qcManage', + authFirstPath: '/spd/basic/mfrs/paltform/mfrsName/authAudit', + authFinalPath: '/spd/basic/mfrs/paltform/mfrsName/authAuditFinal', + certFirstPath: '/spd/basic/mfrs/paltform/mfrsName/regCertAudit', + certFinalPath: '/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal', + certDiffPath: '/spd/basic/mfrs/scjs/yc/certCompare', + evalSchemePath: '/spd/basic/mfrs/paltform/mfrsName/evaluationPlan', + evalIndicatorPath: '/spd/basic/mfrs/paltform/mfrsName/indicators', + holidayPath: '/spd/basic/mfrs/basic/prov/holiday', + menuBasic: '基础设置', + menuVendor: '厂商管理', + menuQuery: '厂商查询', + menuQual: '供应商资质管理', + mockMfrsName: '__NOT_EXIST_MFRS__99999', + mockSupplier: '__NOT_EXIST_SUPPLIER__99999', + mockOrder: '__NOT_EXIST_ORDER__99999', + menuAuthFirst: '授权变更初审', + menuAuthFinal: '授权变更终审', + menuCertFirst: '注册证变更初审', + menuCertFinal: '注册证变更终审', + menuCertDiff: '注册证差异对比', + menuEvalScheme: '评价方案维护', + menuEvalIndicator: '评价指标维护', + menuHoliday: '供应商节假日管理', +} as const; diff --git a/data/warehouse.data.ts b/data/warehouse.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..852f7754de5aeb401d77fbb96fec2f5058002fe6 --- /dev/null +++ b/data/warehouse.data.ts @@ -0,0 +1,12 @@ +/** 基础管理-库房管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */ + +export const warehouseData = { + zonePath: '/spd/basic/storehouse/platform/storehouse/area', + probePath: '/spd/basic/storehouse/storehouse/probe', + menuBasic: '基础设置', + menuGroup: '库房管理', + menuZone: '库区设置', + menuProbe: '探针绑定', + mockZone: '__NOT_EXIST_ZONE__99999', + mockProbe: '__NOT_EXIST_PROBE__99999', +} as const; diff --git a/docs/flake.md b/docs/flake.md index ba0dff7ac5c3aed4f9a05752104622e255351935..7fd6abd89e882223f16e72d0c2019d8126a69e7f 100644 --- a/docs/flake.md +++ b/docs/flake.md @@ -27,8 +27,9 @@ 1. 跑 CI,报告写入 `reports/flake/history.jsonl`(gitignore) 2. `npm run flake:report` → `reports/flake/latest.md` -3. 同一用例**有过有挂**才算 flake;失败率 ≥ 0.3 且有效次数 ≥ 4 列为隔离候选 -4. 只把 **transient / environment** 候选写入 `config/quarantine.json`: +3. **`npm run quarantine:sync`** → 把候选合并进 `config/quarantine.json`(预览);确认后 `npm run quarantine:apply` 落库 +4. 同一用例**有过有挂**才算 flake;失败率 ≥ 0.3 且有效次数 ≥ 4 列为隔离候选 +5. 只把 **transient / environment / script / data** 候选写入 `config/quarantine.json`: ```json { @@ -41,12 +42,13 @@ } ``` +- **自动同步(`scripts/sync-quarantine.mjs`)**:只隔离 `environment / transient / script / data`;`product`(断言/业务缺陷)**绝不自动隔离**;标题无 `STAT-` 编号的候选跳过;已有人工条目(带 ticket/owner)**保留且不被覆盖**;自动项带 `auto: true`,可重复执行(幂等)。 - `quarantine`:Jenkins / `CI=1` 默认 skip;本地仍跑 - `observe`:照常执行,只打标 - `npm run test:quarantine`:只跑清单项,验证是否还抖 - `QUARANTINE_POLICY=never`:主门禁也不 skip -出清单条件:根因已记、有 owner、不是断言失败。修稳后删条目。 +出清单条件:根因已记、有 owner、不是断言失败。修稳后删条目(自动项重新同步会自动回收)。 ## 自愈定位 @@ -56,12 +58,70 @@ | 手段 | 默认 | 断言失败 | |------|------|----------| -| `ACTION_RETRIES`(操作层) | 3 | 不重试 | +| `ACTION_RETRIES`(操作层,读操作) | 3 | 不重试 | +| 写操作(`{ operation: 'write' }`) | **1(不重试)** | 不重试 | +| `ACTION_RETRY_BUDGET_MS`(单次操作总预算) | 45_000 | — | | `PW_RETRIES`(整条用例) | 0 | 会重试,不要开 | | `testFlaky` / `PW_FLAKY_RETRIES` | 按需 1 | 不重试 | +### 写操作幂等安全 + +导出、提交、删除、审批、打印等**会产生副作用**的点击,重试前无法确认服务端是否已执行,盲目重试会重复触发。 +因此 `BasePage.click` / `fill` 支持 `{ operation: 'write' }`:默认只尝试 1 次,且主定位失败后**不再尝试 fallback**。 + +```ts +// 查询/打开面板等读操作:默认走 3 次重试 +await this.click(queryBtn, '点击查询'); + +// 导出/打印/提交等写操作:只点一次,不重试、不 fallback +await this.click(exportBtn, '点击导出', { operation: 'write' }); + +// 业务确认重复执行安全(如按单据号覆盖式保存)才显式声明幂等 +await this.click(saveBtn, '保存', { operation: 'write', idempotent: true }); +``` + +### 超时总预算 + +`retryAction` 的 `deadlineMs`(默认 `ACTION_RETRY_BUDGET_MS=45_000`)限制**单次操作**的总耗时,含首次尝试与退避等待;到点即停并抛出最后一次错误。 +避免 `attempts × 单次超时` 在慢页面下叠成几十秒慢失败。 + +```ts +await retryAction(() => action(), { label: '慢接口', deadlineMs: 30_000 }); +``` + +## 失败频次统计(经常失败的用例) + +目标:回答「**哪些用例经常挂**」,而不是只看单次红绿。区别于 flake(有过有挂),此处**只累计失败**,跨构建持久化。 + +原理: + +1. 每条用例 `afterEach` 按 `STAT-` 编号累加 `runs/passed/failed/consecutiveFails`,写入 `reports/flake/failure-counts.json`(跨构建累计,重启不清零)。 +2. 失败次数 ≥ `FREQ_FAIL_MIN`(默认 2)**且** 失败率 ≥ `FREQ_FAIL_RATE`(默认 0.2)判定为「经常失败」。 +3. 给用例追加 annotation,报告可检索、可筛: + + | annotation | 含义 | + |------------|------| + | `failureRuns` | `失败/执行`,如 `4/5` | + | `failureRate` | 失败率,如 `0.80` | + | `consecutiveFails` | 连续失败次数(通过即归零) | + | `frequentFailure` | 达标标记(经常失败) | + +4. 榜单与命令: + +```bash +npm run failures:report # 表格:经常失败的用例 top30 +node scripts/failure-report.mjs --json # 机器可读,供看板/流水线消费 +node scripts/failure-report.mjs --min-fails=3 --rate=0.3 --top=50 +``` + +- 累计与落盘在 worker 的 `afterEach`(`mergeAndSaveFailureStore` 多 worker 合并安全);`reporters/failure-trend.reporter.ts` 在主进程 `onEnd` 刷新榜单并在日志打印命中项。 +- Jenkins 邮件摘要(`scripts/ci-email-summary.mjs`)自动追加「经常失败的用例」小节。 +- 编号以外的用例(无 `STAT-`)不参与统计。 + ## 产物 - `reports/latest/flake/latest.md` — 趋势与候选 - `reports/latest/flake/run.json` — 当次分类计数 - `reports/latest/flake/history.jsonl` — 当次明细(并追加到 `reports/flake/history.jsonl`) +- `reports/flake/failure-counts.json` — 跨构建失败频次累计(经常失败判定依据) +- `reports/flake/frequent-failures.md` — 经常失败用例榜单(每次运行刷新) diff --git a/docs/jenkins.md b/docs/jenkins.md index 8a1ecb90de9632c6a2743e0d9ff288db5c07ba6e..028a369ce640c4b23f6e716785e66e95e21e0ae5 100644 --- a/docs/jenkins.md +++ b/docs/jenkins.md @@ -8,7 +8,7 @@ | 任务 | 套件 | 建议 cron | 说明 | |------|------|-----------|------| -| `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:全部报表模块已打 `@smoke` 的用例 | +| `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:报表与基础设置中已打 `@smoke` 的用例(含厂商、准入、基础管理、手术、库房、流程) | | `spd-ui-nightly` / `SPD` | `nightly` / `full` | `H 23 * * 1,3,5` | 周一、三、五 **23 点** 回归(Jenkins 任务 `SPD` 当前按此时段跑 full) | | `spd-ui-full` | `full` | `H 3 * * 0` | 周末含全列排序,最慢 | @@ -155,6 +155,6 @@ HTML 报告链接只有 **能打开 Jenkins** 的人才能看。外网领导打 ## Git 上线检查 - `.env`、`auth/*.json`、`.playwright-browsers/`、`reports/*`(除 README)已在 `.gitignore` -- 推送后 Jenkins 用凭据注入 `TEST_USER` / `TEST_PASS` +- 推送后 Jenkins 用凭据注入 `TEST_USER` / `TEST_PASS`;可选在任务环境变量增加 `TEST_USER_2` / `TEST_PASS_2`,worker 按序号在双账号间轮换 - GitLab:根目录 `.gitlab-ci.yml` 默认只跑 `typecheck`;冒烟需配置 Variables `TEST_USER` / `TEST_PASS`,在 Web/定时流水线手动触发 - GitHub Actions 默认跑 `smoke`,完整回归留给 Jenkins 夜间任务 diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\347\224\250\344\276\213\346\261\207\346\212\245.xlsx" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\347\224\250\344\276\213\346\261\207\346\212\245.xlsx" new file mode 100644 index 0000000000000000000000000000000000000000..9b885f1a088fea4dd39b16661df8767d68e2dbe3 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\347\224\250\344\276\213\346\261\207\346\212\245.xlsx" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144646_528_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144646_528_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..10709a0e76ce7805df86dd91db5e18a55446adee Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144646_528_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144714_529_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144714_529_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..cea323f9193a5b42ec05e6e96ee78962c047dd77 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144714_529_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144735_530_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144735_530_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..74a7e1606095bd4ff137841e200496fd6aeeba3d Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232/\345\276\256\344\277\241\345\233\276\347\211\207_20261020144735_530_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163330_532_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163330_532_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..c0f640b13f888062bf2ba328ccb41b9c42cf2b6e Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163330_532_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163407_533_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163407_533_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..d544fe10feb499f0eb420f5fce5e9bb44f312fd8 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256/\345\276\256\344\277\241\345\233\276\347\211\207_20261020163407_533_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143712_527_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143712_527_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..fe351f714b084e53ac419d2f7519daf304f351b6 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143712_527_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143340_525_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143340_525_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..059b7d9531ac97312bcb7902c1e556e22e81d216 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143340_525_5.png" differ diff --git "a/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143641_526_5.png" "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143641_526_5.png" new file mode 100644 index 0000000000000000000000000000000000000000..c6f77f27154870f9c15b5ee50fe6347b67e57d40 Binary files /dev/null and "b/docs/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206/\345\276\256\344\277\241\345\233\276\347\211\207_20261020143641_526_5.png" differ diff --git a/fixtures/index.ts b/fixtures/index.ts index cfb0cfb8905ccdd3d847e63999db1526d8c327fb..1d82633c413a64102d34665b1a804fac30e8cceb 100644 --- a/fixtures/index.ts +++ b/fixtures/index.ts @@ -1,4 +1,4 @@ -import { test as base, expect, type TestDetails, type TestInfo } from '@playwright/test'; +import { test as base, expect, type Route, type TestDetails, type TestInfo } from '@playwright/test'; import { retryAction } from '../core/action-retry'; import { applyCaseMeta } from '../core/case-meta'; @@ -6,17 +6,42 @@ import { FLAKY_TAG, resolveFlakyRetries } from '../core/flaky'; import { annotateFailureClass, classifyFromTestInfo } from '../core/failure-classify'; import { applyQuarantine } from '../core/quarantine'; import { attachFailureDiagnostics } from '../core/diagnostics'; +import { annotateFailureFrequencyForTest } from '../core/failure-tracker'; import { attachRuntimeHealthGuard, getRuntimeHealthGuard, } from '../core/runtime-health.util'; import { failWhen, guardedSpecSkip } from '../pages/spd/helpers/guarded-skip.helper'; +import { resolveAuthStorageStatePath } from '../utils/auth-pool.util'; import { buildPageFixtures, type AppPageFixtures } from './page-registry'; type AppFixtures = AppPageFixtures; +const BLOCKED_RESOURCE_TYPES = new Set(['image', 'media', 'font']); +const ANALYTICS_URL = + /(?:google-analytics|googletagmanager|hm\.baidu\.com|cnzz\.com|hotjar|sentry\.io|umeng\.com|growingio|sensorsdata|clarity\.ms)/i; + +/** 拦截图片、字体、音视频和统计脚本。不拦截 document / xhr / fetch,报表查询仍走网络。 */ +async function abortHeavyAssets(route: Route): Promise { + const request = route.request(); + if (BLOCKED_RESOURCE_TYPES.has(request.resourceType()) || ANALYTICS_URL.test(request.url())) { + await route.abort(); + return; + } + await route.continue(); +} + +/** + * 业务 fixture:storageState 来自 auth.setup 写入的文件。 + * 配置 TEST_USER_2/TEST_PASS_2 时按 parallelIndex 在双账号间轮换,减轻互踢会话。 + * 每条用例仍新建独立 context + page;此处不持有跨用例 session。 + */ export const test = base.extend({ + storageState: async ({}, use, workerInfo) => { + await use(resolveAuthStorageStatePath(workerInfo.parallelIndex)); + }, page: async ({ page }, use) => { + await page.context().route('**/*', abortHeavyAssets); attachRuntimeHealthGuard(page); page.context().on('page', (popup) => { attachRuntimeHealthGuard(popup); @@ -33,17 +58,27 @@ test.beforeEach(({}, testInfo) => { test.afterEach(async ({ page }, testInfo) => { const failed = testInfo.status !== testInfo.expectedStatus; - if (failed) { - if (page && !page.isClosed()) { - await attachFailureDiagnostics(page, testInfo, getRuntimeHealthGuard(page)); + try { + if (failed) { + if (page && !page.isClosed()) { + await attachFailureDiagnostics(page, testInfo, getRuntimeHealthGuard(page)); + } else { + annotateFailureClass(testInfo, classifyFromTestInfo(testInfo)); + } return; } - annotateFailureClass(testInfo, classifyFromTestInfo(testInfo)); - return; - } - const guard = page && !page.isClosed() ? getRuntimeHealthGuard(page) : undefined; - if (guard) { - await guard.assertClean(`用例收尾:${testInfo.title}`); + const guard = page && !page.isClosed() ? getRuntimeHealthGuard(page) : undefined; + if (guard) { + await guard.assertClean(`用例收尾:${testInfo.title}`); + } + } finally { + // 跨构建失败频次:reporter 负责累计落盘,这里基于最新统计打 annotation(只读), + // 保证诊断/健康检查异常也不影响打标;无编号用例自动跳过。 + try { + annotateFailureFrequencyForTest(testInfo); + } catch { + // 统计不影响用例结果 + } } }); diff --git a/fixtures/page-registry.ts b/fixtures/page-registry.ts index 724ee8be14e7795eac371a7de51d34a63ac38d96..0250e510cbf48467430f62cceebb24eead378d47 100644 --- a/fixtures/page-registry.ts +++ b/fixtures/page-registry.ts @@ -3,7 +3,37 @@ import type { Page } from '@playwright/test'; import { LoginPage } from '../pages/auth/login.page'; import { AnaChgPage } from '../pages/spd/ana-chg.page'; import { AnaDeptPage } from '../pages/spd/ana-dept.page'; +import { AdmitMaterialListPage } from '../pages/spd/admit-material-list.page'; +import { AdmitMaterialPage } from '../pages/spd/admit-material.page'; +import { AdmitProductApplyPage } from '../pages/spd/admit-product-apply.page'; +import { AdmitProductListPage } from '../pages/spd/admit-product-list.page'; +import { AdmitSupplyPage } from '../pages/spd/admit-supply.page'; +import { AdmitTaskPage } from '../pages/spd/admit-task.page'; import { AnaProvPage } from '../pages/spd/ana-prov.page'; +import { BasicBranchPage } from '../pages/spd/basic-branch.page'; +import { BasicFinanceMonthPage } from '../pages/spd/basic-finance-month.page'; +import { BasicHospitalInfoPage } from '../pages/spd/basic-hospital-info.page'; +import { BasicNoticePage } from '../pages/spd/basic-notice.page'; +import { BasicRolePage } from '../pages/spd/basic-role.page'; +import { BasicUserPage } from '../pages/spd/basic-user.page'; +import { BasicUserUnionPage } from '../pages/spd/basic-user-union.page'; +import { FlowDefPage } from '../pages/spd/flow-def.page'; +import { FlowInstPage } from '../pages/spd/flow-inst.page'; +import { WarehouseProbePage } from '../pages/spd/warehouse-probe.page'; +import { WarehouseZonePage } from '../pages/spd/warehouse-zone.page'; +import { SurgeryBasePkgPage } from '../pages/spd/surgery-base-pkg.page'; +import { SurgeryOperTypePage } from '../pages/spd/surgery-oper-type.page'; +import { SurgeryStdPkgPage } from '../pages/spd/surgery-std-pkg.page'; +import { VendorAuthFinalPage } from '../pages/spd/vendor-auth-final.page'; +import { VendorAuthFirstPage } from '../pages/spd/vendor-auth-first.page'; +import { VendorCertDiffPage } from '../pages/spd/vendor-cert-diff.page'; +import { VendorCertFinalPage } from '../pages/spd/vendor-cert-final.page'; +import { VendorCertFirstPage } from '../pages/spd/vendor-cert-first.page'; +import { VendorEvalIndicatorPage } from '../pages/spd/vendor-eval-indicator.page'; +import { VendorEvalSchemePage } from '../pages/spd/vendor-eval-scheme.page'; +import { VendorHolidayPage } from '../pages/spd/vendor-holiday.page'; +import { VendorQualPage } from '../pages/spd/vendor-qual.page'; +import { VendorQueryPage } from '../pages/spd/vendor-query.page'; import { CenterReportEntryPage } from '../pages/spd/center-report-entry.page'; import { CenterReportLmmPsiPage } from '../pages/spd/center-report-lmm-psi.page'; import { CenterReportLmmSheetPage } from '../pages/spd/center-report-lmm-sheet.page'; @@ -120,6 +150,36 @@ export const PAGE_CTORS = { anaChgPage: AnaChgPage, anaDeptPage: AnaDeptPage, anaProvPage: AnaProvPage, + admitMaterialPage: AdmitMaterialPage, + admitProductApplyPage: AdmitProductApplyPage, + admitMaterialListPage: AdmitMaterialListPage, + admitProductListPage: AdmitProductListPage, + admitSupplyPage: AdmitSupplyPage, + admitTaskPage: AdmitTaskPage, + basicNoticePage: BasicNoticePage, + basicHospitalInfoPage: BasicHospitalInfoPage, + basicFinanceMonthPage: BasicFinanceMonthPage, + basicBranchPage: BasicBranchPage, + basicUserPage: BasicUserPage, + basicUserUnionPage: BasicUserUnionPage, + basicRolePage: BasicRolePage, + warehouseZonePage: WarehouseZonePage, + warehouseProbePage: WarehouseProbePage, + flowDefPage: FlowDefPage, + flowInstPage: FlowInstPage, + surgeryBasePkgPage: SurgeryBasePkgPage, + surgeryOperTypePage: SurgeryOperTypePage, + surgeryStdPkgPage: SurgeryStdPkgPage, + vendorQueryPage: VendorQueryPage, + vendorQualPage: VendorQualPage, + vendorAuthFirstPage: VendorAuthFirstPage, + vendorAuthFinalPage: VendorAuthFinalPage, + vendorCertFirstPage: VendorCertFirstPage, + vendorCertFinalPage: VendorCertFinalPage, + vendorCertDiffPage: VendorCertDiffPage, + vendorEvalSchemePage: VendorEvalSchemePage, + vendorEvalIndicatorPage: VendorEvalIndicatorPage, + vendorHolidayPage: VendorHolidayPage, reagEntryPage: ReagEntryPage, reconDeptAccountPage: ReconDeptAccountPage, reconDeptNetInPage: ReconDeptNetInPage, diff --git a/locators/spd/admit-material-list.locator.yaml b/locators/spd/admit-material-list.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b5458b91c673a80fb44c1728c9a74b418f208590 --- /dev/null +++ b/locators/spd/admit-material-list.locator.yaml @@ -0,0 +1,469 @@ +# Auto-generated from admit-material-list-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/zrlist +# Scanned: 2026-10-20T08:46:04.224Z +# Page: 基础设置 > 准入管理 > 物料准入单列表 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan + +page: admit-material-list +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/zrlist + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitMaterialList: + description: "物料准入单列表" + element_type: tab + section: "main::main" + strategy: text + value: "物料准入单列表" + playwright: "page.getByText('物料准入单列表')" + + tab_admitMaterialList: + description: "物料准入单列表" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/productOrder/zrlist" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/productOrder/zrlist')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + consumableTypeSelect: + description: "请选择耗材类型" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + consumableTypeSelect_2: + description: "请选择耗材类型低值耗材高值耗材检验试剂" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + purchaseMode: + description: "耗材类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_purModes" + playwright: "page.locator('#table_search_form_purModes')" + + # --- main::pagination --- + pagination_action: + description: "共 24 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_actionBtn: + description: "操作" + element_type: button + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + # --- main::detail:产品准入单详情 --- + el_e5aea1e689b9e8bf9be7a88bBtn: + description: "审批进程" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "审批进程" + playwright: "page.getByRole('button', { name: '审批进程' })" + + el_e58d95e68daee697a5e5bf97Btn: + description: "单据日志" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "单据日志" + playwright: "page.getByRole('button', { name: '单据日志' })" + + backBtn: + description: "返 回" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + el_e8afb7e8be93e585a5e789a9e69699e7bc96e7a081e38081e5908de7a7b0Input: + description: "请输入物料编码、名称" + element_type: text + section: "main::detail:产品准入单详情" + strategy: placeholder + value: "请输入物料编码、名称" + playwright: "page.getByPlaceholder('请输入物料编码、名称')" + + exportBtn: + description: "导 出" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "导 出" + playwright: "page.getByRole('button', { name: '导 出' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::detail:产品准入单详情" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + el_e9809ae8bf87Btn: + description: "通过" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: switch + name: "通过" + playwright: "page.getByRole('switch', { name: '通过' })" + + el_e99984e4bbb6Btn: + description: "附件" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "附件" + playwright: "page.getByRole('button', { name: '附件' })" + + el_e585b131e69da1313530e69da12fe9a1b5: + description: "共 1 条150 条/页" + element_type: ul + section: "main::detail:产品准入单详情" + strategy: text + value: "共 1 条150 条/页" + playwright: "page.getByText('共 1 条150 条/页')" + + prevPageBtn: + description: "上一页" + element_type: li + section: "main::detail:产品准入单详情" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + page_1: + description: "1" + element_type: pagination_item + section: "main::detail:产品准入单详情" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + nextPageBtn: + description: "下一页" + element_type: li + section: "main::detail:产品准入单详情" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::detail:产品准入单详情" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/admit-material.locator.yaml b/locators/spd/admit-material.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..41c8a27f30a538a265bd0cb5ce038a39a87cc3ef --- /dev/null +++ b/locators/spd/admit-material.locator.yaml @@ -0,0 +1,1280 @@ +# Auto-generated from admit-material-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/ZRlist +# Scanned: 2026-10-20T08:45:26.257Z +# Page: 基础设置 > 准入管理 > 物料准入管理 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan +# Note: log view was opened during scan + +page: admit-material +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/ZRlist + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitMaterial: + description: "物料准入管理" + element_type: tab + section: "main::main" + strategy: text + value: "物料准入管理" + playwright: "page.getByText('物料准入管理')" + + tab_admitMaterial: + description: "物料准入管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/productApply/ZRlist" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/productApply/ZRlist')" + + createDateDateRange: + description: "新增日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "新增日期" + playwright: "page.getByText('新增日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + createDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + consumableTypeSelect: + description: "请选择耗材类型" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + el_e585b3e88194e4baa7e59381Btn: + description: "关联产品" + element_type: button + section: "main::main" + strategy: role + role: button + name: "关联产品" + playwright: "page.getByRole('button', { name: '关联产品' })" + + el_e7949fe68890e794b3e8afb7e58d95Btn: + description: "生成申请单" + element_type: button + section: "main::main" + strategy: role + role: button + name: "生成申请单" + playwright: "page.getByRole('button', { name: '生成申请单' })" + + el_e4b880e994aee7949fe68890Btn: + description: "一键生成" + element_type: button + section: "main::main" + strategy: role + role: button + name: "一键生成" + playwright: "page.getByRole('button', { name: '一键生成' })" + + el_e4bd9ce5ba9fBtn: + description: "作 废" + element_type: button + section: "main::main" + strategy: role + role: button + name: "作 废" + playwright: "page.getByRole('button', { name: '作 废' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + logBtn: + description: "日志" + element_type: button + section: "main::main" + strategy: role + role: button + name: "日志" + playwright: "page.getByRole('button', { name: '日志' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + consumableTypeSelect_2: + description: "请选择耗材类型低值耗材高值耗材检验试剂" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + purchaseMode: + description: "耗材类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_purModes" + playwright: "page.locator('#table_search_form_purModes')" + + # --- main::pagination --- + pagination_el_e585b135e69da1313530e69da12fe9a1b5: + description: "共 5 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 5 条150 条/页" + playwright: "page.getByText('共 5 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院 --- + productName: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsName" + playwright: "page.locator('#form_item_goodsName')" + + registrantSelect: + description: "湖南海琅生物科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "器械注册人" + playwright: "page.getByText('器械注册人', { exact: true })" + + manufacturer: + description: "器械注册人" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsMfrsId" + playwright: "page.locator('#form_item_goodsMfrsId')" + + specModel: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsSpec" + playwright: "page.locator('#form_item_goodsSpec')" + + el_e6b3a8e5868ce8a784e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_regCertSpec" + playwright: "page.locator('#form_item_regCertSpec')" + + el_e6b3a8e5868ce59e8be58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_regCertModel" + playwright: "page.locator('#form_item_regCertModel')" + + el_e58d95e4bd8dInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_unit" + playwright: "page.locator('#form_item_unit')" + + el_e5a283e58685e5a283e5a496Select: + description: "境内" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "境内境外" + playwright: "page.getByText('境内境外', { exact: true })" + + el_e5a283e58685e5a283e5a496Input: + description: "境内境外" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_abroadFlag" + playwright: "page.locator('#form_item_abroadFlag')" + + el_3638e58886e7b1bbSelect: + description: "医用高分子材料及制品" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "68分类" + playwright: "page.getByText('68分类', { exact: true })" + + el_3638e58886e7b1bbInput: + description: "68分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_kindSixtyeightCode" + playwright: "page.locator('#form_item_kindSixtyeightCode')" + + el_3138e58886e7b1bbSelect: + description: "临床检验器械" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "18分类" + playwright: "page.getByText('18分类', { exact: true })" + + el_3138e58886e7b1bbInput: + description: "18分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_kind18Code" + playwright: "page.locator('#form_item_kind18Code')" + + el_e696b0e7aea1e79086e7b1bbe588abSelect: + description: "Ⅱ类" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "新管理类别" + playwright: "page.getByText('新管理类别', { exact: true })" + + el_e696b0e7aea1e79086e7b1bbe588abInput: + description: "新管理类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_riskLevel" + playwright: "page.locator('#form_item_riskLevel')" + + el_e697a7e7aea1e79086e7b1bbe588abSelect: + description: "Ⅱ类" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "旧管理类别" + playwright: "page.getByText('旧管理类别', { exact: true })" + + el_e697a7e7aea1e79086e7b1bbe588abInput: + description: "旧管理类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_consumableLevel" + playwright: "page.locator('#form_item_consumableLevel')" + + el_e7bb93e69e84e7bb84e68890Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsDesc" + playwright: "page.locator('#form_item_goodsDesc')" + + el_e582a8e5ad98e69da1e4bbb6Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_storageConditions" + playwright: "page.locator('#form_item_storageConditions')" + + el_e58c85e8a385e8a784e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_invUsage" + playwright: "page.locator('#form_item_invUsage')" + + el_e58f97e68998e7949fe4baa7e59586Select: + description: "湖南海琅生物科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "受托生产商" + playwright: "page.getByText('受托生产商', { exact: true })" + + el_e58f97e68998e7949fe4baa7e59586Input: + description: "受托生产商" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_trustMrfsName" + playwright: "page.locator('#form_item_trustMrfsName')" + + el_e59381e7898cInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_brand" + playwright: "page.locator('#form_item_brand')" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Select: + description: "诊断药品" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否医疗器械" + playwright: "page.getByText('是否医疗器械', { exact: true })" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Input: + description: "是否医疗器械" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_instrFlag" + playwright: "page.locator('#form_item_instrFlag')" + + el_e9878de9878fInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + materialCode: + description: "填了则按用户填写的,未填写则系统自动编码" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsCode" + playwright: "page.locator('#form_item_goodsCode')" + + registrationNo: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsRegCert" + playwright: "page.locator('#form_item_goodsRegCert')" + + el_e69c9be6b5b7e6b3a8e5868ce8af81e7949fe69588e697a5e69c9fDateRange: + description: "望海注册证生效日期" + element_type: datepicker + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "望海注册证生效日期" + playwright: "page.getByText('望海注册证生效日期', { exact: true })" + + el_e69c9be6b5b7e6b3a8e5868ce8af81e7949fe69588e697a5e69c9fInput: + description: "请选择" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_whCertStartDate" + playwright: "page.locator('#form_item_whCertStartDate')" + + el_e69c9be6b5b7e6b3a8e5868ce8af81e69c89e69588e69c9fe887b3DateRange: + description: "望海注册证有效期至" + element_type: datepicker + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "望海注册证有效期至" + playwright: "page.getByText('望海注册证有效期至', { exact: true })" + + el_e69c9be6b5b7e6b3a8e5868ce8af81e69c89e69588e69c9fe887b3Input: + description: "请选择" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_whCertEndDate" + playwright: "page.locator('#form_item_whCertEndDate')" + + el_e69c9be6b5b7e7949fe4baa7e4bc81e4b89ae7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_manufactCode" + playwright: "page.locator('#form_item_manufactCode')" + + el_e69c9be6b5b7e5819ce794a8e697b6e997b4DateRange: + description: "望海停用时间" + element_type: datepicker + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "望海停用时间" + playwright: "page.getByText('望海停用时间', { exact: true })" + + el_e69c9be6b5b7e5819ce794a8e697b6e997b4Input: + description: "请选择" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_enDate" + playwright: "page.locator('#form_item_enDate')" + + el_e69c9be6b5b7e69bb4e696b0e697b6e997b4DateRange: + description: "望海更新时间" + element_type: datepicker + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "望海更新时间" + playwright: "page.getByText('望海更新时间', { exact: true })" + + el_e69c9be6b5b7e69bb4e696b0e697b6e997b4Input: + description: "请选择" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_updateDate" + playwright: "page.locator('#form_item_updateDate')" + + el_e58aa9e8aeb0e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_inputCode" + playwright: "page.locator('#form_item_inputCode')" + + el_e696b0e697a7e585b3e88194e4baa7e59381Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_link_hos_goods_name" + playwright: "page.locator('#form_item_link_hos_goods_name')" + + el_e98082e794a8e88c83e59bb4Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_purpose" + playwright: "page.locator('#form_item_purpose')" + + el_e58cbbe4bf9de7bc96e7a081efbc883237e4bd8defbc89Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_miTwentysevenCode" + playwright: "page.locator('#form_item_miTwentysevenCode')" + + el_e58cbbe4bf9de5908de7a7b0Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_miDjmc" + playwright: "page.locator('#form_item_miDjmc')" + + el_e58cbbe4bf9de8a784e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_miGoodsGg" + playwright: "page.locator('#form_item_miGoodsGg')" + + el_e58cbbe4bf9de59e8be58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_miGoodsXh" + playwright: "page.locator('#form_item_miGoodsXh')" + + el_e4baa7e59381e4bf97e7a7b0Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsGeneralName" + playwright: "page.locator('#form_item_goodsGeneralName')" + + remark: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + el_e98787e8b4ade590abe9878fInput: + description: "采购的最小包装数量,默认1" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "采购的最小包装数量,默认1" + playwright: "page.getByPlaceholder('采购的最小包装数量,默认1')" + + el_e98787e8b4ade58d95e4bd8dInput: + description: "采购的包装单位名称" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_purchaseUnit" + playwright: "page.locator('#form_item_purchaseUnit')" + + el_e8afb7e9a286e590abe9878fInput: + description: "请领的最小包装含量,默认1" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "请领的最小包装含量,默认1" + playwright: "page.getByPlaceholder('请领的最小包装含量,默认1')" + + el_e8afb7e9a286e58d95e4bd8dInput: + description: "请领的最小包装单位名称" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_buyUnit" + playwright: "page.locator('#form_item_buyUnit')" + + el_e4babae4bbbde8bdace68da2e6af94Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e698afe590a6e4b8b4e98787Select: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否临采" + playwright: "page.getByText('是否临采', { exact: true })" + + el_e698afe590a6e4b8b4e98787Input: + description: "是否临采" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_tempFlag" + playwright: "page.locator('#form_item_tempFlag')" + + el_e698afe590a6e58fafe98787e8b4adSelect: + description: "是" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否可采购" + playwright: "page.getByText('是否可采购', { exact: true })" + + el_e698afe590a6e58fafe98787e8b4adInput: + description: "是否可采购" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_purchaseFlag" + playwright: "page.locator('#form_item_purchaseFlag')" + + statusSelect: + description: "启用" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + statusInput: + description: "状态" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsStatus" + playwright: "page.locator('#form_item_goodsStatus')" + + consumableCategorySelect: + description: "低值易耗品 / 低值易耗品-其他材料 / 基础卫生材料-其他材料-其他材料" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "耗材分类" + playwright: "page.getByText('耗材分类', { exact: true })" + + consumableType: + description: "耗材分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_goodsKind" + playwright: "page.locator('#form_item_goodsKind')" + + el_e88097e69d90e7b1bbe588abSelect: + description: "低值" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "耗材类别" + playwright: "page.getByText('耗材类别', { exact: true })" + + el_e88097e69d90e7b1bbe588abInput: + description: "耗材类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_purMode" + playwright: "page.locator('#form_item_purMode')" + + el_e8b4a2e58aa1e58886e7b1bbSelect: + description: "卫生材料 / 卫材-低值易耗" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "财务分类" + playwright: "page.getByText('财务分类', { exact: true })" + + el_e8b4a2e58aa1e58886e7b1bbInput: + description: "财务分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_fcatKind" + playwright: "page.locator('#form_item_fcatKind')" + + el_e5b8a6e9878fe58886e7b1bbSelect: + description: "非带量 / 自行采购" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "带量分类" + playwright: "page.getByText('带量分类', { exact: true })" + + el_e5b8a6e9878fe58886e7b1bbInput: + description: "带量分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_vbpKind" + playwright: "page.locator('#form_item_vbpKind')" + + el_e698afe590a6e9ab98e580bcSelect: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否高值" + playwright: "page.getByText('是否高值', { exact: true })" + + el_e698afe590a6e9ab98e580bcInput: + description: "是否高值" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_higHvalue" + playwright: "page.locator('#form_item_higHvalue')" + + el_e698afe590a6e68d90e8b5a0Select: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否捐赠" + playwright: "page.getByText('是否捐赠', { exact: true })" + + el_e698afe590a6e68d90e8b5a0Input: + description: "是否捐赠" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_ecoBat" + playwright: "page.locator('#form_item_ecoBat')" + + el_e6b4bbe58aa8e68f8fe8bfb0Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_ecoBatRemark" + playwright: "page.locator('#form_item_ecoBatRemark')" + + el_e698afe590a6e99990e588b6e7a791e5aea4e98787e8b4adSelect: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否限制科室采购" + playwright: "page.getByText('是否限制科室采购', { exact: true })" + + el_e698afe590a6e99990e588b6e7a791e5aea4e98787e8b4adInput: + description: "是否限制科室采购" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_deptFlag" + playwright: "page.locator('#form_item_deptFlag')" + + el_e98787e8b4ade695b0e68daee69da5e6ba90Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "采购数据来源" + playwright: "page.getByText('采购数据来源', { exact: true })" + + el_e98787e8b4ade695b0e68daee69da5e6ba90Input: + description: "采购数据来源" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_dataSources" + playwright: "page.locator('#form_item_dataSources')" + + el_e789a9e6b581e4b8ade5bf83Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "物流中心" + playwright: "page.getByText('物流中心', { exact: true })" + + el_e789a9e6b581e4b8ade5bf83Input: + description: "物流中心" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_logisticsCenter" + playwright: "page.locator('#form_item_logisticsCenter')" + + el_e698afe590a6e5ad98e59ca8e887aae8b58be7a081Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否存在自赋码" + playwright: "page.getByText('是否存在自赋码', { exact: true })" + + el_e698afe590a6e5ad98e59ca8e887aae8b58be7a081Input: + description: "是否存在自赋码" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_uniqueIdFlag" + playwright: "page.locator('#form_item_uniqueIdFlag')" + + el_e698afe590a6e998b2e796abe789a9e8b584Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否防疫物资" + playwright: "page.getByText('是否防疫物资', { exact: true })" + + el_e698afe590a6e998b2e796abe789a9e8b584Input: + description: "是否防疫物资" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_plagueFlag" + playwright: "page.locator('#form_item_plagueFlag')" + + el_e698afe590a6e5a487e8b4a7e5ba93e587bae5ba93Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否备货库出库" + playwright: "page.getByText('是否备货库出库', { exact: true })" + + el_e698afe590a6e5a487e8b4a7e5ba93e587bae5ba93Input: + description: "是否备货库出库" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_stockUpFlag" + playwright: "page.locator('#form_item_stockUpFlag')" + + el_e698afe590a6e69c89554449Select: + description: "医疗器械GS1-UDI" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否有UDI" + playwright: "page.getByText('是否有UDI', { exact: true })" + + el_e698afe590a6e69c89554449Input: + description: "是否有UDI" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_UDIType" + playwright: "page.locator('#form_item_UDIType')" + + el_e98787e8b4ade68890e69cace5bd92e5b19ee7a791e5aea4Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "采购成本归属科室" + playwright: "page.getByText('采购成本归属科室', { exact: true })" + + el_e98787e8b4ade68890e69cace5bd92e5b19ee7a791e5aea4Input: + description: "采购成本归属科室" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_affiliationDept" + playwright: "page.locator('#form_item_affiliationDept')" + + el_e789a9e8b584e7aea1e79086e58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "物资管理分类" + playwright: "page.getByText('物资管理分类', { exact: true })" + + el_e789a9e8b584e7aea1e79086e58886e7b1bbInput: + description: "物资管理分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_materialClassification" + playwright: "page.locator('#form_item_materialClassification')" + + el_e7a88ee78e87Select: + description: "13%" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "税率" + playwright: "page.getByText('税率', { exact: true })" + + el_e7a88ee78e87Input: + description: "税率" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_taxRate" + playwright: "page.locator('#form_item_taxRate')" + + el_e698afe590a652464944e7aea1e79086Select: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否RFID管理" + playwright: "page.getByText('是否RFID管理', { exact: true })" + + el_e698afe590a652464944e7aea1e79086Input: + description: "是否RFID管理" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_rfidFlag" + playwright: "page.locator('#form_item_rfidFlag')" + + el_e698afe590a6e586b7e993bee8bf90e8be93Select: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否冷链运输" + playwright: "page.getByText('是否冷链运输', { exact: true })" + + el_e698afe590a6e586b7e993bee8bf90e8be93Input: + description: "是否冷链运输" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_isColdTransport" + playwright: "page.locator('#form_item_isColdTransport')" + + el_e99480e594aee59586Select: + description: "浙江高仰医疗科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId: + description: "销售商" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_provId" + playwright: "page.locator('#form_item_provId')" + + el_e98787e8b4ade58d95e4bbb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e4b89ae58aa1e6a8a1e5bc8fSelect: + description: "直销" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "业务模式" + playwright: "page.getByText('业务模式', { exact: true })" + + el_e4b89ae58aa1e6a8a1e5bc8fInput: + description: "业务模式" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_bizMode" + playwright: "page.locator('#form_item_bizMode')" + + dimensionSupplierSelect: + description: "浙江高仰医疗科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "供应商" + playwright: "page.getByText('供应商', { exact: true })" + + subSupplierId: + description: "供应商" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_subProvId" + playwright: "page.locator('#form_item_subProvId')" + + el_e4ba8ce7baa7e4be9be5ba94e59586e7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_supplierCode" + playwright: "page.locator('#form_item_supplierCode')" + + stockoutDays: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_daysOfStockout" + playwright: "page.locator('#form_item_daysOfStockout')" + + twoHourArrivalSelect: + description: "请选择" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否2小时到货" + playwright: "page.getByText('是否2小时到货', { exact: true })" + + twoHourArrival: + description: "是否2小时到货" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_twoHourFlag" + playwright: "page.locator('#form_item_twoHourFlag')" + + chargeableSelect: + description: "是" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: label_text + value: "是否收费" + playwright: "page.getByText('是否收费', { exact: true })" + + chargeable: + description: "是否收费" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_chargeFlag" + playwright: "page.locator('#form_item_chargeFlag')" + + el_e694b6e8b4b9e8aea1e9878fe58d95e4bd8dInput: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: id + value: "form_item_unitName" + playwright: "page.locator('#form_item_unitName')" + + el_e694b6e8b4b9e58d95e4bbb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + backBtn: + description: "返 回" + element_type: button + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)-新品入院" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + # --- main::modal:操作日志 --- \ No newline at end of file diff --git a/locators/spd/admit-product-apply.locator.yaml b/locators/spd/admit-product-apply.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ac2d2b30d9d8f81b071108aa5e1e816d80f0b338 --- /dev/null +++ b/locators/spd/admit-product-apply.locator.yaml @@ -0,0 +1,1985 @@ +# Auto-generated from admit-product-apply-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/list +# Scanned: 2026-10-20T08:45:49.043Z +# Page: 基础设置 > 准入管理 > 产品准入申请 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan +# Note: log view was opened during scan + +page: admit-product-apply +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/list + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitProductApply: + description: "产品准入申请" + element_type: tab + section: "main::main" + strategy: text + value: "产品准入申请" + playwright: "page.getByText('产品准入申请')" + + tab_admitProductApply: + description: "产品准入申请" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/productApply/list" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/productApply/list')" + + createDateDateRange: + description: "新增日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "新增日期" + playwright: "page.getByText('新增日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + createDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + consumableTypeSelect: + description: "请选择耗材类型" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + el_457863656ce5afbce585a5Btn: + description: "Excel导入" + element_type: button + section: "main::main" + strategy: role + role: button + name: "Excel导入" + playwright: "page.getByRole('button', { name: 'Excel导入' })" + + el_e7949fe68890e794b3e8afb7e58d95Btn: + description: "生成申请单" + element_type: button + section: "main::main" + strategy: role + role: button + name: "生成申请单" + playwright: "page.getByRole('button', { name: '生成申请单' })" + + el_e4b880e994aee7949fe68890Btn: + description: "一键生成" + element_type: button + section: "main::main" + strategy: role + role: button + name: "一键生成" + playwright: "page.getByRole('button', { name: '一键生成' })" + + el_e696b0e5a29ee4baa7e59381Btn: + description: "新增产品" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增产品" + playwright: "page.getByRole('button', { name: '新增产品' })" + + el_e4bd9ce5ba9fBtn: + description: "作 废" + element_type: button + section: "main::main" + strategy: role + role: button + name: "作 废" + playwright: "page.getByRole('button', { name: '作 废' })" + + cpzr202609240001Btn: + description: "CPZR202609240001" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202609240001" + playwright: "page.getByRole('button', { name: 'CPZR202609240001' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + logBtn: + description: "日志" + element_type: button + section: "main::main" + strategy: role + role: button + name: "日志" + playwright: "page.getByRole('button', { name: '日志' })" + + cpzr202609220002Btn: + description: "CPZR202609220002" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202609220002" + playwright: "page.getByRole('button', { name: 'CPZR202609220002' })" + + cpzr202609210002Btn: + description: "CPZR202609210002" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202609210002" + playwright: "page.getByRole('button', { name: 'CPZR202609210002' })" + + cpzr202609210001Btn: + description: "CPZR202609210001" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202609210001" + playwright: "page.getByRole('button', { name: 'CPZR202609210001' })" + + cpzr202610150002Btn: + description: "CPZR202610150002" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202610150002" + playwright: "page.getByRole('button', { name: 'CPZR202610150002' })" + + cpzr202610160001Btn: + description: "CPZR202610160001" + element_type: button + section: "main::main" + strategy: role + role: button + name: "CPZR202610160001" + playwright: "page.getByRole('button', { name: 'CPZR202610160001' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + consumableTypeSelect_2: + description: "请选择耗材类型低值耗材高值耗材检验试剂" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + purchaseMode: + description: "耗材类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_purModes" + playwright: "page.locator('#table_search_form_purModes')" + + # --- main::pagination --- + pagination_el_e585b137e69da1313530e69da12fe9a1b5: + description: "共 7 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 7 条150 条/页" + playwright: "page.getByText('共 7 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::detail:返回新增产品 --- + productName: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsName" + playwright: "page.locator('#form_item_goodsName')" + + registrantSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "器械注册人" + playwright: "page.getByText('器械注册人', { exact: true })" + + manufacturer: + description: "器械注册人" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsMfrsId" + playwright: "page.locator('#form_item_goodsMfrsId')" + + specModel: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsSpec" + playwright: "page.locator('#form_item_goodsSpec')" + + el_e6b3a8e5868ce8a784e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_regCertSpec" + playwright: "page.locator('#form_item_regCertSpec')" + + el_e6b3a8e5868ce59e8be58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_regCertModel" + playwright: "page.locator('#form_item_regCertModel')" + + el_e58d95e4bd8dInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_unit" + playwright: "page.locator('#form_item_unit')" + + el_e5a283e58685e5a283e5a496Select: + description: "境内" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "境内境外" + playwright: "page.getByText('境内境外', { exact: true })" + + el_e5a283e58685e5a283e5a496Input: + description: "境内境外" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_abroadFlag" + playwright: "page.locator('#form_item_abroadFlag')" + + el_3638e58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "68分类" + playwright: "page.getByText('68分类', { exact: true })" + + el_3638e58886e7b1bbInput: + description: "68分类" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_kindSixtyeightCode" + playwright: "page.locator('#form_item_kindSixtyeightCode')" + + el_3138e58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "18分类" + playwright: "page.getByText('18分类', { exact: true })" + + el_3138e58886e7b1bbInput: + description: "18分类" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_kind18Code" + playwright: "page.locator('#form_item_kind18Code')" + + el_e696b0e7aea1e79086e7b1bbe588abSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "新管理类别" + playwright: "page.getByText('新管理类别', { exact: true })" + + el_e696b0e7aea1e79086e7b1bbe588abInput: + description: "新管理类别" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_riskLevel" + playwright: "page.locator('#form_item_riskLevel')" + + el_e697a7e7aea1e79086e7b1bbe588abSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "旧管理类别" + playwright: "page.getByText('旧管理类别', { exact: true })" + + el_e697a7e7aea1e79086e7b1bbe588abInput: + description: "旧管理类别" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_consumableLevel" + playwright: "page.locator('#form_item_consumableLevel')" + + registrationNo: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsRegCert" + playwright: "page.locator('#form_item_goodsRegCert')" + + el_e7bb93e69e84e7bb84e68890Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsDesc" + playwright: "page.locator('#form_item_goodsDesc')" + + el_e582a8e5ad98e69da1e4bbb6Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_storageConditions" + playwright: "page.locator('#form_item_storageConditions')" + + el_e8bf90e8be93e69da1e4bbb6Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_transportConditions" + playwright: "page.locator('#form_item_transportConditions')" + + el_e58f97e68998e7949fe4baa7e59586Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "受托生产商" + playwright: "page.getByText('受托生产商', { exact: true })" + + el_e58f97e68998e7949fe4baa7e59586Input: + description: "受托生产商" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_trustMrfsName" + playwright: "page.locator('#form_item_trustMrfsName')" + + el_e59381e7898cInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_brand" + playwright: "page.locator('#form_item_brand')" + + el_4449e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsDI" + playwright: "page.locator('#form_item_goodsDI')" + + el_e698afe590a6e6a48de585a5Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否植入" + playwright: "page.getByText('是否植入', { exact: true })" + + el_e698afe590a6e6a48de585a5Input: + description: "是否植入" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_implantsFlag" + playwright: "page.locator('#form_item_implantsFlag')" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否医疗器械" + playwright: "page.getByText('是否医疗器械', { exact: true })" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Input: + description: "是否医疗器械" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_instrFlag" + playwright: "page.locator('#form_item_instrFlag')" + + el_e698afe590a6e58db1e58c96e59381Select: + description: "否" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否危化品" + playwright: "page.getByText('是否危化品', { exact: true })" + + el_e698afe590a6e58db1e58c96e59381Input: + description: "是否危化品" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_dangerFlag" + playwright: "page.locator('#form_item_dangerFlag')" + + el_e9878de9878fInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e58786e585a5e59088e5908ce58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_contractNo" + playwright: "page.locator('#form_item_contractNo')" + + el_e58cbbe4bf9de7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miCode" + playwright: "page.locator('#form_item_miCode')" + + el_e58cbbe4bf9de7bc96e7a081efbc883230e4bd8defbc89Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miTwentyCode" + playwright: "page.locator('#form_item_miTwentyCode')" + + el_e58cbbe4bf9de7bc96e7a081efbc883237e4bd8defbc89Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miTwentysevenCode" + playwright: "page.locator('#form_item_miTwentysevenCode')" + + el_e58cbbe4bf9de5908de7a7b0Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miDjmc" + playwright: "page.locator('#form_item_miDjmc')" + + el_e58cbbe4bf9de8a784e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miGoodsGg" + playwright: "page.locator('#form_item_miGoodsGg')" + + el_e58cbbe4bf9de59e8be58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miGoodsXh" + playwright: "page.locator('#form_item_miGoodsXh')" + + el_e58cbbe4bf9de7babfe4b88ae694afe4bb98Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "医保线上支付" + playwright: "page.getByText('医保线上支付', { exact: true })" + + el_e58cbbe4bf9de7babfe4b88ae694afe4bb98Input: + description: "医保线上支付" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_miOnlinePay" + playwright: "page.locator('#form_item_miOnlinePay')" + + el_e4baa7e59381e4bf97e7a7b0Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsGeneralName" + playwright: "page.locator('#form_item_goodsGeneralName')" + + remark: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + el_e98787e8b4ade590abe9878fInput: + description: "采购的最小包装数量,默认1" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "采购的最小包装数量,默认1" + playwright: "page.getByPlaceholder('采购的最小包装数量,默认1')" + + el_e98787e8b4ade58d95e4bd8dInput: + description: "采购的包装单位名称" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_purchaseUnit" + playwright: "page.locator('#form_item_purchaseUnit')" + + el_e8afb7e9a286e590abe9878fInput: + description: "请领的最小包装含量,默认1" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请领的最小包装含量,默认1" + playwright: "page.getByPlaceholder('请领的最小包装含量,默认1')" + + el_e8afb7e9a286e58d95e4bd8dInput: + description: "请领的最小包装单位名称" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_buyUnit" + playwright: "page.locator('#form_item_buyUnit')" + + el_e4babae4bbbde8bdace68da2e6af94Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + chargeableSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否收费" + playwright: "page.getByText('是否收费', { exact: true })" + + chargeable: + description: "是否收费" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_chargeFlag" + playwright: "page.locator('#form_item_chargeFlag')" + + el_e698afe590a6e4b8b4e98787Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否临采" + playwright: "page.getByText('是否临采', { exact: true })" + + el_e698afe590a6e4b8b4e98787Input: + description: "是否临采" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_tempFlag" + playwright: "page.locator('#form_item_tempFlag')" + + el_e698afe590a6e58fafe98787e8b4adSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否可采购" + playwright: "page.getByText('是否可采购', { exact: true })" + + el_e698afe590a6e58fafe98787e8b4adInput: + description: "是否可采购" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_purchaseFlag" + playwright: "page.locator('#form_item_purchaseFlag')" + + statusSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + statusInput: + description: "状态" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsStatus" + playwright: "page.locator('#form_item_goodsStatus')" + + consumableCategorySelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "耗材分类" + playwright: "page.getByText('耗材分类', { exact: true })" + + consumableType: + description: "耗材分类" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsKind" + playwright: "page.locator('#form_item_goodsKind')" + + el_e88097e69d90e7b1bbe588abSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "耗材类别" + playwright: "page.getByText('耗材类别', { exact: true })" + + el_e88097e69d90e7b1bbe588abInput: + description: "耗材类别" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_purMode" + playwright: "page.locator('#form_item_purMode')" + + el_e698afe590a6e99c80e8a681e6b497e6b688Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否需要洗消" + playwright: "page.getByText('是否需要洗消', { exact: true })" + + el_e698afe590a6e99c80e8a681e6b497e6b688Input: + description: "是否需要洗消" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_sterilizeFlag" + playwright: "page.locator('#form_item_sterilizeFlag')" + + el_e8b4a2e58aa1e58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "财务分类" + playwright: "page.getByText('财务分类', { exact: true })" + + el_e8b4a2e58aa1e58886e7b1bbInput: + description: "财务分类" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_fcatKind" + playwright: "page.locator('#form_item_fcatKind')" + + el_e5b8a6e9878fe58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "带量分类" + playwright: "page.getByText('带量分类', { exact: true })" + + el_e5b8a6e9878fe58886e7b1bbInput: + description: "带量分类" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_vbpKind" + playwright: "page.locator('#form_item_vbpKind')" + + materialCode: + description: "填了则按用户填写的,未填写则系统自动编码" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_goodsCode" + playwright: "page.locator('#form_item_goodsCode')" + + el_e698afe590a652464944e7aea1e79086Select: + description: "否" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否RFID管理" + playwright: "page.getByText('是否RFID管理', { exact: true })" + + el_e698afe590a652464944e7aea1e79086Input: + description: "是否RFID管理" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_rfidFlag" + playwright: "page.locator('#form_item_rfidFlag')" + + el_e698afe590a6e68d90e8b5a0Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否捐赠" + playwright: "page.getByText('是否捐赠', { exact: true })" + + el_e698afe590a6e68d90e8b5a0Input: + description: "是否捐赠" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_ecoBat" + playwright: "page.locator('#form_item_ecoBat')" + + el_e698afe590a6e69c89554449Select: + description: "无码" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否有UDI" + playwright: "page.getByText('是否有UDI', { exact: true })" + + el_e698afe590a6e69c89554449Input: + description: "是否有UDI" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_UDIType" + playwright: "page.locator('#form_item_UDIType')" + + el_e7a88ee78e87Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "税率" + playwright: "page.getByText('税率', { exact: true })" + + el_e7a88ee78e87Input: + description: "税率" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_taxRate" + playwright: "page.locator('#form_item_taxRate')" + + el_e698afe590a6e99990e9878fe98787e8b4adSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "是否限量采购" + playwright: "page.getByText('是否限量采购', { exact: true })" + + el_e698afe590a6e99990e9878fe98787e8b4adInput: + description: "是否限量采购" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_limitPurchase" + playwright: "page.locator('#form_item_limitPurchase')" + + el_e68b9be6a087e69687e58fb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_bidCode" + playwright: "page.locator('#form_item_bidCode')" + + el_e99480e594aee59586Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId: + description: "销售商" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_provId" + playwright: "page.locator('#form_item_provId')" + + el_e98787e8b4ade58d95e4bbb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e4b89ae58aa1e6a8a1e5bc8fSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "业务模式" + playwright: "page.getByText('业务模式', { exact: true })" + + el_e4b89ae58aa1e6a8a1e5bc8fInput: + description: "业务模式" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_bizMode" + playwright: "page.locator('#form_item_bizMode')" + + dimensionSupplierSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "供应商" + playwright: "page.getByText('供应商', { exact: true })" + + subSupplierId: + description: "供应商" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_subProvId" + playwright: "page.locator('#form_item_subProvId')" + + el_e998b3e98787e7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_hitCode" + playwright: "page.locator('#form_item_hitCode')" + + el_e998b3e98787e4bbb7e6a0bcInput: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e998b3e987874944Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_hitId" + playwright: "page.locator('#form_item_hitId')" + + el_485250e7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_hrp" + playwright: "page.locator('#form_item_hrp')" + + el_484953e7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_herpCode" + playwright: "page.locator('#form_item_herpCode')" + + el_e7babfe4b88ae98787e8b4ade7b1bbe588abSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增产品" + strategy: label_text + value: "线上采购类别" + playwright: "page.getByText('线上采购类别', { exact: true })" + + el_e7babfe4b88ae98787e8b4ade7b1bbe588abInput: + description: "线上采购类别" + element_type: search + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_onlineKind" + playwright: "page.locator('#form_item_onlineKind')" + + el_e4be9be5ba94e59586455250e7bc96e7a081Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: id + value: "form_item_erpCode" + playwright: "page.locator('#form_item_erpCode')" + + el_e4be9be5ba94e59586e4baa7e59381e8bdace68da2e6af94Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e4be9be5ba94e59586e98787e8b4ade4bbb7Input: + description: "请输入" + element_type: text + section: "main::detail:返回新增产品" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + backBtn: + description: "返 回" + element_type: button + section: "main::detail:返回新增产品" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + submitBtn: + description: "提 交" + element_type: button + section: "main::detail:返回新增产品" + strategy: role + role: button + name: "提 交" + playwright: "page.getByRole('button', { name: '提 交' })" + + # --- main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法) --- + productName_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsName" + playwright: "page.locator('#form_item_goodsName')" + + registrantSelect_2: + description: "湖南海琅生物科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "器械注册人" + playwright: "page.getByText('器械注册人', { exact: true })" + + manufacturer_2: + description: "器械注册人" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsMfrsId" + playwright: "page.locator('#form_item_goodsMfrsId')" + + specModel_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsSpec" + playwright: "page.locator('#form_item_goodsSpec')" + + el_e6b3a8e5868ce8a784e6a0bcInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_regCertSpec" + playwright: "page.locator('#form_item_regCertSpec')" + + el_e6b3a8e5868ce59e8be58fb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_regCertModel" + playwright: "page.locator('#form_item_regCertModel')" + + el_e58d95e4bd8dInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_unit" + playwright: "page.locator('#form_item_unit')" + + el_e5a283e58685e5a283e5a496Select_2: + description: "境内" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "境内境外" + playwright: "page.getByText('境内境外', { exact: true })" + + el_e5a283e58685e5a283e5a496Input_2: + description: "境内境外" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_abroadFlag" + playwright: "page.locator('#form_item_abroadFlag')" + + el_3638e58886e7b1bbSelect_2: + description: "医用高分子材料及制品" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "68分类" + playwright: "page.getByText('68分类', { exact: true })" + + el_3638e58886e7b1bbInput_2: + description: "68分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_kindSixtyeightCode" + playwright: "page.locator('#form_item_kindSixtyeightCode')" + + el_3138e58886e7b1bbSelect_2: + description: "临床检验器械" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "18分类" + playwright: "page.getByText('18分类', { exact: true })" + + el_3138e58886e7b1bbInput_2: + description: "18分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_kind18Code" + playwright: "page.locator('#form_item_kind18Code')" + + el_e696b0e7aea1e79086e7b1bbe588abSelect_2: + description: "Ⅱ类" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "新管理类别" + playwright: "page.getByText('新管理类别', { exact: true })" + + el_e696b0e7aea1e79086e7b1bbe588abInput_2: + description: "新管理类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_riskLevel" + playwright: "page.locator('#form_item_riskLevel')" + + el_e697a7e7aea1e79086e7b1bbe588abSelect_2: + description: "Ⅱ类" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "旧管理类别" + playwright: "page.getByText('旧管理类别', { exact: true })" + + el_e697a7e7aea1e79086e7b1bbe588abInput_2: + description: "旧管理类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_consumableLevel" + playwright: "page.locator('#form_item_consumableLevel')" + + registrationNo_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsRegCert" + playwright: "page.locator('#form_item_goodsRegCert')" + + el_e7bb93e69e84e7bb84e68890Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsDesc" + playwright: "page.locator('#form_item_goodsDesc')" + + el_e582a8e5ad98e69da1e4bbb6Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_storageConditions" + playwright: "page.locator('#form_item_storageConditions')" + + el_e8bf90e8be93e69da1e4bbb6Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_transportConditions" + playwright: "page.locator('#form_item_transportConditions')" + + el_e58f97e68998e7949fe4baa7e59586Select_2: + description: "湖南海琅生物科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "受托生产商" + playwright: "page.getByText('受托生产商', { exact: true })" + + el_e58f97e68998e7949fe4baa7e59586Input_2: + description: "受托生产商" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_trustMrfsName" + playwright: "page.locator('#form_item_trustMrfsName')" + + el_e59381e7898cInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_brand" + playwright: "page.locator('#form_item_brand')" + + el_4449e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsDI" + playwright: "page.locator('#form_item_goodsDI')" + + el_e698afe590a6e6a48de585a5Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否植入" + playwright: "page.getByText('是否植入', { exact: true })" + + el_e698afe590a6e6a48de585a5Input_2: + description: "是否植入" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_implantsFlag" + playwright: "page.locator('#form_item_implantsFlag')" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Select_2: + description: "诊断药品" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否医疗器械" + playwright: "page.getByText('是否医疗器械', { exact: true })" + + el_e698afe590a6e58cbbe79697e599a8e6a2b0Input_2: + description: "是否医疗器械" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_instrFlag" + playwright: "page.locator('#form_item_instrFlag')" + + el_e698afe590a6e58db1e58c96e59381Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否危化品" + playwright: "page.getByText('是否危化品', { exact: true })" + + el_e698afe590a6e58db1e58c96e59381Input_2: + description: "是否危化品" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_dangerFlag" + playwright: "page.locator('#form_item_dangerFlag')" + + el_e9878de9878fInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e58786e585a5e59088e5908ce58fb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_contractNo" + playwright: "page.locator('#form_item_contractNo')" + + el_e58cbbe4bf9de7bc96e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miCode" + playwright: "page.locator('#form_item_miCode')" + + el_e58cbbe4bf9de7bc96e7a081efbc883230e4bd8defbc89Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miTwentyCode" + playwright: "page.locator('#form_item_miTwentyCode')" + + el_e58cbbe4bf9de7bc96e7a081efbc883237e4bd8defbc89Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miTwentysevenCode" + playwright: "page.locator('#form_item_miTwentysevenCode')" + + el_e58cbbe4bf9de5908de7a7b0Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miDjmc" + playwright: "page.locator('#form_item_miDjmc')" + + el_e58cbbe4bf9de8a784e6a0bcInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miGoodsGg" + playwright: "page.locator('#form_item_miGoodsGg')" + + el_e58cbbe4bf9de59e8be58fb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miGoodsXh" + playwright: "page.locator('#form_item_miGoodsXh')" + + el_e58cbbe4bf9de7babfe4b88ae694afe4bb98Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "医保线上支付" + playwright: "page.getByText('医保线上支付', { exact: true })" + + el_e58cbbe4bf9de7babfe4b88ae694afe4bb98Input_2: + description: "医保线上支付" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_miOnlinePay" + playwright: "page.locator('#form_item_miOnlinePay')" + + el_e4baa7e59381e4bf97e7a7b0Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsGeneralName" + playwright: "page.locator('#form_item_goodsGeneralName')" + + remark_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + el_e98787e8b4ade590abe9878fInput_2: + description: "采购的最小包装数量,默认1" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "采购的最小包装数量,默认1" + playwright: "page.getByPlaceholder('采购的最小包装数量,默认1')" + + el_e98787e8b4ade58d95e4bd8dInput_2: + description: "采购的包装单位名称" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_purchaseUnit" + playwright: "page.locator('#form_item_purchaseUnit')" + + el_e8afb7e9a286e590abe9878fInput_2: + description: "请领的最小包装含量,默认1" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请领的最小包装含量,默认1" + playwright: "page.getByPlaceholder('请领的最小包装含量,默认1')" + + el_e8afb7e9a286e58d95e4bd8dInput_2: + description: "请领的最小包装单位名称" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_buyUnit" + playwright: "page.locator('#form_item_buyUnit')" + + el_e4babae4bbbde8bdace68da2e6af94Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + chargeableSelect_2: + description: "是" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否收费" + playwright: "page.getByText('是否收费', { exact: true })" + + chargeable_2: + description: "是否收费" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_chargeFlag" + playwright: "page.locator('#form_item_chargeFlag')" + + el_e698afe590a6e4b8b4e98787Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否临采" + playwright: "page.getByText('是否临采', { exact: true })" + + el_e698afe590a6e4b8b4e98787Input_2: + description: "是否临采" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_tempFlag" + playwright: "page.locator('#form_item_tempFlag')" + + el_e698afe590a6e58fafe98787e8b4adSelect_2: + description: "是" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否可采购" + playwright: "page.getByText('是否可采购', { exact: true })" + + el_e698afe590a6e58fafe98787e8b4adInput_2: + description: "是否可采购" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_purchaseFlag" + playwright: "page.locator('#form_item_purchaseFlag')" + + statusSelect_2: + description: "启用" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + statusInput_2: + description: "状态" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsStatus" + playwright: "page.locator('#form_item_goodsStatus')" + + consumableCategorySelect_2: + description: "低值易耗品 / 低值易耗品-其他材料 / 基础卫生材料-其他材料-其他材料" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "耗材分类" + playwright: "page.getByText('耗材分类', { exact: true })" + + consumableType_2: + description: "耗材分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsKind" + playwright: "page.locator('#form_item_goodsKind')" + + el_e88097e69d90e7b1bbe588abSelect_2: + description: "低值" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "耗材类别" + playwright: "page.getByText('耗材类别', { exact: true })" + + el_e88097e69d90e7b1bbe588abInput_2: + description: "耗材类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_purMode" + playwright: "page.locator('#form_item_purMode')" + + el_e698afe590a6e99c80e8a681e6b497e6b688Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否需要洗消" + playwright: "page.getByText('是否需要洗消', { exact: true })" + + el_e698afe590a6e99c80e8a681e6b497e6b688Input_2: + description: "是否需要洗消" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_sterilizeFlag" + playwright: "page.locator('#form_item_sterilizeFlag')" + + el_e8b4a2e58aa1e58886e7b1bbSelect_2: + description: "卫生材料 / 卫材-低值易耗" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "财务分类" + playwright: "page.getByText('财务分类', { exact: true })" + + el_e8b4a2e58aa1e58886e7b1bbInput_2: + description: "财务分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_fcatKind" + playwright: "page.locator('#form_item_fcatKind')" + + el_e5b8a6e9878fe58886e7b1bbSelect_2: + description: "非带量 / 自行采购" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "带量分类" + playwright: "page.getByText('带量分类', { exact: true })" + + el_e5b8a6e9878fe58886e7b1bbInput_2: + description: "带量分类" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_vbpKind" + playwright: "page.locator('#form_item_vbpKind')" + + materialCode_2: + description: "填了则按用户填写的,未填写则系统自动编码" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_goodsCode" + playwright: "page.locator('#form_item_goodsCode')" + + el_e698afe590a652464944e7aea1e79086Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否RFID管理" + playwright: "page.getByText('是否RFID管理', { exact: true })" + + el_e698afe590a652464944e7aea1e79086Input_2: + description: "是否RFID管理" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_rfidFlag" + playwright: "page.locator('#form_item_rfidFlag')" + + el_e698afe590a6e68d90e8b5a0Select_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否捐赠" + playwright: "page.getByText('是否捐赠', { exact: true })" + + el_e698afe590a6e68d90e8b5a0Input_2: + description: "是否捐赠" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_ecoBat" + playwright: "page.locator('#form_item_ecoBat')" + + el_e698afe590a6e69c89554449Select_2: + description: "医疗器械GS1-UDI" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否有UDI" + playwright: "page.getByText('是否有UDI', { exact: true })" + + el_e698afe590a6e69c89554449Input_2: + description: "是否有UDI" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_UDIType" + playwright: "page.locator('#form_item_UDIType')" + + el_e7a88ee78e87Select_2: + description: "13%" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "税率" + playwright: "page.getByText('税率', { exact: true })" + + el_e7a88ee78e87Input_2: + description: "税率" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_taxRate" + playwright: "page.locator('#form_item_taxRate')" + + el_e698afe590a6e99990e9878fe98787e8b4adSelect_2: + description: "否" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "是否限量采购" + playwright: "page.getByText('是否限量采购', { exact: true })" + + el_e698afe590a6e99990e9878fe98787e8b4adInput_2: + description: "是否限量采购" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_limitPurchase" + playwright: "page.locator('#form_item_limitPurchase')" + + el_e68b9be6a087e69687e58fb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_bidCode" + playwright: "page.locator('#form_item_bidCode')" + + el_e99480e594aee59586Select_2: + description: "浙江高仰医疗科技有限公司" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId_2: + description: "销售商" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_provId" + playwright: "page.locator('#form_item_provId')" + + el_e98787e8b4ade58d95e4bbb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e4b89ae58aa1e6a8a1e5bc8fSelect_2: + description: "直销" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "业务模式" + playwright: "page.getByText('业务模式', { exact: true })" + + el_e4b89ae58aa1e6a8a1e5bc8fInput_2: + description: "业务模式" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_bizMode" + playwright: "page.locator('#form_item_bizMode')" + + el_e998b3e98787e7bc96e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_hitCode" + playwright: "page.locator('#form_item_hitCode')" + + el_e998b3e98787e4bbb7e6a0bcInput_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e998b3e987874944Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_hitId" + playwright: "page.locator('#form_item_hitId')" + + el_485250e7bc96e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_hrp" + playwright: "page.locator('#form_item_hrp')" + + el_484953e7bc96e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_herpCode" + playwright: "page.locator('#form_item_herpCode')" + + el_e7babfe4b88ae98787e8b4ade7b1bbe588abSelect_2: + description: "备案采购" + element_type: select + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: label_text + value: "线上采购类别" + playwright: "page.getByText('线上采购类别', { exact: true })" + + el_e7babfe4b88ae98787e8b4ade7b1bbe588abInput_2: + description: "线上采购类别" + element_type: search + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_onlineKind" + playwright: "page.locator('#form_item_onlineKind')" + + el_e4be9be5ba94e59586455250e7bc96e7a081Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: id + value: "form_item_erpCode" + playwright: "page.locator('#form_item_erpCode')" + + el_e4be9be5ba94e59586e4baa7e59381e8bdace68da2e6af94Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + el_e4be9be5ba94e59586e98787e8b4ade4bbb7Input_2: + description: "请输入" + element_type: text + section: "main::detail:返回丙氨酸氨基转移酶测定试剂盒(IFCC法)" + strategy: placeholder + value: "请输入" + playwright: "page.getByPlaceholder('请输入')" + + # --- main::modal:操作日志 --- \ No newline at end of file diff --git a/locators/spd/admit-product-list.locator.yaml b/locators/spd/admit-product-list.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fbd0a2ade6f142e250412156a8bb933edb8e4b17 --- /dev/null +++ b/locators/spd/admit-product-list.locator.yaml @@ -0,0 +1,451 @@ +# Auto-generated from admit-product-list-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/list +# Scanned: 2026-10-20T08:46:15.237Z +# Page: 基础设置 > 准入管理 > 产品准入单列表 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan + +page: admit-product-list +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/list + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitProductList: + description: "产品准入单列表" + element_type: tab + section: "main::main" + strategy: text + value: "产品准入单列表" + playwright: "page.getByText('产品准入单列表')" + + tab_admitProductList: + description: "产品准入单列表" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/productOrder/list" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/productOrder/list')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + consumableTypeSelect: + description: "请选择耗材类型" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + consumableTypeSelect_2: + description: "请选择耗材类型低值耗材高值耗材检验试剂" + element_type: select + section: "main::main" + strategy: label_text + value: "耗材类型" + playwright: "page.getByText('耗材类型', { exact: true })" + + purchaseMode: + description: "耗材类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_purModes" + playwright: "page.locator('#table_search_form_purModes')" + + # --- main::pagination --- + pagination_action: + description: "共 24 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_actionBtn: + description: "操作" + element_type: button + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + # --- main::detail:产品准入单详情 --- + el_e5aea1e689b9e8bf9be7a88bBtn: + description: "审批进程" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "审批进程" + playwright: "page.getByRole('button', { name: '审批进程' })" + + el_e58d95e68daee697a5e5bf97Btn: + description: "单据日志" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "单据日志" + playwright: "page.getByRole('button', { name: '单据日志' })" + + backBtn: + description: "返 回" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + el_e8afb7e8be93e585a5e789a9e69699e7bc96e7a081e38081e5908de7a7b0Input: + description: "请输入物料编码、名称" + element_type: text + section: "main::detail:产品准入单详情" + strategy: placeholder + value: "请输入物料编码、名称" + playwright: "page.getByPlaceholder('请输入物料编码、名称')" + + tableDensity: + description: "column-height" + element_type: span + section: "main::detail:产品准入单详情" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + el_e9809ae8bf87Btn: + description: "通过" + element_type: button + section: "main::detail:产品准入单详情" + strategy: role + role: switch + name: "通过" + playwright: "page.getByRole('switch', { name: '通过' })" + + el_e585b131e69da1313530e69da12fe9a1b5: + description: "共 1 条150 条/页" + element_type: ul + section: "main::detail:产品准入单详情" + strategy: text + value: "共 1 条150 条/页" + playwright: "page.getByText('共 1 条150 条/页')" + + prevPageBtn: + description: "上一页" + element_type: li + section: "main::detail:产品准入单详情" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + page_1: + description: "1" + element_type: pagination_item + section: "main::detail:产品准入单详情" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + nextPageBtn: + description: "下一页" + element_type: li + section: "main::detail:产品准入单详情" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::detail:产品准入单详情" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/admit-shared.locator.ts b/locators/spd/admit-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eefaf2d27ee383d331755da22e7f9daa2ce4358 --- /dev/null +++ b/locators/spd/admit-shared.locator.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +/** 基础设置 · 准入管理。叶子菜单是 .ant-menu-item,不是 role=menuitem。 */ +export class AdmitSharedLocators { + readonly menuBasic: Locator; + readonly admitSubmenuTitle: Locator; + readonly tableHeader: Locator; + readonly empty: Locator; + readonly paginationTotal: Locator; + readonly pageJumpInput: Locator; + readonly nextPage: Locator; + readonly prevPage: Locator; + readonly formError: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.admitSubmenuTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^准入管理$/ }); + this.tableHeader = page.locator('.ant-table-thead th'); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.pageJumpInput = page.locator('.ant-pagination-options-quick-jumper input').first(); + this.nextPage = page.locator('.ant-pagination-next').first(); + this.prevPage = page.locator('.ant-pagination-prev').first(); + this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice'); + } + + menuLeaf(name: string): Locator { + return this.page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first(); + } + + button(name: string, root: Locator | Page = this.page): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return root.getByRole('button', { name: pattern }); + } + + tab(name: string): Locator { + return this.page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first(); + } + + putPage(): Locator { + return this.page.locator('.put-page').last(); + } +} diff --git a/locators/spd/admit-supply.locator.yaml b/locators/spd/admit-supply.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca8a54c7d34d54be414f704131948308812ce020 --- /dev/null +++ b/locators/spd/admit-supply.locator.yaml @@ -0,0 +1,596 @@ +# Auto-generated from admit-supply-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/supplyApply/list +# Scanned: 2026-10-20T08:46:30.524Z +# Page: 基础设置 > 准入管理 > 供货目录申请 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan + +page: admit-supply +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/supplyApply/list + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitSupply: + description: "供货目录申请" + element_type: tab + section: "main::main" + strategy: text + value: "供货目录申请" + playwright: "page.getByText('供货目录申请')" + + tab_admitSupply: + description: "供货目录申请" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/supplyApply/list" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/supplyApply/list')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + el_e99480e594aee59586Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId: + description: "销售商" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_provId" + playwright: "page.locator('#table_search_form_provId')" + + dimensionSupplierSelect: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "供应商" + playwright: "page.getByText('供应商', { exact: true })" + + dimensionSupplierInput: + description: "供应商" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_subProvId" + playwright: "page.locator('#table_search_form_subProvId')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + el_e689b9e9878fe68f90e4baa4283029Btn: + description: "批量提交(0)" + element_type: button + section: "main::main" + strategy: role + role: button + name: "批量提交(0)" + playwright: "page.getByRole('button', { name: '批量提交(0)' })" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + # --- main::pagination --- + pagination_el_e585b13138e69da1313530e69da12fe9a1b5: + description: "共 18 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 18 条150 条/页" + playwright: "page.getByText('共 18 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::detail:返回新增供货目录 --- + el_e99480e594aee59586Select_2: + description: "请选择" + element_type: select + section: "main::detail:返回新增供货目录" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId_2: + description: "销售商" + element_type: search + section: "main::detail:返回新增供货目录" + strategy: id + value: "form_item_provId" + playwright: "page.locator('#form_item_provId')" + + dimensionSupplierSelect_2: + description: "请选择" + element_type: select + section: "main::detail:返回新增供货目录" + strategy: label_text + value: "供应商" + playwright: "page.getByText('供应商', { exact: true })" + + subSupplierId: + description: "供应商" + element_type: search + section: "main::detail:返回新增供货目录" + strategy: id + value: "form_item_subProvId" + playwright: "page.locator('#form_item_subProvId')" + + el_e4b89ae58aa1e6a8a1e5bc8fSelect: + description: "请选择" + element_type: select + section: "main::detail:返回新增供货目录" + strategy: label_text + value: "业务模式" + playwright: "page.getByText('业务模式', { exact: true })" + + el_e698afe590a6e9bb98e8aea4Select: + description: "请选择" + element_type: select + section: "main::detail:返回新增供货目录" + strategy: label_text + value: "是否默认" + playwright: "page.getByText('是否默认', { exact: true })" + + el_e8aea1e58892e7949fe69588e697b6e997b4DateRange: + description: "计划生效时间" + element_type: datepicker + section: "main::detail:返回新增供货目录" + strategy: label_text + value: "计划生效时间" + playwright: "page.getByText('计划生效时间', { exact: true })" + + el_e8aea1e58892e7949fe69588e697b6e997b4Input: + description: "2026-10-20 16:46:32" + element_type: text + section: "main::detail:返回新增供货目录" + strategy: id + value: "form_item_planEffectDate" + playwright: "page.locator('#form_item_planEffectDate')" + + el_e585b3e88194e68e88e69d83e58f98e69bb4e794b3e8afb7Input: + description: "请选择授权变更申请" + element_type: text + section: "main::detail:返回新增供货目录" + strategy: id + value: "form_item_relOrderNo" + playwright: "page.locator('#form_item_relOrderNo')" + + remark: + description: "备注" + element_type: text + section: "main::detail:返回新增供货目录" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + el_30e4b8aa: + description: "上传附件" + element_type: span + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "上传附件" + playwright: "page.getByRole('button', { name: '上传附件' })" + + el_e4b88ae4bca0e99984e4bbb6Btn: + description: "上传附件" + element_type: button + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "上传附件" + playwright: "page.getByRole('button', { name: '上传附件' })" + + addProductBtn: + description: "添加产品" + element_type: button + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "添加产品" + playwright: "page.getByRole('button', { name: '添加产品' })" + + el_e5afbce585a5657863656cBtn: + description: "导入excel" + element_type: button + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "导入excel" + playwright: "page.getByRole('button', { name: '导入excel' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::detail:返回新增供货目录" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + submitBtn: + description: "提 交" + element_type: button + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "提 交" + playwright: "page.getByRole('button', { name: '提 交' })" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::detail:返回新增供货目录" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + # --- main::detail:供货目录详情 --- + el_e5aea1e689b9e8bf9be7a88bBtn: + description: "审批进程" + element_type: button + section: "main::detail:供货目录详情" + strategy: role + role: button + name: "审批进程" + playwright: "page.getByRole('button', { name: '审批进程' })" + + el_e58d95e68daee697a5e5bf97Btn: + description: "单据日志" + element_type: button + section: "main::detail:供货目录详情" + strategy: role + role: button + name: "单据日志" + playwright: "page.getByRole('button', { name: '单据日志' })" + + backBtn: + description: "返 回" + element_type: button + section: "main::detail:供货目录详情" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + tableDensity_2: + description: "column-height" + element_type: span + section: "main::detail:供货目录详情" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + 0Input: + description: "40" + element_type: text + section: "main::detail:供货目录详情" + strategy: role + role: spinbutton + name: "40" + playwright: "page.getByRole('spinbutton', { name: '40' })" + + el_e585b131e69da1313530e69da12fe9a1b5: + description: "共 1 条150 条/页" + element_type: ul + section: "main::detail:供货目录详情" + strategy: text + value: "共 1 条150 条/页" + playwright: "page.getByText('共 1 条150 条/页')" + + prevPageBtn: + description: "上一页" + element_type: li + section: "main::detail:供货目录详情" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + page_1: + description: "1" + element_type: pagination_item + section: "main::detail:供货目录详情" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + nextPageBtn: + description: "下一页" + element_type: li + section: "main::detail:供货目录详情" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::detail:供货目录详情" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/admit-task.locator.yaml b/locators/spd/admit-task.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b2ffbb35d035fdbffb85e7831fb38c4637dcbe02 --- /dev/null +++ b/locators/spd/admit-task.locator.yaml @@ -0,0 +1,348 @@ +# Auto-generated from admit-task-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/accessKind/list +# Scanned: 2026-10-20T08:46:44.025Z +# Page: 基础设置 > 准入管理 > 准入任务分配 + +page: admit-task +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/accessKind/list + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_admitMaterial: + description: "物料准入管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入管理" + playwright: "page.getByRole('menuitem', { name: '物料准入管理' })" + + menu_admitProductApply: + description: "产品准入申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入申请" + playwright: "page.getByRole('menuitem', { name: '产品准入申请' })" + + menu_admitMaterialList: + description: "物料准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "物料准入单列表" + playwright: "page.getByRole('menuitem', { name: '物料准入单列表' })" + + menu_admitProductList: + description: "产品准入单列表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "产品准入单列表" + playwright: "page.getByRole('menuitem', { name: '产品准入单列表' })" + + menu_admitSupply: + description: "供货目录申请" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供货目录申请" + playwright: "page.getByRole('menuitem', { name: '供货目录申请' })" + + menu_admitTask: + description: "准入任务分配" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "准入任务分配" + playwright: "page.getByRole('menuitem', { name: '准入任务分配' })" + + admitTask: + description: "准入任务分配" + element_type: tab + section: "main::main" + strategy: text + value: "准入任务分配" + playwright: "page.getByText('准入任务分配')" + + tab_admitTask: + description: "准入任务分配" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/dataSync/dataSync/accessKind/list" + playwright: "page.locator('#rc-tabs-0-tab-/basic/dataSync/dataSync/accessKind/list')" + + createDateDateRange: + description: "新增日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "新增日期" + playwright: "page.getByText('新增日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + createDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + el_e6938de4bd9ce697a5e69c9fDateRange: + description: "操作日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "操作日期" + playwright: "page.getByText('操作日期', { exact: true })" + + el_e6938de4bd9ce697a5e69c9fInput: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_modifyValue" + playwright: "page.locator('#table_search_form_modifyValue')" + + el_e6938de4bd9ce697a5e69c9fInput_2: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + el_e689b9e9878fe58886e9858dBtn: + description: "批量分配" + element_type: button + section: "main::main" + strategy: role + role: button + name: "批量分配" + playwright: "page.getByRole('button', { name: '批量分配' })" + + el_e58886e9858de7b1bbe59e8bBtn: + description: "分配类型" + element_type: button + section: "main::main" + strategy: role + role: button + name: "分配类型" + playwright: "page.getByRole('button', { name: '分配类型' })" + + # --- main::pagination --- + pagination_el_e585b132e69da1313530e69da12fe9a1b5: + description: "共 2 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 2 条150 条/页" + playwright: "page.getByText('共 2 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/basic-branch.locator.ts b/locators/spd/basic-branch.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb5b3bf4206e457cdb16b85031569646c181d7ec --- /dev/null +++ b/locators/spd/basic-branch.locator.ts @@ -0,0 +1,25 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicSharedLocators } from './basic-shared.locator'; + +/** 院区管理。选择器来自 locators/spd/basic-branch.locator.yaml */ +export class BasicBranchLocators extends BasicSharedLocators { + readonly addBtn: Locator; + readonly campusNameInput: Locator; + readonly addName: Locator; + readonly addAddress: Locator; + readonly editId: Locator; + readonly editName: Locator; + readonly editAddress: Locator; + + constructor(page: Page) { + super(page); + this.addBtn = page.getByRole('button', { name: /新\s*增/ }); + this.campusNameInput = page.getByPlaceholder('院区名称'); + this.addName = page.locator('#basic_name'); + this.addAddress = page.locator('#basic_address'); + this.editId = page.locator('#form_item_id'); + this.editName = page.locator('#form_item_name'); + this.editAddress = page.locator('#form_item_address'); + } +} diff --git a/locators/spd/basic-branch.locator.yaml b/locators/spd/basic-branch.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7d0e3a86d70ba0827cbe5db9b145c56221037876 --- /dev/null +++ b/locators/spd/basic-branch.locator.yaml @@ -0,0 +1,405 @@ +# Auto-generated from basic-branch-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/area +# Scanned: 2026-10-20T08:39:31.067Z +# Page: 基础设置 > 医院资料 > 院区管理 +# Note: dialog/modal was opened during scan +# Note: edit dialog was opened during scan + +page: basic-branch +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/area + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_hospitalInfoRegister: + description: "医院信息登记" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医院信息登记" + playwright: "page.getByRole('menuitem', { name: '医院信息登记' })" + + menu_branchManage: + description: "院区管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "院区管理" + playwright: "page.getByRole('menuitem', { name: '院区管理' })" + + menu_noticeManage: + description: "公告管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "公告管理" + playwright: "page.getByRole('menuitem', { name: '公告管理' })" + + menu_financeMonthSetting: + description: "财务月份设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务月份设置" + playwright: "page.getByRole('menuitem', { name: '财务月份设置' })" + + branchManage: + description: "院区管理" + element_type: tab + section: "main::main" + strategy: text + value: "院区管理" + playwright: "page.getByText('院区管理')" + + tab_branchManage: + description: "院区管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/hospital/paltform/area" + playwright: "page.locator('#rc-tabs-0-tab-/basic/hospital/paltform/area')" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + addInput: + description: "院区名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "院区名称" + playwright: "page.getByPlaceholder('院区名称')" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + # --- main::pagination --- + pagination_el_e585b13138e69da1313530e69da12fe9a1b5: + description: "共 18 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 18 条150 条/页" + playwright: "page.getByText('共 18 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::modal:新增院区 --- + campusNameInput: + description: "请输入院区名称" + element_type: text + section: "main::modal:新增院区" + strategy: id + value: "basic_name" + playwright: "page.locator('#basic_name')" + + el_e68980e59ca8e59cb0e58cbaSelect: + description: "请选择" + element_type: select + section: "main::modal:新增院区" + strategy: label_text + value: "所在地区" + playwright: "page.getByText('所在地区', { exact: true })" + + el_e8afa6e7bb86e59cb0e59d80Input: + description: "请输入" + element_type: text + section: "main::modal:新增院区" + strategy: id + value: "basic_address" + playwright: "page.locator('#basic_address')" + + el_e590a6: + description: "否" + element_type: radio + section: "main::modal:新增院区" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + el_e698af: + description: "是" + element_type: radio + section: "main::modal:新增院区" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + disable: + description: "停用" + element_type: radio + section: "main::modal:新增院区" + strategy: text + value: "停用" + playwright: "page.getByText('停用')" + + enable: + description: "启用" + element_type: radio + section: "main::modal:新增院区" + strategy: text + value: "启用" + playwright: "page.getByText('启用')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增院区" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:新增院区" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" + + # --- main::modal:编辑院区 --- + el_e999a2e58cbae7bc96e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:编辑院区" + strategy: id + value: "form_item_id" + playwright: "page.locator('#form_item_id')" + + campusNameInput_2: + description: "请输入" + element_type: text + section: "main::modal:编辑院区" + strategy: id + value: "form_item_name" + playwright: "page.locator('#form_item_name')" + + el_e68980e59ca8e59cb0e58cbaSelect_2: + description: "湖北省 / 武汉市 / 洪山区" + element_type: select + section: "main::modal:编辑院区" + strategy: label_text + value: "所在地区" + playwright: "page.getByText('所在地区', { exact: true })" + + el_e8afa6e7bb86e59cb0e59d80Input_2: + description: "请输入" + element_type: text + section: "main::modal:编辑院区" + strategy: id + value: "form_item_address" + playwright: "page.locator('#form_item_address')" + + el_e590a6_2: + description: "否" + element_type: radio + section: "main::modal:编辑院区" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + el_e698af_2: + description: "是" + element_type: radio + section: "main::modal:编辑院区" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + el_e7a681e794a8: + description: "禁用" + element_type: radio + section: "main::modal:编辑院区" + strategy: text + value: "禁用" + playwright: "page.getByText('禁用')" + + enable_2: + description: "启用" + element_type: radio + section: "main::modal:编辑院区" + strategy: text + value: "启用" + playwright: "page.getByText('启用')" diff --git a/locators/spd/basic-finance-month.locator.ts b/locators/spd/basic-finance-month.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..07405094b0dab0720e05057216e99b45a941c7e2 --- /dev/null +++ b/locators/spd/basic-finance-month.locator.ts @@ -0,0 +1,25 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicSharedLocators } from './basic-shared.locator'; + +/** 财务月份设置。选择器来自 locators/spd/basic-finance-month.locator.yaml */ +export class BasicFinanceMonthLocators extends BasicSharedLocators { + readonly settleMonth: Locator; + readonly searchBtn: Locator; + readonly resetBtn: Locator; + readonly modifyBtn: Locator; + readonly autoSettleDateInput: Locator; + readonly saveBtn: Locator; + readonly cancelBtn: Locator; + + constructor(page: Page) { + super(page); + this.settleMonth = page.locator('#form_item_settleMonthDate'); + this.searchBtn = page.getByRole('button', { name: /查\s*询/ }); + this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); + this.modifyBtn = page.getByRole('button', { name: /修\s*改/ }); + this.autoSettleDateInput = page.getByPlaceholder('请选择日期'); + this.saveBtn = page.getByRole('button', { name: /保\s*存/ }); + this.cancelBtn = page.getByRole('button', { name: /取\s*消/ }); + } +} diff --git a/locators/spd/basic-finance-month.locator.yaml b/locators/spd/basic-finance-month.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f2761d165c77f41f7639721ae22414ef8ead0f63 --- /dev/null +++ b/locators/spd/basic-finance-month.locator.yaml @@ -0,0 +1,306 @@ +# Auto-generated from basic-finance-month-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/balance/month +# Scanned: 2026-10-20T08:38:57.574Z +# Page: 基础设置 > 医院资料 > 财务月份设置 + +page: basic-finance-month +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/balance/month + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_hospitalInfoRegister: + description: "医院信息登记" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医院信息登记" + playwright: "page.getByRole('menuitem', { name: '医院信息登记' })" + + menu_branchManage: + description: "院区管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "院区管理" + playwright: "page.getByRole('menuitem', { name: '院区管理' })" + + menu_noticeManage: + description: "公告管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "公告管理" + playwright: "page.getByRole('menuitem', { name: '公告管理' })" + + menu_financeMonthSetting: + description: "财务月份设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务月份设置" + playwright: "page.getByRole('menuitem', { name: '财务月份设置' })" + + financeMonthSetting: + description: "财务月份设置" + element_type: tab + section: "main::main" + strategy: text + value: "财务月份设置" + playwright: "page.getByText('财务月份设置')" + + tab_financeMonthSetting: + description: "财务月份设置" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/hospital/balance/month" + playwright: "page.locator('#rc-tabs-0-tab-/basic/hospital/balance/month')" + + el_e8b4a2e58aa1e69c88e4bbbdDateRange: + description: "财务月份" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "财务月份" + playwright: "page.getByText('财务月份', { exact: true })" + + el_e8b4a2e58aa1e69c88e4bbbdInput: + description: "请选择财务月份" + element_type: text + section: "main::main" + strategy: id + value: "form_item_settleMonthDate" + playwright: "page.locator('#form_item_settleMonthDate')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + el_e4bfaee694b9Btn: + description: "修改" + element_type: button + section: "main::main" + strategy: role + role: button + name: "修改" + playwright: "page.getByRole('button', { name: '修改' })" + + el_e8afb7e98089e68ba9e697a5e69c9fInput: + description: "2026-10-20" + element_type: text + section: "main::main" + strategy: placeholder + value: "请选择日期" + playwright: "page.getByPlaceholder('请选择日期')" + + saveBtn: + description: "保存" + element_type: button + section: "main::main" + strategy: role + role: button + name: "保存" + playwright: "page.getByRole('button', { name: '保存' })" + + cancelBtn: + description: "取消" + element_type: button + section: "main::main" + strategy: role + role: button + name: "取消" + playwright: "page.getByRole('button', { name: '取消' })" + + # --- main::pagination --- + pagination_el_e585b13236e69da1313530e69da12fe9a1b5: + description: "共 26 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 26 条150 条/页" + playwright: "page.getByText('共 26 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/basic-hospital-info.locator.ts b/locators/spd/basic-hospital-info.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac7139065af01a94536919d7cf1568f364946690 --- /dev/null +++ b/locators/spd/basic-hospital-info.locator.ts @@ -0,0 +1,29 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicSharedLocators } from './basic-shared.locator'; + +/** 医院信息登记。选择器来自 locators/spd/basic-hospital-info.locator.yaml */ +export class BasicHospitalInfoLocators extends BasicSharedLocators { + readonly editBtn: Locator; + readonly saveBtn: Locator; + readonly cancelBtn: Locator; + readonly linkman: Locator; + readonly contactWay: Locator; + readonly shortName: Locator; + readonly loginBgUpload: Locator; + readonly logoUpload: Locator; + readonly previewLink: Locator; + + constructor(page: Page) { + super(page); + this.editBtn = page.getByRole('button', { name: /编\s*辑/ }); + this.saveBtn = page.getByRole('button', { name: /保\s*存/ }); + this.cancelBtn = page.getByRole('button', { name: /取\s*消/ }); + this.linkman = page.locator('#form_item_linkman'); + this.contactWay = page.locator('#form_item_contactWay'); + this.shortName = page.locator('#form_item_anotherName'); + this.loginBgUpload = page.locator('input[type="file"]').nth(0); + this.logoUpload = page.locator('input[type="file"]').nth(1); + this.previewLink = page.getByRole('link', { name: '预览文件' }); + } +} diff --git a/locators/spd/basic-hospital-info.locator.yaml b/locators/spd/basic-hospital-info.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..170b8f9d57b4422425138a51f6d418609810e197 --- /dev/null +++ b/locators/spd/basic-hospital-info.locator.yaml @@ -0,0 +1,271 @@ +# Auto-generated from basic-hospital-info-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/info +# Scanned: 2026-10-20T08:38:47.297Z +# Page: 基础设置 > 医院资料 > 医院信息登记 + +page: basic-hospital-info +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/info + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_hospitalInfoRegister: + description: "医院信息登记" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医院信息登记" + playwright: "page.getByRole('menuitem', { name: '医院信息登记' })" + + menu_branchManage: + description: "院区管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "院区管理" + playwright: "page.getByRole('menuitem', { name: '院区管理' })" + + menu_noticeManage: + description: "公告管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "公告管理" + playwright: "page.getByRole('menuitem', { name: '公告管理' })" + + menu_financeMonthSetting: + description: "财务月份设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务月份设置" + playwright: "page.getByRole('menuitem', { name: '财务月份设置' })" + + hospitalInfoRegister: + description: "医院信息登记" + element_type: tab + section: "main::main" + strategy: text + value: "医院信息登记" + playwright: "page.getByText('医院信息登记')" + + tab_hospitalInfoRegister: + description: "医院信息登记" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/hospital/paltform/info" + playwright: "page.locator('#rc-tabs-0-tab-/basic/hospital/paltform/info')" + + editBtn: + description: "编 辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编 辑" + playwright: "page.getByRole('button', { name: '编 辑' })" + + el_e799bbe5bd95e9a1b5e8838ce699af: + description: "登录页背景" + element_type: a + section: "main::main" + strategy: label_text + value: "登录页背景" + playwright: "page.getByText('登录页背景', { exact: true })" + + el_e799bbe5bd95e9a1b5e8838ce699af_2: + description: "预览文件" + element_type: a + section: "main::main" + strategy: label_text + value: "登录页背景" + playwright: "page.getByText('登录页背景', { exact: true })" + + el_e58cbbe999a26c6f676f: + description: "医院logo" + element_type: a + section: "main::main" + strategy: label_text + value: "医院logo" + playwright: "page.getByText('医院logo', { exact: true })" + + el_e58cbbe999a26c6f676f_2: + description: "预览文件" + element_type: a + section: "main::main" + strategy: label_text + value: "医院logo" + playwright: "page.getByText('医院logo', { exact: true })" + + saveBtn: + description: "保 存" + element_type: button + section: "main::main" + strategy: role + role: button + name: "保 存" + playwright: "page.getByRole('button', { name: '保 存' })" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::main" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + el_e88194e7b3bbe4babaInput: + description: "20260701测试1" + element_type: text + section: "main::main" + strategy: id + value: "form_item_linkman" + playwright: "page.locator('#form_item_linkman')" + + el_e88194e7b3bbe696b9e5bc8fInput: + description: "10000000005" + element_type: text + section: "main::main" + strategy: id + value: "form_item_contactWay" + playwright: "page.locator('#form_item_contactWay')" + + el_e58cbbe999a2e7ae80e7a7b0Input: + description: "邹城市人民医院" + element_type: text + section: "main::main" + strategy: id + value: "form_item_anotherName" + playwright: "page.locator('#form_item_anotherName')" diff --git a/locators/spd/basic-notice.locator.ts b/locators/spd/basic-notice.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..53e427ece5eff7c54524d82a42d7c8fbaf2ddde4 --- /dev/null +++ b/locators/spd/basic-notice.locator.ts @@ -0,0 +1,25 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicSharedLocators } from './basic-shared.locator'; + +/** 公告管理。选择器来自 locators/spd/basic-notice.locator.yaml */ +export class BasicNoticeLocators extends BasicSharedLocators { + readonly noticeTitle: Locator; + readonly publishScopeSelect: Locator; + readonly searchBtn: Locator; + readonly resetBtn: Locator; + readonly addBtn: Locator; + readonly richTextEditor: Locator; + readonly modalTitleInput: Locator; + + constructor(page: Page) { + super(page); + this.noticeTitle = page.locator('#form_item_title'); + this.publishScopeSelect = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first(); + this.searchBtn = page.getByRole('button', { name: /查\s*询/ }); + this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); + this.addBtn = page.getByRole('button', { name: /新\s*增/ }); + this.richTextEditor = page.locator('.ql-editor').first(); + this.modalTitleInput = this.dialog.locator('#form_item_title'); + } +} diff --git a/locators/spd/basic-notice.locator.yaml b/locators/spd/basic-notice.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d9686b86e9a5ab095f22a0fd040b1a9d664265f0 --- /dev/null +++ b/locators/spd/basic-notice.locator.yaml @@ -0,0 +1,549 @@ +# Auto-generated from basic-notice-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/notice +# Scanned: 2026-10-20T08:38:11.083Z +# Page: 基础设置 > 医院资料 > 公告管理 +# Note: filter dropdown was opened during scan +# Note: dialog/modal was opened during scan +# Note: edit dialog was opened during scan + +page: basic-notice +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/notice + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_hospitalInfoRegister: + description: "医院信息登记" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医院信息登记" + playwright: "page.getByRole('menuitem', { name: '医院信息登记' })" + + menu_branchManage: + description: "院区管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "院区管理" + playwright: "page.getByRole('menuitem', { name: '院区管理' })" + + menu_noticeManage: + description: "公告管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "公告管理" + playwright: "page.getByRole('menuitem', { name: '公告管理' })" + + menu_financeMonthSetting: + description: "财务月份设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务月份设置" + playwright: "page.getByRole('menuitem', { name: '财务月份设置' })" + + noticeManage: + description: "公告管理" + element_type: tab + section: "main::main" + strategy: text + value: "公告管理" + playwright: "page.getByText('公告管理')" + + tab_noticeManage: + description: "公告管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/hospital/paltform/notice" + playwright: "page.locator('#rc-tabs-0-tab-/basic/hospital/paltform/notice')" + + noticeTitle: + description: "公告标题" + element_type: text + section: "main::main" + strategy: id + value: "form_item_title" + playwright: "page.locator('#form_item_title')" + + publishDateDateRange: + description: "发布日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "发布日期" + playwright: "page.getByText('发布日期', { exact: true })" + + publishDateInput: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "form_item_date" + playwright: "page.locator('#form_item_date')" + + publishDateInput_2: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + publishScopeSelect: + description: "发布范围" + element_type: select + section: "main::main" + strategy: label_text + value: "发布范围" + playwright: "page.getByText('发布范围', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e588a0e999a4Btn: + description: "删除" + element_type: button + section: "main::main" + strategy: role + role: button + name: "删除" + playwright: "page.getByRole('button', { name: '删除' })" + + # --- main::pagination --- + pagination_el_e585b13635e69da131323530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 65 条1250 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 65 条1250 条/页跳至页" + playwright: "page.getByText('共 65 条1250 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::modal:新增公告 --- + noticeTitle_2: + description: "请输入" + element_type: text + section: "main::modal:新增公告" + strategy: id + value: "form_item_title" + playwright: "page.locator('#form_item_title')" + + el_e585a8e983a8: + description: "全部" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "全部" + playwright: "page.getByText('全部')" + + el_e585a8e999a2: + description: "全院" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "全院" + playwright: "page.getByText('全院')" + + el_e4b8ade5bf83e5ba93: + description: "中心库" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "中心库" + playwright: "page.getByText('中心库')" + + el_e99480e594aee59586: + description: "销售商" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "销售商" + playwright: "page.getByText('销售商')" + + el_e698af: + description: "是" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + el_e590a6: + description: "否" + element_type: radio + section: "main::modal:新增公告" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + expireDateDateRange: + description: "截止日期" + element_type: datepicker + section: "main::modal:新增公告" + strategy: label_text + value: "截止日期" + playwright: "page.getByText('截止日期', { exact: true })" + + expireDate: + description: "请选择日期" + element_type: text + section: "main::modal:新增公告" + strategy: id + value: "form_item_expireDate" + playwright: "page.locator('#form_item_expireDate')" + + contentBtn: + description: "内容" + element_type: button + section: "main::modal:新增公告" + strategy: label_text + value: "内容" + playwright: "page.getByText('内容', { exact: true })" + + content: + description: "内容" + element_type: span + section: "main::modal:新增公告" + strategy: role + role: button + name: "内容" + playwright: "page.getByRole('button', { name: '内容' })" + + content_2: + description: "内容" + element_type: div + section: "main::modal:新增公告" + strategy: label_text + value: "内容" + playwright: "page.getByText('内容', { exact: true })" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增公告" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + el_e58f91e5b883Btn: + description: "发 布" + element_type: button + section: "main::modal:新增公告" + strategy: role + role: button + name: "发 布" + playwright: "page.getByRole('button', { name: '发 布' })" + + # --- main::modal --- + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" + + # --- main::modal:编辑公告 --- + noticeTitle_3: + description: "请输入" + element_type: text + section: "main::modal:编辑公告" + strategy: id + value: "form_item_title" + playwright: "page.locator('#form_item_title')" + + el_e585a8e983a8_2: + description: "全部" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "全部" + playwright: "page.getByText('全部')" + + el_e585a8e999a2_2: + description: "全院" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "全院" + playwright: "page.getByText('全院')" + + el_e4b8ade5bf83e5ba93_2: + description: "中心库" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "中心库" + playwright: "page.getByText('中心库')" + + el_e99480e594aee59586_2: + description: "销售商" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "销售商" + playwright: "page.getByText('销售商')" + + el_e698af_2: + description: "是" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + el_e590a6_2: + description: "否" + element_type: radio + section: "main::modal:编辑公告" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + expireDateDateRange_2: + description: "截止日期" + element_type: datepicker + section: "main::modal:编辑公告" + strategy: label_text + value: "截止日期" + playwright: "page.getByText('截止日期', { exact: true })" + + expireDate_2: + description: "2026-10-20" + element_type: text + section: "main::modal:编辑公告" + strategy: id + value: "form_item_expireDate" + playwright: "page.locator('#form_item_expireDate')" + + content_3: + description: "内容" + element_type: span + section: "main::modal:编辑公告" + strategy: role + role: button + name: "内容" + playwright: "page.getByRole('button', { name: '内容' })" + + content_4: + description: "存在与平台价格不一致的产品,请注意执行调价!" + element_type: div + section: "main::modal:编辑公告" + strategy: label_text + value: "内容" + playwright: "page.getByText('内容', { exact: true })" diff --git a/locators/spd/basic-role.locator.ts b/locators/spd/basic-role.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d365796dd2fa7438fea1db60923fed91a4cbdf9 --- /dev/null +++ b/locators/spd/basic-role.locator.ts @@ -0,0 +1,45 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicUserSharedLocators } from './basic-user-shared.locator'; + +/** 角色管理。选择器来自 locators/spd/basic-role.locator.yaml */ +export class BasicRoleLocators extends BasicUserSharedLocators { + readonly saveBtn: Locator; + readonly refreshBtn: Locator; + readonly addRoleBtn: Locator; + readonly roleSearch: Locator; + readonly roleTree: Locator; + readonly roleSider: Locator; + /** 左栏底部/右下角编辑图标(选中角色后可见) */ + readonly editRoleIcon: Locator; + + constructor(page: Page) { + super(page); + this.saveBtn = page.getByRole('button', { name: /保\s*存/ }); + this.refreshBtn = page.getByRole('button', { name: /刷\s*新/ }); + this.addRoleBtn = page.getByRole('button', { name: /^角\s*色$/ }); + this.roleSearch = page + .getByPlaceholder(/角色/) + .or(page.locator('.ant-layout-sider input, .ant-layout-sider .ant-input')) + .first(); + this.roleTree = page.locator('.menu-role-tree').first(); + this.roleSider = page.locator('.ant-layout-sider').first(); + this.editRoleIcon = this.roleSider + .locator('button, a, [role="button"]') + .filter({ has: page.locator('.anticon-edit, .anticon-form') }) + .last() + .or(this.roleSider.locator('.anticon-edit, .anticon-form').last()); + } + + treeNode(title: string): Locator { + return this.roleTree.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }); + } + + treeCheckbox(title: string): Locator { + return this.roleTree + .locator('.ant-tree-treenode') + .filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) }) + .locator('.ant-tree-checkbox') + .first(); + } +} diff --git a/locators/spd/basic-role.locator.yaml b/locators/spd/basic-role.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..75680db97999cb74bcd9931253b0662d16571038 --- /dev/null +++ b/locators/spd/basic-role.locator.yaml @@ -0,0 +1,5001 @@ +# Auto-generated from basic-role-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/role +# Scanned: 2026-10-20T08:31:14.084Z +# Page: 基础设置 > 用户管理 > 角色管理 + +page: basic-role +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/role + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_roleManage: + description: "角色管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "角色管理" + playwright: "page.getByRole('menuitem', { name: '角色管理' })" + + menu_userManage: + description: "用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "用户管理" + playwright: "page.getByRole('menuitem', { name: '用户管理' })" + + menu_unionUserManage: + description: "医共体用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医共体用户管理" + playwright: "page.getByRole('menuitem', { name: '医共体用户管理' })" + + roleManage: + description: "角色管理" + element_type: tab + section: "main::main" + strategy: text + value: "角色管理" + playwright: "page.getByText('角色管理')" + + tab_roleManage: + description: "角色管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/user/platform/role" + playwright: "page.locator('#rc-tabs-0-tab-/basic/user/platform/role')" + + el_e8a792e889b2Btn: + description: "角色" + element_type: button + section: "main::main" + strategy: role + role: button + name: "角色" + playwright: "page.getByRole('button', { name: '角色' })" + + saveBtn: + description: "保 存" + element_type: button + section: "main::main" + strategy: role + role: button + name: "保 存" + playwright: "page.getByRole('button', { name: '保 存' })" + + el_e588b7e696b0Btn: + description: "刷 新" + element_type: button + section: "main::main" + strategy: role + role: button + name: "刷 新" + playwright: "page.getByRole('button', { name: '刷 新' })" + + el_535044e58685e7bd91e7b3bbe7bb9f5043e7abaf: + description: "SPD内网系统PC端" + element_type: checkbox + section: "main::main" + strategy: text + value: "SPD内网系统PC端" + playwright: "page.getByText('SPD内网系统PC端')" + + tree_node_basicSetting_e59fbae7a180: + description: "基础设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础设置\\s*$/ })" + + tree_node_hospitalInfo_e58cbbe999a2: + description: "医院资料" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医院资料" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医院资料\\s*$/ })" + + tree_node_hospitalInfoRegister_e58cbbe999a2: + description: "医院信息登记" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医院信息登记" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医院信息登记\\s*$/ })" + + tree_node_branchManage_e999a2e58cba: + description: "院区管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区管理\\s*$/ })" + + tree_node_noticeManage_e585ace5918a: + description: "公告管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "公告管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*公告管理\\s*$/ })" + + tree_node_financeMonthSetting_e8b4a2e58aa1: + description: "财务月份设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务月份设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务月份设置\\s*$/ })" + + tree_node_e8bf90e7bbb4: + description: "运维管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "运维管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*运维管理\\s*$/ })" + + tree_node_e88f9ce58d95: + description: "菜单控制规则设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "菜单控制规则设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*菜单控制规则设置\\s*$/ })" + + tree_node_e58cbbe999a2: + description: "医院配置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医院配置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医院配置\\s*$/ })" + + tree_el_e88f9ce58d95e58f97e68ea7e58897e8a1a8_2: + description: "菜单受控列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "菜单受控列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*菜单受控列表\\s*$/ })" + + tree_node_e999a2e58cba: + description: "院区配置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区配置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区配置\\s*$/ })" + + tree_node_e7a791e5aea4: + description: "科室配置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室配置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室配置\\s*$/ })" + + tree_node_e69cafe8afad: + description: "术语配置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "术语配置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*术语配置\\s*$/ })" + + tree_node_e8b584e6ba90: + description: "资源模板设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "资源模板设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*资源模板设置\\s*$/ })" + + tree_node_e5ad97e585b8: + description: "字典管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "字典管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*字典管理\\s*$/ })" + + tree_node_e68993e58db0: + description: "打印机配置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "打印机配置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*打印机配置\\s*$/ })" + + tree_node_e7bc96e7a081: + description: "编码规则管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "编码规则管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*编码规则管理\\s*$/ })" + + tree_node_504441e68e88: + description: "PDA授权" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "PDA授权" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*PDA授权\\s*$/ })" + + tree_node_504441e78988: + description: "PDA版本管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "PDA版本管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*PDA版本管理\\s*$/ })" + + tree_node_e7bc93e5ad98: + description: "缓存监控" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "缓存监控" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*缓存监控\\s*$/ })" + + tree_node_e6938de4bd9c: + description: "操作日志" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "操作日志" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*操作日志\\s*$/ })" + + tree_node_e6b688e681af: + description: "消息通知模板" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "消息通知模板" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*消息通知模板\\s*$/ })" + + tree_el_e6b688e681afe9809ae79fa5e8aebee7bdae_2: + description: "消息通知设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "消息通知设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*消息通知设置\\s*$/ })" + + tree_node_e58a9fe883bd: + description: "功能注册" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "功能注册" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*功能注册\\s*$/ })" + + tree_node_e4bbbbe58aa1: + description: "任务执行记录查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "任务执行记录查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*任务执行记录查询\\s*$/ })" + + tree_node_vendorManage_e58e82e59586: + description: "厂商管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "厂商管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*厂商管理\\s*$/ })" + + tree_node_vendorQuery_e58e82e59586: + description: "厂商查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "厂商查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*厂商查询\\s*$/ })" + + tree_node_vendorQual_e4be9be5ba94: + description: "供应商资质管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商资质管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商资质管理\\s*$/ })" + + tree_node_authFirst_e68e88e69d83: + description: "授权变更初审" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "授权变更初审" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*授权变更初审\\s*$/ })" + + tree_node_certFirst_e6b3a8e5868c: + description: "注册证变更初审" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "注册证变更初审" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*注册证变更初审\\s*$/ })" + + tree_node_certFinal_e6b3a8e5868c: + description: "注册证变更终审" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "注册证变更终审" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*注册证变更终审\\s*$/ })" + + tree_node_authFinal_e68e88e69d83: + description: "授权变更终审" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "授权变更终审" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*授权变更终审\\s*$/ })" + + tree_node_certDiff_e6b3a8e5868c: + description: "注册证差异对比" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "注册证差异对比" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*注册证差异对比\\s*$/ })" + + tree_node_evalScheme_e8af84e4bbb7: + description: "评价方案维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "评价方案维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*评价方案维护\\s*$/ })" + + tree_node_evalIndicator_e8af84e4bbb7: + description: "评价指标维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "评价指标维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*评价指标维护\\s*$/ })" + + tree_node_vendorHoliday_e4be9be5ba94: + description: "供应商节假日管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商节假日管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商节假日管理\\s*$/ })" + + tree_node_e4baa7e59381: + description: "产品管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品管理\\s*$/ })" + + tree_el_e4baa7e59381e6a0b8e5afb9e9878de696b0e58f91e8b5b7_2: + description: "产品核对重新发起" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品核对重新发起" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品核对重新发起\\s*$/ })" + + tree_el_e4baa7e59381e7bbb4e68aa4_2: + description: "产品维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品维护\\s*$/ })" + + tree_node_e789a9e69699: + description: "物料信息管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物料信息管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物料信息管理\\s*$/ })" + + tree_el_e4baa7e59381e5889de5aea1e6a0b8e5afb9_2: + description: "产品初审核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品初审核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品初审核对\\s*$/ })" + + tree_el_e4baa7e59381e4be9be5ba94e585b3e7b3bbe7bbb4e68aa4_2: + description: "产品供应关系维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品供应关系维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品供应关系维护\\s*$/ })" + + tree_el_e4baa7e59381e789a9e4bbb7e6a0b8e5afb9_2: + description: "产品物价核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品物价核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品物价核对\\s*$/ })" + + tree_el_e999a2e58cbae4baa7e59381e8aebee7bdae_2: + description: "院区产品设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区产品设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区产品设置\\s*$/ })" + + tree_el_e999a2e58cbae58d95e58583e58c85e8aebee7bdae_2: + description: "院区单元包设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区单元包设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区单元包设置\\s*$/ })" + + tree_node_e4b8ade5bf83: + description: "中心库上下限建议(AI)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库上下限建议(AI)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库上下限建议(AI)\\s*$/ })" + + tree_node_e8afb7e9a286: + description: "请领出库渠道设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领出库渠道设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领出库渠道设置\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e4b88ae4b88be99990e5bbbae8aeae_2: + description: "中心库上下限建议" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库上下限建议" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库上下限建议\\s*$/ })" + + tree_node_e7bb93e7ae97: + description: "结算点设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算点设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算点设置\\s*$/ })" + + tree_node_e8b083e4bbb7: + description: "调价管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "调价管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*调价管理\\s*$/ })" + + tree_el_e4baa7e59381e4bfa1e681afe58f98e69bb4_2: + description: "产品信息变更" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品信息变更" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品信息变更\\s*$/ })" + + tree_node_e58f98e69bb4: + description: "变更单明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "变更单明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*变更单明细查询\\s*$/ })" + + tree_node_e5a496e59bb4: + description: "外围系统产品新增" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "外围系统产品新增" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*外围系统产品新增\\s*$/ })" + + tree_el_e5a496e59bb4e7b3bbe7bb9fe4baa7e59381e58f98e69bb4e7aea1e79086_2: + description: "外围系统产品变更管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "外围系统产品变更管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*外围系统产品变更管理\\s*$/ })" + + tree_el_e4baa7e593814449e69fa5e8afa2_2: + description: "产品DI查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品DI查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品DI查询\\s*$/ })" + + tree_node_e7bcbae8b4a7: + description: "缺货目录维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "缺货目录维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*缺货目录维护\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e5ba93e58cbae4baa7e59381e8aebee7bdae_2: + description: "中心库库区产品设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库库区产品设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库库区产品设置\\s*$/ })" + + tree_el_e7bcbae8b4a7e79baee5bd95e6988ee7bb86_2: + description: "缺货目录明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "缺货目录明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*缺货目录明细\\s*$/ })" + + tree_el_e789a9e69699e4bfa1e681afe58f98e69bb4_2: + description: "物料信息变更" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物料信息变更" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物料信息变更\\s*$/ })" + + tree_el_e4baa7e593814449e58f98e69bb4_2: + description: "产品DI变更" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品DI变更" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品DI变更\\s*$/ })" + + tree_el_e4baa7e59381e8b584e8b4a8e9bd90e585a8e8a784e58899_2: + description: "产品资质齐全规则" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品资质齐全规则" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品资质齐全规则\\s*$/ })" + + tree_el_e4baa7e59381e8b584e8b4a8e4b880e8a788_2: + description: "产品资质一览" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品资质一览" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品资质一览\\s*$/ })" + + tree_el_e789a9e69699e58f98e69bb4e58d95e6988ee7bb86e69fa5e8afa2_2: + description: "物料变更单明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物料变更单明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物料变更单明细查询\\s*$/ })" + + tree_el_e7a791e5aea4e7aea1e79086_2: + description: "科室管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室管理\\s*$/ })" + + tree_el_e7a791e5aea4e799bbe8aeb0_2: + description: "科室登记" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室登记" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室登记\\s*$/ })" + + tree_el_e7a791e5aea4e4baa7e59381e58886e9858d_2: + description: "科室产品分配" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室产品分配" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室产品分配\\s*$/ })" + + tree_el_e7a791e5aea4e5ba93e58cbae4baa7e59381e8aebee7bdae_2: + description: "科室库区产品设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室库区产品设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室库区产品设置\\s*$/ })" + + tree_el_e7a791e5aea4e8b79fe58fb0e99990e9a29de4bbbbe58aa1_2: + description: "科室跟台限额任务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室跟台限额任务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室跟台限额任务\\s*$/ })" + + tree_el_e8afb7e9a286e6a8a1e69dbfe7bbb4e68aa4_2: + description: "请领模板维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领模板维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领模板维护\\s*$/ })" + + tree_node_e887aae58aa8: + description: "自动请领排程" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "自动请领排程" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*自动请领排程\\s*$/ })" + + tree_el_e7a791e5aea4e4babae59198e69fa5e8afa2_2: + description: "科室人员查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室人员查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室人员查询\\s*$/ })" + + tree_el_e7a791e5aea4e4b88ae4b88be99990e5bbbae8aeae_2: + description: "科室上下限建议" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室上下限建议" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室上下限建议\\s*$/ })" + + tree_el_e7a791e5aea4e4b88ae4b88be99990e5bbbae8aeaeefbc884149efbc89_2: + description: "科室上下限建议(AI)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室上下限建议(AI)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室上下限建议(AI)\\s*$/ })" + + tree_el_e7a791e5aea4e9a284e7ae97e7aea1e79086_2: + description: "科室预算管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室预算管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室预算管理\\s*$/ })" + + tree_node_e6a380e9aa8c: + description: "检验机器管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "检验机器管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*检验机器管理\\s*$/ })" + + tree_el_e7a791e5aea4e98081e8b4a7e8aea1e58892_2: + description: "科室送货计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室送货计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室送货计划\\s*$/ })" + + tree_el_e7a791e5aea4e58886e7b1bbe7bbb4e68aa4_2: + description: "科室分类维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室分类维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室分类维护\\s*$/ })" + + tree_el_e7a791e5aea4e4baa7e593814149e9a284e6b58be7bb93e8aeba_2: + description: "科室产品AI预测结论" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室产品AI预测结论" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室产品AI预测结论\\s*$/ })" + + tree_node_e5ba93e688bf: + description: "库房管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库房管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库房管理\\s*$/ })" + + tree_node_e5ba93e58cba: + description: "库区设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库区设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库区设置\\s*$/ })" + + tree_node_e68ea2e99288: + description: "探针绑定" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "探针绑定" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*探针绑定\\s*$/ })" + + tree_node_e58d8ee6b885: + description: "华清通道" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "华清通道" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*华清通道\\s*$/ })" + + tree_node_userManage_e794a8e688b7: + description: "用户管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "用户管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*用户管理\\s*$/ })" + + tree_node_roleManage_e8a792e889b2: + description: "角色管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "角色管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*角色管理\\s*$/ })" + + tree_userManage_2: + description: "用户管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "用户管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*用户管理\\s*$/ })" + + tree_node_unionUserManage_e58cbbe585b1: + description: "医共体用户管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医共体用户管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医共体用户管理\\s*$/ })" + + tree_node_e6b581e7a88b: + description: "流程设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "流程设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*流程设置\\s*$/ })" + + tree_node_e5aea1e689b9: + description: "审批流程设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "审批流程设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*审批流程设置\\s*$/ })" + + tree_el_e5aea1e689b9e6b581e5ae9ee4be8b_2: + description: "审批流实例" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "审批流实例" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*审批流实例\\s*$/ })" + + tree_node_e68ea5e58fa3: + description: "接口管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "接口管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*接口管理\\s*$/ })" + + tree_el_e68ea5e58fa3e697a5e5bf97_2: + description: "接口日志" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "接口日志" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*接口日志\\s*$/ })" + + tree_node_e68ea8e98081: + description: "推送物价" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "推送物价" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*推送物价\\s*$/ })" + + tree_node_e694b6e8b4b9: + description: "收费项目对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "收费项目对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*收费项目对码\\s*$/ })" + + tree_el_e5a496e59bb4e4baa7e59381e58f98e69bb4_2: + description: "外围产品变更" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "外围产品变更" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*外围产品变更\\s*$/ })" + + tree_node_484953e4bbb7: + description: "HIS价对照查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "HIS价对照查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*HIS价对照查询\\s*$/ })" + + tree_node_e5a496e983a8: + description: "外部变更明细信息查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "外部变更明细信息查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*外部变更明细信息查询\\s*$/ })" + + tree_node_e58cbbe4bf9d: + description: "医保信息核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医保信息核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医保信息核对\\s*$/ })" + + tree_node_e58e82e59586: + description: "厂商对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "厂商对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*厂商对码\\s*$/ })" + + tree_node_e4bba3e4b8ba: + description: "代为科室请领(已确认)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "代为科室请领(已确认)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*代为科室请领(已确认)\\s*$/ })" + + tree_node_e4bba3e4ba8c: + description: "代二级科室请领产品" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "代二级科室请领产品" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*代二级科室请领产品\\s*$/ })" + + tree_node_484953e4baa7: + description: "HIS产品信息变更" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "HIS产品信息变更" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*HIS产品信息变更\\s*$/ })" + + tree_node_484953e7a791: + description: "HIS科室查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "HIS科室查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*HIS科室查询\\s*$/ })" + + tree_node_484953e4baba: + description: "HIS人员查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "HIS人员查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*HIS人员查询\\s*$/ })" + + tree_node_484953e694b6: + description: "HIS收费项与产品对照关系查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "HIS收费项与产品对照关系查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*HIS收费项与产品对照关系查询\\s*$/ })" + + tree_el_e999a2e58cbae5afb9e7a081_2: + description: "院区对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区对码\\s*$/ })" + + tree_el_e999a2e58cbae5afb9e7a081_3: + description: "院区对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区对码\\s*$/ })" + + tree_el_e999a2e58cbae5afb9e7a081_4: + description: "院区对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区对码\\s*$/ })" + + tree_node_e4bd8ee580bc: + description: "低值计费接口查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "低值计费接口查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*低值计费接口查询\\s*$/ })" + + tree_node_e699bae883bd: + description: "智能柜与库区对码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能柜与库区对码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*智能柜与库区对码\\s*$/ })" + + tree_node_e789a9e8b584: + description: "物资字典推送记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物资字典推送记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物资字典推送记录\\s*$/ })" + + tree_node_e5b9b3e58fb0: + description: "平台产品价格差异查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "平台产品价格差异查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*平台产品价格差异查询\\s*$/ })" + + tree_el_e8afb7e9a286e58d95e68ea5e58fa3e69fa5e8afa2_2: + description: "请领单接口查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领单接口查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领单接口查询\\s*$/ })" + + tree_node_surgerySetting_e6898be69caf: + description: "手术套包设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包设置\\s*$/ })" + + tree_node_basePkgMaintenance_e59fbae7a180: + description: "基础包维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础包维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础包维护\\s*$/ })" + + tree_node_operTypeMaintenance_e69cafe7a78d: + description: "术种维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "术种维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*术种维护\\s*$/ })" + + tree_node_stdPkgMaintenance_e6a087e58786: + description: "标准套包维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "标准套包维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*标准套包维护\\s*$/ })" + + tree_node_admitManage_e58786e585a5: + description: "准入管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "准入管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*准入管理\\s*$/ })" + + tree_node_admitMaterial_e789a9e69699: + description: "物料准入管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物料准入管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物料准入管理\\s*$/ })" + + tree_node_admitProductApply_e4baa7e59381: + description: "产品准入申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品准入申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品准入申请\\s*$/ })" + + tree_node_admitMaterialList_e789a9e69699: + description: "物料准入单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物料准入单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物料准入单列表\\s*$/ })" + + tree_node_admitProductList_e4baa7e59381: + description: "产品准入单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品准入单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品准入单列表\\s*$/ })" + + tree_node_admitSupply_e4be9be8b4a7: + description: "供货目录申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供货目录申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供货目录申请\\s*$/ })" + + tree_node_admitTask_e58786e585a5: + description: "准入任务分配" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "准入任务分配" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*准入任务分配\\s*$/ })" + + tree_node_purchase_e98787e8b4ad: + description: "采购管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购管理\\s*$/ })" + + tree_node_e98787e8b4ad: + description: "采购计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购计划\\s*$/ })" + + tree_node_e99990e9878f: + description: "限量任务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "限量任务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*限量任务\\s*$/ })" + + tree_el_e98787e8b4ade58d95e7aea1e79086_2: + description: "采购单管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购单管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购单管理\\s*$/ })" + + tree_el_e98787e8b4ade8aea1e58892e6988ee7bb86_2: + description: "采购计划明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购计划明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购计划明细\\s*$/ })" + + tree_el_e8afb7e9a286e5aea1e6a0b8_2: + description: "请领审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领审核\\s*$/ })" + + tree_node_e4b8b4e98787: + description: "临采任务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "临采任务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*临采任务\\s*$/ })" + + tree_node_e5b8a6e9878f: + description: "带量任务管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量任务管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量任务管理\\s*$/ })" + + tree_el_e5b8a6e9878fe98787e8b4ade79baee5bd95_2: + description: "带量采购目录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量采购目录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量采购目录\\s*$/ })" + + tree_node_e69c88e5baa6: + description: "月度备货计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "月度备货计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*月度备货计划\\s*$/ })" + + tree_node_e7a791e7a094: + description: "科研项目设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科研项目设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科研项目设置\\s*$/ })" + + tree_node_e6898be69caf: + description: "手术通知单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术通知单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术通知单\\s*$/ })" + + tree_node_e79c81e98787: + description: "省采订单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "省采订单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*省采订单\\s*$/ })" + + tree_node_e6b19fe88b8f: + description: "江苏省采" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "江苏省采" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*江苏省采\\s*$/ })" + + tree_node_e7bb8fe8b4b9: + description: "经费来源类型" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "经费来源类型" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*经费来源类型\\s*$/ })" + + tree_el_e7bcbae8b4a7e58f8de9a688_2: + description: "缺货反馈" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "缺货反馈" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*缺货反馈\\s*$/ })" + + tree_node_e68d90e8b5a0: + description: "捐赠活动设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "捐赠活动设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*捐赠活动设置\\s*$/ })" + + tree_node_e4be9be5ba94: + description: "供应商评价" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商评价" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商评价\\s*$/ })" + + tree_node_opening_e69c9fe5889d: + description: "期初管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "期初管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*期初管理\\s*$/ })" + + tree_node_e69c9fe5889d: + description: "期初录入" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "期初录入" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*期初录入\\s*$/ })" + + tree_el_e69c9fe5889de6988ee7bb86e69fa5e8afa2_2: + description: "期初明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "期初明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*期初明细查询\\s*$/ })" + + tree_node_e5bfabe9809f: + description: "快速盘点记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速盘点记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速盘点记录\\s*$/ })" + + tree_node_centerStock_e4b8ade5bf83: + description: "中心库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库管理\\s*$/ })" + + tree_node_e585a5e5ba93: + description: "入库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库管理\\s*$/ })" + + tree_node_e9aa8ce694b6: + description: "验收管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收管理\\s*$/ })" + + tree_el_e9aa8ce694b6e694b6e8b4a7_2: + description: "验收收货" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收收货" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收收货\\s*$/ })" + + tree_node_e4b88ae69eb6: + description: "上架入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "上架入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*上架入库\\s*$/ })" + + tree_el_e9aa8ce694b6e58fb0e8b4a6_2: + description: "验收台账" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收台账" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收台账\\s*$/ })" + + tree_el_e7a791e5aea4e9a9b3e59b9ee7a1aee8aea4_2: + description: "科室驳回确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室驳回确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室驳回确认\\s*$/ })" + + tree_node_e6898be5b7a5: + description: "手工入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工入库\\s*$/ })" + + tree_node_e585b6e4bb96: + description: "其他物资入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "其他物资入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*其他物资入库\\s*$/ })" + + tree_node_e8af95e58982: + description: "试剂性能验证单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂性能验证单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂性能验证单\\s*$/ })" + + tree_node_e7b4a7e680a5: + description: "紧急使用登记" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "紧急使用登记" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*紧急使用登记\\s*$/ })" + + tree_node_e8b4b4e7a081: + description: "贴码核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "贴码核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*贴码核对\\s*$/ })" + + tree_el_e9aa8ce694b6e7a1aee8aea4_2: + description: "验收确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收确认\\s*$/ })" + + tree_node_e68abde9aa8c: + description: "抽验记录查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "抽验记录查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*抽验记录查询\\s*$/ })" + + tree_el_e9aa8ce694b6e8b4b4e7a081e4bbbbe58aa1e58886e9858d_2: + description: "验收贴码任务分配" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收贴码任务分配" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收贴码任务分配\\s*$/ })" + + tree_node_e8b79fe58fb0: + description: "跟台耗材识别仪入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台耗材识别仪入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台耗材识别仪入库\\s*$/ })" + + tree_node_e587bae5ba93: + description: "出库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库管理\\s*$/ })" + + tree_node_e7949fe68890: + description: "生成拣货单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "生成拣货单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*生成拣货单\\s*$/ })" + + tree_node_e68ba3e8b4a7: + description: "拣货执行" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拣货执行" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拣货执行\\s*$/ })" + + tree_el_e587bae5ba93e5a48de6a0b8_2: + description: "出库复核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库复核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库复核\\s*$/ })" + + tree_el_e6898be5b7a5e587bae5ba93_2: + description: "手工出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工出库\\s*$/ })" + + tree_node_e68aa5e68d9f: + description: "报损出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "报损出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*报损出库\\s*$/ })" + + tree_node_e8b083e68ba8: + description: "调拨出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "调拨出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*调拨出库\\s*$/ })" + + tree_node_e7a7bbe5ba93: + description: "移库出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "移库出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*移库出库\\s*$/ })" + + tree_node_e98080e8b4a7: + description: "退货出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退货出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退货出库\\s*$/ })" + + tree_el_e68ba3e8b4a7e58d95e7aea1e79086_2: + description: "拣货单管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拣货单管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拣货单管理\\s*$/ })" + + tree_el_e587bae5ba93e58d95e58897e8a1a8_2: + description: "出库单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单列表\\s*$/ })" + + tree_el_e8b79fe58fb0e98080e8b4a7e587bae5ba93_2: + description: "跟台退货出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台退货出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台退货出库\\s*$/ })" + + tree_el_e98080e8b4a7e6988ee7bb86e69fa5e8afa2_2: + description: "退货明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退货明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退货明细查询\\s*$/ })" + + tree_el_e587bae5ba93e6988ee7bb86e69fa5e8afa2_2: + description: "出库明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库明细查询\\s*$/ })" + + tree_el_e68ba3e8b4a7e58886e9858de7aea1e79086_2: + description: "拣货分配管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拣货分配管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拣货分配管理\\s*$/ })" + + tree_el_e68ba3e8b4a7e6988ee7bb86e69fa5e8afa2_2: + description: "拣货明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拣货明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拣货明细查询\\s*$/ })" + + tree_el_e587bae5ba93e6a0b8e9aa8c_2: + description: "出库核验" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库核验" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库核验\\s*$/ })" + + tree_node_e59088e58d95: + description: "合单出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "合单出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*合单出库\\s*$/ })" + + tree_node_e8a385e7aeb1: + description: "装箱贴码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "装箱贴码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*装箱贴码\\s*$/ })" + + tree_el_e7a791e5aea4e59088e58d95e587bae5ba93_2: + description: "科室合单出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室合单出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室合单出库\\s*$/ })" + + tree_el_e59088e58d95e587bae5ba93e58d95_2: + description: "合单出库单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "合单出库单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*合单出库单\\s*$/ })" + + tree_node_e7aeb1e7a081: + description: "箱码打印记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "箱码打印记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*箱码打印记录\\s*$/ })" + + tree_node_e4baa4e68ea5: + description: "交接记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "交接记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*交接记录\\s*$/ })" + + tree_node_e68abde6a380: + description: "抽检记录查看" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "抽检记录查看" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*抽检记录查看\\s*$/ })" + + tree_el_e9aa8ce694b6e7a1aee8aea4_3: + description: "验收确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收确认\\s*$/ })" + + tree_node_e5ba93e58685: + description: "库内作业" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库内作业" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库内作业\\s*$/ })" + + tree_node_e68993e7a081: + description: "打码中心" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "打码中心" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*打码中心\\s*$/ })" + + tree_node_e68891e8a681: + description: "我要加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "我要加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*我要加工\\s*$/ })" + + tree_node_e58aa0e5b7a5: + description: "加工执行" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "加工执行" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*加工执行\\s*$/ })" + + tree_el_e5bfabe9809fe58aa0e5b7a5_2: + description: "快速加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速加工\\s*$/ })" + + tree_el_e5bfabe9809fe7bb84e58c85_2: + description: "快速组包" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速组包" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速组包\\s*$/ })" + + tree_node_e8b4a7e4bd8d: + description: "货位调整台账" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "货位调整台账" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*货位调整台账\\s*$/ })" + + tree_el_e58aa0e5b7a5e58d95e58897e8a1a8_2: + description: "加工单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "加工单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*加工单列表\\s*$/ })" + + tree_el_e8b4a7e4bd8de8b083e695b4_2: + description: "货位调整" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "货位调整" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*货位调整\\s*$/ })" + + tree_node_e68b86e99bb6: + description: "拆零加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拆零加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拆零加工\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e5ba93e58cbae7a7bbe58aa8_2: + description: "中心库库区移动" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库库区移动" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库库区移动\\s*$/ })" + + tree_node_e5ba93e5ad98: + description: "库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存查询\\s*$/ })" + + tree_el_e5ba93e5ad98e6b187e680bbe69fa5e8afa2_2: + description: "库存汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存汇总查询\\s*$/ })" + + tree_el_e5ba93e5ad98e6988ee7bb86e69fa5e8afa2_2: + description: "库存明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存明细查询\\s*$/ })" + + tree_node_e585a8e999a2: + description: "全院库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "全院库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*全院库存查询\\s*$/ })" + + tree_node_e4b88de889af: + description: "不良事件" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "不良事件" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*不良事件\\s*$/ })" + + tree_el_e4b88de889afe4ba8be4bbb6e7aea1e79086_2: + description: "不良事件管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "不良事件管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*不良事件管理\\s*$/ })" + + tree_el_e4b88de889afe4ba8be4bbb6e5aea1e689b9_2: + description: "不良事件审批" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "不良事件审批" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*不良事件审批\\s*$/ })" + + tree_node_e79b98e782b9: + description: "盘点管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点管理\\s*$/ })" + + tree_el_e79b98e782b9e8aea1e58892e7aea1e79086_2: + description: "盘点计划管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点计划管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点计划管理\\s*$/ })" + + tree_el_e79b98e782b9e5bd95e585a5_2: + description: "盘点录入" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点录入" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点录入\\s*$/ })" + + tree_el_e79b98e782b9e7bb9fe8aea1_2: + description: "盘点统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点统计\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e5b7a5e4bd9ce58fb0_2: + description: "中心库工作台" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库工作台" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库工作台\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e5b7a5e4bd9ce58fb0_3: + description: "中心库工作台" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库工作台" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库工作台\\s*$/ })" + + tree_el_e7a7bbe5ba93e794b3e8afb7e7aea1e79086_2: + description: "移库申请管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "移库申请管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*移库申请管理\\s*$/ })" + + tree_el_e7a7bbe5ba93e794b3e8afb7_2: + description: "移库申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "移库申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*移库申请\\s*$/ })" + + tree_el_e7a7bbe5ba93e794b3e8afb7e6988ee7bb86e69fa5e8afa2_2: + description: "移库申请明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "移库申请明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*移库申请明细查询\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e7a7bbe5ba93e794b3e8afb7_2: + description: "中心库移库申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库移库申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库移库申请\\s*$/ })" + + tree_node_e585bbe68aa4: + description: "养护管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "养护管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*养护管理\\s*$/ })" + + tree_el_e585bbe68aa4e7aea1e79086_2: + description: "养护管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "养护管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*养护管理\\s*$/ })" + + tree_el_e699bae883bde98787e8b4ad_2: + description: "智能采购" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能采购" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*智能采购\\s*$/ })" + + tree_node_4149e98787e8: + description: "AI采购建议" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI采购建议" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI采购建议\\s*$/ })" + + tree_node_4149e9a284e6: + description: "AI预测分析比对-每日预测" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI预测分析比对-每日预测" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI预测分析比对-每日预测\\s*$/ })" + + tree_el_4149e9a284e6b58be58886e69e90e6af94e5afb92de697a5e59d87e9a284e6b58b_2: + description: "AI预测分析比对-日均预测" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI预测分析比对-日均预测" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI预测分析比对-日均预测\\s*$/ })" + + tree_node_deptBusiness_e7a791e5aea4: + description: "科室业务管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室业务管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室业务管理\\s*$/ })" + + tree_el_e8afb7e9a286e7aea1e79086_2: + description: "请领管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领管理\\s*$/ })" + + tree_el_e68891e8a681e8afb7e9a286_2: + description: "我要请领" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "我要请领" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*我要请领\\s*$/ })" + + tree_el_e7b4a7e680a5e8afb7e9a286_2: + description: "紧急请领" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "紧急请领" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*紧急请领\\s*$/ })" + + tree_node_e4bba3e7a791: + description: "代科室下单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "代科室下单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*代科室下单\\s*$/ })" + + tree_el_e8afb7e9a286e58d95e7a1aee8aea4_2: + description: "请领单确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领单确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领单确认\\s*$/ })" + + tree_el_e8afb7e9a286e58d95e69fa5e8afa2_2: + description: "请领单查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领单查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领单查询\\s*$/ })" + + tree_el_e8afb7e9a286e58d95e6988ee7bb86e69fa5e8afa2_2: + description: "请领单明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领单明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领单明细查询\\s*$/ })" + + tree_el_e8afb7e9a286e69caae5ae8ce68890e8bf9be5baa6e69fa5e8afa2_2: + description: "请领未完成进度查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领未完成进度查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领未完成进度查询\\s*$/ })" + + tree_el_e8b79fe58fb0e6898be69cafe794b3e8afb7_2: + description: "跟台手术申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台手术申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台手术申请\\s*$/ })" + + tree_el_e8b79fe58fb0e5a597e9a490e7bbb4e68aa4_2: + description: "跟台套餐维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台套餐维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台套餐维护\\s*$/ })" + + tree_node_e5a487e8b4a7: + description: "备货耗材包管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "备货耗材包管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*备货耗材包管理\\s*$/ })" + + tree_el_e585a5e5ba93e7aea1e79086_2: + description: "入库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库管理\\s*$/ })" + + tree_el_e7a791e5aea4e585a5e5ba93_2: + description: "科室入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室入库\\s*$/ })" + + tree_el_e585a5e5ba93e58d95e69fa5e8afa2_2: + description: "入库单查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库单查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库单查询\\s*$/ })" + + tree_node_e6b688e88097: + description: "消耗退回" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "消耗退回" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*消耗退回\\s*$/ })" + + tree_node_e98080e5ba93: + description: "退库核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退库核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退库核对\\s*$/ })" + + tree_el_e587bae5ba93e7aea1e79086_2: + description: "出库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库管理\\s*$/ })" + + tree_el_e6898be5b7a5e6b688e88097_2: + description: "手工消耗" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工消耗" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工消耗\\s*$/ })" + + tree_el_e98080e5ba93e587bae5ba93_2: + description: "退库出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退库出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退库出库\\s*$/ })" + + tree_el_e587bae5ba93e58d95e69fa5e8afa2_2: + description: "出库单查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单查询\\s*$/ })" + + tree_node_e9a286e794a8: + description: "领用出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "领用出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*领用出库\\s*$/ })" + + tree_el_e6898be5b7a5e9a286e794a8e587bae5ba93_2: + description: "手工领用出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工领用出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工领用出库\\s*$/ })" + + tree_el_e8b79fe58fb0e4bdbfe794a8e7a1aee8aea4_2: + description: "跟台使用确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台使用确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台使用确认\\s*$/ })" + + tree_node_e4b889e7baa7: + description: "三级库调拨" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "三级库调拨" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*三级库调拨\\s*$/ })" + + tree_node_e4ba8ce7baa7: + description: "二级库调拨" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "二级库调拨" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*二级库调拨\\s*$/ })" + + tree_el_e7a791e5aea4e98080e5ba9328e585ade999a229_2: + description: "科室退库(六院)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室退库(六院)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室退库(六院)\\s*$/ })" + + tree_node_e69da1e7a081: + description: "条码使用确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "条码使用确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*条码使用确认\\s*$/ })" + + tree_el_e5ba93e58685e7aea1e79086_2: + description: "库内管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库内管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库内管理\\s*$/ })" + + tree_node_e689abe7a081: + description: "扫码发放" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "扫码发放" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*扫码发放\\s*$/ })" + + tree_el_e5ba93e58cbae7a7bbe58aa8_2: + description: "库区移动" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库区移动" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库区移动\\s*$/ })" + + tree_node_e58f91e694be: + description: "发放单查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发放单查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发放单查询\\s*$/ })" + + tree_node_e58d95e793b6: + description: "单瓶条码补打" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "单瓶条码补打" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*单瓶条码补打\\s*$/ })" + + tree_node_e6b497e6b688: + description: "洗消管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "洗消管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*洗消管理\\s*$/ })" + + tree_el_e8b79fe58fb0e88097e69d90e5a48de9aa8c_2: + description: "跟台耗材复验" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台耗材复验" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台耗材复验\\s*$/ })" + + tree_el_e4b889e7baa7e5ba93e697a5e6b885e7aea1e79086_2: + description: "三级库日清管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "三级库日清管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*三级库日清管理\\s*$/ })" + + tree_el_e6898be5b7a5e6a0b8e99480e7bb93e7ae97_2: + description: "手工核销结算" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工核销结算" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工核销结算\\s*$/ })" + + tree_el_e8b79fe58fb0e69da1e7a081e68993e58db0_2: + description: "跟台条码打印" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跟台条码打印" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跟台条码打印\\s*$/ })" + + tree_el_e5ba93e5ad98e69fa5e8afa2_2: + description: "库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存查询\\s*$/ })" + + tree_el_e7a791e5aea4e5ba93e5ad98e69fa5e8afa2_2: + description: "科室库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室库存查询\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e7aea1e79086_2: + description: "手术套包管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包管理\\s*$/ })" + + tree_node_e59fbae7a180: + description: "基础包加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础包加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础包加工\\s*$/ })" + + tree_el_e59fbae7a180e58c85e5ba93e5ad98e69fa5e8afa2_2: + description: "基础包库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础包库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础包库存查询\\s*$/ })" + + tree_el_e6898be69cafe68e92e7a88be7aea1e79086_2: + description: "手术排程管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术排程管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术排程管理\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e794b3e8afb7_2: + description: "手术套包申请" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包申请" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包申请\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e58aa0e5b7a5_2: + description: "手术套包加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包加工\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e585a5e5ba93_2: + description: "手术套包入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包入库\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e9a284e58aa0e5b7a5_2: + description: "手术套包预加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包预加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包预加工\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e587bae5ba93_2: + description: "手术套包出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包出库\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e6b688e88097_2: + description: "手术套包消耗" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包消耗" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包消耗\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e98080e5ba93_2: + description: "手术套包退库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包退库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包退库\\s*$/ })" + + tree_el_e6898be69cafe5a597e58c85e5ba93e5ad98e69fa5e8afa2_2: + description: "手术套包库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手术套包库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手术套包库存查询\\s*$/ })" + + tree_el_e79b98e782b9e7aea1e79086_2: + description: "盘点管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点管理\\s*$/ })" + + tree_el_e79b98e782b9e8aea1e58892e7aea1e79086_3: + description: "盘点计划管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点计划管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点计划管理\\s*$/ })" + + tree_el_e79b98e782b9e5bd95e585a5_3: + description: "盘点录入" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点录入" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点录入\\s*$/ })" + + tree_node_e79b98e4ba8f: + description: "盘亏处理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘亏处理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘亏处理\\s*$/ })" + + tree_el_e79b98e782b9e7bb9fe8aea1_3: + description: "盘点统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "盘点统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*盘点统计\\s*$/ })" + + tree_el_e7a791e5aea4e5b7a5e4bd9ce58fb0_2: + description: "科室工作台" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室工作台" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室工作台\\s*$/ })" + + tree_el_e7a791e5aea4e5b7a5e4bd9ce58fb0_3: + description: "科室工作台" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室工作台" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室工作台\\s*$/ })" + + tree_el_e5a487e8b4a7e8aea1e58892_2: + description: "备货计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "备货计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*备货计划\\s*$/ })" + + tree_el_e7a791e5aea4e5a487e8b4a7e8aea1e58892e58897e8a1a8_2: + description: "科室备货计划列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室备货计划列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室备货计划列表\\s*$/ })" + + tree_el_e7a791e5aea4e5a487e8b4a7e8aea1e58892e69fa5e8afa2_2: + description: "科室备货计划查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室备货计划查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室备货计划查询\\s*$/ })" + + tree_el_e5a487e8b4a7e8aea1e58892e6b187e680bbe5aea1e689b9_2: + description: "备货计划汇总审批" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "备货计划汇总审批" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*备货计划汇总审批\\s*$/ })" + + tree_el_e7a791e5aea4e699bae883bde8a1a5e8b4a7_2: + description: "科室智能补货" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室智能补货" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室智能补货\\s*$/ })" + + tree_el_e5ba93e5ad98e699bae883bde7aea1e79086e9a9bee9a9b6e888b1_2: + description: "库存智能管理驾驶舱" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存智能管理驾驶舱" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存智能管理驾驶舱\\s*$/ })" + + tree_node_4149e8a1a5e8: + description: "AI补货建议" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI补货建议" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI补货建议\\s*$/ })" + + tree_el_4149e9a284e6b58be58886e69e90e6af94e5afb92de6af8fe697a5e9a284e6b58b_2: + description: "AI预测分析比对-每日预测" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI预测分析比对-每日预测" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI预测分析比对-每日预测\\s*$/ })" + + tree_el_4149e9a284e6b58be58886e69e90e6af94e5afb92de697a5e59d87e9a284e6b58b_3: + description: "AI预测分析比对-日均预测" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "AI预测分析比对-日均预测" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*AI预测分析比对-日均预测\\s*$/ })" + + tree_node_finance_e8b4a2e58aa1: + description: "财务管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务管理\\s*$/ })" + + tree_el_e7bb93e7ae97e7aea1e79086_2: + description: "结算管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算管理\\s*$/ })" + + tree_node_e585a5e8b4a6: + description: "入账明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入账明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入账明细查询\\s*$/ })" + + tree_el_e7bb93e7ae97e58d95e5aea1e6a0b8_2: + description: "结算单审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算单审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算单审核\\s*$/ })" + + tree_el_e7949fe68890e7bb93e7ae97e58d95_2: + description: "生成结算单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "生成结算单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*生成结算单\\s*$/ })" + + tree_el_e7bb93e7ae97e6988ee7bb86e7a791e5aea4e6a0b8e5afb9_2: + description: "结算明细科室核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算明细科室核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算明细科室核对\\s*$/ })" + + tree_node_e58f91e7a5a8: + description: "发票核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票核对\\s*$/ })" + + tree_el_e58f91e7a5a8e68f90e4baa4_2: + description: "发票提交" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票提交" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票提交\\s*$/ })" + + tree_el_e58f91e7a5a8e5bd92e6a1a3_2: + description: "发票归档" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票归档" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票归档\\s*$/ })" + + tree_el_e7949fe68890e4bb98e6acbee58d95_2: + description: "生成付款单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "生成付款单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*生成付款单\\s*$/ })" + + tree_el_e58f91e7a5a8e6988ee7bb86e7a791e5aea4e6a0b8e5afb9_2: + description: "发票明细科室核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票明细科室核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票明细科室核对\\s*$/ })" + + tree_el_e58f91e7a5a8e69fa5e8afa2_2: + description: "发票查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票查询\\s*$/ })" + + tree_el_e7bb93e7ae97e5bc80e7a5a8e6988ee7bb86e69fa5e8afa2_2: + description: "结算开票明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算开票明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算开票明细查询\\s*$/ })" + + tree_el_e58f91e7a5a8e5bd95e585a528e8b4a7e7a5a8e5908ce8a18c29_2: + description: "发票录入(货票同行)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票录入(货票同行)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票录入(货票同行)\\s*$/ })" + + tree_node_e68890e69cac: + description: "成本明细科室核对" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "成本明细科室核对" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*成本明细科室核对\\s*$/ })" + + tree_el_e68ea5e58fa3e58f91e7a5a8e7a1aee8aea4efbc88e8b4a7e7a5a8e5908ce8a18cefbc89_2: + description: "接口发票确认(货票同行)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "接口发票确认(货票同行)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*接口发票确认(货票同行)\\s*$/ })" + + tree_node_e7bb93e5ad98: + description: "结存管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结存管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结存管理\\s*$/ })" + + tree_node_e8b4a2e58aa1: + description: "财务结存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务结存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务结存\\s*$/ })" + + tree_node_e789a9e6b581: + description: "物流结存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物流结存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物流结存\\s*$/ })" + + tree_node_orthoVolume_e9aaa8e7a791: + description: "骨科带量" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "骨科带量" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*骨科带量\\s*$/ })" + + tree_el_e59fbae7a180e695b0e68dae_2: + description: "基础数据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础数据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础数据\\s*$/ })" + + tree_el_e5b8a6e9878fe7b3bbe7bb9fe7aea1e79086_2: + description: "带量系统管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量系统管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量系统管理\\s*$/ })" + + tree_node_e9aaa8e7a791: + description: "骨科带量目录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "骨科带量目录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*骨科带量目录\\s*$/ })" + + tree_node_e695b0e68dae: + description: "数据上报" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "数据上报" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*数据上报\\s*$/ })" + + tree_node_e69caae6b885: + description: "未清洗数据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "未清洗数据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*未清洗数据\\s*$/ })" + + tree_node_e5be85e7a1ae: + description: "待确认数据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "待确认数据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*待确认数据\\s*$/ })" + + tree_node_e5be85e4b88a: + description: "待上报数据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "待上报数据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*待上报数据\\s*$/ })" + + tree_node_e5b7b2e4b88a: + description: "已上报数据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "已上报数据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*已上报数据\\s*$/ })" + + tree_el_e5b8a6e9878fe68aa5e8a1a8e58886e69e90_2: + description: "带量报表分析" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量报表分析" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量报表分析\\s*$/ })" + + tree_el_e9aaa8e7a791e5b8a6e9878fe5ae8ce68890e8bf9be5baa6_2: + description: "骨科带量完成进度" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "骨科带量完成进度" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*骨科带量完成进度\\s*$/ })" + + tree_node_general_e7bbbce59088: + description: "综合管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "综合管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*综合管理\\s*$/ })" + + tree_node_e8aea1e98080: + description: "计退费管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "计退费管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*计退费管理\\s*$/ })" + + tree_el_e6b688e88097e8aea1e8b4b9_2: + description: "消耗计费" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "消耗计费" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*消耗计费\\s*$/ })" + + tree_el_e7a791e5aea4e587bae5ba93e6b187e680bbe68aa5e8a1a8_2: + description: "科室出库汇总报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室出库汇总报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室出库汇总报表\\s*$/ })" + + tree_el_e7b4a7e680a5e585a5e5ba93e58f8ae8aea1e8b4b9_2: + description: "紧急入库及计费" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "紧急入库及计费" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*紧急入库及计费\\s*$/ })" + + tree_node_e9ab98e580bc: + description: "高值计退费" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "高值计退费" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*高值计退费\\s*$/ })" + + tree_el_e4be9be5ba94e59586e585a5e5ba93e6b187e680bbe68aa5e8a1a8_2: + description: "供应商入库汇总报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商入库汇总报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商入库汇总报表\\s*$/ })" + + tree_node_e694b6e694af: + description: "收支汇总报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "收支汇总报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*收支汇总报表\\s*$/ })" + + tree_el_e8aea1e98080e8b4b9e8aeb0e5bd95_2: + description: "计退费记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "计退费记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*计退费记录\\s*$/ })" + + tree_el_e7a791e5aea4e587bae5ba93e6b187e680bbe68aa5e8a1a87632_2: + description: "科室出库汇总报表v2" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室出库汇总报表v2" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室出库汇总报表v2\\s*$/ })" + + tree_node_e88097e69d90: + description: "耗材发放记录" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "耗材发放记录" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*耗材发放记录\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e4b89ae58aa1_2: + description: "中心库业务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库业务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库业务\\s*$/ })" + + tree_el_e587bae5ba93e6a0b8e9aa8c_3: + description: "出库核验" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库核验" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库核验\\s*$/ })" + + tree_el_e7a791e5aea4e8afb7e9a286e5aea1e99885_2: + description: "科室请领审阅" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室请领审阅" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室请领审阅\\s*$/ })" + + tree_el_e98787e8b4ade6b187e680bbe5aea1e689b9_2: + description: "采购汇总审批" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购汇总审批" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购汇总审批\\s*$/ })" + + tree_finance_2: + description: "财务管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务管理\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace7a1aee8aea4_2: + description: "科室成本确认" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本确认" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本确认\\s*$/ })" + + tree_el_e6a380e9aa8ce9a1b9e79baee4b88ee6a380e9aa8ce8af95e58982e5afb9e785a7e7bbb4e68aa4_2: + description: "检验项目与检验试剂对照维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "检验项目与检验试剂对照维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*检验项目与检验试剂对照维护\\s*$/ })" + + tree_node_4249e68aa5e8: + description: "BI报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "BI报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*BI报表\\s*$/ })" + + tree_el_4249e68aa5e8a1a8e58fafe8a786e58c96_2: + description: "BI报表可视化" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "BI报表可视化" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*BI报表可视化\\s*$/ })" + + tree_node_yangcai_e998b3e98787: + description: "阳采管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "阳采管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*阳采管理\\s*$/ })" + + tree_node_e998b3e98787: + description: "阳采字典管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "阳采字典管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*阳采字典管理\\s*$/ })" + + tree_node_processCenter_e6b581e7a88b: + description: "流程中心" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "流程中心" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*流程中心\\s*$/ })" + + tree_el_e5aea1e689b9e4b8ade5bf83_2: + description: "审批中心" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "审批中心" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*审批中心\\s*$/ })" + + tree_node_e68891e79a84: + description: "我的" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "我的" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*我的\\s*$/ })" + + tree_node_e983a8e997a8: + description: "部门待审批单据" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "部门待审批单据" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*部门待审批单据\\s*$/ })" + + tree_node_logistics_e5908ee58ba4: + description: "后勤物资管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "后勤物资管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*后勤物资管理\\s*$/ })" + + tree_basicSetting_2: + description: "基础设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "基础设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*基础设置\\s*$/ })" + + tree_el_e4be9be5ba94e59586e7aea1e79086_2: + description: "供应商管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商管理\\s*$/ })" + + tree_el_e789a9e8b584e5ad97e585b8e7aea1e79086_2: + description: "物资字典管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "物资字典管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*物资字典管理\\s*$/ })" + + tree_el_e7a791e5aea4e5ad97e585b8e58886e9858d_2: + description: "科室字典分配" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室字典分配" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室字典分配\\s*$/ })" + + tree_el_e999a2e58cbae789a9e8b584e8aebee7bdae_2: + description: "院区物资设置" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院区物资设置" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院区物资设置\\s*$/ })" + + tree_el_e8afb7e9a286e6a8a1e78988e7bbb4e68aa4_2: + description: "请领模版维护" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领模版维护" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领模版维护\\s*$/ })" + + tree_purchase_2: + description: "采购管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购管理\\s*$/ })" + + tree_el_e7a791e5aea4e5a487e8b4a7e8aea1e58892_2: + description: "科室备货计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室备货计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室备货计划\\s*$/ })" + + tree_el_e5a487e8b4a7e8aea1e58892e6b187e680bb_2: + description: "备货计划汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "备货计划汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*备货计划汇总\\s*$/ })" + + tree_el_e98787e8b4ade8aea1e58892_2: + description: "采购计划" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购计划" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购计划\\s*$/ })" + + tree_el_e98787e8b4ade8aea1e58892e6988ee7bb86e69fa5e8afa2_2: + description: "采购计划明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购计划明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购计划明细查询\\s*$/ })" + + tree_el_e98787e8b4ade58d95e7aea1e79086_3: + description: "采购单管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购单管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购单管理\\s*$/ })" + + tree_node_e9858de98081: + description: "配送管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "配送管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*配送管理\\s*$/ })" + + tree_el_e887aae58aa8e98787e8b4ade5bbbae8aeae_2: + description: "自动采购建议" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "自动采购建议" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*自动采购建议\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e4b89ae58aa1_3: + description: "中心库业务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库业务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库业务\\s*$/ })" + + tree_el_e9aa8ce694b6e7aea1e79086_2: + description: "验收管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "验收管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*验收管理\\s*$/ })" + + tree_el_e585a5e5ba93e58d95e58897e8a1a8_2: + description: "入库单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库单列表\\s*$/ })" + + tree_el_e6898be5b7a5e585a5e5ba93_2: + description: "手工入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工入库\\s*$/ })" + + tree_el_e6898be5b7a5e587bae5ba93_3: + description: "手工出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工出库\\s*$/ })" + + tree_el_e587bae5ba93e58d95e58897e8a1a8_3: + description: "出库单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单列表\\s*$/ })" + + tree_el_e98080e8b4a7e587bae5ba93_2: + description: "退货出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退货出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退货出库\\s*$/ })" + + tree_el_e587bae5ba93e58d95e5aea1e6a0b8_2: + description: "出库单审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单审核\\s*$/ })" + + tree_el_e8afb7e9a286e4b8ade5bf83e5ba93e5aea1e6a0b8_2: + description: "请领中心库审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领中心库审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领中心库审核\\s*$/ })" + + tree_el_e8afb7e9a286e587bae5ba93_2: + description: "请领出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领出库\\s*$/ })" + + tree_el_e4b88ae69eb6e585a5e5ba93_2: + description: "上架入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "上架入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*上架入库\\s*$/ })" + + tree_el_e98080e8b4a7e6988ee7bb86e69fa5e8afa2_3: + description: "退货明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退货明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退货明细查询\\s*$/ })" + + tree_el_e7a791e5aea4e4b89ae58aa1_2: + description: "科室业务" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室业务" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室业务\\s*$/ })" + + tree_el_e7a791e5aea4e585a5e5ba93_3: + description: "科室入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室入库\\s*$/ })" + + tree_el_e68891e8a681e8afb7e9a286_3: + description: "我要请领" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "我要请领" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*我要请领\\s*$/ })" + + tree_el_e8afb7e9a286e7a791e5aea4e5aea1e6a0b8_2: + description: "请领科室审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领科室审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领科室审核\\s*$/ })" + + tree_el_e585a5e5ba93e58d95e58897e8a1a8_3: + description: "入库单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库单列表\\s*$/ })" + + tree_el_e587bae5ba93e58d95e58897e8a1a8_4: + description: "出库单列表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单列表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单列表\\s*$/ })" + + tree_el_e7a791e5aea4e6b688e88097_2: + description: "科室消耗" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室消耗" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室消耗\\s*$/ })" + + tree_el_e7a791e5aea4e98080e5ba93_2: + description: "科室退库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室退库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室退库\\s*$/ })" + + tree_el_e5ba93e5ad98e69fa5e8afa2_3: + description: "库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存查询\\s*$/ })" + + tree_el_e5ba93e5ad98e6b187e680bbe69fa5e8afa2_3: + description: "库存汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存汇总查询\\s*$/ })" + + tree_el_e5ba93e5ad98e6988ee7bb86_2: + description: "库存明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存明细\\s*$/ })" + + tree_finance_3: + description: "财务管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务管理\\s*$/ })" + + tree_el_e58f91e7a5a8e5bd95e585a5_2: + description: "发票录入" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票录入" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票录入\\s*$/ })" + + tree_el_e58f91e7a5a8e5aea1e6a0b8_2: + description: "发票审核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票审核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票审核\\s*$/ })" + + tree_el_e58f91e7a5a8e69fa5e8afa2_3: + description: "发票查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发票查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发票查询\\s*$/ })" + + tree_node_statReport_e7bb9fe8aea1: + description: "统计报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "统计报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*统计报表\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e587bae585a5e5ba93e68aa5e8a1a8_2: + description: "中心库出入库报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库出入库报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库出入库报表\\s*$/ })" + + tree_node_inOutDetailQuery_e587bae585a5: + description: "出入库明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出入库明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出入库明细查询\\s*$/ })" + + tree_node_inboundSummaryQuery_e585a5e5ba93: + description: "入库汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库汇总查询\\s*$/ })" + + tree_node_outboundSummaryQuery_e587bae5ba93: + description: "出库汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库汇总查询\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e4baa7e59381e8bf9be99480e5ad98_2: + description: "中心库产品进销存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库产品进销存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库产品进销存\\s*$/ })" + + tree_node_e587bae585a5: + description: "出入库明细查询(后勤)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出入库明细查询(后勤)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出入库明细查询(后勤)\\s*$/ })" + + tree_el_e4baa7e59381e591a8e8bdace78e87e68aa5e8a1a8_2: + description: "产品周转率报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品周转率报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品周转率报表\\s*$/ })" + + tree_el_e4b8ade5bf83e5ba93e8bf9be99480e5ad9828e5908ee58ba429_2: + description: "中心库进销存(后勤)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库进销存(后勤)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库进销存(后勤)\\s*$/ })" + + tree_node_e58780e585a5: + description: "净入库汇总查询(多维度)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "净入库汇总查询(多维度)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*净入库汇总查询(多维度)\\s*$/ })" + + tree_node_e58780e587ba: + description: "净出库汇总查询(多维度)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "净出库汇总查询(多维度)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*净出库汇总查询(多维度)\\s*$/ })" + + tree_el_e7a791e5aea4e587bae585a5e5ba93e68aa5e8a1a8_2: + description: "科室出入库报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室出入库报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室出入库报表\\s*$/ })" + + tree_inOutDetailQuery_2: + description: "出入库明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出入库明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出入库明细查询\\s*$/ })" + + tree_inboundSummaryQuery_2: + description: "入库汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入库汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入库汇总查询\\s*$/ })" + + tree_outboundSummaryQuery_2: + description: "出库汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库汇总查询\\s*$/ })" + + tree_el_e7a791e5aea4e4baa7e59381e8bf9be99480e5ad98_2: + description: "科室产品进销存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室产品进销存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室产品进销存\\s*$/ })" + + tree_el_e587bae585a5e5ba93e6988ee7bb86e69fa5e8afa228e5908ee58ba4efbc89_2: + description: "出入库明细查询(后勤)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出入库明细查询(后勤)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出入库明细查询(后勤)\\s*$/ })" + + tree_el_e7a791e5aea4e4baa7e59381e591a8e8bdace78e87e68aa5e8a1a8_2: + description: "科室产品周转率报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室产品周转率报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室产品周转率报表\\s*$/ })" + + tree_el_e7bb93e7ae97e68890e69cace68aa5e8a1a8_2: + description: "结算成本报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算成本报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算成本报表\\s*$/ })" + + tree_el_e68890e69cace585a5e8b4a6e6988ee7bb86e69fa5e8afa2_2: + description: "成本入账明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "成本入账明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*成本入账明细查询\\s*$/ })" + + tree_el_e7bb93e7ae97e585a5e8b4a6e6988ee7bb86e69fa5e8afa2_2: + description: "结算入账明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算入账明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算入账明细查询\\s*$/ })" + + tree_el_e8b4a2e58aa1e58886e7b1bbe8bf9be99480e5ad98_2: + description: "财务分类进销存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务分类进销存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务分类进销存\\s*$/ })" + + tree_el_e8b4a2e58aa1e4baa7e59381e8bf9be99480e5ad98_2: + description: "财务产品进销存" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务产品进销存" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务产品进销存\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace58886e7b1bbe6b187e680bb_2: + description: "科室成本分类汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本分类汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本分类汇总\\s*$/ })" + + tree_el_e4be9be5ba94e59586e585a5e8b4a6e58886e7b1bbe6b187e680bb_2: + description: "供应商入账分类汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商入账分类汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商入账分类汇总\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace58886e7b1bbe6b187e680bb28e8aea1e8b4b9e7a791e5aea429_2: + description: "科室成本分类汇总(计费科室)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本分类汇总(计费科室)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本分类汇总(计费科室)\\s*$/ })" + + tree_el_e7a791e5aea4e58780e585a5e68890e69cace69fa5e8afa2_2: + description: "科室净入成本查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室净入成本查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室净入成本查询\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace68e92e5908d_2: + description: "科室成本排名" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本排名" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本排名\\s*$/ })" + + tree_el_e4baa7e59381e68890e69cace68e92e5908d_2: + description: "产品成本排名" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "产品成本排名" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*产品成本排名\\s*$/ })" + + tree_el_e68890e69cace585a5e8b4a6e6b187e680bbe69fa5e8afa2_2: + description: "成本入账汇总查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "成本入账汇总查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*成本入账汇总查询\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace58886e7b1bbe6b187e680bb_3: + description: "科室成本分类汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本分类汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本分类汇总\\s*$/ })" + + tree_el_e4be9be5ba94e59586e585a5e8b4a6e58886e7b1bbe6b187e680bbe697a7_2: + description: "供应商入账分类汇总旧" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商入账分类汇总旧" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商入账分类汇总旧\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace58886e7b1bbe6b187e680bb28e8aea1e8b4b9e7a791e5aea429e697a7_2: + description: "科室成本分类汇总(计费科室)旧" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本分类汇总(计费科室)旧" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本分类汇总(计费科室)旧\\s*$/ })" + + tree_el_e8b4a2e58aa1e585a5e5ba93e58d95_2: + description: "财务入库单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务入库单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务入库单\\s*$/ })" + + tree_el_e8b4a2e58aa1e587bae5ba93e58d95_2: + description: "财务出库单" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务出库单" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务出库单\\s*$/ })" + + tree_el_e68890e69cace585a5e8b4a6e6988ee7bb86e69fa5e8afa2efbc88e689a7e8a18ce7a791e5aea4e7bbb4e5baa6efbc89_2: + description: "成本入账明细查询(执行科室维度)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "成本入账明细查询(执行科室维度)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*成本入账明细查询(执行科室维度)\\s*$/ })" + + tree_el_e7a791e5aea4e68890e69cace58886e7b1bbe6b187e680bb28e5908ee58ba429_2: + description: "科室成本分类汇总(后勤)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室成本分类汇总(后勤)" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室成本分类汇总(后勤)\\s*$/ })" + + tree_node_e5afb9e8b4a6: + description: "对账报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "对账报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*对账报表\\s*$/ })" + + tree_el_e7a791e5aea4e5afb9e8b4a6e69fa5e8afa2_2: + description: "科室对账查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室对账查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室对账查询\\s*$/ })" + + tree_el_e7a791e5aea4e58780e585a5e6b187e680bbe8a1a8_2: + description: "科室净入汇总表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室净入汇总表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室净入汇总表\\s*$/ })" + + tree_el_e8afb7e9a286e587bae5ba93e58886e7b1bbe6b187e680bbe8a1a8_2: + description: "请领出库分类汇总表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "请领出库分类汇总表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*请领出库分类汇总表\\s*$/ })" + + tree_el_e98787e8b4ade58886e7b1bbe6b187e680bb_2: + description: "采购分类汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购分类汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购分类汇总\\s*$/ })" + + tree_el_e98787e8b4ade585a5e5ba93e6b187e680bbe8a1a8_2: + description: "采购入库汇总表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购入库汇总表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购入库汇总表\\s*$/ })" + + tree_el_e4be9be5ba94e59586e5afb9e8b4a6e69fa5e8afa2_2: + description: "供应商对账查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商对账查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商对账查询\\s*$/ })" + + tree_el_e7a791e5aea4e58780e587bae6b187e680bbe8a1a8_2: + description: "科室净出汇总表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室净出汇总表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室净出汇总表\\s*$/ })" + + tree_node_e8bfbde6baaf: + description: "追溯报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "追溯报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*追溯报表\\s*$/ })" + + tree_node_e79bb4e9858d: + description: "直配请领单追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "直配请领单追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*直配请领单追溯\\s*$/ })" + + tree_el_e69da1e7a081e8bfbde6baaf_2: + description: "条码追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "条码追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*条码追溯\\s*$/ })" + + tree_el_e79bb4e9858de8afb7e9a286e6988ee7bb86e69fa5e8afa2_2: + description: "直配请领明细查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "直配请领明细查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*直配请领明细查询\\s*$/ })" + + tree_el_e7a791e5aea4e8aea2e58d95e5ae8ce68890e68385e586b5e8bfbde6baafe68aa5e8a1a8_2: + description: "科室订单完成情况追溯报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室订单完成情况追溯报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室订单完成情况追溯报表\\s*$/ })" + + tree_el_e98787e8b4ade58d95e8bfbde6baaf_2: + description: "采购单追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "采购单追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*采购单追溯\\s*$/ })" + + tree_el_e7a791e5aea4e8afb7e9a286e58d95e8bfbde6baaf_2: + description: "科室请领单追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室请领单追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室请领单追溯\\s*$/ })" + + tree_el_e7bb93e7ae97e5bc80e7a5a8e69fa5e8afa2_2: + description: "结算开票查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算开票查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算开票查询\\s*$/ })" + + tree_el_e7bb93e7ae97e5bc80e7a5a8e69fa5e8afa2_3: + description: "结算开票查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算开票查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算开票查询\\s*$/ })" + + tree_el_e4be9be5ba94e59586e5bc80e7a5a8e58886e7b1bbe6b187e680bb_2: + description: "供应商开票分类汇总" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商开票分类汇总" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商开票分类汇总\\s*$/ })" + + tree_el_e7bb93e7ae97e58d95e58f91e7a5a8e78ab6e68081e69fa5e8afa2_2: + description: "结算单发票状态查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算单发票状态查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*结算单发票状态查询\\s*$/ })" + + tree_el_e58f91e694bee6b688e88097e68aa5e8a1a8_2: + description: "发放消耗报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发放消耗报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发放消耗报表\\s*$/ })" + + tree_el_e9ab98e580bce6b688e88097e8bfbde6baaf_2: + description: "高值消耗追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "高值消耗追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*高值消耗追溯\\s*$/ })" + + tree_el_e6b688e88097e8b68be58abfe58886e69e90_2: + description: "消耗趋势分析" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "消耗趋势分析" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*消耗趋势分析\\s*$/ })" + + tree_node_e682a3e88085: + description: "患者消耗追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "患者消耗追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*患者消耗追溯\\s*$/ })" + + tree_node_e5a49ae6aca1: + description: "多次计费耗材追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "多次计费耗材追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*多次计费耗材追溯\\s*$/ })" + + tree_el_e7a791e5aea4e6b688e88097e68e92e5908d_2: + description: "科室消耗排名" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室消耗排名" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室消耗排名\\s*$/ })" + + tree_node_e9a284e8ada6: + description: "预警报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "预警报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*预警报表\\s*$/ })" + + tree_node_e6bb9ee79599: + description: "滞留预警" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "滞留预警" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*滞留预警\\s*$/ })" + + tree_el_e7bcbae8b4a7e68aa5e8a1a8_2: + description: "缺货报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "缺货报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*缺货报表\\s*$/ })" + + tree_node_e8bf91e69c9f: + description: "近期效期查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "近期效期查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*近期效期查询\\s*$/ })" + + tree_node_e4bd8ee582a8: + description: "低储预警" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "低储预警" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*低储预警\\s*$/ })" + + tree_node_e5bc82e5b8b8: + description: "异常分析报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "异常分析报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*异常分析报表\\s*$/ })" + + tree_node_e8aea1e8b4b9: + description: "计费消耗差异报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "计费消耗差异报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*计费消耗差异报表\\s*$/ })" + + tree_el_e4bd8ee580bce8aea1e8b4b9e68ea5e58fa3e5b7aee5bc82e7bb9fe8aea1_2: + description: "低值计费接口差异统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "低值计费接口差异统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*低值计费接口差异统计\\s*$/ })" + + tree_el_e4bd8ee580bce8aea1e8b4b9e6b688e88097e5b7aee5bc82e7bb9fe8aea1_2: + description: "低值计费消耗差异统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "低值计费消耗差异统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*低值计费消耗差异统计\\s*$/ })" + + tree_el_e8bf90e7bbb4e68aa5e8a1a8_2: + description: "运维报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "运维报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*运维报表\\s*$/ })" + + tree_el_e585a5e8b4a6e6a0b8e5afb9e69fa5e8afa2_2: + description: "入账核对查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "入账核对查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*入账核对查询\\s*$/ })" + + tree_el_e699bae883bde69f9ce68aa5e8a1a8_2: + description: "智能柜报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能柜报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*智能柜报表\\s*$/ })" + + tree_el_e699bae883bde69f9ce5ba93e5ad98e69fa5e8afa2_2: + description: "智能柜库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能柜库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*智能柜库存查询\\s*$/ })" + + tree_el_e6938de4bd9ce6b581e6b0b4e69fa5e8afa2_2: + description: "操作流水查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "操作流水查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*操作流水查询\\s*$/ })" + + tree_el_e9a286e794a8e69caae8bf98e59b9ee69fa5e8afa2_2: + description: "领用未还回查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "领用未还回查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*领用未还回查询\\s*$/ })" + + tree_el_e699bae883bde69f9ce5b7aee5bc82e68aa5e8a1a8_2: + description: "智能柜差异报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能柜差异报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*智能柜差异报表\\s*$/ })" + + tree_node_e694bfe7ad96: + description: "政策导向" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "政策导向" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*政策导向\\s*$/ })" + + tree_node_e9878de782b9: + description: "重点管控耗材统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "重点管控耗材统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*重点管控耗材统计\\s*$/ })" + + tree_el_e5b8a6e9878fe4bbbbe58aa1e7bb9fe8aea1_2: + description: "带量任务统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量任务统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量任务统计\\s*$/ })" + + tree_el_e5b8a6e9878fe6988ee7bb86e5ae8ce68890e78e87e69fa5e8afa2_2: + description: "带量明细完成率查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量明细完成率查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量明细完成率查询\\s*$/ })" + + tree_el_e5b8a6e9878fe7a791e5aea4e4bbbbe58aa1e6988ee7bb86_2: + description: "带量科室任务明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量科室任务明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量科室任务明细\\s*$/ })" + + tree_el_e5b8a6e9878fe79baee5bd95e6b688e88097e695b0e68daee7bb9fe8aea1_2: + description: "带量目录消耗数据统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "带量目录消耗数据统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*带量目录消耗数据统计\\s*$/ })" + + tree_node_37e4bd8de58c: + description: "7位医保码带量分析" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "7位医保码带量分析" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*7位医保码带量分析\\s*$/ })" + + tree_node_e7bb9fe8aea1: + description: "统计分析" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "统计分析" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*统计分析\\s*$/ })" + + tree_el_e8b083e4bbb7e7bb9fe8aea1e68aa5e8a1a8_2: + description: "调价统计报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "调价统计报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*调价统计报表\\s*$/ })" + + tree_el_e8b4a2e58aa1e695b0e68daee58886e69e902de7a791e5aea4e68890e69cac_2: + description: "财务数据分析-科室成本" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务数据分析-科室成本" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务数据分析-科室成本\\s*$/ })" + + tree_el_e8b4a2e58aa1e695b0e68daee58886e69e902de4be9be5ba94e59586e585a5e8b4a6_2: + description: "财务数据分析-供应商入账" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "财务数据分析-供应商入账" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*财务数据分析-供应商入账\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1_2: + description: "试剂批号统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1e6988ee7bb86_2: + description: "试剂批号统计明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计明细\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1e6988ee7bb86_3: + description: "试剂批号统计明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计明细\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1_3: + description: "试剂批号统计" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1e6988ee7bb86_4: + description: "试剂批号统计明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计明细\\s*$/ })" + + tree_el_e8af95e58982e689b9e58fb7e7bb9fe8aea1e6988ee7bb86_5: + description: "试剂批号统计明细" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "试剂批号统计明细" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*试剂批号统计明细\\s*$/ })" + + tree_node_dataViz_e695b0e68dae: + description: "数据可视化" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "数据可视化" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*数据可视化\\s*$/ })" + + tree_node_e6b58be8af95: + description: "测试0422" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "测试0422" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*测试0422\\s*$/ })" + + tree_el_e4be9be5ba94e59586e585b3e88194e4baa4e69893e79c8be69dbf_2: + description: "供应商关联交易看板" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "供应商关联交易看板" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*供应商关联交易看板\\s*$/ })" + + tree_node_31: + description: "1" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "1" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*1\\s*$/ })" + + tree_node_e8b7b3e8bdac: + description: "跳转测试6" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跳转测试6" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跳转测试6\\s*$/ })" + + tree_node_3132313232: + description: "12122" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "12122" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*12122\\s*$/ })" + + tree_el_e8b7b3e8bdace6b58be8af9537_2: + description: "跳转测试7" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "跳转测试7" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*跳转测试7\\s*$/ })" + + el_535044e58685e7bd91e7b3bbe7bb9f706461: + description: "SPD内网系统pda" + element_type: checkbox + section: "main::main" + strategy: text + value: "SPD内网系统pda" + playwright: "page.getByText('SPD内网系统pda')" + + tree_centerStock_2: + description: "中心库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "中心库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*中心库管理\\s*$/ })" + + tree_node_e588b0e8b4a7: + description: "到货验收" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "到货验收" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*到货验收\\s*$/ })" + + tree_el_e4b88ae69eb6e585a5e5ba93_3: + description: "上架入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "上架入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*上架入库\\s*$/ })" + + tree_el_e68ba3e8b4a7e4b88be69eb6_2: + description: "拣货下架" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "拣货下架" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*拣货下架\\s*$/ })" + + tree_el_e6898be5b7a5e587bae5ba93_4: + description: "手工出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工出库\\s*$/ })" + + tree_el_e8b083e68ba8e587bae5ba93_2: + description: "调拨出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "调拨出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*调拨出库\\s*$/ })" + + tree_el_e98080e8b4a7e587bae5ba93_3: + description: "退货出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退货出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退货出库\\s*$/ })" + + tree_el_e7a7bbe5ba93e587bae5ba93_2: + description: "移库出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "移库出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*移库出库\\s*$/ })" + + tree_el_e587bae5ba93e5a48de6a0b8_3: + description: "出库复核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库复核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库复核\\s*$/ })" + + tree_el_e5bfabe9809fe58aa0e5b7a5_3: + description: "快速加工" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速加工" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速加工\\s*$/ })" + + tree_el_e58aa0e5b7a5e689a7e8a18c_2: + description: "加工执行" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "加工执行" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*加工执行\\s*$/ })" + + tree_el_e5ba93e5ad98e79b98e782b9_2: + description: "库存盘点" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存盘点" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存盘点\\s*$/ })" + + tree_el_e5ba93e5ad98e69fa5e8afa2_4: + description: "库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存查询\\s*$/ })" + + tree_el_e8b4a7e4bd8de8b083e695b4_3: + description: "货位调整" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "货位调整" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*货位调整\\s*$/ })" + + tree_el_e5bfabe9809fe689abe7a081_2: + description: "快速扫码" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速扫码" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速扫码\\s*$/ })" + + tree_el_e69c9fe5889de5bd95e585a5_2: + description: "期初录入" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "期初录入" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*期初录入\\s*$/ })" + + tree_node_e59b9ee58d95: + description: "回单管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "回单管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*回单管理\\s*$/ })" + + tree_node_e689b9e9878f: + description: "批量货位调整" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "批量货位调整" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*批量货位调整\\s*$/ })" + + tree_node_e8819ae58d95: + description: "聚单拣货" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "聚单拣货" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*聚单拣货\\s*$/ })" + + tree_el_e8819ae58d95e5a48de6a0b8_2: + description: "聚单复核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "聚单复核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*聚单复核\\s*$/ })" + + tree_el_e4bba3e7a791e5aea4e585a5e5ba93_2: + description: "代科室入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "代科室入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*代科室入库\\s*$/ })" + + tree_el_e68abde9aa8ce7aea1e79086_2: + description: "抽验管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "抽验管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*抽验管理\\s*$/ })" + + tree_el_e587bae5ba93e58d95e7bb91e5ae9a_2: + description: "出库单绑定" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "出库单绑定" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*出库单绑定\\s*$/ })" + + tree_node_e999a2e58685: + description: "院内配送" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院内配送" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院内配送\\s*$/ })" + + tree_el_e999a2e58685e98081e8bebe_2: + description: "院内送达" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "院内送达" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*院内送达\\s*$/ })" + + tree_el_e5bfabe9809fe68b86e58c85_2: + description: "快速拆包" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "快速拆包" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*快速拆包\\s*$/ })" + + tree_node_e694b6e8b4a7: + description: "收货复核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "收货复核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*收货复核\\s*$/ })" + + tree_el_e7aeb1e7a081e68993e58db0_2: + description: "箱码打印" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "箱码打印" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*箱码打印\\s*$/ })" + + tree_el_e8819ae58d95e9aa8ce694b6_2: + description: "聚单验收" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "聚单验收" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*聚单验收\\s*$/ })" + + tree_node_e58cbbe5b7a5: + description: "医工抽检" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "医工抽检" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*医工抽检\\s*$/ })" + + tree_el_e7a791e5aea4e5ba93e7aea1e79086_2: + description: "科室库管理" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室库管理" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室库管理\\s*$/ })" + + tree_el_e7a791e5aea4e585a5e5ba93_4: + description: "科室入库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "科室入库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*科室入库\\s*$/ })" + + tree_el_e58f91e694bee587bae5ba93_2: + description: "发放出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "发放出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*发放出库\\s*$/ })" + + tree_el_e98080e5ba93e587bae5ba93_3: + description: "退库出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退库出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退库出库\\s*$/ })" + + tree_el_e9a286e794a8e587bae5ba93_2: + description: "领用出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "领用出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*领用出库\\s*$/ })" + + tree_el_e6898be5b7a5e9a286e794a8e587bae5ba93_3: + description: "手工领用出库" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工领用出库" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工领用出库\\s*$/ })" + + tree_el_e6898be5b7a5e6b688e88097_3: + description: "手工消耗" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "手工消耗" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*手工消耗\\s*$/ })" + + tree_el_e5ba93e5ad98e79b98e782b9_3: + description: "库存盘点" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存盘点" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存盘点\\s*$/ })" + + tree_el_e5ba93e5ad98e69fa5e8afa2_5: + description: "库存查询" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库存查询" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库存查询\\s*$/ })" + + tree_el_e98080e5ba93e5a48de6a0b8_2: + description: "退库复核" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "退库复核" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*退库复核\\s*$/ })" + + tree_el_e5ba93e58cbae8b4a7e4bd8de7a7bbe58aa8_2: + description: "库区货位移动" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "库区货位移动" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*库区货位移动\\s*$/ })" + + tree_statReport_2: + description: "统计报表" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "统计报表" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*统计报表\\s*$/ })" + + tree_el_e69da1e7a081e8bfbde6baaf_3: + description: "条码追溯" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "条码追溯" + tree: ".menu-role-tree" + playwright: "page.locator('.menu-role-tree .ant-tree-title').filter({ hasText: /^\\s*条码追溯\\s*$/ })" diff --git a/locators/spd/basic-shared.locator.ts b/locators/spd/basic-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4cd411e4aec3baa272fd905c9996fcc59f6f616 --- /dev/null +++ b/locators/spd/basic-shared.locator.ts @@ -0,0 +1,46 @@ +import type { Locator, Page } from '@playwright/test'; + +/** 基础设置 · 医院资料 共用菜单 / 表格 / 分页 / 弹窗。 */ +export class BasicSharedLocators { + readonly menuBasic: Locator; + readonly menuHospital: Locator; + readonly table: Locator; + readonly tableHeader: Locator; + readonly dataRows: Locator; + readonly empty: Locator; + readonly pagination: Locator; + readonly paginationTotal: Locator; + readonly pageJumpInput: Locator; + readonly nextPage: Locator; + readonly dialog: Locator; + readonly confirm: Locator; + readonly formError: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.menuHospital = page.getByRole('menuitem', { name: '医院资料' }); + this.table = page.locator('.ant-table').first(); + this.tableHeader = page.locator('.ant-table-thead th'); + this.dataRows = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + this.pagination = page.locator('.ant-pagination').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.pageJumpInput = page.locator('.ant-pagination-options-quick-jumper input').first(); + this.nextPage = page.locator('.ant-pagination-next').first(); + this.dialog = page.getByRole('dialog'); + this.confirm = page.locator('.ant-modal-confirm, .ant-popconfirm, .ant-modal').filter({ hasText: '提示' }).last(); + this.formError = page.locator('.ant-form-item-explain-error'); + } + + menuItem(name: string): Locator { + return this.page.getByRole('menuitem', { name, exact: true }); + } + + button(name: string | RegExp): Locator { + return this.page.getByRole('button', { name }); + } + + dialogButton(name: string | RegExp): Locator { + return this.dialog.getByRole('button', { name }); + } +} diff --git a/locators/spd/basic-user-shared.locator.ts b/locators/spd/basic-user-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..902634677f115f20ccb0a36888b02fc36419a0ff --- /dev/null +++ b/locators/spd/basic-user-shared.locator.ts @@ -0,0 +1,72 @@ +import type { Locator, Page } from '@playwright/test'; + +import { COLUMN_SETTINGS_TRIGGER_CSS } from './center-report-shared.locator'; + +/** 基础设置 · 用户管理。叶子菜单是 .ant-menu-item;一级与叶子均可叫「用户管理」。 */ +export class BasicUserSharedLocators { + readonly menuBasic: Locator; + readonly userSubmenuTitle: Locator; + readonly tableHeader: Locator; + readonly tableWrapper: Locator; + readonly columnSettingsTrigger: Locator; + readonly empty: Locator; + readonly paginationTotal: Locator; + readonly pageSizeChanger: Locator; + readonly nextPage: Locator; + readonly formError: Locator; + readonly advancedFilter: Locator; + readonly nameInput: Locator; + readonly dialog: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.userSubmenuTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^用户管理$/ }); + this.tableHeader = page.locator('.ant-table-thead th'); + this.tableWrapper = page.locator('.ant-table-wrapper').first(); + this.columnSettingsTrigger = page.locator(COLUMN_SETTINGS_TRIGGER_CSS).first(); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.pageSizeChanger = page.locator('.ant-pagination-options-size-changer').first(); + this.nextPage = page.locator('.ant-pagination-next').first(); + this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice'); + this.advancedFilter = page.locator('.advance-filter').first(); + this.nameInput = page.getByPlaceholder('姓名、登录账号'); + this.dialog = page.locator('.ant-modal-wrap .ant-modal:visible, .ant-drawer-open').last(); + } + + menuLeaf(name: string): Locator { + return this.page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first(); + } + + button(name: string, root: Locator | Page = this.page): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return root.getByRole('button', { name: pattern }); + } + + formItem(label: string, root: Locator | Page = this.page): Locator { + return root.locator('.ant-form-item').filter({ hasText: label }).first(); + } + + formSelect(label: string, root: Locator | Page = this.page): Locator { + return this.formItem(label, root).locator('.ant-select').first(); + } + + columnSettingsPanel(): Locator { + return this.page + .locator('.ant-popover:visible, .ant-dropdown:visible') + .filter({ hasText: '字段设置' }) + .first(); + } + + columnSettingsCheckboxRow(label: string): Locator { + return this.columnSettingsPanel() + .locator('.ant-checkbox-wrapper, label') + .filter({ hasText: label }) + .first(); + } + + columnHeaderCell(columnName: string): Locator { + return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first(); + } +} diff --git a/locators/spd/basic-user-union.locator.ts b/locators/spd/basic-user-union.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9677f9e6cc40d4657cc9275f5230e16495df234 --- /dev/null +++ b/locators/spd/basic-user-union.locator.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; + +import { BasicUserLocators } from './basic-user.locator'; + +/** 医共体用户管理。选择器来自 locators/spd/basic-user-union.locator.yaml */ +export class BasicUserUnionLocators extends BasicUserLocators { + constructor(page: Page) { + super(page); + } +} diff --git a/locators/spd/basic-user-union.locator.yaml b/locators/spd/basic-user-union.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a278e9782916e876fb91b22685cd38acbd8a4354 --- /dev/null +++ b/locators/spd/basic-user-union.locator.yaml @@ -0,0 +1,533 @@ +# Auto-generated from basic-user-union-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/personCom +# Scanned: 2026-10-20T07:55:55.473Z +# Page: 基础设置 > 用户管理 > 医共体用户管理 +# Note: advanced filter panel was opened during scan +# Note: filter dropdown was opened during scan +# Note: edit dialog was opened during scan + +page: basic-user-union +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/personCom + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_roleManage: + description: "角色管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "角色管理" + playwright: "page.getByRole('menuitem', { name: '角色管理' })" + + menu_userManage: + description: "用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "用户管理" + playwright: "page.getByRole('menuitem', { name: '用户管理' })" + + menu_unionUserManage: + description: "医共体用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医共体用户管理" + playwright: "page.getByRole('menuitem', { name: '医共体用户管理' })" + + unionUserManage: + description: "医共体用户管理" + element_type: tab + section: "main::main" + strategy: text + value: "医共体用户管理" + playwright: "page.getByText('医共体用户管理')" + + tab_unionUserManage: + description: "医共体用户管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/user/platform/personCom" + playwright: "page.locator('#rc-tabs-0-tab-/basic/user/platform/personCom')" + + el_e5a793e5908dInput: + description: "姓名、登录账号" + element_type: text + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e68980e5b19ee58cbbe999a2Select: + description: "邹城市人民医院(test)" + element_type: select + section: "main::main" + strategy: label_text + value: "所属医院" + playwright: "page.getByText('所属医院', { exact: true })" + + el_e68980e5b19ee58cbbe999a2Input: + description: "所属医院" + element_type: search + section: "main::main" + strategy: id + value: "form_item_corpId" + playwright: "page.locator('#form_item_corpId')" + + el_e88194e7b3bbe696b9e5bc8fInput: + description: "联系方式" + element_type: text + section: "main::main" + strategy: id + value: "form_item_tel" + playwright: "page.locator('#form_item_tel')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + advancedFilterBtn: + description: "高级筛选" + element_type: filter_trigger + section: "main::main" + strategy: css + value: ".advance-filter" + playwright: "page.locator('.advance-filter')" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + el_457863656ce5afbce585a5Btn: + description: "Excel导入" + element_type: button + section: "main::main" + strategy: role + role: button + name: "Excel导入" + playwright: "page.getByRole('button', { name: 'Excel导入' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e68e88e69d83Btn: + description: "授权" + element_type: button + section: "main::main" + strategy: role + role: button + name: "授权" + playwright: "page.getByRole('button', { name: '授权' })" + + el_e9878de7bdaee5af86e7a081Btn: + description: "重置密码" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重置密码" + playwright: "page.getByRole('button', { name: '重置密码' })" + + el_e7a791e5aea4Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "科室" + playwright: "page.getByText('科室', { exact: true })" + + el_e5a793e5908dInput_2: + description: "姓名" + element_type: search + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e8a792e889b2Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "角色" + playwright: "page.getByText('角色', { exact: true })" + + el_e8a792e889b2Input: + description: "角色" + element_type: search + section: "main::main" + strategy: id + value: "form_item_roleCode" + playwright: "page.locator('#form_item_roleCode')" + + filterApplyBtn: + description: "筛 选" + element_type: button + section: "main::main" + strategy: role + role: button + name: "筛 选" + playwright: "page.getByRole('button', { name: '筛 选' })" + + # --- main::pagination --- + pagination_el_e585b1313432e69da13132333530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 142 条12350 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 142 条12350 条/页跳至页" + playwright: "page.getByText('共 142 条12350 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_page_3: + description: "3" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::panel:高级筛选 --- + drawer_el_e5a793e5908dInput: + description: "姓名、登录账号" + element_type: text + section: "main::panel:高级筛选" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + drawer_el_e68980e5b19ee58cbbe999a2Select: + description: "邹城市人民医院(test)" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "所属医院" + playwright: "page.getByText('所属医院', { exact: true })" + + drawer_el_e68980e5b19ee58cbbe999a2Input: + description: "所属医院" + element_type: search + section: "main::panel:高级筛选" + strategy: id + value: "form_item_corpId" + playwright: "page.locator('#form_item_corpId')" + + drawer_el_e88194e7b3bbe696b9e5bc8fInput: + description: "联系方式" + element_type: text + section: "main::panel:高级筛选" + strategy: id + value: "form_item_tel" + playwright: "page.locator('#form_item_tel')" + + drawer_el_e7a791e5aea4Select: + description: "请选择" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "科室" + playwright: "page.getByText('科室', { exact: true })" + + drawer_el_e5a793e5908dInput_2: + description: "姓名" + element_type: search + section: "main::panel:高级筛选" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + drawer_el_e8a792e889b2Select: + description: "请选择" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "角色" + playwright: "page.getByText('角色', { exact: true })" + + drawer_el_e8a792e889b2Input: + description: "角色" + element_type: search + section: "main::panel:高级筛选" + strategy: id + value: "form_item_roleCode" + playwright: "page.locator('#form_item_roleCode')" + + # --- main::panel:drawer:footer --- + # --- main::modal:编辑用户 --- + el_e8b4a6e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_userCode" + playwright: "page.locator('#form_item_userCode')" + + el_e5a793e5908dInput_3: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e88194e7b3bbe696b9e5bc8fInput_2: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_tel" + playwright: "page.locator('#form_item_tel')" + + el_e58cbbe999a2e59198e5b7a5e7bc96e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_erpCode" + playwright: "page.locator('#form_item_erpCode')" + + el_e7adbee5908de59bbee78987: + description: "签名图片" + element_type: span + section: "main::modal:编辑用户" + strategy: role + role: button + name: "签名图片" + playwright: "page.getByRole('button', { name: '签名图片' })" + + el_e7a681e794a8: + description: "禁用" + element_type: radio + section: "main::modal:编辑用户" + strategy: text + value: "禁用" + playwright: "page.getByText('禁用')" + + enable: + description: "启用" + element_type: radio + section: "main::modal:编辑用户" + strategy: text + value: "启用" + playwright: "page.getByText('启用')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:编辑用户" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:编辑用户" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" diff --git a/locators/spd/basic-user.locator.ts b/locators/spd/basic-user.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2f192ed7b6fadafcafa381ae9b8f6599f365fa3 --- /dev/null +++ b/locators/spd/basic-user.locator.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; + +import { BasicUserSharedLocators } from './basic-user-shared.locator'; + +/** 用户管理。选择器来自 locators/spd/basic-user.locator.yaml */ +export class BasicUserLocators extends BasicUserSharedLocators { + readonly deptInput: Locator; + readonly roleInput: Locator; + readonly excelImport: Locator; + readonly signature: Locator; + + constructor(page: Page) { + super(page); + this.deptInput = page.locator('#form_item_deptId'); + this.roleInput = page.locator('#form_item_roleCode'); + this.excelImport = page.getByRole('button', { name: 'Excel导入' }); + this.signature = page.getByText('签名图片'); + } + + dialogUserCode(): Locator { + return this.dialog.locator('#form_item_userCode'); + } + + dialogName(): Locator { + return this.dialog.locator('#form_item_cname'); + } + + dialogTel(): Locator { + return this.dialog.locator('#form_item_tel'); + } + + dialogErp(): Locator { + return this.dialog.locator('#form_item_erpCode'); + } +} diff --git a/locators/spd/basic-user.locator.yaml b/locators/spd/basic-user.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7077c9cf28e0bc375a258c5b34cb3ee9146d9453 --- /dev/null +++ b/locators/spd/basic-user.locator.yaml @@ -0,0 +1,517 @@ +# Auto-generated from basic-user-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/person +# Scanned: 2026-10-20T07:54:40.566Z +# Page: 基础设置 > 用户管理 > 用户管理 +# Note: advanced filter panel was opened during scan +# Note: filter dropdown was opened during scan +# Note: edit dialog was opened during scan + +page: basic-user +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/person + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_roleManage: + description: "角色管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "角色管理" + playwright: "page.getByRole('menuitem', { name: '角色管理' })" + + menu_userManage: + description: "用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "用户管理" + playwright: "page.getByRole('menuitem', { name: '用户管理' })" + + menu_unionUserManage: + description: "医共体用户管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "医共体用户管理" + playwright: "page.getByRole('menuitem', { name: '医共体用户管理' })" + + userManage: + description: "用户管理" + element_type: tab + section: "main::main" + strategy: text + value: "用户管理" + playwright: "page.getByText('用户管理')" + + tab_userManage: + description: "用户管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/user/platform/person" + playwright: "page.locator('#rc-tabs-0-tab-/basic/user/platform/person')" + + el_e5a793e5908dInput: + description: "姓名、登录账号" + element_type: text + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e7a791e5aea4Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "科室" + playwright: "page.getByText('科室', { exact: true })" + + el_e5a793e5908dInput_2: + description: "姓名" + element_type: search + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e8a792e889b2Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "角色" + playwright: "page.getByText('角色', { exact: true })" + + el_e8a792e889b2Input: + description: "角色" + element_type: search + section: "main::main" + strategy: id + value: "form_item_roleCode" + playwright: "page.locator('#form_item_roleCode')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + advancedFilterBtn: + description: "高级筛选" + element_type: filter_trigger + section: "main::main" + strategy: css + value: ".advance-filter" + playwright: "page.locator('.advance-filter')" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + el_457863656ce5afbce585a5Btn: + description: "Excel导入" + element_type: button + section: "main::main" + strategy: role + role: button + name: "Excel导入" + playwright: "page.getByRole('button', { name: 'Excel导入' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e68e88e69d83Btn: + description: "授权" + element_type: button + section: "main::main" + strategy: role + role: button + name: "授权" + playwright: "page.getByRole('button', { name: '授权' })" + + el_e9878de7bdaee5af86e7a081Btn: + description: "重置密码" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重置密码" + playwright: "page.getByRole('button', { name: '重置密码' })" + + el_e5a793e5908dInput_3: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e7a791e5aea4Input: + description: "科室" + element_type: search + section: "main::main" + strategy: id + value: "form_item_deptId" + playwright: "page.locator('#form_item_deptId')" + + statusSelect: + description: "状态" + element_type: select + section: "main::main" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + filterApplyBtn: + description: "筛 选" + element_type: button + section: "main::main" + strategy: role + role: button + name: "筛 选" + playwright: "page.getByRole('button', { name: '筛 选' })" + + # --- main::pagination --- + pagination_el_e585b1313432e69da13132333530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 142 条12350 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 142 条12350 条/页跳至页" + playwright: "page.getByText('共 142 条12350 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_page_3: + description: "3" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::panel:高级筛选 --- + drawer_el_e5a793e5908dInput: + description: "请输入" + element_type: text + section: "main::panel:高级筛选" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + drawer_el_e7a791e5aea4Select: + description: "请选择" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "科室" + playwright: "page.getByText('科室', { exact: true })" + + drawer_el_e7a791e5aea4Input: + description: "科室" + element_type: search + section: "main::panel:高级筛选" + strategy: id + value: "form_item_deptId" + playwright: "page.locator('#form_item_deptId')" + + drawer_el_e8a792e889b2Select: + description: "请选择" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "角色" + playwright: "page.getByText('角色', { exact: true })" + + drawer_el_e8a792e889b2Input: + description: "角色" + element_type: search + section: "main::panel:高级筛选" + strategy: id + value: "form_item_roleCode" + playwright: "page.locator('#form_item_roleCode')" + + drawer_statusSelect: + description: "状态" + element_type: select + section: "main::panel:高级筛选" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + # --- main::panel:drawer:footer --- + # --- main::modal:编辑用户 --- + el_e8b4a6e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_userCode" + playwright: "page.locator('#form_item_userCode')" + + el_e5a793e5908dInput_4: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e88194e7b3bbe696b9e5bc8fInput: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_tel" + playwright: "page.locator('#form_item_tel')" + + el_e58cbbe999a2e59198e5b7a5e7bc96e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:编辑用户" + strategy: id + value: "form_item_erpCode" + playwright: "page.locator('#form_item_erpCode')" + + el_e7adbee5908de59bbee78987: + description: "签名图片" + element_type: span + section: "main::modal:编辑用户" + strategy: role + role: button + name: "签名图片" + playwright: "page.getByRole('button', { name: '签名图片' })" + + el_e7a681e794a8: + description: "禁用" + element_type: radio + section: "main::modal:编辑用户" + strategy: text + value: "禁用" + playwright: "page.getByText('禁用')" + + enable: + description: "启用" + element_type: radio + section: "main::modal:编辑用户" + strategy: text + value: "启用" + playwright: "page.getByText('启用')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:编辑用户" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:编辑用户" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" diff --git a/locators/spd/flow-def.locator.ts b/locators/spd/flow-def.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..be08d405c4258ef08acaece704346ab763cb4efa --- /dev/null +++ b/locators/spd/flow-def.locator.ts @@ -0,0 +1,37 @@ +import type { Locator, Page } from '@playwright/test'; + +import { WhFlowSharedLocators } from './wh-flow-shared.locator'; + +/** 审批流程设置。选择器来自 locators/spd/flow-def.locator.yaml */ +export class FlowDefLocators extends WhFlowSharedLocators { + readonly typeSearch: Locator; + readonly addFlowBtn: Locator; + readonly saveBtn: Locator; + readonly resetBtn: Locator; + readonly defaultFlow: Locator; + readonly customFlow: Locator; + readonly conditionCancel: Locator; + + constructor(page: Page) { + super(page); + this.typeSearch = page.getByPlaceholder('流程名称'); + this.addFlowBtn = page.getByRole('button', { name: '新增流程' }); + this.saveBtn = page.getByRole('button', { name: /保\s*存/ }); + this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); + this.defaultFlow = page.getByText('默认审批流', { exact: true }).first(); + this.customFlow = page.getByText('自定义审批流', { exact: true }).first(); + this.conditionCancel = page.getByRole('button', { name: /取\s*消/ }); + } + + typeItems(): Locator { + return this.page.locator( + '.ant-layout-sider .ant-menu-item, .ant-layout-sider li, [class*="left"] .ant-menu-item, [class*="biz"] li', + ); + } + + selectedType(): Locator { + return this.page + .locator('.ant-layout-sider .ant-menu-item-selected, .ant-layout-sider .ant-menu-item-active, [class*="selected"]') + .first(); + } +} diff --git a/locators/spd/flow-def.locator.yaml b/locators/spd/flow-def.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..77068ad415611b7e881e5289162d0c1f28b97e5e --- /dev/null +++ b/locators/spd/flow-def.locator.yaml @@ -0,0 +1,298 @@ +# Auto-generated from flow-def-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowSet +# Scanned: 2026-10-20T09:05:57.781Z +# Page: 基础设置 > 流程设置 > 审批流程设置 + +page: flow-def +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowSet + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_el_e5aea1e689b9e6b581e7a88be8aebee7bdae: + description: "审批流程设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "审批流程设置" + playwright: "page.getByRole('menuitem', { name: '审批流程设置' })" + + menu_el_e5aea1e689b9e6b581e5ae9ee4be8b: + description: "审批流实例" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "审批流实例" + playwright: "page.getByRole('menuitem', { name: '审批流实例' })" + + el_e5aea1e689b9e6b581e7a88be8aebee7bdae: + description: "审批流程设置" + element_type: tab + section: "main::main" + strategy: text + value: "审批流程设置" + playwright: "page.getByText('审批流程设置')" + + tab_el_e5aea1e689b9e6b581e7a88be8aebee7bdae: + description: "审批流程设置" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/flowSet/workflow/flowSet" + playwright: "page.locator('#rc-tabs-0-tab-/basic/flowSet/workflow/flowSet')" + + el_e5aea1e689b9e6b581e7a88be8aebee7bdaeInput: + description: "流程名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "流程名称" + playwright: "page.getByPlaceholder('流程名称')" + + el_e696b0e5a29ee6b581e7a88bBtn: + description: "新增流程" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增流程" + playwright: "page.getByRole('button', { name: '新增流程' })" + + Input: + description: "1" + element_type: text + section: "main::main" + strategy: id + value: "form_item_pageList_0_modelName" + playwright: "page.locator('#form_item_pageList_0_modelName')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e588a0e999a4Btn: + description: "删除" + element_type: button + section: "main::main" + strategy: role + role: button + name: "删除" + playwright: "page.getByRole('button', { name: '删除' })" + + 2Input_2: + description: "2" + element_type: text + section: "main::main" + strategy: id + value: "form_item_pageList_1_modelName" + playwright: "page.locator('#form_item_pageList_1_modelName')" + + saveBtn: + description: "保 存" + element_type: button + section: "main::main" + strategy: role + role: button + name: "保 存" + playwright: "page.getByRole('button', { name: '保 存' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + form_item_pagelist_2_modelnameInput: + description: "text" + element_type: text + section: "main::main" + strategy: id + value: "form_item_pageList_2_modelName" + playwright: "page.locator('#form_item_pageList_2_modelName')" + + # --- main::modal:条件配置 --- + el_e6b7bbe58aa0Btn: + description: "添 加" + element_type: button + section: "main::modal:条件配置" + strategy: role + role: button + name: "添 加" + playwright: "page.getByRole('button', { name: '添 加' })" + + el_e698afe590a6e8b685e99990Select: + description: "是否超限" + element_type: select + section: "main::modal:条件配置" + strategy: text + value: "是否超限" + playwright: "page.getByText('是否超限')" + + form_item_tablelist_0_conditiontypeInput: + description: "search" + element_type: search + section: "main::modal:条件配置" + strategy: id + value: "form_item_tableList_0_conditionType" + playwright: "page.locator('#form_item_tableList_0_conditionType')" + + el_e698afSelect: + description: "是" + element_type: select + section: "main::modal:条件配置" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + form_item_tablelist_0_conditiontextInput: + description: "search" + element_type: search + section: "main::modal:条件配置" + strategy: id + value: "form_item_tableList_0_conditionText" + playwright: "page.locator('#form_item_tableList_0_conditionText')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:条件配置" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" diff --git a/locators/spd/flow-inst.locator.ts b/locators/spd/flow-inst.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..7146b18eef87a13c7218e3109ea1083b4bdfe15f --- /dev/null +++ b/locators/spd/flow-inst.locator.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; + +import { WhFlowSharedLocators } from './wh-flow-shared.locator'; + +/** 审批流实例。选择器来自 locators/spd/flow-inst.locator.yaml */ +export class FlowInstLocators extends WhFlowSharedLocators { + readonly orderNo: Locator; + readonly summary: Locator; + readonly startDate: Locator; + readonly endDate: Locator; + readonly queryBtn: Locator; + readonly resetBtn: Locator; + readonly detailBtn: Locator; + readonly backBtn: Locator; + + constructor(page: Page) { + super(page); + this.orderNo = page.locator('#form_item_orderNo').or(page.getByPlaceholder('单据编号')).first(); + this.summary = page.locator('#form_item_msg').or(page.getByPlaceholder('摘要')).first(); + this.startDate = page.locator('#form_item_dateValue').first(); + this.endDate = page.getByPlaceholder('截止时间').first(); + this.queryBtn = page.getByRole('button', { name: /查\s*询/ }); + this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); + this.detailBtn = page.getByRole('button', { name: '详情' }); + this.backBtn = page.getByRole('button', { name: /返\s*回/ }); + } + + statusSelect(): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: '审批状态' }).locator('.ant-select').first(); + } + + bizTypeSelect(): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: '业务类型' }).locator('.ant-select').first(); + } +} diff --git a/locators/spd/flow-inst.locator.yaml b/locators/spd/flow-inst.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1965fd1434ec133ff7834fcca5e2f3ef0b2be0f6 --- /dev/null +++ b/locators/spd/flow-inst.locator.yaml @@ -0,0 +1,444 @@ +# Auto-generated from flow-inst-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowCase +# Scanned: 2026-10-20T09:08:51.288Z +# Page: 基础设置 > 流程设置 > 审批流实例 +# Note: filter dropdown was opened during scan +# Note: detail view was opened during scan + +page: flow-inst +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowCase + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_el_e5aea1e689b9e6b581e7a88be8aebee7bdae: + description: "审批流程设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "审批流程设置" + playwright: "page.getByRole('menuitem', { name: '审批流程设置' })" + + menu_el_e5aea1e689b9e6b581e5ae9ee4be8b: + description: "审批流实例" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "审批流实例" + playwright: "page.getByRole('menuitem', { name: '审批流实例' })" + + el_e5aea1e689b9e6b581e5ae9ee4be8b: + description: "审批流实例" + element_type: tab + section: "main::main" + strategy: text + value: "审批流实例" + playwright: "page.getByText('审批流实例')" + + tab_el_e5aea1e689b9e6b581e5ae9ee4be8b: + description: "审批流实例" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/flowSet/workflow/flowCase" + playwright: "page.locator('#rc-tabs-0-tab-/basic/flowSet/workflow/flowCase')" + + documentNo: + description: "单据编号" + element_type: text + section: "main::main" + strategy: id + value: "form_item_orderNo" + playwright: "page.locator('#form_item_orderNo')" + + el_e5aea1e689b9e78ab6e68081Select: + description: "审批状态" + element_type: select + section: "main::main" + strategy: label_text + value: "审批状态" + playwright: "page.getByText('审批状态', { exact: true })" + + el_e5aea1e689b9e78ab6e68081Input: + description: "审批状态" + element_type: search + section: "main::main" + strategy: id + value: "form_item_procState" + playwright: "page.locator('#form_item_procState')" + + el_e58f91e8b5b7e697b6e997b4DateRange: + description: "发起时间" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "发起时间" + playwright: "page.getByText('发起时间', { exact: true })" + + el_e58f91e8b5b7e697b6e997b4Input: + description: "开始时间" + element_type: text + section: "main::main" + strategy: id + value: "form_item_dateValue" + playwright: "page.locator('#form_item_dateValue')" + + el_e58f91e8b5b7e697b6e997b4Input_2: + description: "截止时间" + element_type: text + section: "main::main" + strategy: placeholder + value: "截止时间" + playwright: "page.getByPlaceholder('截止时间')" + + el_e4b89ae58aa1e7b1bbe59e8bSelect: + description: "业务类型" + element_type: select + section: "main::main" + strategy: label_text + value: "业务类型" + playwright: "page.getByText('业务类型', { exact: true })" + + el_e4b89ae58aa1e7b1bbe59e8bInput: + description: "业务类型" + element_type: search + section: "main::main" + strategy: id + value: "form_item_businessType" + playwright: "page.locator('#form_item_businessType')" + + el_e69198e8a681Input: + description: "摘要" + element_type: text + section: "main::main" + strategy: id + value: "form_item_msg" + playwright: "page.locator('#form_item_msg')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + el_e58f91e8b5b7e5aea1e689b9e8928be58d8ee58f91e8b5b7e4babae5a484e79086e697b6e997b4efbc9a323032362d31302d323031363a34383a353028e88097e697b630e5b08fe697b630e58886e9929f30e7a79229e5aea1e689b9e6848fe8a781efbc9ae28094: + description: "发起审批蒋华发起人处理时间:2026-10-20 16:48:50(耗时0小时0分钟0秒)审批意见:—" + element_type: div + section: "main::main" + strategy: role + role: button + name: "发起审批蒋华发起人处理时间:2026-10-20 16:48:50(耗时0小时0分钟0秒)审批意见:—" + playwright: "page.getByRole('button', { name: '发起审批蒋华发起人处理时间:2026-10-20 16:48:50(耗时0小时0分钟0秒)审批意见:—' })" + + el_e5aea1e689b9e9809ae8bf87e8928be58d8ee8bf90e890a5e4b8bbe7aea1e5a484e79086e697b6e997b4efbc9a323032362d31302d323031363a34383a353028e88097e697b630e5b08fe697b630e58886e9929f31e7a79229e5aea1e689b9e6848fe8a781efbc9ae887aae58aa8e5aea1e689b9: + description: "审批通过蒋华运营主管处理时间:2026-10-20 16:48:50(耗时0小时0分钟1秒)审批意见:自动审批" + element_type: div + section: "main::main" + strategy: role + role: button + name: "审批通过蒋华运营主管处理时间:2026-10-20 16:48:50(耗时0小时0分钟1秒)审批意见:自动审批" + playwright: "page.getByRole('button', { name: '审批通过蒋华运营主管处理时间:2026-10-20 16:48:50(耗时0小时0分钟1秒)审批意见:自动审批' })" + + # --- main::pagination --- + pagination_el_e585b132333632e69da13132333435e280a2e280a2e280a234383530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 2362 条12345•••4850 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 2362 条12345•••4850 条/页跳至页" + playwright: "page.getByText('共 2362 条12345•••4850 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_page_3: + description: "3" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + pagination_page_4: + description: "4" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "4" + playwright: "page.getByText('4')" + + pagination_page_5: + description: "5" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "5" + playwright: "page.getByText('5')" + + pagination_el_e59091e5908e35e9a1b5: + description: "向后 5 页" + element_type: li + section: "main::pagination" + strategy: text + value: "向后 5 页" + playwright: "page.getByText('向后 5 页')" + + pagination_page_48: + description: "48" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "48" + playwright: "page.getByText('48')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::detail:采购计划详情 --- + backBtn: + description: "返 回" + element_type: button + section: "main::detail:采购计划详情" + strategy: role + role: button + name: "返 回" + playwright: "page.getByRole('button', { name: '返 回' })" + + tableDensity_2: + description: "column-height" + element_type: span + section: "main::detail:采购计划详情" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + el_e585b132e69da1313530e69da12fe9a1b5: + description: "共 2 条150 条/页" + element_type: ul + section: "main::detail:采购计划详情" + strategy: text + value: "共 2 条150 条/页" + playwright: "page.getByText('共 2 条150 条/页')" + + prevPageBtn: + description: "上一页" + element_type: li + section: "main::detail:采购计划详情" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + page_1: + description: "1" + element_type: pagination_item + section: "main::detail:采购计划详情" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + nextPageBtn: + description: "下一页" + element_type: li + section: "main::detail:采购计划详情" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::detail:采购计划详情" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/surgery-base-pkg.locator.yaml b/locators/spd/surgery-base-pkg.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b6d58210d5a8f431c55cf61309435adf5921eca4 --- /dev/null +++ b/locators/spd/surgery-base-pkg.locator.yaml @@ -0,0 +1,636 @@ +# Auto-generated from surgery-base-pkg-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/baseMaintenance +# Scanned: 2026-10-20T08:47:17.301Z +# Page: 基础设置 > 手术套包设置 > 基础包维护 +# Note: dialog/modal was opened during scan + +page: surgery-base-pkg +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/baseMaintenance + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_basePkgMaintenance: + description: "基础包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础包维护" + playwright: "page.getByRole('menuitem', { name: '基础包维护' })" + + menu_operTypeMaintenance: + description: "术种维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "术种维护" + playwright: "page.getByRole('menuitem', { name: '术种维护' })" + + menu_stdPkgMaintenance: + description: "标准套包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "标准套包维护" + playwright: "page.getByRole('menuitem', { name: '标准套包维护' })" + + basePkgMaintenance: + description: "基础包维护" + element_type: tab + section: "main::main" + strategy: text + value: "基础包维护" + playwright: "page.getByText('基础包维护')" + + tab_basePkgMaintenance: + description: "基础包维护" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/operationSetting/baseMaintenance" + playwright: "page.locator('#rc-tabs-0-tab-/basic/operationSetting/baseMaintenance')" + + addCategoryBtn: + description: "新增分类" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增分类" + playwright: "page.getByRole('button', { name: '新增分类' })" + + addBasePkgBtn: + description: "新增基础包" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增基础包" + playwright: "page.getByRole('button', { name: '新增基础包' })" + + categorySearchInput: + description: "请输入分类名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "请输入分类名称" + playwright: "page.getByPlaceholder('请输入分类名称')" + + tree_node_e7b1bb313231: + description: "类12121313" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类12121313" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类12121313\\s*$/ })" + + tree_node_e58c85323131: + description: "包2112412" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包2112412" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包2112412\\s*$/ })" + + tree_node_e58c85415554: + description: "包AUTO-BASE-1792473862012-33" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包AUTO-BASE-1792473862012-33" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473862012-33\\s*$/ })" + + tree_el_e58c854155544f2d424153452d313739323437333839313334382d313539_2: + description: "包AUTO-BASE-1792473891348-159" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包AUTO-BASE-1792473891348-159" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473891348-159\\s*$/ })" + + tree_el_e58c854155544f2d424153452d313739323437333933343839312d393337_2: + description: "包AUTO-BASE-1792473934891-937" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包AUTO-BASE-1792473934891-937" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473934891-937\\s*$/ })" + + tree_node_e7b1bb323032: + description: "类20240324" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类20240324" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20240324\\s*$/ })" + + tree_el_e7b1bb3230323530353136_2: + description: "类20250516" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类20250516" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20250516\\s*$/ })" + + tree_el_e7b1bb3230323630373039_2: + description: "类20260709" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类20260709" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20260709\\s*$/ })" + + tree_el_e7b1bb32303236e59b9ee5bd92e6b58be8af95_2: + description: "类2026回归测试" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类2026回归测试" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2026回归测试\\s*$/ })" + + tree_node_e7b1bb323531: + description: "类2512回归" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类2512回归" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2512回归\\s*$/ })" + + tree_node_e7b1bb544553: + description: "类TEST" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类TEST" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类TEST\\s*$/ })" + + tree_node_e7b1bb6a6d5f: + description: "类jm_基础包分类01" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类jm_基础包分类01" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm_基础包分类01\\s*$/ })" + + tree_el_e7b1bb6a6d5fe59fbae7a180e58c85e58886e7b1bb3032_2: + description: "类jm_基础包分类02" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类jm_基础包分类02" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm_基础包分类02\\s*$/ })" + + tree_node_e7b1bbe58fa3: + description: "类口腔科基础包分类1" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类口腔科基础包分类1" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类口腔科基础包分类1\\s*$/ })" + + tree_node_e7b1bbe59fba: + description: "类基础包0613" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类基础包0613" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类基础包0613\\s*$/ })" + + tree_el_e7b1bbe59fbae7a180e58c8531_2: + description: "类基础包1" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类基础包1" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类基础包1\\s*$/ })" + + tree_node_e7b1bbe5a496: + description: "类外科手术" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类外科手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类外科手术\\s*$/ })" + + tree_node_e7b1bbe5b091: + description: "类少" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类少" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类少\\s*$/ })" + + tree_node_e7b1bbe6898b: + description: "类手术分类" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类手术分类" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类手术分类\\s*$/ })" + + tree_node_e7b1bbe69588: + description: "类效期" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类效期" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类效期\\s*$/ })" + + tree_node_e7b1bbe88386: + description: "类胆囊手术" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类胆囊手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类胆囊手术\\s*$/ })" + + tree_node_e7b1bbe9ab98: + description: "类高值包" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类高值包" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类高值包\\s*$/ })" + + tree_node_e7b1bb31: + description: "类1" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类1" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类1\\s*$/ })" + + tree_node_e7b1bb33: + description: "类3" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类3" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类3\\s*$/ })" + + tree_node_e7b1bb6a6d37: + description: "类jm73" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类jm73" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm73\\s*$/ })" + + tree_node_e7b1bbe5bf83: + description: "类心内科手术" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类心内科手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类心内科手术\\s*$/ })" + + tree_node_e7b1bbe9aaa8: + description: "类骨科手术" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类骨科手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类骨科手术\\s*$/ })" + + productName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_keyword" + playwright: "page.locator('#table_search_form_keyword')" + + specModel: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsSpec" + playwright: "page.locator('#table_search_form_goodsSpec')" + + manufacturer: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsMfrsName" + playwright: "page.locator('#table_search_form_goodsMfrsName')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + addProductBtn: + description: "添加产品" + element_type: button + section: "main::main" + strategy: role + role: button + name: "添加产品" + playwright: "page.getByRole('button', { name: '添加产品' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + removeBtn: + description: "移除" + element_type: button + section: "main::main" + strategy: role + role: button + name: "移除" + playwright: "page.getByRole('button', { name: '移除' })" + + # --- main::pagination --- + pagination_el_e585b133e69da1313530e69da12fe9a1b5: + description: "共 3 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 3 条150 条/页" + playwright: "page.getByText('共 3 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::modal:新增基础包 --- + basePkgName: + description: "请输入" + element_type: text + section: "main::modal:新增基础包" + strategy: id + value: "form_item_pkgName" + playwright: "page.locator('#form_item_pkgName')" + + basePkgCategorySelect: + description: "请选择" + element_type: select + section: "main::modal:新增基础包" + strategy: label_text + value: "所属分类" + playwright: "page.getByText('所属分类', { exact: true })" + + basePkgCategory: + description: "所属分类" + element_type: search + section: "main::modal:新增基础包" + strategy: id + value: "form_item_kindId" + playwright: "page.locator('#form_item_kindId')" + + basePkgCode: + description: "请输入" + element_type: text + section: "main::modal:新增基础包" + strategy: id + value: "form_item_pkgCode" + playwright: "page.locator('#form_item_pkgCode')" + + remark: + description: "请输入" + element_type: textarea + section: "main::modal:新增基础包" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增基础包" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 认" + element_type: button + section: "main::modal:新增基础包" + strategy: role + role: button + name: "确 认" + playwright: "page.getByRole('button', { name: '确 认' })" + + # --- main::modal:新增分类 --- + basePkgCategoryName: + description: "请输入" + element_type: text + section: "main::modal:新增分类" + strategy: id + value: "form_item_kindName" + playwright: "page.locator('#form_item_kindName')" diff --git a/locators/spd/surgery-oper-type.locator.yaml b/locators/spd/surgery-oper-type.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..92e169e6993c8fae6c5b507435ddcca5a876eb2c --- /dev/null +++ b/locators/spd/surgery-oper-type.locator.yaml @@ -0,0 +1,335 @@ +# Auto-generated from surgery-oper-type-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/typeMaintenance +# Scanned: 2026-10-20T08:47:41.945Z +# Page: 基础设置 > 手术套包设置 > 术种维护 +# Note: filter dropdown was opened during scan +# Note: dialog/modal was opened during scan + +page: surgery-oper-type +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/typeMaintenance + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_basePkgMaintenance: + description: "基础包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础包维护" + playwright: "page.getByRole('menuitem', { name: '基础包维护' })" + + menu_operTypeMaintenance: + description: "术种维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "术种维护" + playwright: "page.getByRole('menuitem', { name: '术种维护' })" + + menu_stdPkgMaintenance: + description: "标准套包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "标准套包维护" + playwright: "page.getByRole('menuitem', { name: '标准套包维护' })" + + operTypeMaintenance: + description: "术种维护" + element_type: tab + section: "main::main" + strategy: text + value: "术种维护" + playwright: "page.getByText('术种维护')" + + tab_operTypeMaintenance: + description: "术种维护" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/operationSetting/typeMaintenance" + playwright: "page.locator('#rc-tabs-0-tab-/basic/operationSetting/typeMaintenance')" + + addBtn: + description: "新增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增" + playwright: "page.getByRole('button', { name: '新增' })" + + operTypeCategoryInput: + description: "请输入术种分类名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "请输入术种分类名称" + playwright: "page.getByPlaceholder('请输入术种分类名称')" + + operationName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_operationName" + playwright: "page.locator('#table_search_form_operationName')" + + diseaseName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_disease" + playwright: "page.locator('#table_search_form_disease')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + addOperTypeBtn: + description: "新增术种" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增术种" + playwright: "page.getByRole('button', { name: '新增术种' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + enableBtn: + description: "启用" + element_type: button + section: "main::main" + strategy: role + role: button + name: "启用" + playwright: "page.getByRole('button', { name: '启用' })" + + # --- main::pagination --- + pagination_el_e585b132e69da1313530e69da12fe9a1b5: + description: "共 2 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 2 条150 条/页" + playwright: "page.getByText('共 2 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + pagination_pageSizeSelect_2: + description: "50 条/页205010020 条/页50 条/页100 条/页200 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页205010020 条/页50 条/页100 条/页200 条/页" + playwright: "page.getByText('50 条/页205010020 条/页50 条/页100 条/页200 条/页')" + + # --- main::modal:分类维护 --- + operTypeCategoryName: + description: "请输入" + element_type: text + section: "main::modal:分类维护" + strategy: id + value: "basic_kindName" + playwright: "page.locator('#basic_kindName')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:分类维护" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + saveBtn: + description: "保 存" + element_type: button + section: "main::modal:分类维护" + strategy: role + role: button + name: "保 存" + playwright: "page.getByRole('button', { name: '保 存' })" diff --git a/locators/spd/surgery-shared.locator.ts b/locators/spd/surgery-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..4874578beb58098f5ae6969615accac8fced8ddb --- /dev/null +++ b/locators/spd/surgery-shared.locator.ts @@ -0,0 +1,103 @@ +import type { Locator, Page } from '@playwright/test'; + +/** + * 基础设置 · 手术套包设置(基础包维护 / 术种维护 / 标准套包维护)。 + * 定位来源:locators/spd/surgery-*.locator.yaml(由交互元素扫描提炼)。 + * 三页同组页签 + 左树 + 右列表 + 分页 + 弹窗,共用类只放稳定定位。 + */ +export class SurgerySharedLocators { + readonly menuBasic: Locator; + readonly surgerySubmenuTitle: Locator; + + readonly leftTree: Locator; + readonly treeSearchInput: Locator; + + readonly tableHeader: Locator; + readonly tableBody: Locator; + readonly empty: Locator; + + readonly pagination: Locator; + readonly paginationTotal: Locator; + readonly paginationActive: Locator; + readonly paginationPrev: Locator; + readonly paginationNext: Locator; + + readonly dialog: Locator; + readonly formError: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.surgerySubmenuTitle = page + .locator('.ant-menu-submenu-title') + .filter({ hasText: /^\s*手术套包设置\s*$/ }) + .first(); + + this.leftTree = page.locator('.ant-tree').first(); + this.treeSearchInput = page.locator('.ant-tree ~ * input, .ant-tree input').first(); + + this.tableHeader = page.locator('.ant-table-thead th'); + this.tableBody = page.locator('.ant-table-tbody'); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + + this.pagination = page.locator('.ant-pagination').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.paginationActive = page.locator('.ant-pagination-item-active').first(); + this.paginationPrev = page.locator('.ant-pagination-prev').first(); + this.paginationNext = page.locator('.ant-pagination-next').first(); + + this.dialog = page.locator('.ant-modal-content, .ant-modal-wrap .ant-modal, .ant-drawer-open').last(); + this.formError = page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-notification-notice'); + } + + /** 侧栏叶子菜单(基础包维护/术种维护/标准套包维护,既有 role=menuitem 也有 .ant-menu-item)。 */ + menuLeaf(name: string): Locator { + const pattern = new RegExp(`^\\s*${name}\\s*$`); + return this.page + .getByRole('menuitem', { name: pattern }) + .or(this.page.locator('.ant-menu-item').filter({ hasText: pattern })) + .first(); + } + + /** 同组页签(URL 型 rc-tabs 或文本 Tab 均覆盖)。 */ + tab(name: string): Locator { + const pattern = new RegExp(name); + return this.page + .locator('.ant-tabs-tab') + .filter({ hasText: pattern }) + .first() + .or(this.page.getByRole('tab', { name: pattern }).first()) + .or(this.page.getByText(pattern).first()); + } + + button(name: string, root: Locator | Page = this.page): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return root.getByRole('button', { name: pattern }); + } + + /** 树节点:按可见文本包含匹配。 */ + treeNode(text: string): Locator { + return this.leftTree.locator('.ant-tree-node-content-wrapper').filter({ hasText: text }).first(); + } + + treeNodes(): Locator { + return this.leftTree.locator('.ant-tree-node-content-wrapper'); + } + + /** 弹窗内表单控件按 id 定位(CSV 明确给出 #form_item_xxx / #basic_kindName)。 */ + byId(id: string): Locator { + return this.page.locator(id.startsWith('#') ? id : `#${id}`).first(); + } + + /** 弹窗内确定类按钮(「确 认」「确 定」「保 存」「确定(N)」)。 */ + confirmLikeButton(root: Locator | Page = this.dialog): Locator { + return root + .getByRole('button') + .filter({ hasText: /确\s*[认定]|保\s*存/ }) + .first(); + } + + cancelLikeButton(root: Locator | Page = this.dialog): Locator { + return root.getByRole('button', { name: /取\s*消/ }).first(); + } +} diff --git a/locators/spd/surgery-std-pkg.locator.yaml b/locators/spd/surgery-std-pkg.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..53787493650d74ffdc94995a862f6e1bbe844468 --- /dev/null +++ b/locators/spd/surgery-std-pkg.locator.yaml @@ -0,0 +1,688 @@ +# Auto-generated from surgery-std-pkg-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/standardMaintenance +# Scanned: 2026-10-20T08:47:54.322Z +# Page: 基础设置 > 手术套包设置 > 标准套包维护 + +page: surgery-std-pkg +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/standardMaintenance + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_basePkgMaintenance: + description: "基础包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础包维护" + playwright: "page.getByRole('menuitem', { name: '基础包维护' })" + + menu_operTypeMaintenance: + description: "术种维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "术种维护" + playwright: "page.getByRole('menuitem', { name: '术种维护' })" + + menu_stdPkgMaintenance: + description: "标准套包维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "标准套包维护" + playwright: "page.getByRole('menuitem', { name: '标准套包维护' })" + + stdPkgMaintenance: + description: "标准套包维护" + element_type: tab + section: "main::main" + strategy: text + value: "标准套包维护" + playwright: "page.getByText('标准套包维护')" + + tab_stdPkgMaintenance: + description: "标准套包维护" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/operationSetting/standardMaintenance" + playwright: "page.locator('#rc-tabs-0-tab-/basic/operationSetting/standardMaintenance')" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + stdPkgTreeSearchInput: + description: "请输入术种分类名称、术种名称、标准套包名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "请输入术种分类名称、术种名称、标准套包名称" + playwright: "page.getByPlaceholder('请输入术种分类名称、术种名称、标准套包名称')" + + tree_node_e7b1bbe69caa: + description: "类未建分类(2)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类未建分类(2)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类未建分类(2)\\s*$/ })" + + tree_node_e69caf313128: + description: "术11(4)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "术11(4)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*术11(4)\\s*$/ })" + + tree_node_e58c857171: + description: "包qq" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包qq" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包qq\\s*$/ })" + + tree_node_e58c857474: + description: "包tt" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包tt" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包tt\\s*$/ })" + + tree_node_e58c85e695a3: + description: "包散货包" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包散货包" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包散货包\\s*$/ })" + + tree_node_e58c85e6b7b7: + description: "包混包" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "包混包" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包混包\\s*$/ })" + + tree_node_e69caf313231: + description: "术1212(0)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "术1212(0)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*术1212(0)\\s*$/ })" + + tree_node_e7b1bb313334: + description: "类134(2)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类134(2)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类134(2)\\s*$/ })" + + tree_node_e7b1bb323032: + description: "类20240325(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类20240325(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20240325(1)\\s*$/ })" + + tree_el_e7b1bb3230323530353136283129_2: + description: "类20250516(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类20250516(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20250516(1)\\s*$/ })" + + tree_el_e7b1bb32303236283129_2: + description: "类2026(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类2026(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2026(1)\\s*$/ })" + + tree_node_e7b1bb323531: + description: "类2512回归(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类2512回归(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2512回归(1)\\s*$/ })" + + tree_node_e7b1bb414141: + description: "类AAAAA(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类AAAAA(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类AAAAA(1)\\s*$/ })" + + tree_node_e7b1bb544553: + description: "类TEST(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类TEST(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类TEST(1)\\s*$/ })" + + tree_node_e7b1bb6a6d37: + description: "类jm73(2)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类jm73(2)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm73(2)\\s*$/ })" + + tree_node_e7b1bb6a6d5f: + description: "类jm_术种分类01(4)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类jm_术种分类01(4)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm_术种分类01(4)\\s*$/ })" + + tree_node_e7b1bbe58fa3: + description: "类口腔内科手术(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类口腔内科手术(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类口腔内科手术(1)\\s*$/ })" + + tree_el_e7b1bbe58fa3e88594e5a496e7a791e6898be69caf283329_2: + description: "类口腔外科手术(3)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类口腔外科手术(3)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类口腔外科手术(3)\\s*$/ })" + + tree_node_e7b1bbe69caf: + description: "类术种0613(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类术种0613(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类术种0613(1)\\s*$/ })" + + tree_node_e7b1bbe883b8: + description: "类胸外科(1)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类胸外科(1)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类胸外科(1)\\s*$/ })" + + tree_node_e7b1bbe9aaa8: + description: "类骨外科(8)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类骨外科(8)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类骨外科(8)\\s*$/ })" + + tree_el_e7b1bbe9aaa8e7a791283229_2: + description: "类骨科(2)" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "类骨科(2)" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类骨科(2)\\s*$/ })" + + productName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_keyword" + playwright: "page.locator('#table_search_form_keyword')" + + specModel: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsSpec" + playwright: "page.locator('#table_search_form_goodsSpec')" + + manufacturer: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsMfrsName" + playwright: "page.locator('#table_search_form_goodsMfrsName')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + addBasePkgBtn: + description: "添加基础包" + element_type: button + section: "main::main" + strategy: role + role: button + name: "添加基础包" + playwright: "page.getByRole('button', { name: '添加基础包' })" + + addLoosePartBtn: + description: "添加散件" + element_type: button + section: "main::main" + strategy: role + role: button + name: "添加散件" + playwright: "page.getByRole('button', { name: '添加散件' })" + + productOverviewBtn: + description: "产品概览" + element_type: button + section: "main::main" + strategy: role + role: button + name: "产品概览" + playwright: "page.getByRole('button', { name: '产品概览' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + removeBtn: + description: "移除" + element_type: button + section: "main::main" + strategy: role + role: button + name: "移除" + playwright: "page.getByRole('button', { name: '移除' })" + + # --- main::modal:添加基础包 --- + addBasePkgSearchInput: + description: "请输入分类/基础包名称" + element_type: text + section: "main::modal:添加基础包" + strategy: placeholder + value: "请输入分类/基础包名称" + playwright: "page.getByPlaceholder('请输入分类/基础包名称')" + + tree_node_e7b1bb313231: + description: "类12121313" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类12121313" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类12121313\\s*$/ })" + + tree_node_e58c85415554: + description: "包AUTO-BASE-1792473862012-33" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "包AUTO-BASE-1792473862012-33" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473862012-33\\s*$/ })" + + Input: + description: "1" + element_type: text + section: "main::modal:添加基础包" + strategy: role + role: spinbutton + name: "1" + playwright: "page.getByRole('spinbutton', { name: '1' })" + + tree_el_e58c854155544f2d424153452d313739323437333839313334382d313539_2: + description: "包AUTO-BASE-1792473891348-159" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "包AUTO-BASE-1792473891348-159" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473891348-159\\s*$/ })" + + tree_el_e58c854155544f2d424153452d313739323437333933343839312d393337_2: + description: "包AUTO-BASE-1792473934891-937" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "包AUTO-BASE-1792473934891-937" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*包AUTO-BASE-1792473934891-937\\s*$/ })" + + tree_el_e7b1bb3230323430333234_2: + description: "类20240324" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类20240324" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20240324\\s*$/ })" + + tree_el_e7b1bb3230323530353136_2: + description: "类20250516" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类20250516" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类20250516\\s*$/ })" + + tree_el_e7b1bb32303236e59b9ee5bd92e6b58be8af95_2: + description: "类2026回归测试" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类2026回归测试" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2026回归测试\\s*$/ })" + + tree_el_e7b1bb32353132e59b9ee5bd92_2: + description: "类2512回归" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类2512回归" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类2512回归\\s*$/ })" + + tree_el_e7b1bb54455354_2: + description: "类TEST" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类TEST" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类TEST\\s*$/ })" + + tree_el_e7b1bb6a6d5fe59fbae7a180e58c85e58886e7b1bb3031_2: + description: "类jm_基础包分类01" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类jm_基础包分类01" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm_基础包分类01\\s*$/ })" + + tree_el_e7b1bb6a6d5fe59fbae7a180e58c85e58886e7b1bb3032_2: + description: "类jm_基础包分类02" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类jm_基础包分类02" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm_基础包分类02\\s*$/ })" + + tree_el_e7b1bbe58fa3e88594e7a791e59fbae7a180e58c85e58886e7b1bb31_2: + description: "类口腔科基础包分类1" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类口腔科基础包分类1" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类口腔科基础包分类1\\s*$/ })" + + tree_node_e7b1bbe59fba: + description: "类基础包0613" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类基础包0613" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类基础包0613\\s*$/ })" + + tree_node_e7b1bbe5a496: + description: "类外科手术" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类外科手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类外科手术\\s*$/ })" + + tree_node_e7b1bbe69588: + description: "类效期" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类效期" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类效期\\s*$/ })" + + tree_node_e7b1bbe88386: + description: "类胆囊手术" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类胆囊手术" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类胆囊手术\\s*$/ })" + + tree_node_e7b1bbe9ab98: + description: "类高值包" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类高值包" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类高值包\\s*$/ })" + + tree_el_e7b1bb6a6d3733_2: + description: "类jm73" + element_type: tree_title + section: "main::modal:添加基础包" + strategy: tree_node + value: "类jm73" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*类jm73\\s*$/ })" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:添加基础包" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确定(0)" + element_type: button + section: "main::modal:添加基础包" + strategy: role + role: button + name: "确定(0)" + playwright: "page.getByRole('button', { name: '确定(0)' })" diff --git a/locators/spd/vendor-auth-final.locator.yaml b/locators/spd/vendor-auth-final.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a86cf0a75b5abaa6f33a917f90a9f05b1a693b9b --- /dev/null +++ b/locators/spd/vendor-auth-final.locator.yaml @@ -0,0 +1,318 @@ +# Auto-generated from vendor-auth-final-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAuditFinal +# Scanned: 2026-10-20T08:41:58.554Z +# Page: 基础设置 > 厂商管理 > 授权变更终审 +# Note: filter dropdown was opened during scan + +page: vendor-auth-final +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAuditFinal + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + authFinal: + description: "授权变更终审" + element_type: tab + section: "main::main" + strategy: text + value: "授权变更终审" + playwright: "page.getByText('授权变更终审')" + + tab_authFinal: + description: "授权变更终审" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/authAuditFinal" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/authAuditFinal')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + documentNo: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_orderNo" + playwright: "page.locator('#table_search_form_orderNo')" + + el_e99480e594aee59586Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId: + description: "销售商" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_provId" + playwright: "page.locator('#table_search_form_provId')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" diff --git a/locators/spd/vendor-auth-first.locator.yaml b/locators/spd/vendor-auth-first.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ce73903b7cd6d6be312143b678d31ee4b2822ec4 --- /dev/null +++ b/locators/spd/vendor-auth-first.locator.yaml @@ -0,0 +1,318 @@ +# Auto-generated from vendor-auth-first-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAudit +# Scanned: 2026-10-20T08:41:49.683Z +# Page: 基础设置 > 厂商管理 > 授权变更初审 +# Note: filter dropdown was opened during scan + +page: vendor-auth-first +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAudit + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + authFirst: + description: "授权变更初审" + element_type: tab + section: "main::main" + strategy: text + value: "授权变更初审" + playwright: "page.getByText('授权变更初审')" + + tab_authFirst: + description: "授权变更初审" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/authAudit" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/authAudit')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + documentNo: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_orderNo" + playwright: "page.locator('#table_search_form_orderNo')" + + el_e99480e594aee59586Select: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "销售商" + playwright: "page.getByText('销售商', { exact: true })" + + supplierId: + description: "销售商" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_provId" + playwright: "page.locator('#table_search_form_provId')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" diff --git a/locators/spd/vendor-cert-diff.locator.yaml b/locators/spd/vendor-cert-diff.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c456b08afa0ae72b001b6cb2df28eb2b0fff7313 --- /dev/null +++ b/locators/spd/vendor-cert-diff.locator.yaml @@ -0,0 +1,408 @@ +# Auto-generated from vendor-cert-diff-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/scjs/yc/certCompare +# Scanned: 2026-10-20T08:43:43.547Z +# Page: 基础设置 > 厂商管理 > 注册证差异对比 + +page: vendor-cert-diff +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/scjs/yc/certCompare + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + certDiff: + description: "注册证差异对比" + element_type: tab + section: "main::main" + strategy: text + value: "注册证差异对比" + playwright: "page.getByText('注册证差异对比')" + + tab_certDiff: + description: "注册证差异对比" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/scjs/yc/certCompare" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/scjs/yc/certCompare')" + + el_e789a9e69699e5908de7a7b0Input: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsName" + playwright: "page.locator('#table_search_form_goodsName')" + + specModel: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_goodsSpec" + playwright: "page.locator('#table_search_form_goodsSpec')" + + statusSelect: + description: "启用" + element_type: select + section: "main::main" + strategy: label_text + value: "状态" + playwright: "page.getByText('状态', { exact: true })" + + statusInput: + description: "状态" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_mgiTbStatus" + playwright: "page.locator('#table_search_form_mgiTbStatus')" + + searchBtn: + description: "查询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查询" + playwright: "page.getByRole('button', { name: '查询' })" + + resetBtn: + description: "重置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重置" + playwright: "page.getByRole('button', { name: '重置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + el_e7ab8be58db3e69bb4e696b0Btn: + description: "立即更新" + element_type: button + section: "main::main" + strategy: role + role: button + name: "立即更新" + playwright: "page.getByRole('button', { name: '立即更新' })" + + exportBtn: + description: "导出" + element_type: button + section: "main::main" + strategy: role + role: button + name: "导出" + playwright: "page.getByRole('button', { name: '导出' })" + + # --- main::pagination --- + pagination_el_e585b137363537e69da13132333435e280a2e280a2e280a23339323030e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 7657 条12345•••39200 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 7657 条12345•••39200 条/页跳至页" + playwright: "page.getByText('共 7657 条12345•••39200 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_page_3: + description: "3" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + pagination_page_4: + description: "4" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "4" + playwright: "page.getByText('4')" + + pagination_page_5: + description: "5" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "5" + playwright: "page.getByText('5')" + + pagination_el_e59091e5908e35e9a1b5: + description: "向后 5 页" + element_type: li + section: "main::pagination" + strategy: text + value: "向后 5 页" + playwright: "page.getByText('向后 5 页')" + + pagination_page_39: + description: "39" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "39" + playwright: "page.getByText('39')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "200 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "200 条/页" + playwright: "page.getByText('200 条/页')" diff --git a/locators/spd/vendor-cert-final.locator.yaml b/locators/spd/vendor-cert-final.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b1aa0c62ff0d0467ee33d197bf9cd48246516918 --- /dev/null +++ b/locators/spd/vendor-cert-final.locator.yaml @@ -0,0 +1,318 @@ +# Auto-generated from vendor-cert-final-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal +# Scanned: 2026-10-20T08:42:16.674Z +# Page: 基础设置 > 厂商管理 > 注册证变更终审 +# Note: filter dropdown was opened during scan + +page: vendor-cert-final +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + certFinal: + description: "注册证变更终审" + element_type: tab + section: "main::main" + strategy: text + value: "注册证变更终审" + playwright: "page.getByText('注册证变更终审')" + + tab_certFinal: + description: "注册证变更终审" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/regCertAuditFinal" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/regCertAuditFinal')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + documentNo: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_orderNo" + playwright: "page.locator('#table_search_form_orderNo')" + + changeTypeSelect: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "变更类型" + playwright: "page.getByText('变更类型', { exact: true })" + + changeTypeInput: + description: "变更类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_chgType" + playwright: "page.locator('#table_search_form_chgType')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" diff --git a/locators/spd/vendor-cert-first.locator.yaml b/locators/spd/vendor-cert-first.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..16ce563ed7df0227af0581270583ea491f69d62a --- /dev/null +++ b/locators/spd/vendor-cert-first.locator.yaml @@ -0,0 +1,318 @@ +# Auto-generated from vendor-cert-first-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAudit +# Scanned: 2026-10-20T08:42:07.556Z +# Page: 基础设置 > 厂商管理 > 注册证变更初审 +# Note: filter dropdown was opened during scan + +page: vendor-cert-first +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAudit + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + certFirst: + description: "注册证变更初审" + element_type: tab + section: "main::main" + strategy: text + value: "注册证变更初审" + playwright: "page.getByText('注册证变更初审')" + + tab_certFirst: + description: "注册证变更初审" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/regCertAudit" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/regCertAudit')" + + operationDateDateRange: + description: "制单日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "制单日期" + playwright: "page.getByText('制单日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + operationDateInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + documentNo: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_orderNo" + playwright: "page.locator('#table_search_form_orderNo')" + + changeTypeSelect: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "变更类型" + playwright: "page.getByText('变更类型', { exact: true })" + + changeTypeInput: + description: "变更类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_chgType" + playwright: "page.locator('#table_search_form_chgType')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" diff --git a/locators/spd/vendor-eval-indicator.locator.yaml b/locators/spd/vendor-eval-indicator.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d6f01bfc597ab7f30d4ebb1f08ba62cc66356b6c --- /dev/null +++ b/locators/spd/vendor-eval-indicator.locator.yaml @@ -0,0 +1,509 @@ +# Auto-generated from vendor-eval-indicator-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/indicators +# Scanned: 2026-10-20T08:44:01.895Z +# Page: 基础设置 > 厂商管理 > 评价指标维护 +# Note: filter dropdown was opened during scan +# Note: dialog/modal was opened during scan + +page: vendor-eval-indicator +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/indicators + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + evalIndicator: + description: "评价指标维护" + element_type: tab + section: "main::main" + strategy: text + value: "评价指标维护" + playwright: "page.getByText('评价指标维护')" + + tab_evalIndicator: + description: "评价指标维护" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/indicators" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/indicators')" + + el_e68c87e6a087e58886e7b1bb: + description: "指标分类" + element_type: a + section: "main::main" + strategy: text + value: "指标分类" + playwright: "page.getByText('指标分类')" + + addBtn: + description: "新增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新增" + playwright: "page.getByRole('button', { name: '新增' })" + + categoryNameInput: + description: "分类名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "分类名称" + playwright: "page.getByPlaceholder('分类名称')" + + tree_node_e68c87e6a087: + description: "指标一级分类" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "指标一级分类" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*指标一级分类\\s*$/ })" + + tree_node_313131: + description: "111" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "111" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*111\\s*$/ })" + + tree_node_323232: + description: "222" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "222" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*222\\s*$/ })" + + tree_node_53e6b58be8af: + description: "S测试" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "S测试" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*S测试\\s*$/ })" + + tree_node_e6b58be8af95: + description: "测试指标" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "测试指标" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*测试指标\\s*$/ })" + + tree_node_323630363236: + description: "260626回归指标分类" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "260626回归指标分类" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*260626回归指标分类\\s*$/ })" + + tree_node_30363137e68c: + description: "0617指标" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "0617指标" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*0617指标\\s*$/ })" + + el_e68c87e6a087e7bc96e7a081Input: + description: "请输入指标编码" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_indicatorsCode" + playwright: "page.locator('#table_search_form_indicatorsCode')" + + el_e68c87e6a087e5908de7a7b0Input: + description: "请输入指标名称" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_indicatorsName" + playwright: "page.locator('#table_search_form_indicatorsName')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + detailBtn: + description: "详情" + element_type: button + section: "main::main" + strategy: role + role: button + name: "详情" + playwright: "page.getByRole('button', { name: '详情' })" + + logBtn: + description: "日志" + element_type: button + section: "main::main" + strategy: role + role: button + name: "日志" + playwright: "page.getByRole('button', { name: '日志' })" + + # --- main::pagination --- + pagination_indicatorCode: + description: "共 31 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_indicatorCodeBtn: + description: "指标编号" + element_type: button + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_indicatorCode_2: + description: "共 31 条150 条/页205010020 条/页50 条/页100 条/页200 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + pagination_pageSizeSelect_2: + description: "50 条/页205010020 条/页50 条/页100 条/页200 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "指标编号" + playwright: "page.getByText('指标编号', { exact: true })" + + # --- main::modal:新增 --- + el_e4b88ae7baa7e58886e7b1bbSelect: + description: "请选择" + element_type: select + section: "main::modal:新增" + strategy: label_text + value: "上级分类" + playwright: "page.getByText('上级分类', { exact: true })" + + basePkgCategoryName: + description: "分类名称" + element_type: text + section: "main::modal:新增" + strategy: id + value: "form_item_kindName" + playwright: "page.locator('#form_item_kindName')" + + enable: + description: "启用" + element_type: radio + section: "main::modal:新增" + strategy: text + value: "启用" + playwright: "page.getByText('启用')" + + disable: + description: "停用" + element_type: radio + section: "main::modal:新增" + strategy: text + value: "停用" + playwright: "page.getByText('停用')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:新增" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" diff --git a/locators/spd/vendor-eval-scheme.locator.yaml b/locators/spd/vendor-eval-scheme.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6ba7b30ec2145008a1bf31160be0fc09bd6d1f08 --- /dev/null +++ b/locators/spd/vendor-eval-scheme.locator.yaml @@ -0,0 +1,370 @@ +# Auto-generated from vendor-eval-scheme-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/evaluationPlan +# Scanned: 2026-10-20T08:43:52.262Z +# Page: 基础设置 > 厂商管理 > 评价方案维护 +# Note: dialog/modal was opened during scan + +page: vendor-eval-scheme +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/evaluationPlan + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + evalScheme: + description: "评价方案维护" + element_type: tab + section: "main::main" + strategy: text + value: "评价方案维护" + playwright: "page.getByText('评价方案维护')" + + tab_evalScheme: + description: "评价方案维护" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/evaluationPlan" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/evaluationPlan')" + + el_e5889be5bbbae697a5e69c9fDateRange: + description: "创建日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "创建日期" + playwright: "page.getByText('创建日期', { exact: true })" + + dateRange: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_dateValue" + playwright: "page.locator('#table_search_form_dateValue')" + + el_e5889be5bbbae697a5e69c9fInput: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + el_e590afe794a8e697a5e69c9fDateRange: + description: "启用日期" + element_type: datepicker + section: "main::main" + strategy: label_text + value: "启用日期" + playwright: "page.getByText('启用日期', { exact: true })" + + el_e590afe794a8e697a5e69c9fInput: + description: "开始日期" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_startTimeValue" + playwright: "page.locator('#table_search_form_startTimeValue')" + + el_e590afe794a8e697a5e69c9fInput_2: + description: "结束日期" + element_type: text + section: "main::main" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + schemeCodeInput: + description: "请输入方案编码" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_planCode" + playwright: "page.locator('#table_search_form_planCode')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + expandFilterBtn: + description: "展开" + element_type: button + section: "main::main" + strategy: role + role: button + name: "展开" + playwright: "page.getByRole('button', { name: '展开' })" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + # --- main::modal:新增方案 --- + el_e696b9e6a188e5908de7a7b0Input: + description: "方案名称" + element_type: text + section: "main::modal:新增方案" + strategy: id + value: "form_item_planName" + playwright: "page.locator('#form_item_planName')" + + remark: + description: "限255字符" + element_type: textarea + section: "main::modal:新增方案" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增方案" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:新增方案" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" diff --git a/locators/spd/vendor-holiday.locator.yaml b/locators/spd/vendor-holiday.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bd1d0eb4a622c8ff867ba3095b917066d97855e2 --- /dev/null +++ b/locators/spd/vendor-holiday.locator.yaml @@ -0,0 +1,468 @@ +# Auto-generated from vendor-holiday-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/prov/holiday +# Scanned: 2026-10-20T08:44:28.895Z +# Page: 基础设置 > 厂商管理 > 供应商节假日管理 +# Note: dialog/modal was opened during scan +# Note: edit dialog was opened during scan + +page: vendor-holiday +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/prov/holiday + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + vendorHoliday: + description: "供应商节假日管理" + element_type: tab + section: "main::main" + strategy: text + value: "供应商节假日管理" + playwright: "page.getByText('供应商节假日管理')" + + tab_vendorHoliday: + description: "供应商节假日管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/basic/prov/holiday" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/basic/prov/holiday')" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + nameOrYearInput: + description: "请输入名称/年度" + element_type: text + section: "main::main" + strategy: placeholder + value: "请输入名称/年度" + playwright: "page.getByPlaceholder('请输入名称/年度')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e689b9e9878fe588a0e999a4Btn: + description: "批量删除" + element_type: button + section: "main::main" + strategy: role + role: button + name: "批量删除" + playwright: "page.getByRole('button', { name: '批量删除' })" + + # --- main::pagination --- + pagination_add: + description: "共 16 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "新 增" + playwright: "page.getByText('新 增', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "新 增" + playwright: "page.getByText('新 增', { exact: true })" + + pagination_addBtn: + description: "新 增" + element_type: button + section: "main::pagination" + strategy: label_text + value: "新 增" + playwright: "page.getByText('新 增', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "新 增" + playwright: "page.getByText('新 增', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "新 增" + playwright: "page.getByText('新 增', { exact: true })" + + # --- main::modal:新增 --- + el_e5908de7a7b0Input: + description: "请输入" + element_type: text + section: "main::modal:新增" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + yearDateRange: + description: "年度" + element_type: datepicker + section: "main::modal:新增" + strategy: label_text + value: "年度" + playwright: "page.getByText('年度', { exact: true })" + + yearInput: + description: "2026" + element_type: text + section: "main::modal:新增" + strategy: id + value: "form_item_yearly" + playwright: "page.locator('#form_item_yearly')" + + el_e8b5b7e6ada2e697a5e69c9fDateRange: + description: "起止日期" + element_type: datepicker + section: "main::modal:新增" + strategy: label_text + value: "起止日期" + playwright: "page.getByText('起止日期', { exact: true })" + + el_e8b5b7e6ada2e697a5e69c9fInput: + description: "开始日期" + element_type: text + section: "main::modal:新增" + strategy: id + value: "form_item_holidayDate" + playwright: "page.locator('#form_item_holidayDate')" + + el_e8b5b7e6ada2e697a5e69c9fInput_2: + description: "结束日期" + element_type: text + section: "main::modal:新增" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + remark: + description: "请输入" + element_type: textarea + section: "main::modal:新增" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 认" + element_type: button + section: "main::modal:新增" + strategy: role + role: button + name: "确 认" + playwright: "page.getByRole('button', { name: '确 认' })" + + # --- main::modal:编辑 --- + el_e5908de7a7b0Input_2: + description: "请输入" + element_type: text + section: "main::modal:编辑" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e5908de7a7b0: + description: "close-circle" + element_type: span + section: "main::modal:编辑" + strategy: label + value: "close-circle" + playwright: "page.getByLabel('close-circle')" + + yearDateRange_2: + description: "年度" + element_type: datepicker + section: "main::modal:编辑" + strategy: label_text + value: "年度" + playwright: "page.getByText('年度', { exact: true })" + + yearInput_2: + description: "2026" + element_type: text + section: "main::modal:编辑" + strategy: id + value: "form_item_yearly" + playwright: "page.locator('#form_item_yearly')" + + el_e8b5b7e6ada2e697a5e69c9fDateRange_2: + description: "起止日期" + element_type: datepicker + section: "main::modal:编辑" + strategy: label_text + value: "起止日期" + playwright: "page.getByText('起止日期', { exact: true })" + + el_e8b5b7e6ada2e697a5e69c9fInput_3: + description: "开始日期" + element_type: text + section: "main::modal:编辑" + strategy: id + value: "form_item_holidayDate" + playwright: "page.locator('#form_item_holidayDate')" + + el_e8b5b7e6ada2e697a5e69c9fInput_4: + description: "结束日期" + element_type: text + section: "main::modal:编辑" + strategy: placeholder + value: "结束日期" + playwright: "page.getByPlaceholder('结束日期')" + + remark_2: + description: "请输入" + element_type: textarea + section: "main::modal:编辑" + strategy: id + value: "form_item_remark" + playwright: "page.locator('#form_item_remark')" diff --git a/locators/spd/vendor-qual.locator.yaml b/locators/spd/vendor-qual.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..183cf02aa9b898a0c8d31f1d25bdd1fc4ff4087e --- /dev/null +++ b/locators/spd/vendor-qual.locator.yaml @@ -0,0 +1,362 @@ +# Auto-generated from vendor-qual-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/qcManage +# Scanned: 2026-10-20T08:41:30.230Z +# Page: 基础设置 > 厂商管理 > 供应商资质管理 +# Note: filter dropdown was opened during scan + +page: vendor-qual +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/qcManage + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + vendorQual: + description: "供应商资质管理" + element_type: tab + section: "main::main" + strategy: text + value: "供应商资质管理" + playwright: "page.getByText('供应商资质管理')" + + tab_vendorQual: + description: "供应商资质管理" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/basic/qcManage" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/basic/qcManage')" + + supplierName: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "table_search_form_provName" + playwright: "page.locator('#table_search_form_provName')" + + el_e8af81e4bbb6e7b1bbe59e8bSelect: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "证件类型" + playwright: "page.getByText('证件类型', { exact: true })" + + el_e8af81e4bbb6e7b1bbe59e8bInput: + description: "证件类型" + element_type: search + section: "main::main" + strategy: id + value: "table_search_form_certType" + playwright: "page.locator('#table_search_form_certType')" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + exportBtn: + description: "导 出" + element_type: button + section: "main::main" + strategy: role + role: button + name: "导 出" + playwright: "page.getByRole('button', { name: '导 出' })" + + viewDetailBtn: + description: "查看" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查看" + playwright: "page.getByRole('button', { name: '查看' })" + + # --- main::pagination --- + pagination_action: + description: "共 30 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_actionBtn: + description: "操作" + element_type: button + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "操作" + playwright: "page.getByText('操作', { exact: true })" + + # --- main::modal:承诺书 --- + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:承诺书" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" diff --git a/locators/spd/vendor-query.locator.yaml b/locators/spd/vendor-query.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..45d44d8210e3eeaf11b4f3470bc42aa5875741ff --- /dev/null +++ b/locators/spd/vendor-query.locator.yaml @@ -0,0 +1,347 @@ +# Auto-generated from vendor-query-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/factory +# Scanned: 2026-10-20T08:41:21.432Z +# Page: 基础设置 > 厂商管理 > 厂商查询 + +page: vendor-query +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/factory + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_vendorQuery: + description: "厂商查询" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "厂商查询" + playwright: "page.getByRole('menuitem', { name: '厂商查询' })" + + menu_vendorQual: + description: "供应商资质管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商资质管理" + playwright: "page.getByRole('menuitem', { name: '供应商资质管理' })" + + menu_authFirst: + description: "授权变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更初审" + playwright: "page.getByRole('menuitem', { name: '授权变更初审' })" + + menu_certFirst: + description: "注册证变更初审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更初审" + playwright: "page.getByRole('menuitem', { name: '注册证变更初审' })" + + menu_certFinal: + description: "注册证变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证变更终审" + playwright: "page.getByRole('menuitem', { name: '注册证变更终审' })" + + menu_authFinal: + description: "授权变更终审" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "授权变更终审" + playwright: "page.getByRole('menuitem', { name: '授权变更终审' })" + + menu_certDiff: + description: "注册证差异对比" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "注册证差异对比" + playwright: "page.getByRole('menuitem', { name: '注册证差异对比' })" + + menu_evalScheme: + description: "评价方案维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价方案维护" + playwright: "page.getByRole('menuitem', { name: '评价方案维护' })" + + menu_evalIndicator: + description: "评价指标维护" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "评价指标维护" + playwright: "page.getByRole('menuitem', { name: '评价指标维护' })" + + menu_vendorHoliday: + description: "供应商节假日管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "供应商节假日管理" + playwright: "page.getByRole('menuitem', { name: '供应商节假日管理' })" + + vendorQuery: + description: "厂商查询" + element_type: tab + section: "main::main" + strategy: text + value: "厂商查询" + playwright: "page.getByText('厂商查询')" + + tab_vendorQuery: + description: "厂商查询" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/factory" + playwright: "page.locator('#rc-tabs-0-tab-/basic/mfrs/paltform/mfrsName/factory')" + + mfrsNameInput: + description: "厂商名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "厂商名称" + playwright: "page.getByPlaceholder('厂商名称')" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + # --- main::pagination --- + pagination_el_e585b1343430e69da13132333435e280a2e280a2e280a2393530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 440 条12345•••950 条/页跳至页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 440 条12345•••950 条/页跳至页" + playwright: "page.getByText('共 440 条12345•••950 条/页跳至页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_page_2: + description: "2" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + pagination_page_3: + description: "3" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + pagination_page_4: + description: "4" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "4" + playwright: "page.getByText('4')" + + pagination_page_5: + description: "5" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "5" + playwright: "page.getByText('5')" + + pagination_el_e59091e5908e35e9a1b5: + description: "向后 5 页" + element_type: li + section: "main::pagination" + strategy: text + value: "向后 5 页" + playwright: "page.getByText('向后 5 页')" + + pagination_page_9: + description: "9" + element_type: pagination_item + section: "main::pagination" + strategy: text + value: "9" + playwright: "page.getByText('9')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" diff --git a/locators/spd/vendor-shared.locator.ts b/locators/spd/vendor-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1587878e47a98fc9d9cbca1b53fa02903b76f49 --- /dev/null +++ b/locators/spd/vendor-shared.locator.ts @@ -0,0 +1,46 @@ +import type { Locator, Page } from '@playwright/test'; + +/** 基础设置 · 厂商管理。叶子菜单是 .ant-menu-item,不是 role=menuitem。 */ +export class VendorSharedLocators { + readonly menuBasic: Locator; + readonly vendorSubmenuTitle: Locator; + readonly tableHeader: Locator; + readonly empty: Locator; + readonly paginationTotal: Locator; + readonly nextPage: Locator; + readonly prevPage: Locator; + readonly formError: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.vendorSubmenuTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^厂商管理$/ }); + this.tableHeader = page.locator('.ant-table-thead th'); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.nextPage = page.locator('.ant-pagination-next').first(); + this.prevPage = page.locator('.ant-pagination-prev').first(); + this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice'); + } + + menuLeaf(name: string): Locator { + return this.page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first(); + } + + button(name: string, root: Locator | Page = this.page): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return root.getByRole('button', { name: pattern }); + } + + tab(name: string): Locator { + return this.page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first(); + } + + putPage(): Locator { + return this.page.locator('.put-page').last(); + } + + dialog(): Locator { + return this.page.locator('.ant-modal-wrap .ant-modal, .ant-drawer-open').last(); + } +} diff --git a/locators/spd/warehouse-probe.locator.ts b/locators/spd/warehouse-probe.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..f55e611aa7fc19b636bf7f0275700ba08b64b41c --- /dev/null +++ b/locators/spd/warehouse-probe.locator.ts @@ -0,0 +1,40 @@ +import type { Locator, Page } from '@playwright/test'; + +import { WhFlowSharedLocators } from './wh-flow-shared.locator'; + +/** 探针绑定。选择器来自 locators/spd/warehouse-probe.locator.yaml */ +export class WarehouseProbeLocators extends WhFlowSharedLocators { + readonly queryBtn: Locator; + readonly resetBtn: Locator; + readonly bindBtn: Locator; + readonly modalCancel: Locator; + + constructor(page: Page) { + super(page); + this.queryBtn = page.getByRole('button', { name: /查\s*询/ }); + this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); + this.bindBtn = page.getByRole('button', { name: '绑定货位' }); + this.modalCancel = page.getByRole('button', { name: /取\s*消/ }); + } + + probeIdInput(): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: '探针ID' }).locator('input').first(); + } + + hostCodeInput(): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: '主机编码' }).locator('input').first(); + } + + bindSelect(): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: '是否绑定货位' }).locator('.ant-select').first(); + } + + bindModal(): Locator { + return this.page.locator('.ant-modal:visible').filter({ hasText: /货位绑定探针/ }).first(); + } + + confirmCountBtn(root?: Locator): Locator { + const scope = root ?? this.page; + return scope.getByRole('button', { name: /确\s*定/ }).first(); + } +} diff --git a/locators/spd/warehouse-probe.locator.yaml b/locators/spd/warehouse-probe.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a5ba02d3b801c52d45e7a9aff6954a50b854c91f --- /dev/null +++ b/locators/spd/warehouse-probe.locator.yaml @@ -0,0 +1,461 @@ +# Auto-generated from warehouse-probe-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/storehouse/probe +# Scanned: 2026-10-20T09:05:17.123Z +# Page: 基础设置 > 库房管理 > 探针绑定 +# Note: filter dropdown was opened during scan + +page: warehouse-probe +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/storehouse/probe + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_el_e5ba93e58cbae8aebee7bdae: + description: "库区设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "库区设置" + playwright: "page.getByRole('menuitem', { name: '库区设置' })" + + menu_el_e68ea2e99288e7bb91e5ae9a: + description: "探针绑定" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "探针绑定" + playwright: "page.getByRole('menuitem', { name: '探针绑定' })" + + menu_el_e58d8ee6b885e9809ae98193: + description: "华清通道" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "华清通道" + playwright: "page.getByRole('menuitem', { name: '华清通道' })" + + el_e68ea2e99288e7bb91e5ae9a: + description: "探针绑定" + element_type: tab + section: "main::main" + strategy: text + value: "探针绑定" + playwright: "page.getByText('探针绑定')" + + tab_el_e68ea2e99288e7bb91e5ae9a: + description: "探针绑定" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/storehouse/storehouse/probe" + playwright: "page.locator('#rc-tabs-0-tab-/basic/storehouse/storehouse/probe')" + + el_e68ea2e992884944Input: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e68ea2e992884944Input_2: + description: "请输入" + element_type: text + section: "main::main" + strategy: id + value: "form_item_cname" + playwright: "page.locator('#form_item_cname')" + + el_e698afe590a6e7bb91e5ae9ae8b4a7e4bd8dSelect: + description: "请选择" + element_type: select + section: "main::main" + strategy: label_text + value: "是否绑定货位" + playwright: "page.getByText('是否绑定货位', { exact: true })" + + searchBtn: + description: "查 询" + element_type: button + section: "main::main" + strategy: role + role: button + name: "查 询" + playwright: "page.getByRole('button', { name: '查 询' })" + + resetBtn: + description: "重 置" + element_type: button + section: "main::main" + strategy: role + role: button + name: "重 置" + playwright: "page.getByRole('button', { name: '重 置' })" + + tableDensity: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + 8Btn: + description: "28" + element_type: button + section: "main::main" + strategy: role + role: button + name: "28" + playwright: "page.getByRole('button', { name: '28' })" + + el_e7bb91e5ae9ae8b4a7e4bd8dBtn: + description: "绑定货位" + element_type: button + section: "main::main" + strategy: role + role: button + name: "绑定货位" + playwright: "page.getByRole('button', { name: '绑定货位' })" + + Btn: + description: "3" + element_type: button + section: "main::main" + strategy: role + role: button + name: "3" + playwright: "page.getByRole('button', { name: '3' })" + + # --- main::pagination --- + pagination_el_e585b13131e69da1313530e69da12fe9a1b5: + description: "共 11 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: text + value: "共 11 条150 条/页" + playwright: "page.getByText('共 11 条150 条/页')" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + # --- main::modal:货位绑定探针 --- + el_e5bd93e5898de5ba93e688bfSelect: + description: "千泉山院区中心库房" + element_type: select + section: "main::modal:货位绑定探针" + strategy: label_text + value: "当前库房" + playwright: "page.getByText('当前库房', { exact: true })" + + el_e5bd93e5898de5ba93e688bfInput: + description: "当前库房" + element_type: search + section: "main::modal:货位绑定探针" + strategy: id + value: "form_item_deptId" + playwright: "page.locator('#form_item_deptId')" + + el_e5bd93e5898de5ba93e58cbaSelect: + description: "当前库区" + element_type: select + section: "main::modal:货位绑定探针" + strategy: label_text + value: "当前库区" + playwright: "page.getByText('当前库区', { exact: true })" + + el_e5bd93e5898de5ba93e58cbaInput: + description: "当前库区" + element_type: search + section: "main::modal:货位绑定探针" + strategy: id + value: "form_item_areaCode" + playwright: "page.locator('#form_item_areaCode')" + + el_e8b4a7e4bd8de69fa5e8afa2Input: + description: "请输入" + element_type: text + section: "main::modal:货位绑定探针" + strategy: id + value: "form_item_shelfNo" + playwright: "page.locator('#form_item_shelfNo')" + + el_e8b4a7e4bd8de78ab6e68081Select: + description: "货位状态" + element_type: select + section: "main::modal:货位绑定探针" + strategy: label_text + value: "货位状态" + playwright: "page.getByText('货位状态', { exact: true })" + + el_e9bb98e8aea4e8b4a7e4bd8dSelect: + description: "默认货位" + element_type: select + section: "main::modal:货位绑定探针" + strategy: label_text + value: "默认货位" + playwright: "page.getByText('默认货位', { exact: true })" + + el_e585b132363236e69da13132333435e280a2e280a2e280a235333530e69da12fe9a1b5e8b7b3e887b3e9a1b5: + description: "共 2626 条12345•••5350 条/页跳至页" + element_type: ul + section: "main::modal:货位绑定探针" + strategy: text + value: "共 2626 条12345•••5350 条/页跳至页" + playwright: "page.getByText('共 2626 条12345•••5350 条/页跳至页')" + + prevPageBtn: + description: "上一页" + element_type: li + section: "main::modal:货位绑定探针" + strategy: text + value: "上一页" + playwright: "page.getByText('上一页')" + + page_1: + description: "1" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + page_2: + description: "2" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: text + value: "2" + playwright: "page.getByText('2')" + + page_3: + description: "3" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: text + value: "3" + playwright: "page.getByText('3')" + + page_4: + description: "4" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: text + value: "4" + playwright: "page.getByText('4')" + + page_5: + description: "5" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: text + value: "5" + playwright: "page.getByText('5')" + + el_e59091e5908e35e9a1b5: + description: "向后 5 页" + element_type: li + section: "main::modal:货位绑定探针" + strategy: text + value: "向后 5 页" + playwright: "page.getByText('向后 5 页')" + + page_53: + description: "53" + element_type: pagination_item + section: "main::modal:货位绑定探针" + strategy: text + value: "53" + playwright: "page.getByText('53')" + + nextPageBtn: + description: "下一页" + element_type: li + section: "main::modal:货位绑定探针" + strategy: text + value: "下一页" + playwright: "page.getByText('下一页')" + + pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::modal:货位绑定探针" + strategy: text + value: "50 条/页" + playwright: "page.getByText('50 条/页')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:货位绑定探针" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确定(0)" + element_type: button + section: "main::modal:货位绑定探针" + strategy: role + role: button + name: "确定(0)" + playwright: "page.getByRole('button', { name: '确定(0)' })" diff --git a/locators/spd/warehouse-zone.locator.ts b/locators/spd/warehouse-zone.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cac2c3989be08c05b8a92a627beffd9c7f4db0c --- /dev/null +++ b/locators/spd/warehouse-zone.locator.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from '@playwright/test'; + +import { WhFlowSharedLocators } from './wh-flow-shared.locator'; + +/** 库区设置。选择器来自 locators/spd/warehouse-zone.locator.yaml */ +export class WarehouseZoneLocators extends WhFlowSharedLocators { + readonly warehouseNameInput: Locator; + readonly zoneSearch: Locator; + readonly syncBtn: Locator; + readonly addBtn: Locator; + readonly tree: Locator; + readonly shelfNo: Locator; + readonly shelfName: Locator; + readonly address: Locator; + readonly cancelBtn: Locator; + readonly confirmBtn: Locator; + + constructor(page: Page) { + super(page); + this.warehouseNameInput = page.getByPlaceholder('库房名称'); + this.zoneSearch = page.getByPlaceholder('库区编号/名称'); + this.syncBtn = page.getByRole('button', { name: '同步' }); + this.addBtn = page.getByRole('button', { name: /新\s*增/ }); + this.tree = page.locator('.ant-tree').first(); + this.shelfNo = page.locator('#basic_shelfNo'); + this.shelfName = page.locator('#basic_shelfName'); + this.address = page.locator('#basic_address'); + this.cancelBtn = page.getByRole('button', { name: /取\s*消/ }); + this.confirmBtn = page.getByRole('button', { name: /确\s*定/ }); + } + + treeTitles(): Locator { + return this.tree.locator('.ant-tree-title'); + } + + selectedTreeTitle(): Locator { + return this.tree.locator('.ant-tree-treenode-selected .ant-tree-title').first(); + } +} diff --git a/locators/spd/warehouse-zone.locator.yaml b/locators/spd/warehouse-zone.locator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..54a155a9bba7b5524726439f7a43cc2d215f7f03 --- /dev/null +++ b/locators/spd/warehouse-zone.locator.yaml @@ -0,0 +1,591 @@ +# Auto-generated from warehouse-zone-elements.json +# Source: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/platform/storehouse/area +# Scanned: 2026-10-20T09:08:25.864Z +# Page: 基础设置 > 库房管理 > 库区设置 +# Note: dialog/modal was opened during scan +# Note: edit dialog was opened during scan + +page: warehouse-zone +module: spd-basic +url: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/platform/storehouse/area + +locators: + # --- main::main --- + menu_home: + description: "首页" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "首页" + playwright: "page.getByRole('menuitem', { name: '首页' })" + + menu_basicSetting: + description: "基础设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "基础设置" + playwright: "page.getByRole('menuitem', { name: '基础设置' })" + + menu_purchase: + description: "采购管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "采购管理" + playwright: "page.getByRole('menuitem', { name: '采购管理' })" + + menu_opening: + description: "期初管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "期初管理" + playwright: "page.getByRole('menuitem', { name: '期初管理' })" + + menu_centerStock: + description: "中心库管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "中心库管理" + playwright: "page.getByRole('menuitem', { name: '中心库管理' })" + + menu_deptBusiness: + description: "科室业务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "科室业务管理" + playwright: "page.getByRole('menuitem', { name: '科室业务管理' })" + + menu_finance: + description: "财务管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "财务管理" + playwright: "page.getByRole('menuitem', { name: '财务管理' })" + + menu_orthoVolume: + description: "骨科带量" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "骨科带量" + playwright: "page.getByRole('menuitem', { name: '骨科带量' })" + + menu_general: + description: "综合管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "综合管理" + playwright: "page.getByRole('menuitem', { name: '综合管理' })" + + menu_yangcai: + description: "阳采管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "阳采管理" + playwright: "page.getByRole('menuitem', { name: '阳采管理' })" + + menu_processCenter: + description: "流程中心" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "流程中心" + playwright: "page.getByRole('menuitem', { name: '流程中心' })" + + menu_logistics: + description: "后勤物资管理" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "后勤物资管理" + playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })" + + menu_statReport: + description: "统计报表" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "统计报表" + playwright: "page.getByRole('menuitem', { name: '统计报表' })" + + menu_dataViz: + description: "数据可视化" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "数据可视化" + playwright: "page.getByRole('menuitem', { name: '数据可视化' })" + + menu_el_e5ba93e58cbae8aebee7bdae: + description: "库区设置" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "库区设置" + playwright: "page.getByRole('menuitem', { name: '库区设置' })" + + menu_el_e68ea2e99288e7bb91e5ae9a: + description: "探针绑定" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "探针绑定" + playwright: "page.getByRole('menuitem', { name: '探针绑定' })" + + menu_el_e58d8ee6b885e9809ae98193: + description: "华清通道" + element_type: li + section: "main::main" + strategy: role + role: menuitem + name: "华清通道" + playwright: "page.getByRole('menuitem', { name: '华清通道' })" + + el_e5ba93e58cbae8aebee7bdae: + description: "库区设置" + element_type: tab + section: "main::main" + strategy: text + value: "库区设置" + playwright: "page.getByText('库区设置')" + + tab_el_e5ba93e58cbae8aebee7bdae: + description: "库区设置" + element_type: div + section: "main::main" + strategy: id + value: "rc-tabs-0-tab-/basic/storehouse/platform/storehouse/area" + playwright: "page.locator('#rc-tabs-0-tab-/basic/storehouse/platform/storehouse/area')" + + el_e5908ce6ada5Btn: + description: "同步" + element_type: button + section: "main::main" + strategy: role + role: button + name: "同步" + playwright: "page.getByRole('button', { name: '同步' })" + + el_e5ba93e688bfe4bfa1e681afInput: + description: "库房名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "库房名称" + playwright: "page.getByPlaceholder('库房名称')" + + tree_node_e58589e8b0b7: + description: "光谷院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "光谷院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*光谷院区中心库房\\s*$/ })" + + tree_node_e6b4aae5b1b1: + description: "洪山院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "洪山院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*洪山院区中心库房\\s*$/ })" + + tree_node_31323035e999: + description: "1205院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "1205院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*1205院区中心库房\\s*$/ })" + + tree_node_e7bb93e7ae97: + description: "结算院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "结算院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*结算院区中心库房\\s*$/ })" + + tree_node_e59b9ee5bd92: + description: "回归测试院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "回归测试院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*回归测试院区中心库房\\s*$/ })" + + tree_node_e5a4a9e5bf83: + description: "天心阁院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "天心阁院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*天心阁院区中心库房\\s*$/ })" + + tree_node_e58d83e6b389: + description: "千泉山院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "千泉山院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*千泉山院区中心库房\\s*$/ })" + + tree_node_e9a9ace78e8b: + description: "马王堆院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "马王堆院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*马王堆院区中心库房\\s*$/ })" + + tree_node_e4b8bbe999a2: + description: "主院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "主院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*主院区中心库房\\s*$/ })" + + tree_node_e5b2b3e9ba93: + description: "岳麓山院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "岳麓山院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*岳麓山院区中心库房\\s*$/ })" + + tree_node_e695b0e68dae: + description: "数据检查院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "数据检查院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*数据检查院区中心库房\\s*$/ })" + + tree_node_e4b89ce59c9f: + description: "东土城院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "东土城院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*东土城院区中心库房\\s*$/ })" + + tree_node_e699bae883bd: + description: "智能院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "智能院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*智能院区中心库房\\s*$/ })" + + tree_node_e887aae58aa8: + description: "自动采购/请领建议专用院区中心库房" + element_type: tree_title + section: "main::main" + strategy: tree_node + value: "自动采购/请领建议专用院区中心库房" + tree: ".ant-tree" + playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*自动采购/请领建议专用院区中心库房\\s*$/ })" + + addBtn: + description: "新 增" + element_type: button + section: "main::main" + strategy: role + role: button + name: "新 增" + playwright: "page.getByRole('button', { name: '新 增' })" + + addInput: + description: "库区编号/名称" + element_type: text + section: "main::main" + strategy: placeholder + value: "库区编号/名称" + playwright: "page.getByPlaceholder('库区编号/名称')" + + el_e5ba93e688bfe4bfa1e681af: + description: "column-height" + element_type: span + section: "main::main" + strategy: label + value: "column-height" + playwright: "page.getByLabel('column-height')" + + editBtn: + description: "编辑" + element_type: button + section: "main::main" + strategy: role + role: button + name: "编辑" + playwright: "page.getByRole('button', { name: '编辑' })" + + el_e4babae59198e7aea1e79086Btn: + description: "人员管理" + element_type: button + section: "main::main" + strategy: role + role: button + name: "人员管理" + playwright: "page.getByRole('button', { name: '人员管理' })" + + el_e8b4a7e4bd8de7aea1e79086Btn: + description: "货位管理" + element_type: button + section: "main::main" + strategy: role + role: button + name: "货位管理" + playwright: "page.getByRole('button', { name: '货位管理' })" + + disableBtn: + description: "停用" + element_type: button + section: "main::main" + strategy: role + role: button + name: "停用" + playwright: "page.getByRole('button', { name: '停用' })" + + enableBtn: + description: "启用" + element_type: button + section: "main::main" + strategy: role + role: button + name: "启用" + playwright: "page.getByRole('button', { name: '启用' })" + + # --- main::pagination --- + pagination_el_e5ba93e688bfe4bfa1e681af: + description: "共 5 条150 条/页" + element_type: ul + section: "main::pagination" + strategy: label_text + value: "库房信息" + playwright: "page.getByText('库房信息', { exact: true })" + + pagination_prevPageBtn: + description: "上一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "库房信息" + playwright: "page.getByText('库房信息', { exact: true })" + + pagination_el_e5ba93e688bfe4bfa1e681afBtn: + description: "库房信息" + element_type: button + section: "main::pagination" + strategy: label_text + value: "库房信息" + playwright: "page.getByText('库房信息', { exact: true })" + + pagination_page_1: + description: "1" + element_type: pagination_item + section: "main::pagination" + strategy: css + value: ".ant-pagination-item-active" + playwright: "page.locator('.ant-pagination-item-active')" + + pagination_nextPageBtn: + description: "下一页" + element_type: li + section: "main::pagination" + strategy: label_text + value: "库房信息" + playwright: "page.getByText('库房信息', { exact: true })" + + pagination_pageSizeSelect: + description: "50 条/页" + element_type: select + section: "main::pagination" + strategy: label_text + value: "库房信息" + playwright: "page.getByText('库房信息', { exact: true })" + + # --- main::modal:新增库区 --- + el_e5ba93e58cbae7bc96e58fb7Input: + description: "请输入" + element_type: text + section: "main::modal:新增库区" + strategy: id + value: "basic_shelfNo" + playwright: "page.locator('#basic_shelfNo')" + + storageAreaInput: + description: "请输入" + element_type: text + section: "main::modal:新增库区" + strategy: id + value: "basic_shelfName" + playwright: "page.locator('#basic_shelfName')" + + el_e5ba93e58cbae59cb0e59d80Input: + description: "请输入" + element_type: text + section: "main::modal:新增库区" + strategy: id + value: "basic_address" + playwright: "page.locator('#basic_address')" + + el_e590a6: + description: "否" + element_type: radio + section: "main::modal:新增库区" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + el_e698af: + description: "是" + element_type: radio + section: "main::modal:新增库区" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + el_e88194e7b3bbe4babaSelect: + description: "联系人" + element_type: select + section: "main::modal:新增库区" + strategy: label_text + value: "联系人" + playwright: "page.getByText('联系人', { exact: true })" + + el_e88194e7b3bbe4babaInput: + description: "联系人" + element_type: search + section: "main::modal:新增库区" + strategy: id + value: "basic_linkman" + playwright: "page.locator('#basic_linkman')" + + el_e88194e7b3bbe696b9e5bc8fInput: + description: "请输入" + element_type: text + section: "main::modal:新增库区" + strategy: id + value: "basic_recPhone" + playwright: "page.locator('#basic_recPhone')" + + cancelBtn: + description: "取 消" + element_type: button + section: "main::modal:新增库区" + strategy: role + role: button + name: "取 消" + playwright: "page.getByRole('button', { name: '取 消' })" + + confirmBtn: + description: "确 定" + element_type: button + section: "main::modal:新增库区" + strategy: role + role: button + name: "确 定" + playwright: "page.getByRole('button', { name: '确 定' })" + + # --- main::modal:编辑库区信息 --- + el_e5ba93e58cbae7bc96e58fb7Input_2: + description: "请输入" + element_type: text + section: "main::modal:编辑库区信息" + strategy: id + value: "basic_shelfNo" + playwright: "page.locator('#basic_shelfNo')" + + storageAreaInput_2: + description: "请输入" + element_type: text + section: "main::modal:编辑库区信息" + strategy: id + value: "basic_shelfName" + playwright: "page.locator('#basic_shelfName')" + + el_e5ba93e58cbae59cb0e59d80Input_2: + description: "请输入" + element_type: text + section: "main::modal:编辑库区信息" + strategy: id + value: "basic_address" + playwright: "page.locator('#basic_address')" + + el_e590a6_2: + description: "否" + element_type: radio + section: "main::modal:编辑库区信息" + strategy: text + value: "否" + playwright: "page.getByText('否')" + + el_e698af_2: + description: "是" + element_type: radio + section: "main::modal:编辑库区信息" + strategy: text + value: "是" + playwright: "page.getByText('是')" + + el_e88194e7b3bbe4babaSelect_2: + description: "蒋中心库" + element_type: select + section: "main::modal:编辑库区信息" + strategy: label_text + value: "联系人" + playwright: "page.getByText('联系人', { exact: true })" + + el_e88194e7b3bbe4babaInput_2: + description: "联系人" + element_type: search + section: "main::modal:编辑库区信息" + strategy: id + value: "basic_linkman" + playwright: "page.locator('#basic_linkman')" + + el_e88194e7b3bbe696b9e5bc8fInput_2: + description: "请输入" + element_type: text + section: "main::modal:编辑库区信息" + strategy: id + value: "basic_recPhone" + playwright: "page.locator('#basic_recPhone')" diff --git a/locators/spd/wh-flow-shared.locator.ts b/locators/spd/wh-flow-shared.locator.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f6defb1e8132a4e5935791281126b09760b0200 --- /dev/null +++ b/locators/spd/wh-flow-shared.locator.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +/** 库房管理 / 流程设置共用。叶子菜单是 .ant-menu-item。 */ +export class WhFlowSharedLocators { + readonly menuBasic: Locator; + readonly tableHeader: Locator; + readonly empty: Locator; + readonly paginationTotal: Locator; + readonly nextPage: Locator; + readonly prevPage: Locator; + readonly formError: Locator; + + constructor(protected readonly page: Page) { + this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); + this.tableHeader = page.locator('.ant-table-thead th'); + this.empty = page.locator('.ant-empty, .ant-empty-description').first(); + this.paginationTotal = page.locator('.ant-pagination-total-text').first(); + this.nextPage = page.locator('.ant-pagination-next').first(); + this.prevPage = page.locator('.ant-pagination-prev').first(); + this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice'); + } + + groupSubmenuTitle(name: string): Locator { + return this.page.locator('.ant-menu-submenu-title').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }); + } + + menuLeaf(name: string): Locator { + return this.page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first(); + } + + button(name: string, root: Locator | Page = this.page): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return root.getByRole('button', { name: pattern }); + } + + putPage(): Locator { + return this.page.locator('.put-page').last(); + } + + dialog(): Locator { + return this.page.locator('.ant-modal-wrap .ant-modal:visible, .ant-drawer-open').last(); + } +} diff --git a/package.json b/package.json index 46366a13b418603bfe36fa3f2f2a82f58f7cd894..50bdc76c7007a5ad30396f24e030b4ec7243b0be 100644 --- a/package.json +++ b/package.json @@ -24,19 +24,13 @@ "test:page": "node scripts/run-page.mjs", "suites": "node scripts/run-ci.mjs --list", "flake:report": "node scripts/flake-report.mjs", + "failures:report": "node scripts/failure-report.mjs", + "quarantine:sync": "node scripts/sync-quarantine.mjs", + "quarantine:apply": "node scripts/sync-quarantine.mjs --write", "email:summary": "node scripts/ci-email-summary.mjs", "test:quarantine": "node scripts/run-ci.mjs --suite=quarantine", "test:smoke": "playwright test tests/spd/login.spec.ts tests/spd/home.spec.ts --grep @smoke", "test:smoke:allure": "ALLURE=1 npm run test:smoke && npm run allure:generate", - "test:stat-iod-002": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-002.spec.ts --headed --project=chromium --workers=1", - "test:stat-iod-003": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-003.spec.ts --headed --project=chromium --workers=1", - "test:stat-iod-004": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-004.spec.ts --headed --project=chromium --workers=1", - "test:stat-iod-006": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-006.spec.ts --headed --project=chromium --workers=1", - "test:stat-iod-043": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-043.spec.ts --headed --project=chromium --workers=1", - "test:stat-iod-044": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-044.spec.ts --headed --project=chromium --workers=1", - "test:stat-ies-027": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-entry.stat-ies-027.spec.ts --headed --project=chromium --workers=1", - "test:stat-oos-004": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-004.spec.ts --headed --project=chromium --workers=1", - "test:stat-oos-006": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-006.spec.ts --headed --project=chromium --workers=1", "test:center-report": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium", "test:center-report:all": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium --workers=3", "test:center-report-entry": "playwright test tests/spd/center-report-entry.spec.ts --project=chromium", @@ -49,12 +43,6 @@ "test:center-report-lmm-sheet:all": "playwright test tests/spd/center-report-lmm-sheet.spec.ts --project=chromium", "test:center-report-turnover": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium", "test:center-report-turnover:all": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium", - "test:stat-ptr-002": "SLOW_MO=500 playwright test tests/spd/center-report-turnover.stat-ptr-002.spec.ts --headed --project=chromium --workers=1", - "test:stat-lms-005": "SLOW_MO=500 playwright test tests/spd/center-report-lmm-sheet.stat-lms-005.spec.ts --headed --project=chromium --workers=1", - "test:stat-nos-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-out.stat-nos-007.spec.ts --headed --project=chromium --workers=1", - "test:stat-nes-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-entry.stat-nes-007.spec.ts --headed --project=chromium --workers=1", - "test:stat-psi-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-psi.stat-psi-004.spec.ts --headed --project=chromium --workers=1", - "test:stat-lmp-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts --headed --project=chromium --workers=1", "test:center-report-lmm-psi": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium", "test:center-report-lmm-psi:all": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium", "test:center-report-net-entry": "playwright test tests/spd/center-report-net-entry.spec.ts --project=chromium", @@ -84,9 +72,78 @@ "discover:scan-invoice-trace": "node scripts/scan-invoice-trace-batch.mjs", "discover:scan-consume": "node scripts/scan-consume-batch.mjs", "discover:scan-extended": "node scripts/scan-extended-report-batch.mjs", + "discover:scan-admit": "node scripts/scan-admit-batch.mjs", + "discover:scan-basic": "node scripts/scan-basic-batch.mjs", + "discover:scan-vendor": "node scripts/scan-vendor-batch.mjs", + "discover:scan-surgery": "node scripts/scan-surgery-batch.mjs", + "discover:scan-user": "node scripts/scan-user-batch.mjs", + "discover:scan-warehouse": "node scripts/scan-wh-flow-batch.mjs warehouse-zone warehouse-probe", + "discover:scan-flow": "node scripts/scan-wh-flow-batch.mjs flow-def flow-inst", + "discover:scan-wh-flow": "node scripts/scan-wh-flow-batch.mjs", + "discover:yaml-from-json-basic": "node scripts/json-to-locator-yaml.mjs --all-new", + "generate:testpoints-vendor": "python3 scripts/generate-vendor-testpoints.py", + "generate:testpoints-user": "python3 scripts/generate-user-testpoints.py", + "generate:testpoints-warehouse": "python3 scripts/generate-wh-flow-testpoints.py warehouse-zone warehouse-probe", + "generate:testpoints-flow": "python3 scripts/generate-wh-flow-testpoints.py flow-def flow-inst", + "generate:testpoints-wh-flow": "python3 scripts/generate-wh-flow-testpoints.py", + "generate:md-user": "python3 scripts/generate-user-md.py", + "generate:md-wh-flow": "python3 scripts/generate-wh-flow-md.py", + "generate:md-csv-wh-flow": "python3 scripts/generate-wh-flow-md.py", + "test:warehouse-zone": "playwright test tests/spd/warehouse-zone.spec.ts --project=chromium --grep @smoke", + "test:warehouse-zone:all": "playwright test tests/spd/warehouse-zone.spec.ts --project=chromium --workers=1", + "test:warehouse-probe": "playwright test tests/spd/warehouse-probe.spec.ts --project=chromium --grep @smoke", + "test:warehouse-probe:all": "playwright test tests/spd/warehouse-probe.spec.ts --project=chromium --workers=1", + "test:flow-def": "playwright test tests/spd/flow-def.spec.ts --project=chromium --grep @smoke", + "test:flow-def:all": "playwright test tests/spd/flow-def.spec.ts --project=chromium --workers=1", + "test:flow-inst": "playwright test tests/spd/flow-inst.spec.ts --project=chromium --grep @smoke", + "test:flow-inst:all": "playwright test tests/spd/flow-inst.spec.ts --project=chromium --workers=1", + "test:wh-flow:smoke": "playwright test tests/spd/warehouse-zone.spec.ts tests/spd/warehouse-probe.spec.ts tests/spd/flow-def.spec.ts tests/spd/flow-inst.spec.ts --project=chromium --grep @smoke --workers=1", + "test:wh-flow:all": "playwright test tests/spd/warehouse-zone.spec.ts tests/spd/warehouse-probe.spec.ts tests/spd/flow-def.spec.ts tests/spd/flow-inst.spec.ts --project=chromium --workers=1", + "assert:wh-flow-csv-spec": "node scripts/assert-wh-flow-csv-spec-sync.mjs", + "assert:wh-flow-isolation": "node scripts/assert-vendor-no-reuse-open.mjs", + "generate:md-csv-user": "python3 scripts/generate-user-md.py", + "test:basic-user": "playwright test tests/spd/basic-user.spec.ts --project=chromium --grep @smoke", + "test:basic-user:all": "playwright test tests/spd/basic-user.spec.ts --project=chromium --workers=1", + "test:basic-user-union": "playwright test tests/spd/basic-user-union.spec.ts --project=chromium --grep @smoke", + "test:basic-user-union:all": "playwright test tests/spd/basic-user-union.spec.ts --project=chromium --workers=1", + "test:basic-role": "playwright test tests/spd/basic-role.spec.ts --project=chromium --grep @smoke", + "test:basic-role:all": "playwright test tests/spd/basic-role.spec.ts --project=chromium --workers=1", + "test:user:smoke": "playwright test tests/spd/basic-user.spec.ts tests/spd/basic-user-union.spec.ts tests/spd/basic-role.spec.ts --project=chromium --grep @smoke --workers=1", + "test:user:all": "playwright test tests/spd/basic-user.spec.ts tests/spd/basic-user-union.spec.ts tests/spd/basic-role.spec.ts --project=chromium --workers=1", + "assert:user-csv-spec": "node scripts/assert-user-csv-spec-sync.mjs", + "assert:basic-csv-spec": "node scripts/assert-basic-csv-spec-sync.mjs", + "test:vendor-query": "playwright test tests/spd/vendor-query.spec.ts --project=chromium --grep @smoke", + "test:vendor-query:all": "playwright test tests/spd/vendor-query.spec.ts --project=chromium --workers=1", + "test:vendor-qual": "playwright test tests/spd/vendor-qual.spec.ts --project=chromium --grep @smoke", + "test:vendor-qual:all": "playwright test tests/spd/vendor-qual.spec.ts --project=chromium --workers=1", + "test:vendor:smoke": "playwright test tests/spd/vendor-query.spec.ts tests/spd/vendor-qual.spec.ts tests/spd/vendor-audit.spec.ts tests/spd/vendor-cert-diff.spec.ts tests/spd/vendor-eval-scheme.spec.ts tests/spd/vendor-eval-indicator.spec.ts tests/spd/vendor-holiday.spec.ts --project=chromium --grep @smoke --workers=1", + "test:vendor-audit:smoke": "playwright test tests/spd/vendor-audit.spec.ts --project=chromium --grep @smoke --workers=1", + "test:vendor-audit:all": "playwright test tests/spd/vendor-audit.spec.ts --project=chromium --workers=1", + "test:vendor-cert-diff": "playwright test tests/spd/vendor-cert-diff.spec.ts --project=chromium --grep @smoke", + "test:vendor-cert-diff:all": "playwright test tests/spd/vendor-cert-diff.spec.ts --project=chromium --workers=1", + "test:vendor-eval-scheme": "playwright test tests/spd/vendor-eval-scheme.spec.ts --project=chromium --grep @smoke", + "test:vendor-eval-scheme:all": "playwright test tests/spd/vendor-eval-scheme.spec.ts --project=chromium --workers=1", + "test:vendor-eval-indicator": "playwright test tests/spd/vendor-eval-indicator.spec.ts --project=chromium --grep @smoke", + "test:vendor-eval-indicator:all": "playwright test tests/spd/vendor-eval-indicator.spec.ts --project=chromium --workers=1", + "test:vendor-holiday": "playwright test tests/spd/vendor-holiday.spec.ts --project=chromium --grep @smoke", + "test:vendor-holiday:all": "playwright test tests/spd/vendor-holiday.spec.ts --project=chromium --workers=1", + "generate:md-csv-vendor": "python3 scripts/generate-vendor-md-csv.py", + "assert:vendor-csv-spec": "node scripts/assert-vendor-csv-spec-sync.mjs", + "assert:vendor-isolation": "node scripts/assert-vendor-no-reuse-open.mjs", + "test:surgery-base-pkg": "playwright test tests/spd/surgery-base-pkg.spec.ts --project=chromium --grep @smoke", + "test:surgery-base-pkg:all": "playwright test tests/spd/surgery-base-pkg.spec.ts --project=chromium --workers=1", + "test:surgery-oper-type": "playwright test tests/spd/surgery-oper-type.spec.ts --project=chromium --grep @smoke", + "test:surgery-oper-type:all": "playwright test tests/spd/surgery-oper-type.spec.ts --project=chromium --workers=1", + "test:surgery-std-pkg": "playwright test tests/spd/surgery-std-pkg.spec.ts --project=chromium --grep @smoke", + "test:surgery-std-pkg:all": "playwright test tests/spd/surgery-std-pkg.spec.ts --project=chromium --workers=1", + "test:surgery:smoke": "playwright test tests/spd/surgery-base-pkg.spec.ts tests/spd/surgery-oper-type.spec.ts tests/spd/surgery-std-pkg.spec.ts --project=chromium --grep @smoke --workers=1", + "test:surgery:all": "playwright test tests/spd/surgery-base-pkg.spec.ts tests/spd/surgery-oper-type.spec.ts tests/spd/surgery-std-pkg.spec.ts --project=chromium --workers=1", + "assert:surgery-csv-spec": "node scripts/assert-surgery-csv-spec-sync.mjs", + "test:vendor:all": "playwright test tests/spd/vendor-query.spec.ts tests/spd/vendor-qual.spec.ts tests/spd/vendor-audit.spec.ts tests/spd/vendor-cert-diff.spec.ts tests/spd/vendor-eval-scheme.spec.ts tests/spd/vendor-eval-indicator.spec.ts tests/spd/vendor-holiday.spec.ts --project=chromium --workers=1", "discover:yaml-from-json": "node scripts/json-to-locator-yaml.mjs --all-extended", "update:ui-specs-extended": "python3 scripts/update-extended-ui-specs.py", "update:ui-specs-consume": "python3 scripts/update-consume-ui-specs.py", + "sync:ui-specs-user": "python3 scripts/sync-docs-ui-specs.py --only USER-001,USER-002,USER-003", "generate:testpoints-invoice-trace": "python3 scripts/generate-invoice-trace-testpoints.py", "generate:testpoints-consume": "python3 scripts/generate-consume-testpoints.py", "generate:testpoints-extended": "python3 scripts/generate-extended-testpoints.py", @@ -111,9 +168,6 @@ "test:invoice-balance-status": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium", "test:invoice-balance-status:all": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium --workers=1", "test:trace-direct-buy": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium", - "test:stat-tby-004": "SLOW_MO=500 playwright test tests/spd/trace-direct-buy.stat-tby-004.spec.ts --headed --project=chromium --workers=1", - "test:stat-tbc-004": "SLOW_MO=500 playwright test tests/spd/trace-barcode.stat-tbc-004.spec.ts --headed --project=chromium --workers=1", - "test:stat-tdb-004": "SLOW_MO=500 playwright test tests/spd/trace-dept-buy.stat-tdb-004.spec.ts --headed --project=chromium --workers=1", "test:trace-direct-buy:all": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium --workers=1", "test:trace-barcode": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium", "test:trace-barcode:all": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium --workers=1", diff --git a/pages/spd/admit-material-list.page.ts b/pages/spd/admit-material-list.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..89f158385582bc6223b05e217e0f877a743b0b86 --- /dev/null +++ b/pages/spd/admit-material-list.page.ts @@ -0,0 +1,164 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 物料准入单列表。详情里的「通过」只取消,不确认审批。 */ +export class AdmitMaterialListPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.materialListPath, + pathPart: 'productOrder/zrlist', + menu: admitData.menuMaterialList, + forbidden: ['supplyApply/', 'accessKind/', 'productApply/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + async filterByOrderOrFallback(): Promise { + await this.query(); + await this.queryByFirstAvailable([ + { column: '准入单号', filter: '准入单号', kind: 'input' }, + { column: '注册证号', filter: '注册证号', kind: 'input' }, + { column: '产品名称', filter: '产品名称', kind: 'input' }, + { column: '器械注册人', filter: '器械注册人', kind: 'input' }, + ]); + } + + async resetFilters(): Promise { + await this.query(); + const filled = await this.fillFirstVisibleFilter(); + await this.reset(); + if (filled) await this.expectInputCleared(filled); + await this.expectListOrEmpty(); + } + + async mockEmpty() { + const cert = this.textInput('注册证号'); + if (await cert.isVisible().catch(() => false)) { + await this.fillFilter('注册证号', admitData.mockCert); + } else { + await this.fillFilter('产品名称', admitData.mockGoods); + } + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.query(); + return this.headerTexts(); + } + + async switchPassed(): Promise { + await this.clickTabAndMatchCount('已通过'); + await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.firstRow(); + const order = await this.tryCell(row, '准入单号'); + await this.openPutFromRow(row, '详情'); + if (order) await expect(this.put()).toContainText(order.slice(0, 8)); + await expect(this.put().getByText('审批进程').first()).toBeVisible(); + await this.leaveWithoutCommit(); + } + + async detailCountMatches(): Promise { + await this.query(); + const row = await this.firstRow(); + const countText = (await this.cellText(row, '产品条数')).replace(/\s/g, ''); + await this.openPutFromRow(row, '详情'); + await expect(this.put()).toContainText(countText); + await this.leaveWithoutCommit(); + } + + async openApproval(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + await this.openDetailSection('审批进程'); + await this.leaveWithoutCommit(); + } + + async exportDetail(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + const exp = this.button('导出', this.put()).first(); + if (!(await exp.isVisible().catch(() => false))) { + await this.leaveWithoutCommit(); + await this.skipWhenDataInsufficient('详情无导出'); + } + await this.assertBeforeBusinessAction('详情导出'); + await this.click(exp, '导出'); + await this.assertPageHealthy('详情导出'); + expect(this.page.url()).not.toMatch(/\/login/); + await expect(this.put().getByRole('button', { name: /返\s*回/ }).first()).toBeVisible(); + await this.leaveWithoutCommit(); + } + + async detailSearchMock(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + const input = this.putInput('产品名称'); + const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first(); + await this.fill(target, admitData.mockGoods, '详情搜索 mock'); + const query = this.button('查询', this.put()).first(); + if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询'); + await this.waitForTableReady(); + const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false); + const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count(); + expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy(); + await this.leaveWithoutCommit(); + } + + async passedNotConfirm(): Promise { + await this.skipIfTabEmpty('已通过'); + const row = await this.firstRow(); + const order = await this.tryCell(row, '准入单号'); + await this.openPutFromRow(row, '详情'); + const pass = this.button('通过', this.put()).first(); + const visible = await pass.isVisible().catch(() => false); + const disabled = visible && ((await pass.isDisabled().catch(() => false)) || ((await pass.getAttribute('class')) ?? '').includes('disabled')); + if (!visible || disabled) { + await this.leaveWithoutCommit(); + } else { + await this.click(pass, '通过'); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last(); + if (await confirm.isVisible().catch(() => false)) { + await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消通过'); + } + await this.leaveWithoutCommit(); + } + await this.clickTab('已通过'); + if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible(); + } + + private async fillFirstVisibleFilter(): Promise { + for (const label of ['注册证号', '产品名称', '器械注册人']) { + if (await this.textInput(label).isVisible().catch(() => false)) { + await this.fillFilter(label, '样'); + return label; + } + } + return ''; + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '销售商', kind: 'text', value: '样本' }, + { label: '耗材类型', kind: 'select', value: '样本' }, + { label: '器械注册人', kind: 'text', value: '样本' }, + { label: '注册证号', kind: 'text', value: '样本' }, + { label: '供应商', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/admit-material.page.ts b/pages/spd/admit-material.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..05cc3531899ab41f0bf0fb39348fcfe48d85f4b9 --- /dev/null +++ b/pages/spd/admit-material.page.ts @@ -0,0 +1,197 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 物料准入管理。写操作只取消,不确认生成/作废/关联。 */ +export class AdmitMaterialPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.materialPath, + pathPart: 'productApply/ZRlist', + menu: admitData.menuMaterial, + forbidden: ['productOrder/', 'supplyApply/', 'accessKind/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + async filterByProductName(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 40)); + await this.query(); + await this.expectRowsContain('产品名称', name); + } + + async filterByGoodsType(): Promise { + await this.query(); + const type = await this.sampleCell('耗材类型'); + await this.chooseSelect('耗材类型', type); + await this.query(); + await this.expectRowsContain('耗材类型', type); + } + + async filterByNameAndType(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + const type = await this.sampleCell('耗材类型'); + await this.fillFilter('产品名称', name.slice(0, 40)); + await this.chooseSelect('耗材类型', type); + await this.query(); + await this.expectRowsContain('产品名称', name); + await this.expectRowsContain('耗材类型', type); + } + + async resetFilters(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 20)); + await this.reset(); + await this.expectInputCleared('产品名称'); + await this.expectListOrEmpty(); + } + + async collapseKeepsName(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.collapseKeeps('产品名称', name.slice(0, 20)); + } + + async mockEmptyName() { + await this.fillFilter('产品名称', admitData.mockGoods); + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.query(); + return this.headerTexts(); + } + + async switchPending(): Promise { + await this.clickTab('待申请'); + await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/); + await this.expectListOrEmpty(); + } + + async pendingCountMatches(): Promise { + await this.clickTabAndMatchCount('待申请'); + } + + async generateNeedsSelection(): Promise { + await this.expectNeedsSelection('生成申请单'); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.firstRow(); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '详情'); + await expect(this.put()).toContainText(name.slice(0, 8)); + await this.leaveWithoutCommit(); + await expect(this.button('查询').first()).toBeVisible(); + } + + async detailReadonly(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + const save = this.button('保存', this.put()); + expect(await save.count(), '详情不应提供保存').toBe(0); + const nameInput = this.putInput('产品名称'); + if (await nameInput.isVisible().catch(() => false)) { + await expect(nameInput).toBeDisabled(); + } + await this.leaveWithoutCommit(); + } + + async editBackfill(): Promise { + await this.clickTab('待申请'); + const row = await this.skipIfNoAction('编辑', '待申请无编辑'); + const name = await this.cellText(row, '产品名称'); + const spec = await this.tryCell(row, '规格型号'); + await this.openPutFromRow(row, '编辑'); + await expect(this.putInput('产品名称')).toHaveValue(name); + if (spec && (await this.putInput('规格型号').isVisible().catch(() => false))) { + await expect(this.putInput('规格型号')).toHaveValue(spec); + } + await this.leaveWithoutCommit(); + } + + async editCancel(): Promise { + await this.clickTab('待申请'); + const row = await this.skipIfNoAction('编辑', '待申请无编辑'); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '编辑'); + const input = this.putInput('产品名称'); + await this.fill(input, `${name}-取消`, '临时改名'); + await this.leaveWithoutCommit(); + await this.clickTab('待申请'); + const again = await this.skipIfNoAction('编辑', '待申请无编辑'); + await this.openPutFromRow(again, '编辑'); + await expect(this.putInput('产品名称')).toHaveValue(name); + await this.leaveWithoutCommit(); + } + + async editClearName(): Promise { + await this.clickTab('待申请'); + const row = await this.skipIfNoAction('编辑', '待申请无编辑'); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '编辑'); + await this.fill(this.putInput('产品名称'), '', '清空产品名称'); + await this.saveOnPut(); + await this.expectBlockedAndStay(); + await this.leaveWithoutCommit(); + await this.clickTab('待申请'); + const again = await this.rowWithAction('编辑'); + expect(again, '取消后原行仍应可编辑').toBeTruthy(); + await this.openPutFromRow(again as NonNullable, '编辑'); + await expect(this.putInput('产品名称')).toHaveValue(name); + await this.leaveWithoutCommit(); + } + + async effectiveNoEdit(): Promise { + await this.skipIfTabEmpty('已生效'); + const edit = (await this.listRows()).getByRole('button', { name: /编\s*辑/ }); + expect(await edit.count(), '已生效行不应提供编辑').toBe(0); + } + + async relateCancel(): Promise { + await this.query(); + const total = await this.getTotalCount(); + const supplier = await this.cellText(await this.firstRow(), '默认进院供应商'); + await this.clickToolbarThenCancel('关联产品'); + expect(await this.getTotalCount(), '取消关联后总数不变').toBe(total); + expect(await this.cellText(await this.firstRow(), '默认进院供应商')).toBe(supplier); + } + + async voidCancel(): Promise { + await this.query(); + const voidBefore = await this.readTabCount('已作废'); + const row = await this.checkFirstRow(); + const name = await this.cellText(row, '产品名称'); + await this.clickButton('作废'); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last(); + await expect(confirm, '作废应出现确认框').toBeVisible(); + await expect(confirm.getByRole('button', { name: /取\s*消/ })).toBeVisible(); + await expect(confirm.getByRole('button', { name: /确\s*定/ })).toBeVisible(); + await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消作废'); + await expect(this.page.getByText(name.slice(0, 12)).first(), '取消后该行仍在').toBeVisible(); + if (voidBefore !== null) { + expect(await this.readTabCount('已作废'), '已作废计数不变').toBe(voidBefore); + } + } + + async openLog(): Promise { + await this.query(); + const row = await this.skipIfNoAction('日志', '列表无日志入口'); + await this.assertBeforeBusinessAction('日志'); + await this.click(this.rowAction(row, '日志'), '日志'); + const layer = this.page.locator('.ant-modal:visible, .ant-drawer:visible, .put-page:visible').last(); + await expect(layer, '日志层应打开').toBeVisible(); + await this.assertUiReady('日志'); + await this.leaveWithoutCommit(); + } +} diff --git a/pages/spd/admit-product-apply.page.ts b/pages/spd/admit-product-apply.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..9deb035d3f1e661b3e7610ce6eab6c4752e4bd32 --- /dev/null +++ b/pages/spd/admit-product-apply.page.ts @@ -0,0 +1,169 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData, uniqProductName } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 产品准入申请。新增与作废只取消,不确认保存、导入或作废。 */ +export class AdmitProductApplyPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.productApplyPath, + pathPart: 'productApply/list', + menu: admitData.menuProductApply, + forbidden: ['ZRlist', 'productOrder/', 'supplyApply/', 'accessKind/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + async filterByProductName(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 40)); + await this.query(); + await this.expectRowsContain('产品名称', name); + } + + async filterByNameAndType(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + const type = await this.sampleCell('耗材类型'); + await this.fillFilter('产品名称', name.slice(0, 40)); + await this.chooseSelect('耗材类型', type); + await this.query(); + await this.expectRowsContain('产品名称', name); + } + + async resetFilters(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 20)); + await this.reset(); + await this.expectInputCleared('产品名称'); + await this.expectListOrEmpty(); + } + + async mockEmpty() { + await this.fillFilter('产品名称', admitData.mockGoods); + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.query(); + return this.headerTexts(); + } + + async switchPending(): Promise { + await this.clickTab('待申请'); + await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/); + await this.expectListOrEmpty(); + } + + async goPage2(): Promise { + await this.query(); + await this.goToPage2('产品名称'); + } + + async jumpPage2(): Promise { + await this.query(); + await this.jumpToPage(2); + } + + async openAddAndBack(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增产品'); + await expect(this.put(), '新增产品页').toBeVisible(); + for (const label of ['产品名称', '器械注册人', '规格型号']) { + await expect(this.put().getByText(label).first(), label).toBeVisible(); + } + await this.leaveWithoutCommit(); + expect(await this.getTotalCount()).toBe(total); + } + + async addCancel(): Promise { + const total = await this.getTotalCount(); + const name = uniqProductName(); + await this.clickButton('新增产品'); + await expect(this.put()).toBeVisible(); + await this.fill(this.putInput('产品名称'), name, '填写新产品名称'); + await this.leaveWithoutCommit(); + await this.fillFilter('产品名称', name); + await this.query(); + await this.expectMockEmpty(); + await this.reset(); + expect(await this.getTotalCount(), '取消后总数不变').toBe(total); + } + + async excelCancel(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('Excel导入'); + const layer = this.page.locator('.ant-modal:visible, .put-page:visible, .ant-drawer:visible').last(); + await expect(layer, 'Excel 导入层').toBeVisible(); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount()).toBe(total); + } + + async emptyNameBlocked(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增产品'); + await expect(this.put()).toBeVisible(); + await this.fill(this.putInput('产品名称'), '', '产品名称保持空'); + await this.saveOnPut(); + await this.expectBlockedAndStay(); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount(), '空名称不应落库').toBe(total); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.firstRow(); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '详情'); + await expect(this.put()).toContainText(name.slice(0, 8)); + await this.leaveWithoutCommit(); + } + + async editBackfill(): Promise { + await this.clickTab('待申请'); + const row = await this.skipIfNoAction('编辑', '待申请无编辑'); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '编辑'); + await expect(this.putInput('产品名称')).toHaveValue(name); + await this.leaveWithoutCommit(); + } + + async editCancel(): Promise { + await this.clickTab('待申请'); + const row = await this.skipIfNoAction('编辑', '待申请无编辑'); + const name = await this.cellText(row, '产品名称'); + await this.openPutFromRow(row, '编辑'); + await this.fill(this.putInput('产品名称'), `${name}-取消`, '临时改名'); + await this.leaveWithoutCommit(); + await this.clickTab('待申请'); + const again = await this.skipIfNoAction('编辑', '待申请无编辑'); + await this.openPutFromRow(again, '编辑'); + await expect(this.putInput('产品名称')).toHaveValue(name); + await this.leaveWithoutCommit(); + } + + async voidCancel(): Promise { + await this.query(); + const voidBefore = await this.readTabCount('已作废'); + await this.checkFirstRow(); + const name = await this.cellText(await this.firstRow(), '产品名称'); + await this.clickButton('作废'); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last(); + await expect(confirm).toBeVisible(); + await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消作废'); + await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible(); + if (voidBefore !== null) expect(await this.readTabCount('已作废')).toBe(voidBefore); + } + + async passedNoEdit(): Promise { + await this.skipIfTabEmpty('已通过'); + expect(await (await this.listRows()).getByRole('button', { name: /编\s*辑/ }).count(), '已通过不应提供编辑').toBe(0); + } +} diff --git a/pages/spd/admit-product-list.page.ts b/pages/spd/admit-product-list.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0ce53608537d7ace3081de4956d640584babd88 --- /dev/null +++ b/pages/spd/admit-product-list.page.ts @@ -0,0 +1,136 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */ +export class AdmitProductListPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.productListPath, + pathPart: 'productOrder/list', + menu: admitData.menuProductList, + forbidden: ['zrlist', 'productApply/', 'supplyApply/', 'accessKind/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '销售商', kind: 'text', value: '样本' }, + { label: '耗材类型', kind: 'select', value: '样本' }, + { label: '器械注册人', kind: 'text', value: '样本' }, + { label: '注册证号', kind: 'text', value: '样本' }, + { label: '供应商', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } + + async filterByProductOrFallback(): Promise { + await this.query(); + await this.queryByFirstAvailable([ + { column: '产品名称', filter: '产品名称', kind: 'input' }, + { column: '注册证号', filter: '注册证号', kind: 'input' }, + { column: '器械注册人', filter: '器械注册人', kind: 'input' }, + ]); + } + + async resetFilters(): Promise { + await this.query(); + const label = (await this.textInput('产品名称').isVisible().catch(() => false)) ? '产品名称' : '注册证号'; + await this.fillFilter(label, '样'); + await this.reset(); + await this.expectInputCleared(label); + await this.expectListOrEmpty(); + } + + async mockEmpty() { + if (await this.textInput('产品名称').isVisible().catch(() => false)) { + await this.fillFilter('产品名称', admitData.mockGoods); + } else { + await this.fillFilter('注册证号', admitData.mockCert); + } + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.query(); + return this.headerTexts(); + } + + async switchPassed(): Promise { + await this.clickTabAndMatchCount('已通过'); + await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.firstRow(); + const order = await this.tryCell(row, '准入单号'); + await this.openPutFromRow(row, '详情'); + if (order) await expect(this.put()).toContainText(order.slice(0, 8)); + await this.leaveWithoutCommit(); + } + + async detailCountMatches(): Promise { + await this.query(); + const row = await this.firstRow(); + const countText = (await this.cellText(row, '产品条数')).replace(/\s/g, ''); + await this.openPutFromRow(row, '详情'); + await expect(this.put()).toContainText(countText); + await this.leaveWithoutCommit(); + } + + async openBillLog(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + await this.openDetailSection('单据日志'); + await this.leaveWithoutCommit(); + } + + async detailSearchMock(): Promise { + await this.query(); + await this.openPutFromRow(await this.firstRow(), '详情'); + const input = this.putInput('产品名称'); + const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first(); + await this.fill(target, admitData.mockGoods, '详情搜索 mock'); + const query = this.button('查询', this.put()).first(); + if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询'); + await this.waitForTableReady(); + const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false); + const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count(); + expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy(); + await this.leaveWithoutCommit(); + } + + async passedNotConfirm(): Promise { + await this.skipIfTabEmpty('已通过'); + const row = await this.firstRow(); + const order = await this.tryCell(row, '准入单号'); + await this.openPutFromRow(row, '详情'); + const pass = this.button('通过', this.put()).first(); + const visible = await pass.isVisible().catch(() => false); + const disabled = visible && ((await pass.isDisabled().catch(() => false)) || ((await pass.getAttribute('class')) ?? '').includes('disabled')); + if (!visible || disabled) { + await this.leaveWithoutCommit(); + } else { + await this.click(pass, '通过'); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last(); + if (await confirm.isVisible().catch(() => false)) { + await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消通过'); + } + await this.leaveWithoutCommit(); + } + await this.clickTab('已通过'); + if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible(); + } +} diff --git a/pages/spd/admit-shared.page.ts b/pages/spd/admit-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..683fe5f82f4f000ae1018a6833cf80095f9a6273 --- /dev/null +++ b/pages/spd/admit-shared.page.ts @@ -0,0 +1,548 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator'; +import { BasePage } from '../../core/base.page'; +import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; + +export type AdmitMeta = { + path: string; + /** URL pathname 中用于区分兄弟页的片段,如 productApply/ZRlist */ + pathPart: string; + menu: string; + /** 进入后 URL 不应包含的片段 */ + forbidden?: string[]; +}; + +const DANGER_CONFIRM = /作废|通过|提交|分配|删除|生成/; + +/** 准入管理六页共用:无 iframe、侧栏叶子菜单、表格、Tab、详情层只取消不提交。 */ +export class AdmitSharedPage extends BasePage { + readonly locators: AdmitSharedLocators; + + constructor( + page: Page, + protected readonly meta: AdmitMeta, + ) { + super(page); + this.locators = new AdmitSharedLocators(page); + } + + async open(): Promise { + await this.goto(this.meta.path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.assertUiReady(`打开 ${this.meta.path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + async openFromMenu(): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.locators.menuBasic, '展开基础设置'); + const title = this.locators.admitSubmenuTitle.first(); + const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first(); + const opened = await submenu + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!opened) await this.click(title, '展开准入管理'); + await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`); + await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.assertUiReady(`菜单进入${this.meta.menu}`); + } + + async expectOnPage(): Promise { + expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); + for (const part of this.meta.forbidden ?? []) { + expect(this.page.url(), `不应进入 ${part}`).not.toContain(part); + } + await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible(); + expect((await this.headerTexts()).length, '列表表头').toBeGreaterThan(0); + } + + button(name: string, root?: Locator): Locator { + return this.locators.button(name, root ?? this.page); + } + + async clickButton(name: string, root?: Locator): Promise { + await this.click(this.button(name, root).first(), name); + } + + async query(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.clickButton('查询'); + await this.waitForTableReady(); + } + + async reset(): Promise { + await this.assertBeforeBusinessAction('重置'); + await this.clickButton('重置'); + await this.waitForTableReady(); + } + + textInput(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + const byRole = scope.getByRole('textbox', { name: label }).first(); + return byRole.or(scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first()); + } + + formSelect(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first(); + } + + async fillFilter(label: string, value: string): Promise { + const input = this.textInput(label); + await expect(input, `筛选「${label}」`).toBeVisible(); + await this.fill(input, value, `填写${label}`); + } + + async chooseSelect(label: string, optionText: string, root?: Locator): Promise { + const select = this.formSelect(label, root); + await this.click(select, `打开${label}`); + const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last(); + const keyword = optionText.replace(/\s/g, '').slice(0, 20); + const search = dropdown.locator('input').first(); + if (await search.isVisible().catch(() => false)) { + await search.fill(keyword); + await this.waitSettle(200); + } + const option = dropdown.locator('.ant-select-item-option').filter({ hasText: keyword }).first(); + await this.click(option, `选择${keyword}`); + await this.waitSettle(200); + } + + async selectFirstOption(label: string, root?: Locator): Promise { + const select = this.formSelect(label, root); + if (!(await select.isVisible().catch(() => false))) return false; + await this.click(select, `打开${label}`); + const option = this.page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') + .first(); + if (!(await option.isVisible().catch(() => false))) return false; + await this.click(option, `选择${label}首项`); + await this.waitSettle(200); + return true; + } + + tab(name: string): Locator { + return this.locators.tab(name); + } + + async readTabCount(name: string): Promise { + const text = ((await this.tab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/); + return matched ? Number(matched[1]) : null; + } + + async clickTab(name: string): Promise { + await this.assertBeforeBusinessAction(`切换${name}`); + await this.click(this.tab(name), `切换${name}`); + await this.waitForTableReady(); + } + + put(): Locator { + return this.locators.putPage(); + } + + async headerCells(): Promise { + const groups = this.page.locator('.ant-table-thead'); + const count = await groups.count(); + let best = groups.first().locator('th'); + let bestLen = 0; + for (let i = 0; i < count; i += 1) { + const cells = groups.nth(i).locator('th'); + const len = await cells.count(); + if (len > bestLen) { + bestLen = len; + best = cells; + } + } + return best; + } + + async headerTexts(): Promise { + return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, '')); + } + + async expectHeaders(names: string[]): Promise { + const text = (await this.headerTexts()).join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } + } + + async headerIndex(name: string): Promise { + const texts = await this.headerTexts(); + const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, ''))); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async listRows(): Promise { + const bodies = this.page.locator('.ant-table-tbody'); + const count = await bodies.count(); + let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + let bestWidth = -1; + for (let i = 0; i < count; i += 1) { + const body = bodies.nth(i); + const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page'))).catch(() => false); + if (insidePut) continue; + const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first(); + const width = await row.locator('td').count().catch(() => 0); + if (width > bestWidth) { + bestWidth = width; + best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)'); + } + } + return best; + } + + async getDataRowCount(): Promise { + return (await this.listRows()).count(); + } + + async getTotalCount(): Promise { + const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async tryCell(row: Locator, header: string): Promise { + try { + return (await this.cellText(row, header)).trim(); + } catch { + return ''; + } + } + + async firstRow(): Promise { + const row = (await this.listRows()).first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible(); + return row; + } + + /** 列表快照供用例层断言(不在此 expect)。 */ + async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const emptyVisible = + (await this.locators.empty.isVisible().catch(() => false)) || + (await this.page.getByText('暂无数据').first().isVisible().catch(() => false)); + return { rows, total, emptyVisible }; + } + + /** @deprecated 优先在 spec 用 snapshotList + assertAdmitListOrEmpty */ + async expectListOrEmpty(): Promise { + const state = await this.snapshotList(); + if (state.rows > 0) { + expect(state.total, '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1); + return; + } + expect(state.emptyVisible || state.total === 0, '无数据时空态应明确').toBeTruthy(); + } + + /** @deprecated 优先在 spec 用 snapshotList + assertAdmitMockEmpty */ + async expectMockEmpty(): Promise { + const state = await this.snapshotList(); + expect(state.rows === 0 || state.emptyVisible, '无匹配应空态').toBeTruthy(); + expect(state.total === 0 || state.rows === 0, '分页总数应为 0').toBeTruthy(); + } + + /** 只读:抽样行某列是否含关键字(用例层断言)。 */ + async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + if (!text.includes(needle)) return false; + } + return true; + } + + async expectRowsContain(header: string, keyword: string): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + expect(count, `「${header}」查询应有结果`).toBeGreaterThan(0); + const limit = Math.min(count, 5); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + expect(text, `第 ${i + 1} 行${header}应包含关键字`).toContain(needle); + } + } + + async sampleCell(header: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 8); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') return text; + } + await this.skipWhenDataInsufficient(`首屏「${header}」为空`); + return ''; + } + + /** 制单日期保持默认,只用样本列去点已有筛选项。 */ + async queryByFirstAvailable( + pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>, + ): Promise { + const row = await this.firstRow(); + for (const pair of pairs) { + const value = (await this.tryCell(row, pair.column)).trim(); + if (!value || value === '--' || value === '-') continue; + if (pair.kind === 'input' && (await this.textInput(pair.filter).isVisible().catch(() => false))) { + await this.fillFilter(pair.filter, value.slice(0, 40)); + await this.query(); + await this.expectRowsContain(pair.column, value); + return; + } + if (pair.kind === 'select' && (await this.formSelect(pair.filter).isVisible().catch(() => false))) { + await this.chooseSelect(pair.filter, value); + await this.query(); + await this.expectRowsContain(pair.column, value); + return; + } + } + await this.guardedSkip('筛选区没有与首行样本对应的条件'); + } + + async collapseKeeps(label: string, value: string): Promise { + const expand = this.button('展开').first(); + if (await expand.isVisible().catch(() => false)) { + await this.click(expand, '展开筛选'); + } + await this.fillFilter(label, value); + const collapse = this.button('收起').first(); + await expect(collapse, '收起按钮').toBeVisible(); + await this.click(collapse, '收起'); + await this.click(this.button('展开').first(), '再次展开'); + await expect(this.textInput(label)).toHaveValue(value); + } + + async expectInputCleared(label: string): Promise { + const input = this.textInput(label); + if (await input.isVisible().catch(() => false)) { + await expect(input).toHaveValue(''); + } + } + + async clickTabAndMatchCount(name: string): Promise { + const marked = await this.readTabCount(name); + await this.clickTab(name); + const total = await this.getTotalCount(); + if (marked !== null) { + expect(total, `${name} Tab 计数应等于分页总数`).toBe(marked); + } + return marked; + } + + async goToPage2(identityHeader: string): Promise { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: async (pageNum) => + this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false), + }); + const first = await this.cellText(await this.firstRow(), identityHeader); + const item = this.page.locator('.ant-pagination-item-2').first(); + await this.click(item, '翻到第 2 页'); + await this.waitForTableReady(); + const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim(); + expect(active).toBe('2'); + const next = await this.cellText(await this.firstRow(), identityHeader); + expect(next, '第 2 页首行应与第 1 页不同').not.toBe(first); + } + + async jumpToPage(pageNum: number): Promise { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: async (n) => this.page.locator(`.ant-pagination-item-${n}`).first().isVisible().catch(() => false), + }); + if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无跳至输入框或仅 1 页'); + } + await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页'); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim(); + expect(active).toBe(String(pageNum)); + } + + async expectPrevDisabled(): Promise { + await expect(this.locators.prevPage, '第 1 页上一页应不可用').toHaveClass(/ant-pagination-disabled/); + } + + rowAction(row: Locator, name: string): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first(); + } + + async rowWithAction(name: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 15); + for (let i = 0; i < count; i += 1) { + const row = rows.nth(i); + if (await this.rowAction(row, name).isVisible().catch(() => false)) return row; + } + return null; + } + + async skipIfNoAction(name: string, detail: string): Promise { + const row = await this.rowWithAction(name); + if (!row) await this.skipWhenDataInsufficient(detail); + return row as Locator; + } + + async skipIfTabEmpty(name: string): Promise { + const marked = await this.readTabCount(name); + await this.clickTab(name); + const rows = await this.getDataRowCount(); + if ((marked === 0 || marked === null) && rows === 0) { + await this.skipWhenDataInsufficient(`${name} Tab 无数据`); + } + } + + async openPutFromRow(row: Locator, action: string): Promise { + await this.assertBeforeBusinessAction(action); + await this.click(this.rowAction(row, action), action); + await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 }); + await this.waitSpinGone(); + await this.assertPageHealthy(action); + } + + async leaveWithoutCommit(): Promise { + for (let i = 0; i < 4; i += 1) { + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-popconfirm:visible, .ant-modal:visible').last(); + if (await confirm.isVisible().catch(() => false)) { + const text = (await confirm.innerText().catch(() => '')) ?? ''; + const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first(); + if (DANGER_CONFIRM.test(text)) { + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '取消确认'); + await this.waitSettle(200); + continue; + } + } + const dismiss = confirm.getByRole('button', { name: /取\s*消|关\s*闭/ }).first(); + if (await dismiss.isVisible().catch(() => false)) { + await this.click(dismiss, '关闭弹层'); + await this.waitSettle(200); + continue; + } + } + const put = this.page.locator('.put-page:visible').last(); + if (!(await put.isVisible().catch(() => false))) return; + const cancel = put.getByRole('button', { name: /取\s*消/ }).first(); + const back = put.getByRole('button', { name: /返\s*回/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '取消'); + } else if (await back.isVisible().catch(() => false)) { + await this.click(back, '返回'); + } else { + return; + } + await this.waitSettle(300); + } + await expect(this.page.locator('.put-page:visible'), '应离开详情或编辑层').toHaveCount(0); + } + + async expectBlockedAndStay(): Promise { + const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/).first(); + await expect(this.locators.formError.first().or(hint), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 }); + const stayed = + (await this.page.locator('.put-page:visible').count()) > 0 || + (await this.page.locator('.ant-modal:visible').count()) > 0; + expect(stayed, '拦截后页面不应关闭').toBeTruthy(); + } + + async clearChecks(): Promise { + for (let i = 0; i < 8; i += 1) { + const checked = this.page.locator('.ant-table-tbody .ant-checkbox-checked').first(); + if (!(await checked.isVisible().catch(() => false))) return; + await checked.click(); + await this.waitSettle(100); + } + } + + async checkFirstRow(): Promise { + const row = await this.firstRow(); + const box = row.locator('.ant-checkbox').first(); + await this.click(box, '勾选首行'); + return row; + } + + /** 未勾选:按钮禁用,或点击后只出现提示 / 可取消的确认,不点确定。 */ + async expectNeedsSelection(name: string): Promise { + await this.clearChecks(); + const total = await this.getTotalCount(); + const button = this.button(name).first(); + await expect(button, name).toBeVisible(); + if (await button.isDisabled().catch(() => false)) return; + const className = (await button.getAttribute('class')) ?? ''; + if (className.includes('ant-btn-disabled')) return; + await this.click(button, name); + const hint = this.page.getByText(/请选择|请勾选|至少选择|未选择/).first(); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last(); + const hinted = await hint.isVisible().catch(() => false); + const confirmed = await confirm.isVisible().catch(() => false); + expect(hinted || confirmed, `${name}未勾选时应不可用或提示`).toBeTruthy(); + if (confirmed) { + const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first(); + if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消'); + } + expect(await this.getTotalCount(), '未勾选不应新增单据').toBe(total); + } + + async clickToolbarThenCancel(name: string): Promise { + await this.assertBeforeBusinessAction(name); + await this.clickButton(name); + const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last(); + await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 }); + await this.leaveWithoutCommit(); + } + + async openDetailSection(name: string): Promise { + const target = this.put().getByRole('button', { name: new RegExp(name) }).or(this.put().getByText(name)).first(); + await this.assertBeforeBusinessAction(name); + await this.click(target, name); + await this.waitSettle(300); + await this.assertUiReady(name); + await expect(this.put(), '详情层仍在').toBeVisible(); + } + + putInput(label: string): Locator { + return this.textInput(label, this.put()); + } + + async saveOnPut(): Promise { + await this.assertBeforeBusinessAction('保存'); + const save = this.button('保存', this.put()).first(); + if (await save.isVisible().catch(() => false)) { + await this.click(save, '保存'); + return; + } + await this.click(this.button('提交', this.put()).first(), '提交'); + } +} diff --git a/pages/spd/admit-supply.page.ts b/pages/spd/admit-supply.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..4326dcdeb63b4e83b56bf7e4dfd0de1d4a90085d --- /dev/null +++ b/pages/spd/admit-supply.page.ts @@ -0,0 +1,174 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData, uniqRemark } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 供货目录申请。提交与批量提交只取消,不确认。 */ +export class AdmitSupplyPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.supplyPath, + pathPart: 'supplyApply/list', + menu: admitData.menuSupply, + forbidden: ['productApply/', 'productOrder/', 'accessKind/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + async filterBySupplier(): Promise { + await this.query(); + const supplier = await this.sampleCell('进院供应商'); + await this.chooseSelect('进院供应商', supplier); + await this.query(); + await this.expectRowsContain('进院供应商', supplier); + } + + async resetFilters(): Promise { + await this.query(); + const supplier = await this.sampleCell('进院供应商'); + await this.chooseSelect('进院供应商', supplier); + await this.reset(); + await this.expectListOrEmpty(); + } + + async mockEmpty() { + if (await this.textInput('变更单号').isVisible().catch(() => false)) { + await this.fillFilter('变更单号', admitData.mockOrder); + } else { + await this.fillFilter('产品名称', admitData.mockGoods); + } + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.query(); + return this.headerTexts(); + } + + async switchPending(): Promise { + await this.clickTabAndMatchCount('待提交'); + await expect(this.tab('待提交')).toHaveClass(/ant-tabs-tab-active/); + } + + async openAddFields(): Promise { + await this.clickButton('新增'); + await expect(this.put()).toBeVisible(); + for (const label of ['进院供应商', '二级供应商', '业务模式', '是否默认', '备注']) { + await expect(this.put().getByText(label).first(), label).toBeVisible(); + } + await this.leaveWithoutCommit(); + } + + async addCancel(): Promise { + const total = await this.getTotalCount(); + const remark = uniqRemark(); + await this.clickButton('新增'); + await this.fill(this.putInput('备注'), remark, '填写备注'); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount(), '未提交总数不变').toBe(total); + await expect(this.page.getByText(remark)).toHaveCount(0); + } + + async switchModeWithoutSubmit(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增'); + await this.selectFirstOption('业务模式', this.put()); + const no = this.put().getByText('否', { exact: true }).first(); + if (await no.isVisible().catch(() => false)) await this.click(no, '切换是否默认'); + else await this.selectFirstOption('是否默认', this.put()); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount()).toBe(total); + } + + async editBackfill(): Promise { + await this.clickTab('待提交'); + const row = await this.skipIfNoAction('编辑', '待提交无编辑'); + const supplier = await this.cellText(row, '进院供应商'); + await this.openPutFromRow(row, '编辑'); + await expect(this.put()).toContainText(supplier.slice(0, 6)); + await this.leaveWithoutCommit(); + } + + async editCancel(): Promise { + await this.clickTab('待提交'); + const row = await this.skipIfNoAction('编辑', '待提交无编辑'); + await this.openPutFromRow(row, '编辑'); + const input = this.putInput('备注'); + const original = await input.inputValue().catch(() => ''); + await this.fill(input, uniqRemark(), '临时备注'); + await this.leaveWithoutCommit(); + await this.clickTab('待提交'); + const again = await this.skipIfNoAction('编辑', '待提交无编辑'); + await this.openPutFromRow(again, '编辑'); + await expect(this.putInput('备注')).toHaveValue(original); + await this.leaveWithoutCommit(); + } + + async emptySupplierBlocked(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增'); + await this.clickButton('提交', this.put()); + await this.expectBlockedAndStay(); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount()).toBe(total); + } + + async noProductBlocked(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增'); + await this.selectFirstOption('进院供应商', this.put()); + await this.selectFirstOption('二级供应商', this.put()); + await this.selectFirstOption('业务模式', this.put()); + await this.clickButton('提交', this.put()); + await this.expectBlockedAndStay(); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount()).toBe(total); + } + + async batchNeedsSelection(): Promise { + await this.expectNeedsSelection('批量提交'); + } + + async batchCancel(): Promise { + await this.skipIfTabEmpty('待提交'); + const row = await this.checkFirstRow(); + const order = await this.tryCell(row, '申请单号'); + await this.clickButton('批量提交'); + const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible, .put-page:visible').last(); + await expect(confirm).toBeVisible(); + const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first(); + await this.click(cancel, '取消批量提交'); + await this.clickTab('待提交'); + if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible(); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.firstRow(); + const order = await this.tryCell(row, '申请单号'); + await this.openPutFromRow(row, '详情'); + if (order) await expect(this.put()).toContainText(order.slice(0, 8)); + await expect(this.put().getByText('审批进程').first()).toBeVisible(); + await this.leaveWithoutCommit(); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '销售商', kind: 'select', value: '样本' }, + { label: '供应商', kind: 'select', value: '样本' }, + { label: '变更单号', kind: 'text', value: '样本' }, + { label: '物料名称', kind: 'text', value: '样本' }, + { label: '规格型号', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/admit-task.page.ts b/pages/spd/admit-task.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..0195ecfeb3365a145b7d9ee6a437bee325378be9 --- /dev/null +++ b/pages/spd/admit-task.page.ts @@ -0,0 +1,141 @@ +import { expect, type Page } from '@playwright/test'; + +import { admitData } from '../../data/admit.data'; +import { AdmitSharedPage } from './admit-shared.page'; + +/** 准入任务分配。分配只取消,不确认。 */ +export class AdmitTaskPage extends AdmitSharedPage { + constructor(page: Page) { + super(page, { + path: admitData.taskPath, + pathPart: 'accessKind/list', + menu: admitData.menuTask, + forbidden: ['productApply/', 'productOrder/', 'supplyApply/'], + }); + } + + async ready(): Promise { + await this.expectOnPage(); + } + + async filterByProductName(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 40)); + await this.query(); + await this.expectRowsContain('产品名称', name); + } + + async switchTabs(): Promise { + await this.clickTabAndMatchCount('待分类'); + await this.clickTabAndMatchCount('已分类'); + await this.clickTab('待分类'); + await expect(this.tab('待分类')).toHaveClass(/ant-tabs-tab-active/); + } + + async resetFilters(): Promise { + await this.query(); + const name = await this.sampleCell('产品名称'); + await this.fillFilter('产品名称', name.slice(0, 20)); + await this.reset(); + await this.expectInputCleared('产品名称'); + await this.expectListOrEmpty(); + } + + async mockEmpty() { + await this.fillFilter('产品名称', admitData.mockGoods); + await this.query(); + return this.snapshotList(); + } + + async coreHeaders(): Promise { + await this.clickTab('待分类'); + return this.headerTexts(); + } + + async goPage2(): Promise { + await this.query(); + await this.goToPage2('产品名称'); + } + + async jumpPage2(): Promise { + await this.query(); + await this.jumpToPage(2); + } + + async prevDisabled(): Promise { + await this.query(); + await this.expectPrevDisabled(); + } + + async assignCancel(): Promise { + await this.clickTab('待分类'); + const before = await this.readTabCount('待分类'); + const row = await this.skipIfNoAction('分配类型', '待分类无分配类型'); + const name = await this.cellText(row, '产品名称'); + await this.click(this.rowAction(row, '分配类型'), '分配类型'); + const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last(); + await expect(layer, '分配层应打开').toBeVisible(); + await this.leaveWithoutCommit(); + await this.clickTab('待分类'); + await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible(); + if (before !== null) expect(await this.readTabCount('待分类')).toBe(before); + } + + async batchNeedsSelection(): Promise { + await this.clickTab('待分类'); + await this.expectNeedsSelection('批量分配'); + } + + async batchCancel(): Promise { + await this.clickTab('待分类'); + if ((await this.getDataRowCount()) === 0) await this.skipWhenDataInsufficient('待分类无数据'); + const before = await this.readTabCount('待分类'); + const row = await this.checkFirstRow(); + const name = await this.cellText(row, '产品名称'); + await this.clickButton('批量分配'); + const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last(); + await expect(layer).toBeVisible(); + await this.leaveWithoutCommit(); + await this.clickTab('待分类'); + await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible(); + if (before !== null) expect(await this.readTabCount('待分类')).toBe(before); + } + + async classifiedNoAssign(): Promise { + await this.skipIfTabEmpty('已分类'); + const assign = (await this.listRows()).getByRole('button', { name: /分配类型/ }); + if ((await assign.count()) === 0) return; + const before = await this.readTabCount('已分类'); + await this.click(assign.first(), '已分类分配类型'); + const hint = this.page.getByText(/不可|不允许|不能|已分类/).first(); + const layer = this.page.locator('.ant-modal:visible, .put-page:visible').last(); + expect( + (await hint.isVisible().catch(() => false)) || (await layer.isVisible().catch(() => false)), + '已分类再分配应被拒绝或需取消', + ).toBeTruthy(); + await this.leaveWithoutCommit(); + if (before !== null) expect(await this.readTabCount('已分类')).toBe(before); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '新增日期', kind: 'dateRange', value: '样本' }, + { label: '操作日期', kind: 'dateRange', value: '样本' }, + { label: '销售商', kind: 'text', value: '样本' }, + { label: '物料名称', kind: 'text', value: '样本' }, + { label: '注册证号', kind: 'text', value: '样本' }, + { label: '操作人', kind: 'text', value: '样本' }, + { label: '器械注册人', kind: 'text', value: '样本' }, + { label: '耗材类型', kind: 'select', value: '样本' }, + { label: '规格型号', kind: 'text', value: '样本' }, + { label: '准入类型', kind: 'select', value: '样本' }, + { label: '准入状态', kind: 'select', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/basic-branch.page.ts b/pages/spd/basic-branch.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..84b1222ddc0fa6b0b1af05b61eae56434f576abe --- /dev/null +++ b/pages/spd/basic-branch.page.ts @@ -0,0 +1,232 @@ +import { expect, type Page } from '@playwright/test'; + +import { basicHospitalData, uniqCampusName } from '../../data/basic-hospital.data'; +import { BasicBranchLocators } from '../../locators/spd/basic-branch.locator'; +import { BasicHospitalSharedPage } from './basic-shared.page'; + +export class BasicBranchPage extends BasicHospitalSharedPage { + readonly locators: BasicBranchLocators; + + constructor(page: Page) { + super(page); + this.locators = new BasicBranchLocators(page); + } + + async open(): Promise { + await this.openPath(basicHospitalData.branchPath); + } + + async resetSessionForReuse(): Promise { + await this.closeDialogIfOpen(); + if (await this.locators.campusNameInput.isVisible().catch(() => false)) { + await this.locators.campusNameInput.fill(''); + await this.page.keyboard.press('Enter').catch(() => undefined); + await this.waitForTableReady(); + } + } + + async expectEntered(): Promise { + await this.expectUrlContains('/spd/basic/hospital/paltform/area'); + await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/balance/month']); + await expect(this.locators.addBtn).toBeVisible(); + await expect(this.locators.campusNameInput).toBeVisible(); + await expect(this.shared.tableHeader.first()).toBeVisible(); + await expect(this.page.getByRole('button', { name: /查\s*询/ })).toHaveCount(0); + await expect(this.page.getByRole('button', { name: /重\s*置/ })).toHaveCount(0); + } + + async extractSample(): Promise<{ sampleCampusName: string; sampleCampusId: string; sampleCampusAddress: string }> { + const row = await this.firstRow(); + return { + sampleCampusName: await this.cellText(row, '院区名称'), + sampleCampusId: await this.cellText(row, '院区编号'), + sampleCampusAddress: await this.cellText(row, '院区地址'), + }; + } + + async searchByName(name: string): Promise { + await this.fill(this.locators.campusNameInput, name, '填写院区名称'); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + } + + async expectNameResults(keyword: string): Promise { + const count = await this.getDataRowCount(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < Math.min(count, 5); i += 1) { + const text = await this.cellText(this.dataRows().nth(i), '院区名称'); + expect(text).toContain(keyword.slice(0, Math.min(keyword.length, 6))); + } + } + + async expectCoreColumns(): Promise { + await this.expectHeaders(['序号', '院区编号', '院区名称', '院区地址', '状态', '启用云仓', '操作']); + await expect((await this.firstRow()).getByRole('button', { name: /编\s*辑/ })).toBeVisible(); + } + + async clearSearch(): Promise { + const filtered = await this.getTotalCount(); + await this.locators.campusNameInput.fill(''); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + const restored = await this.getTotalCount(); + expect(restored, '清空后条数应不少于过滤后').toBeGreaterThanOrEqual(filtered); + } + + async expectMockEmpty(): Promise { + const rows = await this.getDataRowCount(); + const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + expect(rows === 0 || empty).toBeTruthy(); + expect(await this.getTotalCount()).toBe(0); + } + + async openAddDialog(): Promise { + await this.click(this.locators.addBtn, '新 增'); + await expect(this.shared.dialog).toContainText('新增院区'); + await expect(this.locators.addName).toBeVisible(); + await expect(this.dialogFormItem('所在地区')).toBeVisible(); + await expect(this.locators.addAddress).toBeVisible(); + await expect(this.shared.dialog.getByText('主院区')).toBeVisible(); + await expect(this.shared.dialog.getByText('停用')).toBeVisible(); + await expect(this.shared.dialog.getByText('启用').first()).toBeVisible(); + await expect(this.locators.editId).toHaveCount(0); + await expect(this.shared.dialog.getByText('启用云仓')).toHaveCount(0); + } + + async addAndCancel(): Promise { + const total = await this.getTotalCount(); + const name = uniqCampusName(); + await this.openAddDialog(); + await this.fill(this.locators.addName, name, '填写院区名称'); + await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消'); + await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 }); + expect(await this.getTotalCount()).toBe(total); + await this.searchByName(name); + await this.expectMockEmpty(); + await this.locators.campusNameInput.fill(''); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + } + + async addSuccessAndCleanup(): Promise { + const name = uniqCampusName(); + await this.openAddDialog(); + await this.fill(this.locators.addName, name, '填写院区名称'); + await this.pickCascaderFirstPath(); + await this.fill(this.locators.addAddress, '自动化测试地址', '填写详细地址'); + await this.dialogFormItem('主院区').getByText('否', { exact: true }).click(); + await this.dialogFormItem('状态').getByText('启用', { exact: true }).click(); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 }); + await this.searchByName(name); + expect(await this.cellText(await this.firstRow(), '院区名称')).toContain(name); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.shared.dialog).toBeVisible(); + const disable = this.dialogFormItem('状态').getByText(/禁用|停用/).first(); + if (await disable.isVisible().catch(() => false)) { + await disable.click(); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '停用测试院区'); + await this.waitSpinGone(); + } else { + await this.closeDialogIfOpen(); + } + } + + async addEmptyName(): Promise { + await this.openAddDialog(); + await this.pickCascaderFirstPath(); + await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址'); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async addEmptyRegion(): Promise { + await this.openAddDialog(); + await this.fill(this.locators.addName, basicHospitalData.mockCampusNoRegion, '填写名称'); + await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址'); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async addEmptyAddress(): Promise { + await this.openAddDialog(); + await this.fill(this.locators.addName, basicHospitalData.mockCampusNoAddr, '填写名称'); + await this.pickCascaderFirstPath(); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async openEditAndExpectBackfill(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.shared.dialog).toContainText('编辑院区'); + expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId); + expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName); + expect(await this.locators.editAddress.inputValue()).toContain(sample.sampleCampusAddress.slice(0, 4)); + await expect(this.shared.dialog.getByText('启用云仓')).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async expectIdNotChangeable(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.locators.editId).toBeVisible(); + expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId); + const disabled = await this.locators.editId.isDisabled().catch(() => false); + const readonly = await this.locators.editId.getAttribute('readonly'); + if (!disabled && readonly === null) { + await this.fill(this.locators.editId, basicHospitalData.mockCampusId, '尝试改编号'); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await this.waitSpinGone(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId); + } + await this.closeDialogIfOpen(); + } + + async editAndCancel(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await this.fill(this.locators.editName, uniqCampusName(), '改名称'); + await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消'); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName); + await this.closeDialogIfOpen(); + } + + async expectEditStatusLabels(): Promise { + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.shared.dialog.getByText('禁用')).toBeVisible(); + await expect(this.shared.dialog.getByText('启用').first()).toBeVisible(); + await expect(this.dialogFormItem('状态').getByText('停用')).toHaveCount(0); + await this.closeDialogIfOpen(); + } + + async expectCloudWarehouse(): Promise { + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.dialogFormItem('启用云仓').getByText('是', { exact: true })).toBeVisible(); + await expect(this.dialogFormItem('启用云仓').getByText('否', { exact: true })).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async editClearRequired(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await this.locators.editName.fill(''); + await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName); + await this.closeDialogIfOpen(); + } +} + +export const BASIC_BRANCH_PATH = basicHospitalData.branchPath; diff --git a/pages/spd/basic-finance-month.page.ts b/pages/spd/basic-finance-month.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..a319c46fb591c26690ea8c1c60946b41f5278ffd --- /dev/null +++ b/pages/spd/basic-finance-month.page.ts @@ -0,0 +1,226 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; + +import { basicHospitalData } from '../../data/basic-hospital.data'; +import { BasicFinanceMonthLocators } from '../../locators/spd/basic-finance-month.locator'; +import { BasicHospitalSharedPage } from './basic-shared.page'; + +export type FinanceSample = { + sampleFinanceMonth: string; + sampleStartDate: string; + sampleEndDate: string; + sampleAutoSettleDate: string; +}; + +export class BasicFinanceMonthPage extends BasicHospitalSharedPage { + readonly locators: BasicFinanceMonthLocators; + + constructor(page: Page) { + super(page); + this.locators = new BasicFinanceMonthLocators(page); + } + + async open(): Promise { + await this.openPath(basicHospitalData.financeMonthPath); + } + + async search(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.click(this.locators.searchBtn, '查 询'); + await this.waitForTableReady(); + } + + async resetSessionForReuse(options?: { search?: boolean }): Promise { + if (await this.locators.cancelBtn.isVisible().catch(() => false)) { + await this.click(this.locators.cancelBtn, '取消行内编辑'); + } + if (await this.locators.resetBtn.isVisible().catch(() => false)) { + await this.assertBeforeBusinessAction('重置'); + await this.click(this.locators.resetBtn, '重 置'); + await this.waitForTableReady(); + } + if (options?.search) await this.search(); + } + + async expectEntered(): Promise { + await this.expectUrlContains('/spd/basic/hospital/balance/month'); + await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/paltform/area']); + await expect(this.locators.settleMonth).toBeVisible(); + await expect(this.locators.searchBtn).toBeVisible(); + await expect(this.locators.resetBtn).toBeVisible(); + await expect(this.shared.tableHeader.first()).toBeVisible(); + } + + currentMonthRow(): Locator { + return this.dataRows().filter({ hasText: '当前月份' }).first(); + } + + historyRows(): Locator { + return this.dataRows().filter({ hasText: '历史月份' }); + } + + async extractSample(): Promise { + await this.search(); + const row = (await this.currentMonthRow().isVisible().catch(() => false)) + ? this.currentMonthRow() + : await this.firstRow(); + return { + sampleFinanceMonth: await this.cellText(row, '财务月份'), + sampleStartDate: await this.cellText(row, '开始日期'), + sampleEndDate: await this.cellText(row, '结束日期'), + sampleAutoSettleDate: await this.cellText(row, '自动结算日期'), + }; + } + + async queryByMonth(month: string): Promise { + await this.click(this.locators.settleMonth, '打开财务月份'); + const panel = this.page.locator('.ant-picker-dropdown:visible').last(); + const opt = panel.getByText(month, { exact: true }).first(); + if (await opt.isVisible().catch(() => false)) { + await this.click(opt, `选择 ${month}`); + } else { + await this.locators.settleMonth.fill(month); + await this.page.keyboard.press('Enter'); + } + await this.search(); + } + + async expectMonthResults(month: string): Promise { + const count = await this.getDataRowCount(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < Math.min(count, 5); i += 1) { + const text = await this.cellText(this.dataRows().nth(i), '财务月份'); + expect(text).toContain(month); + } + } + + async resetFilters(): Promise { + await this.click(this.locators.resetBtn, '重 置'); + await this.waitForTableReady(); + } + + async expectCoreColumns(): Promise { + await this.expectHeaders(['财务月份', '开始日期', '结束日期', '自动结算日期', '状态', '操作']); + } + + async expectOnlyCurrentEditable(): Promise { + const current = this.currentMonthRow(); + await expect(current, '应存在当前月份行').toBeVisible(); + await expect(current.getByRole('button', { name: /修\s*改/ })).toBeVisible(); + const history = this.historyRows(); + const historyCount = await history.count(); + for (let i = 0; i < historyCount; i += 1) { + await expect(history.nth(i).getByRole('button', { name: /修\s*改/ })).toHaveCount(0); + } + } + + async expectAtMostOneCurrent(): Promise { + const count = await this.dataRows().filter({ hasText: '当前月份' }).count(); + expect(count, '当前月份至多一行').toBeLessThanOrEqual(1); + } + + async expectHistoryNotEditable(): Promise { + const history = this.historyRows(); + if ((await history.count()) === 0) { + test.skip(true, '无历史月份'); + } + await history.first().click({ trial: true }).catch(() => undefined); + await expect(this.locators.saveBtn).toBeHidden(); + await expect(this.locators.autoSettleDateInput).toBeHidden(); + } + + async enterInlineEdit(): Promise { + const row = this.currentMonthRow(); + await expect(row).toBeVisible(); + await this.click(row.getByRole('button', { name: /修\s*改/ }), '修 改'); + await expect(this.locators.saveBtn).toBeVisible(); + await expect(this.locators.cancelBtn).toBeVisible(); + await expect(this.locators.autoSettleDateInput).toBeVisible(); + } + + async expectStartEndReadonly(): Promise { + await this.enterInlineEdit(); + const row = this.currentMonthRow(); + const startInput = row.locator('td').nth(await this.headerIndex('开始日期')).locator('input'); + const endInput = row.locator('td').nth(await this.headerIndex('结束日期')).locator('input'); + if (await startInput.count()) await expect(startInput.first()).toBeDisabled(); + if (await endInput.count()) await expect(endInput.first()).toBeDisabled(); + await expect(this.locators.autoSettleDateInput).toBeEditable(); + await this.click(this.locators.cancelBtn, '取 消'); + } + + async pickAltDateInRange(start: string, end: string, current: string): Promise { + const startDay = Number(start.slice(-2)); + const endDay = Number(end.slice(-2)); + const curDay = Number(current.slice(-2)); + let next = curDay === startDay ? Math.min(endDay, startDay + 1) : startDay; + if (next === curDay) next = Math.min(endDay, curDay + 1); + return `${start.slice(0, 8)}${String(next).padStart(2, '0')}`; + } + + async fillAutoSettle(date: string): Promise { + await this.locators.autoSettleDateInput.click(); + await this.locators.autoSettleDateInput.fill(date); + await this.page.keyboard.press('Enter'); + await this.waitSettle(200); + } + + async cancelWithoutSave(): Promise { + const sample = await this.extractSample(); + await this.enterInlineEdit(); + const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate); + await this.fillAutoSettle(alt); + await this.click(this.locators.cancelBtn, '取 消'); + const shown = await this.cellText(this.currentMonthRow(), '自动结算日期'); + expect(shown).toContain(sample.sampleAutoSettleDate); + } + + async saveAndRestore(): Promise { + const sample = await this.extractSample(); + await this.enterInlineEdit(); + const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate); + await this.fillAutoSettle(alt); + await this.click(this.locators.saveBtn, '保 存'); + await this.waitSpinGone(); + await this.search(); + expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(alt); + await this.enterInlineEdit(); + await this.fillAutoSettle(sample.sampleAutoSettleDate); + await this.click(this.locators.saveBtn, '恢复自动结算日'); + await this.waitSpinGone(); + } + + async saveEmptyDate(): Promise { + const sample = await this.extractSample(); + await this.enterInlineEdit(); + await this.locators.autoSettleDateInput.fill(''); + await this.page.keyboard.press('Escape').catch(() => undefined); + await this.click(this.locators.saveBtn, '保 存'); + await this.expectFormError(); + const shown = await this.cellText(this.currentMonthRow(), '自动结算日期'); + expect(shown.includes(sample.sampleAutoSettleDate) || shown.length > 0).toBeTruthy(); + if (await this.locators.cancelBtn.isVisible().catch(() => false)) { + await this.click(this.locators.cancelBtn, '取 消'); + } + } + + async saveOutOfRange(): Promise { + const sample = await this.extractSample(); + await this.enterInlineEdit(); + try { + await this.fillAutoSettle(basicHospitalData.mockOutOfRangeDate); + await this.click(this.locators.saveBtn, '保 存'); + await this.expectFormError(); + const shown = await this.cellText(this.currentMonthRow(), '自动结算日期'); + expect(shown).not.toContain('1999'); + } catch { + test.skip(true, '日期控件无法选出区间外日期'); + } finally { + if (await this.locators.cancelBtn.isVisible().catch(() => false)) { + await this.click(this.locators.cancelBtn, '取 消'); + } + } + expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(sample.sampleAutoSettleDate); + } +} + +export const BASIC_FINANCE_MONTH_PATH = basicHospitalData.financeMonthPath; diff --git a/pages/spd/basic-hospital-info.page.ts b/pages/spd/basic-hospital-info.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c15d9e98f8b128d410d5905c71a244dbf390dda --- /dev/null +++ b/pages/spd/basic-hospital-info.page.ts @@ -0,0 +1,113 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { basicHospitalData, uniqLinkman } from '../../data/basic-hospital.data'; +import { BasicHospitalInfoLocators } from '../../locators/spd/basic-hospital-info.locator'; +import { BasicHospitalSharedPage } from './basic-shared.page'; + +export class BasicHospitalInfoPage extends BasicHospitalSharedPage { + readonly locators: BasicHospitalInfoLocators; + + constructor(page: Page) { + super(page); + this.locators = new BasicHospitalInfoLocators(page); + } + + async open(): Promise { + await this.openPath(basicHospitalData.hospitalInfoPath); + } + + async expectEntered(): Promise { + await this.expectUrlContains('/spd/basic/hospital/paltform/info'); + await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/area', '/balance/month']); + await expect(this.locators.editBtn, '查看态编辑').toBeVisible(); + await expect(this.page.getByText('医院编码').first()).toBeVisible(); + await expect(this.page.getByText('医院名称').first()).toBeVisible(); + await expect(this.page.getByText(/地址/).first()).toBeVisible(); + } + + async expectViewReadonly(): Promise { + await expect(this.locators.saveBtn).toBeHidden(); + await expect(this.locators.cancelBtn).toBeHidden(); + await this.expectSystemFieldsReadonly(); + } + + async expectSystemFieldsReadonly(): Promise { + for (const label of ['医院编码', '医院名称']) { + const input = this.formItem(label).locator('input:not([type="hidden"])'); + if (await input.count()) { + await expect(input.first(), `${label} 应只读`).toBeDisabled(); + } + } + } + + async previewIfPresent(): Promise { + const preview = this.locators.previewLink.first(); + if (!(await preview.isVisible().catch(() => false))) { + test.skip(true, '均无预览入口'); + } + await this.click(preview, '预览文件'); + const previewLayer = this.page.locator('.ant-image-preview, .ant-modal, .ant-image').first(); + await expect(previewLayer.or(this.page.locator('img').first()), '预览应打开').toBeVisible({ timeout: 8_000 }); + await this.page.keyboard.press('Escape').catch(() => undefined); + } + + async enterEdit(): Promise { + await this.click(this.locators.editBtn, '编 辑'); + await expect(this.locators.saveBtn).toBeVisible(); + await expect(this.locators.cancelBtn).toBeVisible(); + await expect(this.locators.linkman).toBeEditable(); + await expect(this.locators.contactWay).toBeEditable(); + await expect(this.locators.shortName).toBeEditable(); + } + + async readLinkman(): Promise { + if (await this.locators.linkman.isVisible().catch(() => false)) { + return this.locators.linkman.inputValue(); + } + return ((await this.formItem('联系人').innerText()) ?? '').replace(/联系人\*?/, '').trim(); + } + + async cancelWithoutSave(): Promise { + await this.enterEdit(); + const original = await this.readLinkman(); + await this.fill(this.locators.linkman, uniqLinkman(), '改联系人'); + await this.click(this.locators.cancelBtn, '取 消'); + await expect(this.locators.saveBtn).toBeHidden(); + const shown = await this.readLinkman(); + expect(shown).toContain(original); + } + + async saveAndRestore(): Promise { + await this.enterEdit(); + const original = await this.readLinkman(); + const next = uniqLinkman(); + await this.fill(this.locators.linkman, next, '改联系人'); + await this.click(this.locators.saveBtn, '保 存'); + await this.waitSpinGone(); + await this.open(); + await this.enterEdit(); + expect(await this.readLinkman()).toBe(next); + await this.fill(this.locators.linkman, original, '恢复联系人'); + await this.click(this.locators.saveBtn, '保 存恢复'); + await this.waitSpinGone(); + } + + async saveEmptyField(field: 'linkman' | 'contactWay' | 'shortName'): Promise { + await this.enterEdit(); + const locator = this.locators[field]; + await locator.fill(''); + await this.click(this.locators.saveBtn, '保 存'); + await this.expectFormError(); + await expect(this.locators.saveBtn, '仍停留编辑态').toBeVisible(); + await this.click(this.locators.cancelBtn, '取 消'); + } + + async expectUploadInputs(): Promise { + await this.enterEdit(); + await expect(this.locators.loginBgUpload).toHaveCount(1); + await expect(this.page.locator('input[type="file"]')).toHaveCount(2); + await this.click(this.locators.cancelBtn, '取 消'); + } +} + +export const BASIC_HOSPITAL_INFO_PATH = basicHospitalData.hospitalInfoPath; diff --git a/pages/spd/basic-notice.page.ts b/pages/spd/basic-notice.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..90ae899978adc5ea6895156206e46313a80e71d0 --- /dev/null +++ b/pages/spd/basic-notice.page.ts @@ -0,0 +1,244 @@ +import { expect, type Page } from '@playwright/test'; + +import { + basicHospitalData, + uniqNoticeTitle, +} from '../../data/basic-hospital.data'; +import { BasicNoticeLocators } from '../../locators/spd/basic-notice.locator'; +import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; +import { BasicHospitalSharedPage } from './basic-shared.page'; + +export class BasicNoticePage extends BasicHospitalSharedPage { + readonly locators: BasicNoticeLocators; + + constructor(page: Page) { + super(page); + this.locators = new BasicNoticeLocators(page); + } + + async open(): Promise { + await this.openPath(basicHospitalData.noticePath); + } + + async search(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.click(this.locators.searchBtn, '查 询'); + await this.waitForTableReady(); + } + + async resetSessionForReuse(options?: { search?: boolean }): Promise { + await this.closeDialogIfOpen(); + if (await this.locators.resetBtn.isVisible().catch(() => false)) { + await this.assertBeforeBusinessAction('重置'); + await this.click(this.locators.resetBtn, '重 置'); + await this.waitForTableReady(); + } + if (options?.search) await this.search(); + } + + async expectEntered(): Promise { + await this.expectUrlContains('/spd/basic/hospital/paltform/notice'); + await this.expectNotOnSiblingPages(['/paltform/info', '/paltform/area', '/balance/month']); + await expect(this.locators.noticeTitle, '筛选区公告标题').toBeVisible(); + await expect(this.locators.addBtn, '新增按钮').toBeVisible(); + await expect(this.shared.tableHeader.first(), '列表表头').toBeVisible(); + } + + async extractSample(): Promise<{ sampleNoticeTitle: string; samplePublishScope: string }> { + await this.search(); + const row = await this.firstRow(); + return { + sampleNoticeTitle: await this.cellText(row, '公告标题'), + samplePublishScope: await this.cellText(row, '发布范围'), + }; + } + + async queryByTitle(title: string): Promise { + await this.fill(this.locators.noticeTitle, title, '填写公告标题'); + await this.search(); + } + + async expectTitleResultsMatch(keyword: string): Promise { + const count = await this.getDataRowCount(); + expect(count, '标题查询应有结果').toBeGreaterThan(0); + for (let i = 0; i < Math.min(count, 5); i += 1) { + const text = await this.cellText(this.dataRows().nth(i), '公告标题'); + expect(text, `第 ${i + 1} 行标题应包含关键字`).toContain(keyword.slice(0, Math.min(keyword.length, 8))); + } + } + + async queryByScope(scope: string): Promise { + await this.selectDropdown(this.locators.publishScopeSelect, scope); + await this.search(); + } + + async expectScopeResultsMatch(scope: string): Promise { + if (scope === '全部') return; + const count = await this.getDataRowCount(); + expect(count, '范围查询应有结果或空态可接受').toBeGreaterThanOrEqual(0); + for (let i = 0; i < Math.min(count, 5); i += 1) { + const text = await this.cellText(this.dataRows().nth(i), '发布范围'); + expect(text, `第 ${i + 1} 行发布范围`).toContain(scope); + } + } + + async queryByTitleAndScope(title: string, scope: string): Promise { + await this.fill(this.locators.noticeTitle, title, '填写公告标题'); + if (scope && scope !== '全部') { + await this.selectDropdown(this.locators.publishScopeSelect, scope); + } + await this.search(); + } + + async resetFilters(): Promise { + await this.click(this.locators.resetBtn, '重 置'); + await this.waitForTableReady(); + await expect(this.locators.noticeTitle).toHaveValue(''); + } + + async expectMockEmpty(): Promise { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + expect(rows === 0 || empty, '无匹配应空态').toBeTruthy(); + expect(total === 0 || rows === 0, '分页总数应为 0').toBeTruthy(); + } + + async expectCoreColumns(): Promise { + await this.expectHeaders(['公告编号', '公告标题', '发布范围', '发布科室', '发布人', '操作']); + const row = await this.firstRow(); + await expect(row.getByRole('button', { name: /编\s*辑/ })).toBeVisible(); + await expect(row.getByRole('button', { name: /删\s*除/ })).toBeVisible(); + } + + async goToPage2(): Promise { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: (n) => this.hasPaginationItem(n), + }); + const firstNo = await this.cellText(await this.firstRow(), '公告编号'); + await this.clickPaginationPage(2); + await this.waitForTableReady(); + const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim(); + expect(active).toBe('2'); + const nextNo = await this.cellText(await this.firstRow(), '公告编号'); + expect(nextNo, '第 2 页首行应与第 1 页不同').not.toBe(firstNo); + } + + async jumpToPage(pageNum: number): Promise { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: (n) => this.hasPaginationItem(n), + }); + if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无 jumper 或仅 1 页'); + } + await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页'); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim(); + expect(active).toBe(String(pageNum)); + } + + async openAddDialog(): Promise { + await this.click(this.locators.addBtn, '新 增'); + await expect(this.shared.dialog).toBeVisible(); + await expect(this.shared.dialog).toContainText('新增公告'); + await expect(this.locators.modalTitleInput).toBeVisible(); + await expect(this.shared.dialog.getByText('发布范围')).toBeVisible(); + await expect(this.shared.dialog.getByText('置顶')).toBeVisible(); + await expect(this.shared.dialog.getByText('截止日期')).toBeVisible(); + await expect(this.locators.richTextEditor).toBeVisible(); + await expect(this.shared.dialog.getByRole('button', { name: /取\s*消/ })).toBeVisible(); + await expect(this.shared.dialog.getByRole('button', { name: /发\s*布/ })).toBeVisible(); + } + + async addAndCancel(title: string): Promise { + await this.openAddDialog(); + await this.fill(this.locators.modalTitleInput, title, '填写新增标题'); + await this.locators.richTextEditor.fill('自动化取消用例正文'); + await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消'); + await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 }); + await this.queryByTitle(title); + await this.expectMockEmpty(); + } + + async publishAndCleanup(): Promise { + const title = uniqNoticeTitle(); + await this.openAddDialog(); + await this.fill(this.locators.modalTitleInput, title, '填写新增标题'); + await this.shared.dialog.getByText('中心库', { exact: true }).click(); + await this.locators.richTextEditor.fill('自动化发布用例正文'); + await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布'); + await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 }); + await this.queryByTitle(title); + const row = await this.firstRow(); + expect(await this.cellText(row, '公告标题')).toContain(title); + expect(await this.cellText(row, '发布范围')).toContain('中心库'); + await row.getByRole('button', { name: /删\s*除/ }).click(); + const confirm = this.page.getByRole('button', { name: /确\s*定/ }).last(); + if (await confirm.isVisible().catch(() => false)) { + await this.click(confirm, '确定删除测试公告'); + } + await this.waitForTableReady(); + } + + async publishWithEmptyTitle(): Promise { + await this.openAddDialog(); + await this.locators.richTextEditor.fill(basicHospitalData.mockNoticeBody); + await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async publishWithEmptyContent(): Promise { + await this.openAddDialog(); + await this.fill(this.locators.modalTitleInput, basicHospitalData.mockEmptyContentTitle, '填写 mock 标题'); + await this.locators.richTextEditor.fill(''); + await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布'); + await this.expectFormError(); + await expect(this.shared.dialog).toBeVisible(); + await this.closeDialogIfOpen(); + } + + async openEditAndExpectBackfill(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.shared.dialog).toContainText('编辑公告'); + const value = await this.locators.modalTitleInput.inputValue(); + expect(value.replace(/\s/g, '')).toBe(sample.sampleNoticeTitle.replace(/\s/g, '')); + await this.closeDialogIfOpen(); + } + + async editAndCancel(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + await expect(this.shared.dialog).toBeVisible(); + const uniq = uniqNoticeTitle(); + await this.fill(this.locators.modalTitleInput, uniq, '改标题'); + await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消'); + await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click(); + expect(await this.locators.modalTitleInput.inputValue()).toBe(sample.sampleNoticeTitle); + await this.closeDialogIfOpen(); + } + + async deleteConfirmAndCancel(): Promise { + const sample = await this.extractSample(); + await (await this.firstRow()).getByRole('button', { name: /删\s*除/ }).click(); + const box = this.page.locator('.ant-modal, .ant-popconfirm, .ant-modal-confirm').filter({ hasText: /提示/ }).last(); + await expect(box).toBeVisible(); + await expect(box.getByRole('button', { name: /取\s*消/ })).toBeVisible(); + await expect(box.getByRole('button', { name: /确\s*定/ })).toBeVisible(); + await this.click(box.getByRole('button', { name: /取\s*消/ }), '取消删除'); + await this.queryByTitle(sample.sampleNoticeTitle); + expect(await this.getDataRowCount()).toBeGreaterThan(0); + } +} + +export const BASIC_NOTICE_PATH = basicHospitalData.noticePath; diff --git a/pages/spd/basic-role.page.ts b/pages/spd/basic-role.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..fac2de6bfbef177e642dfbfec83a21dffb3dccef --- /dev/null +++ b/pages/spd/basic-role.page.ts @@ -0,0 +1,530 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { userData } from '../../data/user.data'; +import { BasicRoleLocators } from '../../locators/spd/basic-role.locator'; +import { BasicUserSharedPage } from './basic-user-shared.page'; + +/** 角色管理:左右分栏 + 权限树;断言在 tests/spd/basic-role.spec.ts。 */ +export class BasicRolePage extends BasicUserSharedPage { + override readonly locators: BasicRoleLocators; + + constructor(page: Page) { + super(page, { + path: userData.rolePath, + pathPart: '/basic/user/platform/role', + menu: userData.menuRole, + forbidden: ['personCom', '/platform/person'], + }); + this.locators = new BasicRoleLocators(page); + } + + override async waitContentReady(): Promise { + await this.locators.saveBtn.or(this.locators.roleTree).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitSpinGone(); + } + + leftRoleItems(): Locator { + return this.page.locator( + '.ant-layout-sider .ant-table-tbody tr.ant-table-row, .ant-layout-sider .ant-list-item, .ant-layout-sider .ant-menu-item, [class*="role"] .ant-table-row', + ); + } + + async leftRoleNames(): Promise { + const items = this.leftRoleItems(); + const count = await items.count(); + if (count > 0) { + const names: string[] = []; + for (let i = 0; i < Math.min(count, 30); i += 1) { + const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim(); + if (text) names.push(text); + } + if (names.length) return names; + } + const fallback = this.page.locator('.ant-table-tbody tr.ant-table-row td').first(); + if (await fallback.isVisible().catch(() => false)) { + return (await this.page.locator('.ant-table-tbody tr.ant-table-row td:first-child').allTextContents()) + .map((t) => t.trim()) + .filter(Boolean); + } + return []; + } + + async sampleRoleName(): Promise { + const names = await this.leftRoleNames(); + const found = names.find((n) => n && n !== '角色'); + if (!found) { + await this.skipWhenDataInsufficient('左栏无角色'); + return ''; + } + return found; + } + + leftRoleListScrollHost(): Locator { + const sider = this.locators.roleSider; + return sider + .locator('.ant-table-body') + .first() + .or(sider.locator('.rc-virtual-list-holder').first()) + .or(sider.locator('.ant-list').first()); + } + + async assertLeftRoleListVerticalScroll(): Promise { + const first = this.leftRoleItems().first(); + await expect(first, '左栏应有角色行').toBeVisible(); + await this.assertVerticalScrollRegion( + this.leftRoleListScrollHost(), + first, + '数据不足:左栏列表高度未溢出,无法验证滚动', + '左栏列表滚动后', + ); + } + + roleTreeScrollHost(): Locator { + return this.locators.roleTree + .locator('.ant-tree-list-holder') + .first() + .or(this.locators.roleTree.locator('.ant-tree-list').first()) + .or(this.locators.roleTree); + } + + async assertVerticalScrollRegion( + host: Locator, + walkAnchor: Locator, + skipDetail: string, + healthContext: string, + ): Promise { + const hostScrollable = await host + .evaluate((el) => el.scrollHeight > el.clientHeight + 4) + .catch(() => false); + + if (hostScrollable) { + const top0 = await host.evaluate((el) => el.scrollTop); + await host.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + await this.waitSettle(250); + const topDown = await host.evaluate((el) => el.scrollTop); + expect(topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(top0); + const box = await host.boundingBox(); + if (box) { + await this.page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 120)); + await this.page.mouse.wheel(0, -600); + await this.waitSettle(250); + } else { + await host.evaluate((el) => { + el.scrollTop = 0; + }); + } + const topUp = await host.evaluate((el) => el.scrollTop); + expect(topUp, '向上滑动后应离开底部').toBeLessThan(topDown); + await host.evaluate((el) => { + el.scrollTop = 0; + }); + await this.assertUiReady(healthContext); + return; + } + + const walked = await walkAnchor.evaluate((row) => { + let el: HTMLElement | null = row as HTMLElement; + while (el) { + if (el.scrollHeight > el.clientHeight + 4) { + const top0 = el.scrollTop; + el.scrollTop = el.scrollHeight; + const topDown = el.scrollTop; + el.scrollTop = 0; + const topUp = el.scrollTop; + return { ok: true as const, top0, topDown, topUp }; + } + el = el.parentElement; + } + return { ok: false as const }; + }); + if (!walked.ok) { + await this.skipWhenDataInsufficient(skipDetail); + return; + } + expect(walked.topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(walked.top0); + expect(walked.topUp, '向上滚回后应回到顶部').toBeLessThanOrEqual(4); + await this.assertUiReady(healthContext); + } + + async assertRoleTreeVerticalScroll(): Promise { + const name = await this.sampleRoleName(); + await this.selectRole(name); + await expect(this.locators.roleTree, '权限树').toBeVisible(); + await this.expandTreeNode('基础设置'); + const firstNode = this.locators.roleTree.locator('.ant-tree-treenode').first(); + await expect(firstNode, '权限树应有节点').toBeVisible(); + await this.assertVerticalScrollRegion( + this.roleTreeScrollHost(), + firstNode, + '数据不足:右侧权限树高度未溢出,无法验证滚动', + '右侧权限树滚动后', + ); + } + + async selectRole(name: string): Promise { + const item = this.leftRoleItems().filter({ hasText: name }).first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, `选中角色 ${name}`); + } else { + await this.click(this.page.getByText(name, { exact: true }).first(), `选中角色 ${name}`); + } + await this.waitSpinGone(); + await this.waitSettle(400); + } + + async searchRole(keyword: string): Promise { + const input = this.locators.roleSearch; + if (!(await input.isVisible().catch(() => false))) { + const fallback = this.page.locator('input').first(); + await this.fill(fallback, keyword, '角色名称搜索'); + } else { + await this.fill(input, keyword, '角色名称搜索'); + } + await this.page.keyboard.press('Enter').catch(() => undefined); + await this.waitSettle(400); + } + + async clearRoleSearch(): Promise { + const input = this.locators.roleSearch.or(this.page.locator('input').first()); + await input.fill(''); + await this.page.keyboard.press('Enter').catch(() => undefined); + await this.waitSettle(400); + } + + async isRoleSelected(name: string): Promise { + const item = this.leftRoleItems().filter({ hasText: name }).first(); + const cls = ((await item.getAttribute('class').catch(() => '')) ?? '').toLowerCase(); + const aria = (await item.getAttribute('aria-selected').catch(() => '')) ?? ''; + if (/selected|active/.test(cls) || aria === 'true') return true; + const selected = this.page.locator('.ant-table-row-selected, .ant-menu-item-selected, [aria-selected="true"]'); + return (await selected.filter({ hasText: name }).count()) > 0; + } + + async checkedSignature(): Promise { + const nodes = this.locators.roleTree.locator('.ant-tree-treenode'); + const limit = Math.min(await nodes.count(), 40); + const parts: string[] = []; + for (let i = 0; i < limit; i += 1) { + const title = ((await nodes.nth(i).locator('.ant-tree-title').innerText().catch(() => '')) ?? '').trim(); + if (!title) continue; + const checked = (await nodes.nth(i).locator('.ant-tree-checkbox-checked').count()) > 0; + parts.push(`${title}:${checked ? 1 : 0}`); + } + return parts.join('|'); + } + + treeNodeRow(title: string): Locator { + return this.locators.roleTree + .locator('.ant-tree-treenode') + .filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) }) + .first(); + } + + async isTreeNodeExpanded(title: string): Promise { + const switcher = this.treeNodeRow(title).locator('.ant-tree-switcher').first(); + if (!(await switcher.isVisible().catch(() => false))) return false; + return switcher.evaluate((el) => el.classList.contains('ant-tree-switcher_open')).catch(() => false); + } + + async expandTreeNode(title: string): Promise { + const node = this.treeNodeRow(title); + if (!(await node.isVisible().catch(() => false))) return; + const switcher = node.locator('.ant-tree-switcher').first(); + const closed = await switcher + .evaluate((el) => el.classList.contains('ant-tree-switcher_close')) + .catch(() => false); + if (closed) await this.click(switcher, `展开 ${title}`); + await this.waitSettle(200); + } + + async collapseTreeNode(title: string): Promise { + const node = this.treeNodeRow(title); + if (!(await node.isVisible().catch(() => false))) return; + const switcher = node.locator('.ant-tree-switcher').first(); + const open = await switcher + .evaluate((el) => el.classList.contains('ant-tree-switcher_open')) + .catch(() => false); + if (open) await this.click(switcher, `收起 ${title}`); + await this.waitSettle(200); + } + + async isTreeChecked(title: string): Promise { + const box = this.locators.treeCheckbox(title); + const cls = (await box.getAttribute('class').catch(() => '')) ?? ''; + return cls.includes('ant-tree-checkbox-checked'); + } + + async isTreeDisabled(title: string): Promise { + const box = this.locators.treeCheckbox(title); + const cls = (await box.getAttribute('class').catch(() => '')) ?? ''; + return cls.includes('ant-tree-checkbox-disabled'); + } + + async checkModule(moduleName = '厂商管理'): Promise { + const box = this.locators.treeCheckbox(moduleName); + if (!(await box.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient(`权限树无「${moduleName}」节点`); + return; + } + if (await this.isTreeDisabled(moduleName)) { + await this.skipWhenDataInsufficient(`「${moduleName}」勾选被锁定`); + return; + } + await this.click(box, `勾选 ${moduleName}`); + await this.waitSettle(300); + } + + async refreshTree(): Promise { + await this.click(this.locators.refreshBtn, '刷 新'); + await this.waitSpinGone(); + await this.waitSettle(300); + } + + async deleteEntry(roleName: string): Promise { + const row = this.leftRoleItems().filter({ hasText: roleName }).first(); + return row + .getByRole('button', { name: /删/ }) + .or(row.locator('.anticon-delete, [aria-label*="delete"], [aria-label*="删"]')) + .first(); + } + + editEntry(roleName: string): Locator { + const row = this.leftRoleItems().filter({ hasText: roleName }).first(); + return row + .getByRole('button', { name: /编\s*辑/ }) + .or(row.locator('.anticon-edit, .anticon-form, [aria-label*="edit"], [aria-label*="编辑"]')) + .first(); + } + + normalizeRoleLabel(text: string): string { + return text.replace(/\s/g, '').replace(/[((].*$/, '').trim(); + } + + roleNamesMatch(selected: string, inForm: string): boolean { + const a = this.normalizeRoleLabel(selected); + const b = this.normalizeRoleLabel(inForm); + if (!a || !b) return false; + return a === b || a.includes(b) || b.includes(a); + } + + async readEditRoleNameFromDialog(): Promise { + const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last(); + const labeled = dialog + .locator('.ant-form-item') + .filter({ hasText: /角色名称|角\s*色名/ }) + .locator('input') + .first(); + if (await labeled.isVisible().catch(() => false)) { + return ((await labeled.inputValue()) ?? '').trim(); + } + const placeholder = dialog.getByPlaceholder(/角色/).first(); + if (await placeholder.isVisible().catch(() => false)) { + return ((await placeholder.inputValue()) ?? '').trim(); + } + return ((await dialog.locator('input:not([type="hidden"])').first().inputValue().catch(() => '')) ?? '').trim(); + } + + async openEditRoleDialog(roleName?: string): Promise<{ selectedRoleName: string; dialog: Locator }> { + const selectedRoleName = roleName ?? (await this.sampleRoleName()); + await this.selectRole(selectedRoleName); + const cornerEdit = this.locators.editRoleIcon; + const rowEdit = this.editEntry(selectedRoleName); + if (await cornerEdit.isVisible().catch(() => false)) { + await this.click(cornerEdit, '左栏右下角编辑'); + } else if (await rowEdit.isVisible().catch(() => false)) { + await this.click(rowEdit, '行内编辑角色'); + } else { + await this.skipWhenDataInsufficient('编辑入口不可见'); + return { selectedRoleName, dialog: this.dialog() }; + } + await this.waitSettle(400); + const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last(); + await expect(dialog, '编辑角色弹窗').toBeVisible({ timeout: 12_000 }); + return { selectedRoleName, dialog }; + } + + editRoleDialog(): Locator { + return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last(); + } + + async saveEditRoleDialog(): Promise { + const dialog = this.editRoleDialog(); + const save = dialog.getByRole('button', { name: /保\s*存/ }).first(); + await expect(save, '弹框保存按钮').toBeVisible(); + await this.assertBeforeBusinessAction('保存编辑角色'); + await this.click(save, '保存编辑角色', { operation: 'write' }); + await this.waitSpinGone(); + await this.waitSettle(400); + } + + async expectToastSuccess(text: string | RegExp): Promise { + const toast = this.page + .locator('.ant-message-notice, .ant-message-success, .ant-notification-notice, .ant-message') + .filter({ hasText: text }) + .first(); + await expect(toast, `应提示成功:${String(text)}`).toBeVisible({ timeout: 12_000 }); + expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0); + await this.assertPageHealthy('保存后页面健康'); + } + + async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> { + const names = await this.leftRoleNames(); + const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0]; + if (!target) { + await this.skipWhenDataInsufficient('无可删除角色'); + return null; + } + await this.selectRole(target); + const del = await this.deleteEntry(target); + if (!(await del.isVisible().catch(() => false))) { + const globalDel = this.page.locator('.anticon-delete, [aria-label*="delete"]').first(); + if (!(await globalDel.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无删除入口'); + return null; + } + await this.click(globalDel, '删除'); + } else { + await this.click(del, '删除'); + } + const confirm = this.page + .locator('.ant-modal-confirm, .ant-popconfirm, .ant-modal') + .filter({ hasText: /提示|删除|确认/ }) + .last(); + await expect(confirm, '删除确认框').toBeVisible({ timeout: 8_000 }); + return { roleName: target, confirm }; + } + + addRoleDialog(): Locator { + return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last(); + } + + async openAddRoleDialog(): Promise { + const add = this.locators.addRoleBtn; + if (!(await add.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('「角色」按钮不可见'); + return this.addRoleDialog(); + } + await this.assertBeforeBusinessAction('打开新增角色'); + await this.click(add, '角 色'); + await this.waitSettle(400); + const dialog = this.addRoleDialog(); + await expect(dialog, '新增角色弹窗').toBeVisible({ timeout: 12_000 }); + expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0); + await this.assertUiReady('打开新增角色弹窗'); + return dialog; + } + + async openAddRoleForm(): Promise<{ before: number; opened: boolean }> { + const before = (await this.leftRoleNames()).length; + const dialog = await this.openAddRoleDialog(); + const opened = await dialog.isVisible().catch(() => false); + return { before, opened }; + } + + addRoleDialogCloseIcon(): Locator { + const dialog = this.addRoleDialog(); + return dialog + .locator('.ant-modal-close') + .first() + .or(dialog.getByRole('button', { name: /close|关闭/i }).first()) + .or(dialog.getByRole('img', { name: 'close' }).first()); + } + + async closeAddRoleDialogByX(): Promise { + const dialog = this.addRoleDialog(); + await expect(dialog, '新增弹框应可见').toBeVisible(); + const close = this.addRoleDialogCloseIcon(); + await expect(close, '右上角 X 关闭').toBeVisible(); + await this.click(close, '点击新增弹框 X 关闭'); + await expect(dialog, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 }); + expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0); + await this.assertUiReady('X 关闭新增角色弹框后'); + } + + async cancelAddRoleDialog(): Promise { + const dialog = this.addRoleDialog(); + await expect(dialog, '新增弹框应可见').toBeVisible(); + const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first(); + await expect(cancel, '取消按钮').toBeVisible(); + await this.click(cancel, '取消新增角色'); + await expect(dialog, '取消后弹框应关闭').toBeHidden({ timeout: 8_000 }); + expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0); + await this.assertUiReady('取消新增角色后'); + } + + async cancelAddRole(): Promise { + if (await this.addRoleDialog().isVisible().catch(() => false)) { + await this.cancelAddRoleDialog(); + return; + } + const layer = this.dialog().or(this.page.locator('.ant-modal:visible')); + if (await layer.first().isVisible().catch(() => false)) { + await this.closeDialog(); + return; + } + await this.page.keyboard.press('Escape'); + } + + addRoleNameFormItem(): Locator { + const dialog = this.addRoleDialog(); + return this.locators + .formItem('角色名称', dialog) + .or(dialog.locator('.ant-form-item').filter({ hasText: /角色名称|角\s*色名/ }).first()); + } + + queryConfigCheckbox(label = '查询配置化'): Locator { + const dialog = this.addRoleDialog(); + return dialog.locator('.ant-checkbox-wrapper').filter({ hasText: label }).first(); + } + + async ensureQueryConfigCheckbox(label = '查询配置化'): Promise { + const wrapper = this.queryConfigCheckbox(label); + if (!(await wrapper.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient(`新增弹框无「${label}」复选框`); + } + await expect(wrapper, `「${label}」复选框`).toBeVisible(); + return wrapper; + } + + async isQueryConfigChecked(label = '查询配置化'): Promise { + const wrapper = await this.ensureQueryConfigCheckbox(label); + return wrapper.locator('input[type="checkbox"]').isChecked(); + } + + async setQueryConfigChecked(checked: boolean, label = '查询配置化'): Promise { + const wrapper = await this.ensureQueryConfigCheckbox(label); + const input = wrapper.locator('input[type="checkbox"]'); + const current = await input.isChecked(); + if (current !== checked) { + await this.click(wrapper, checked ? `勾选${label}` : `取消勾选${label}`); + await this.waitSettle(200); + } + expect(await input.isChecked(), `${label} 勾选态应为 ${checked}`).toBe(checked); + } + + async saveAddRoleDialog(): Promise { + const dialog = this.addRoleDialog(); + const save = dialog.getByRole('button', { name: /保\s*存/ }).first(); + await expect(save, '弹框保存按钮').toBeVisible(); + await this.click(save, '保存新增角色'); + await this.waitSettle(400); + } + + async expectAddRoleNameRequired(message = '请输入角色名称!'): Promise { + const dialog = this.addRoleDialog(); + await expect(dialog, '新增弹框仍应打开').toBeVisible(); + const item = this.addRoleNameFormItem(); + await expect(item, '角色名称表单项应校验失败').toHaveClass(/ant-form-item-has-error/); + const explain = item.locator('.ant-form-item-explain-error').filter({ hasText: message }); + await expect(explain.first(), `应提示:${message}`).toBeVisible({ timeout: 8_000 }); + const inputWrap = item.locator('input, .ant-input-affix-wrapper').first(); + const wrapClass = (await inputWrap.getAttribute('class').catch(() => '')) ?? ''; + const fieldLooksInvalid = + /ant-input-status-error|ant-input-affix-wrapper-status-error/.test(wrapClass) || + (await item.getAttribute('class').catch(() => ''))?.includes('ant-form-item-has-error'); + expect(fieldLooksInvalid, '角色名称输入框应变红(校验失败态)').toBeTruthy(); + expect(await this.page.locator('.ant-message-error').count(), '不应出现系统级错误 toast').toBe(0); + } +} diff --git a/pages/spd/basic-shared.page.ts b/pages/spd/basic-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2ae570c41d9d631770fce2264936ad39124fcd2 --- /dev/null +++ b/pages/spd/basic-shared.page.ts @@ -0,0 +1,174 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { BasePage } from '../../core/base.page'; +import { basicHospitalData } from '../../data/basic-hospital.data'; +import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator'; + +/** 医院资料页共用:无 iframe、菜单进入、表格/分页/弹窗。 */ +export class BasicHospitalSharedPage extends BasePage { + protected readonly shared: BasicSharedLocators; + + constructor(page: Page) { + super(page); + this.shared = new BasicSharedLocators(page); + } + + async openPath(path: string): Promise { + await this.goto(path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.assertUiReady(`打开 ${path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + async openViaMenu(menuName: string, pathPart: string): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.shared.menuBasic, '展开基础设置'); + const hospital = this.shared.menuHospital; + if (await hospital.isVisible().catch(() => false)) { + await this.click(hospital, '展开医院资料'); + } + await this.click(this.shared.menuItem(menuName), `进入${menuName}`); + await this.page.waitForURL((url) => url.pathname.includes(pathPart), { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.assertUiReady(`菜单进入${menuName}`); + } + + async expectUrlContains(part: string): Promise { + expect(this.page.url(), `URL 应含 ${part}`).toContain(part); + } + + async expectNotOnSiblingPages(forbidden: string[]): Promise { + const url = this.page.url(); + for (const part of forbidden) { + expect(url, `不应进入 ${part}`).not.toContain(part); + } + } + + dataRows(): Locator { + return this.shared.dataRows; + } + + async getDataRowCount(): Promise { + return this.dataRows().count(); + } + + async getTotalCount(): Promise { + const text = (await this.shared.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + async expectListOrEmpty(): Promise { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + if (rows > 0) { + expect(await this.getTotalCount(), '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1); + return; + } + const emptyVisible = await this.shared.empty.isVisible().catch(() => false); + const emptyText = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + expect(emptyVisible || emptyText || (await this.getTotalCount()) === 0, '无数据时空态应明确').toBeTruthy(); + } + + async expectHeaders(names: string[]): Promise { + const text = ((await this.shared.tableHeader.allTextContents()) ?? []).join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name); + } + } + + async headerIndex(name: string): Promise { + const headers = await this.shared.tableHeader.allTextContents(); + const index = headers.findIndex((item) => item.replace(/\s/g, '').includes(name.replace(/\s/g, ''))); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async firstRow(): Promise { + const row = this.dataRows().first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible(); + return row; + } + + async expectFormError(): Promise { + const error = this.shared.formError.first(); + const toast = this.page.locator('.ant-message-error, .ant-notification-notice').first(); + await expect(error.or(toast), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 }); + } + + async closeDialogIfOpen(): Promise { + const dialog = this.shared.dialog; + if (!(await dialog.isVisible().catch(() => false))) return; + const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '关闭弹窗'); + } else { + await this.page.keyboard.press('Escape'); + } + await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined); + } + + async selectDropdown(select: Locator, optionText: string): Promise { + await this.click(select, '打开下拉'); + const option = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option').filter({ hasText: optionText }).first(); + await this.click(option, `选择 ${optionText}`); + await this.waitSettle(200); + } + + async clickPaginationPage(pageNum: number): Promise { + const item = this.page.locator(`.ant-pagination-item-${pageNum}`).first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, `翻到第 ${pageNum} 页`); + return; + } + await this.click(this.shared.nextPage, '下一页'); + } + + async hasPaginationItem(pageNum: number): Promise { + return this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false); + } + + formItem(label: string): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: label }).first(); + } + + dialogFormItem(label: string): Locator { + return this.shared.dialog.locator('.ant-form-item').filter({ hasText: label }).first(); + } + + async pickCascaderFirstPath(): Promise { + const selector = this.shared.dialog.locator('.ant-cascader, .ant-select').filter({ has: this.page.getByText('请选择') }).first(); + const fallback = this.dialogFormItem('所在地区').locator('.ant-select, .ant-cascader').first(); + const target = (await selector.count()) ? selector : fallback; + await this.click(target, '打开所在地区'); + for (let i = 0; i < 3; i += 1) { + const item = this.page.locator('.ant-cascader-menu:visible .ant-cascader-menu-item:not(.ant-cascader-menu-item-disabled)').first(); + if (!(await item.isVisible().catch(() => false))) break; + await this.click(item, `选择地区第 ${i + 1} 级`); + await this.waitSettle(150); + } + } + + uniqStamp(prefix: string): string { + return `${prefix}-${Date.now()}`; + } + + get hospitalData() { + return basicHospitalData; + } +} diff --git a/pages/spd/basic-user-shared.page.ts b/pages/spd/basic-user-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb19c0430626f9eedb75f6483ac194e5ff3d12dd --- /dev/null +++ b/pages/spd/basic-user-shared.page.ts @@ -0,0 +1,367 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { BasePage } from '../../core/base.page'; +import { BasicUserSharedLocators } from '../../locators/spd/basic-user-shared.locator'; +import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; +import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper'; + +export type UserMeta = { + path: string; + pathPart: string; + menu: string; + forbidden?: string[]; +}; + +export type UserListSnapshot = { + rows: number; + total: number; + emptyVisible: boolean; +}; + +/** 用户管理三页共用:无 iframe、侧栏叶子菜单、表格/弹窗。 */ +export class BasicUserSharedPage extends BasePage { + readonly locators: BasicUserSharedLocators; + + constructor( + page: Page, + protected readonly meta: UserMeta, + ) { + super(page); + this.locators = new BasicUserSharedLocators(page); + } + + get pagePath(): string { + return this.meta.path; + } + + async open(): Promise { + await this.goto(this.meta.path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.waitContentReady(); + await this.assertUiReady(`打开 ${this.meta.path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + async openFromMenu(): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.locators.menuBasic, '展开基础设置'); + const title = this.locators.userSubmenuTitle.first(); + const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first(); + const opened = await submenu + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!opened) await this.click(title, '展开用户管理'); + await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`); + await this.page.waitForURL((url) => { + const pathname = url.pathname; + if (!pathname.includes(this.meta.pathPart)) return false; + return !(this.meta.forbidden ?? []).some((token) => pathname.includes(token)); + }, { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(600); + await this.waitContentReady(); + await this.assertUiReady(`菜单进入${this.meta.menu}`); + } + + async waitContentReady(): Promise { + const markers = this.page + .locator('.ant-table-thead th, .ant-empty, .menu-role-tree, .ant-tree, [placeholder="姓名、登录账号"]') + .first(); + await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitForTableReady({ health: 'ui' }); + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + button(name: string, root?: Locator): Locator { + return this.locators.button(name, root ?? this.page); + } + + async clickButton(name: string, root?: Locator): Promise { + await this.click(this.button(name, root).first(), name); + } + + async query(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.clickButton('查询'); + await this.waitForTableReady(); + } + + async reset(): Promise { + await this.assertBeforeBusinessAction('重置'); + await this.clickButton('重置'); + await this.waitForTableReady(); + } + + dialog(): Locator { + return this.locators.dialog; + } + + async headerCells(): Promise { + await this.page + .locator('.ant-table-thead th') + .first() + .waitFor({ state: 'attached', timeout: 12_000 }) + .catch(() => undefined); + return this.page.locator('.ant-table-thead').first().locator('th'); + } + + async headerTexts(): Promise { + return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, '')); + } + + async headerIndex(name: string): Promise { + const texts = await this.headerTexts(); + const needle = name.replace(/\s/g, ''); + const exact = texts.findIndex((item) => item === needle); + if (exact >= 0) return exact; + const index = texts.findIndex((item) => item.includes(needle)); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async listRows(): Promise { + return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + } + + async getDataRowCount(): Promise { + return (await this.listRows()).count(); + } + + async getTotalCount(): Promise { + const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async tryCell(row: Locator, header: string): Promise { + try { + return (await this.cellText(row, header)).trim(); + } catch { + return ''; + } + } + + async firstRow(): Promise { + const row = (await this.listRows()).first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible(); + return row; + } + + async snapshotList(): Promise { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const emptyVisible = + (await this.locators.empty.isVisible().catch(() => false)) || + (await this.page.getByText('暂无数据').first().isVisible().catch(() => false)); + return { rows, total, emptyVisible }; + } + + async sampleCell(header: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 12); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') return text; + } + await this.skipWhenDataInsufficient(`首屏「${header}」为空`); + return ''; + } + + async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + if (!text.includes(needle)) return false; + } + return true; + } + + async fillName(keyword: string): Promise { + await expect(this.locators.nameInput, '姓名/登录账号').toBeVisible(); + await this.fill(this.locators.nameInput, keyword, '填写姓名或登录账号'); + } + + async selectFormOption(label: string, optionText: string): Promise { + const select = this.locators.formSelect(label); + await expect(select, `筛选「${label}」`).toBeVisible(); + await this.click(select, `打开${label}`); + const compact = optionText.replace(/\s/g, '').slice(0, 12); + const escaped = compact.slice(0, 8).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const option = this.page + .locator('.ant-select-dropdown:visible .ant-select-item-option') + .filter({ hasText: new RegExp(escaped) }) + .first(); + await this.click(option, `选择 ${optionText}`); + await this.waitSettle(200); + } + + async expandAdvanced(): Promise { + await this.click(this.locators.advancedFilter, '高级筛选'); + await this.waitSettle(400); + } + + rowAction(row: Locator, name: string): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first(); + } + + async activePageNumber(): Promise { + return ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim(); + } + + async goToPageNumber(pageNum: number): Promise { + if (pageNum <= 1) { + const first = this.page.locator('.ant-pagination-item-1').first(); + if (await first.isVisible().catch(() => false)) { + await this.click(first, '回到第 1 页'); + } else { + await this.click(this.page.locator('.ant-pagination-prev').first(), '上一页'); + } + } else { + const item = this.page.locator(`.ant-pagination-item-${pageNum}`).first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, `翻到第 ${pageNum} 页`); + } else { + await this.click(this.locators.nextPage, '下一页'); + } + } + await this.waitForTableReady(); + return this.activePageNumber(); + } + + async goToPage2(): Promise<{ firstLogin: string; secondLogin: string; active: string }> { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: async (pageNum) => + this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false), + }); + const firstLogin = await this.tryCell(await this.firstRow(), '登录账号'); + const active = await this.goToPageNumber(2); + const secondLogin = await this.tryCell(await this.firstRow(), '登录账号'); + return { firstLogin, secondLogin, active }; + } + + async pageSizeChangerText(): Promise { + return ((await this.locators.pageSizeChanger.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' '); + } + + async changePageSizeTo(toLabel: string): Promise { + const changer = this.locators.pageSizeChanger; + if (!(await changer.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('当前页面无每页条数切换'); + } + const sizeNum = (toLabel.match(/\d+/) ?? ['20'])[0]; + const current = await this.pageSizeChangerText(); + if (current.includes(sizeNum) && current.includes('条')) { + return; + } + await this.click(changer, '打开每页条数'); + const option = this.page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') + .filter({ hasText: new RegExp(`${sizeNum}\\s*条`) }) + .first(); + await this.click(option, `选择 ${toLabel}`); + await this.waitForTableReady(); + const after = await this.pageSizeChangerText(); + if (!after.includes(sizeNum)) { + throw new Error(`切换每页条数未生效:期望含 ${sizeNum},当前「${after || '(空)'}」`); + } + } + + /** 切换为 20 条/页,返回切换前后可见行数与总条数(总条数应不变)。 */ + async switchToTwentyPerPage(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> { + await skipIfInsufficientForPageSizeChange({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + }); + const total = await this.getTotalCount(); + if (total <= 10) { + await this.skipWhenDataInsufficient('总条数不大于 10,无法验证切换 20 条/页后列表变化'); + } + const rowsBefore = await this.getDataRowCount(); + const expectedAfter = Math.min(total, 20); + if (rowsBefore === expectedAfter) { + await this.skipWhenDataInsufficient( + '当前页可见行数已等于切换 20 条/页后的期望,无法验证列表同步变化', + ); + } + await this.changePageSizeTo('20 条/页'); + const rowsAfter = await this.getDataRowCount(); + return { total, rowsBefore, rowsAfter }; + } + + async openAddDialog(): Promise { + await this.clickButton('新增'); + const dialog = this.dialog(); + await expect(dialog, '新增弹窗').toBeVisible({ timeout: 12_000 }); + return dialog; + } + + async openRowAction(name: string): Promise { + const row = await this.firstRow(); + await this.click(this.rowAction(row, name), name); + await this.waitSettle(400); + return row; + } + + async closeDialog(): Promise { + const dialog = this.dialog(); + if (!(await dialog.isVisible().catch(() => false))) return; + const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '关闭弹窗'); + } else { + await this.page.keyboard.press('Escape'); + } + await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined); + } + + async expectBlockedAndStay(): Promise { + const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|已存在|重复|占用/).first(); + await expect(this.locators.formError.first().or(hint), '应出现拦截提示').toBeVisible({ timeout: 8_000 }); + const stayed = + (await this.page.locator('.ant-modal:visible').count()) > 0 || + (await this.page.locator('.ant-drawer-open').count()) > 0; + expect(stayed, '拦截后弹窗不应关闭').toBeTruthy(); + } + + async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> { + await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' }); + await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined); + const onLogin = /\/login/i.test(this.page.url()); + const hasBusinessRow = await this.page + .locator('.ant-table-tbody tr.ant-table-row, .menu-role-tree') + .first() + .isVisible() + .catch(() => false); + return { onLogin, hasBusinessRow }; + } + + async fillDialogField(locator: Locator, value: string, label: string): Promise { + if (!(await locator.isVisible().catch(() => false))) return; + await this.fill(locator, value, label); + } + +} diff --git a/pages/spd/basic-user-union.page.ts b/pages/spd/basic-user-union.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..5694240743c182e139d42f911fed04000445ca64 --- /dev/null +++ b/pages/spd/basic-user-union.page.ts @@ -0,0 +1,150 @@ +import { type Page } from '@playwright/test'; + +import { userData, uniqLogin } from '../../data/user.data'; +import { BasicUserUnionLocators } from '../../locators/spd/basic-user-union.locator'; +import { BasicUserPage, type UserSample } from './basic-user.page'; + +export type UnionSample = UserSample & { + sampleHospital: string; + samplePhone: string; +}; + +/** 医共体用户管理:医院维度差异;断言在 tests/spd/basic-user-union.spec.ts。 */ +export class BasicUserUnionPage extends BasicUserPage { + override readonly locators: BasicUserUnionLocators; + + constructor(page: Page) { + super(page, { + path: userData.unionPath, + pathPart: '/basic/user/platform/personCom', + menu: userData.menuUnion, + forbidden: ['/platform/role'], + }); + this.locators = new BasicUserUnionLocators(page); + } + + async extractUnionSample(): Promise { + await this.query(); + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 12); + for (let i = 0; i < count; i += 1) { + const row = rows.nth(i); + const sampleDept = await this.tryCell(row, '所属科室'); + const samplePhone = await this.tryCell(row, '联系方式'); + const phoneNorm = samplePhone.replace(/\s/g, ''); + if (!sampleDept || sampleDept === '--') continue; + if (!phoneNorm || samplePhone === '--' || phoneNorm.length < 4) continue; + const sampleUserName = await this.cellText(row, '姓名'); + const sampleLoginName = await this.cellText(row, '登录账号'); + const sampleHospital = await this.cellText(row, '所属医院'); + const rawRole = await this.tryCell(row, '角色'); + const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim(); + const sampleStatus = await this.inferRowStatus(row); + return { + sampleUserName, + sampleLoginName, + sampleDept, + sampleRole, + sampleStatus, + sampleHospital, + samplePhone, + }; + } + const row = await this.firstRow(); + const sampleUserName = await this.cellText(row, '姓名'); + const sampleLoginName = await this.cellText(row, '登录账号'); + const sampleHospital = await this.cellText(row, '所属医院'); + const samplePhone = await this.tryCell(row, '联系方式'); + const sampleDept = await this.tryCell(row, '所属科室'); + const rawRole = await this.tryCell(row, '角色'); + const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim(); + const sampleStatus = await this.inferRowStatus(row); + return { sampleUserName, sampleLoginName, sampleDept, sampleRole, sampleStatus, sampleHospital, samplePhone }; + } + + async currentHospitalLabel(): Promise { + const select = this.locators.formSelect('所属医院'); + return ((await select.innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + } + + async switchHospitalIfPossible(): Promise<{ from: string; to: string } | null> { + const from = await this.currentHospitalLabel(); + await this.click(this.locators.formSelect('所属医院'), '打开所属医院'); + const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option'); + const count = await options.count(); + if (count < 2) { + await this.page.keyboard.press('Escape').catch(() => undefined); + await this.skipWhenDataInsufficient('所属医院仅 1 家可选'); + return null; + } + let to = from; + for (let i = 0; i < count; i += 1) { + const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, ''); + if (text && text !== from) { + to = text; + await this.click(options.nth(i), `选择医院 ${text}`); + break; + } + } + if (to === from) { + await this.page.keyboard.press('Escape').catch(() => undefined); + await this.skipWhenDataInsufficient('所属医院无可切换选项'); + return null; + } + await this.query(); + return { from, to }; + } + + async queryComboUnion(sample: UnionSample): Promise { + await this.fillName(sample.sampleUserName); + if (sample.sampleHospital) { + await this.selectFormOption('所属医院', sample.sampleHospital).catch(() => undefined); + } + await this.expandAdvanced(); + const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first(); + const root = (await panel.isVisible().catch(() => false)) ? panel : this.page; + const phone = sample.samplePhone.replace(/\s/g, ''); + if (phone && phone !== '--') { + const telInput = root.locator('#form_item_tel').first(); + if (await telInput.isVisible().catch(() => false)) { + await this.fill(telInput, sample.samplePhone.trim(), '联系方式'); + } + } + if (sample.sampleDept && sample.sampleDept !== '--') { + await this.selectFormOption('科室', sample.sampleDept).catch(() => undefined); + } + if (sample.sampleRole) { + await this.selectFormOption('角色', sample.sampleRole).catch(() => undefined); + } + const filterBtn = this.locators.button('筛选', root).first(); + if (await filterBtn.isVisible().catch(() => false)) { + await this.click(filterBtn, '筛 选'); + } else { + await this.query(); + } + await this.waitForTableReady(); + } + + async filterByDept(dept: string): Promise { + await this.expandAdvanced(); + const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first(); + const root = (await panel.isVisible().catch(() => false)) ? panel : this.page; + await this.selectFormOption('科室', dept); + const filterBtn = this.locators.button('筛选', root).first(); + if (await filterBtn.isVisible().catch(() => false)) { + await this.click(filterBtn, '筛 选'); + } else { + await this.query(); + } + await this.waitForTableReady(); + } + + async addAndCancelOnHospital(): Promise { + const login = uniqLogin(); + await this.openAddDialog(); + await this.fillAddRequired(login, '医共体取消'); + await this.closeDialog(); + await this.queryByName(login); + return login; + } +} diff --git a/pages/spd/basic-user.page.ts b/pages/spd/basic-user.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fbf257a86d03979112c0568b6fab16ea02b2d27 --- /dev/null +++ b/pages/spd/basic-user.page.ts @@ -0,0 +1,367 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { userData, uniqLogin } from '../../data/user.data'; +import { BasicUserLocators } from '../../locators/spd/basic-user.locator'; +import { BasicUserSharedPage, type UserMeta } from './basic-user-shared.page'; +import { + ColumnSettingsControls, + executeColumnSettingsToggleRestore, + findUsableColumnSettingsTrigger, +} from './helpers/column-settings.helper'; + +export type UserSample = { + sampleUserName: string; + sampleLoginName: string; + sampleDept: string; + sampleRole: string; + sampleStatus: '启用' | '禁用'; +}; + +/** 用户管理:动作与取数;断言在 tests/spd/basic-user.spec.ts。 */ +export class BasicUserPage extends BasicUserSharedPage { + override readonly locators: BasicUserLocators; + + private readonly columnSettingsControls: ColumnSettingsControls; + + constructor(page: Page, meta?: UserMeta) { + super( + page, + meta ?? { + path: userData.userPath, + pathPart: '/basic/user/platform/person', + menu: userData.menuUser, + forbidden: ['personCom', '/platform/role'], + }, + ); + this.locators = new BasicUserLocators(page); + this.columnSettingsControls = new ColumnSettingsControls({ + columnSettingsPanel: () => this.locators.columnSettingsPanel(), + resolveTrigger: () => this.resolveColumnSettingsTrigger(), + reportBody: page.locator('body'), + clickOutsideTarget: this.locators.nameInput, + tableClickTarget: this.locators.tableWrapper, + }); + } + + async extractSample(): Promise { + await this.query(); + const row = await this.firstRow(); + const sampleUserName = await this.cellText(row, '姓名'); + const sampleLoginName = await this.cellText(row, '登录账号'); + const sampleDept = await this.cellText(row, '所属科室'); + const rawRole = await this.tryCell(row, '角色'); + const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim(); + const sampleStatus = await this.inferRowStatus(row); + return { sampleUserName, sampleLoginName, sampleDept, sampleRole, sampleStatus }; + } + + /** 列表「状态」列或行内启用/禁用文案。 */ + async inferRowStatus(row: Locator): Promise<'启用' | '禁用'> { + const fromCol = (await this.tryCell(row, '状态')).replace(/\s/g, ''); + if (fromCol.includes('禁用')) return '禁用'; + if (fromCol.includes('启用')) return '启用'; + const rowText = ((await row.innerText()) ?? '').replace(/\s/g, ''); + return rowText.includes('禁用') ? '禁用' : '启用'; + } + + async rowsMatchStatus(status: '启用' | '禁用', sample = 5): Promise { + const rows = await this.listRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + if ((await this.inferRowStatus(rows.nth(i))) !== status) return false; + } + return true; + } + + async queryByName(keyword: string): Promise { + await this.fillName(keyword); + await this.query(); + } + + async queryByDept(dept: string): Promise { + await this.selectFormOption('科室', dept); + await this.query(); + } + + async queryCombo(sample: UserSample): Promise { + await this.fillName(sample.sampleUserName); + if (sample.sampleDept && sample.sampleDept !== '--') { + await this.selectFormOption('科室', sample.sampleDept); + } + if (sample.sampleRole) { + await this.selectFormOption('角色', sample.sampleRole).catch(() => undefined); + } + await this.expandAdvanced(); + const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first(); + const root = (await panel.isVisible().catch(() => false)) ? panel : this.page; + await this.selectFormOption('状态', sample.sampleStatus); + const filterBtn = this.locators.button('筛选', root).first(); + if (await filterBtn.isVisible().catch(() => false)) { + await this.click(filterBtn, '筛 选'); + } else { + await this.query(); + } + await this.waitForTableReady(); + } + + async queryMockEmpty(): Promise { + await this.queryByName(userData.mockUser); + } + + async filterByStatus(status: '启用' | '禁用'): Promise { + await this.expandAdvanced(); + const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first(); + const root = (await panel.isVisible().catch(() => false)) ? panel : this.page; + await this.selectFormOption('状态', status); + const filterBtn = this.locators.button('筛选', root).first(); + if (await filterBtn.isVisible().catch(() => false)) { + await this.click(filterBtn, '筛 选'); + } else { + await this.query(); + } + await this.waitForTableReady(); + } + + async fillAddRequired(login: string, name: string): Promise { + const dialog = this.dialog(); + await this.fillDialogField(this.locators.dialogUserCode(), login, '账号'); + await this.fillDialogField(this.locators.dialogName(), name, '姓名'); + await expect(dialog.getByRole('button', { name: /确\s*定/ })).toBeVisible(); + } + + async addAndCancel(): Promise { + const login = uniqLogin(); + await this.openAddDialog(); + await this.fillAddRequired(login, '自动化取消'); + await this.closeDialog(); + await this.queryByName(login); + return login; + } + + async submitEmptyRequired(): Promise { + await this.openAddDialog(); + await this.click(this.dialog().getByRole('button', { name: /确\s*定/ }), '确 定'); + } + + async submitDuplicateLogin(login: string): Promise { + await this.openAddDialog(); + await this.fillAddRequired(login, '重复账号'); + await this.click(this.dialog().getByRole('button', { name: /确\s*定/ }), '确 定'); + } + + async openEdit(): Promise<{ login: string; name: string }> { + const row = await this.firstRow(); + const login = await this.cellText(row, '登录账号'); + const name = await this.cellText(row, '姓名'); + await this.click(this.rowAction(row, '编辑'), '编辑'); + const dialog = this.dialog(); + await expect(dialog, '编辑用户弹窗').toBeVisible({ timeout: 12_000 }); + return { login, name }; + } + + async editAndCancel(): Promise { + const { name } = await this.openEdit(); + await this.fillDialogField(this.locators.dialogName(), `${name}-tmp`, '临时改姓名'); + await this.closeDialog(); + return name; + } + + async collectRoleFilterOptions(): Promise { + const select = this.locators.formSelect('角色'); + if (!(await select.isVisible().catch(() => false))) return []; + await this.click(select, '打开角色下拉'); + const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option'); + const texts = (await options.allTextContents()) + .map((item) => item.replace(/\s/g, '').replace(/[((].*$/, '')) + .filter(Boolean); + await this.page.keyboard.press('Escape').catch(() => undefined); + return texts; + } + + async authCheckedCount(): Promise { + const tree = this.page.locator('.ant-tree, .menu-role-tree, .ant-drawer-open .ant-tree').first(); + if (!(await tree.isVisible().catch(() => false))) return 0; + return tree.locator('.ant-tree-checkbox-checked').count(); + } + + /** 任选首屏一条登录账号非空的行,供授权等行内操作。 */ + async pickRowWithLogin(): Promise<{ + row: Locator; + sampleLoginName: string; + sampleUserName: string; + }> { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 12); + for (let i = 0; i < count; i += 1) { + const row = rows.nth(i); + const login = (await this.tryCell(row, '登录账号')).replace(/\s/g, ''); + const name = (await this.tryCell(row, '姓名')).trim(); + if (login) { + return { row, sampleLoginName: login, sampleUserName: name }; + } + } + await this.skipWhenDataInsufficient('列表无可用登录账号'); + return { row: await this.firstRow(), sampleLoginName: '', sampleUserName: '' }; + } + + userRoleAuthLayer(): Locator { + return this.dialog() + .filter({ hasText: /用户角色授权|授权/ }) + .or(this.browserPage.locator('.ant-drawer-open').filter({ hasText: /用户角色授权|授权/ })) + .first(); + } + + async openAuthOnRow(row: Locator): Promise { + await this.click(this.rowAction(row, '授权'), '授权'); + await this.waitSettle(400); + } + + async expectUserRoleAuthDialogAccount(login: string, userName: string): Promise { + const layer = this.userRoleAuthLayer(); + await expect(layer, '用户角色授权弹框').toBeVisible({ timeout: 12_000 }); + const title = layer.locator('.ant-modal-title, .ant-drawer-title').first(); + await expect(title, '弹框标题').toContainText(/用户角色授权|授权/); + const compactLogin = login.replace(/\s/g, ''); + const bodyText = ((await layer.innerText()) ?? '').replace(/\s/g, ''); + expect(bodyText, '弹框账号应与所选行登录账号一致').toContain(compactLogin); + const compactName = userName.replace(/\s/g, ''); + if (compactName) { + expect(bodyText, '弹框应展示所选行姓名').toContain(compactName); + } + } + + authRemoveButtons(): Locator { + return this.userRoleAuthLayer().getByRole('button', { name: /^移\s*除$/ }); + } + + async authRemovableRowCount(): Promise { + await expect(this.userRoleAuthLayer(), '用户角色授权弹框').toBeVisible({ timeout: 12_000 }); + return this.authRemoveButtons().count(); + } + + async clickAuthAddRow(): Promise { + const layer = this.userRoleAuthLayer(); + const add = layer.getByRole('button', { name: /^添\s*加$/ }).first(); + if (!(await add.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('授权弹框无「添加」按钮(可能为权限树模式)'); + } + await this.click(add, '添加'); + await this.waitSettle(400); + } + + async clickAuthRemoveLastRow(): Promise { + const removes = this.authRemoveButtons(); + const count = await removes.count(); + if (count === 0) { + throw new Error('授权弹框内无「移除」按钮'); + } + await this.click(removes.nth(count - 1), '移除'); + await this.waitSettle(400); + } + + /** 弹框内角色行:添加一行再移除最后一行(不点保存)。 */ + async runAuthAddThenRemove(): Promise<{ + before: number; + afterAdd: number; + afterRemove: number; + }> { + const before = await this.authRemovableRowCount(); + await this.clickAuthAddRow(); + const afterAdd = await this.authRemovableRowCount(); + await this.clickAuthRemoveLastRow(); + const afterRemove = await this.authRemovableRowCount(); + return { before, afterAdd, afterRemove }; + } + + private async resolveColumnSettingsTrigger(): Promise { + const candidates = [this.locators.columnSettingsTrigger]; + let trigger = await findUsableColumnSettingsTrigger(candidates); + if (!trigger) { + await this.locators.tableWrapper.scrollIntoViewIfNeeded().catch(() => undefined); + const body = this.locators.tableWrapper.locator('.ant-table-body').first(); + await body + .evaluate((el) => { + (el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth; + }) + .catch(() => undefined); + await this.waitSettle(200); + trigger = await findUsableColumnSettingsTrigger(candidates); + } + if (!trigger) { + await this.skipWhenDataInsufficient('列设置齿轮不可见'); + } + return trigger!; + } + + async openColumnSettings(): Promise { + const panel = this.locators.columnSettingsPanel(); + if (await panel.isVisible().catch(() => false)) { + return; + } + const trigger = await this.resolveColumnSettingsTrigger(); + await this.click(trigger, '打开列设置'); + await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 }); + } + + async expectColumnSettingsPanel(): Promise { + const panel = this.locators.columnSettingsPanel(); + await expect(panel).toBeVisible(); + await expect(panel.getByText('列展示')).toBeVisible(); + await expect(panel.getByText('字段设置')).toBeVisible(); + await expect(panel.getByText('重置', { exact: true })).toBeVisible(); + } + + async closeColumnSettingsPanel(): Promise { + await this.columnSettingsControls.closePanel(); + } + + async pickToggleColumnName(): Promise { + for (const name of userData.columnSettingsToggleCandidates) { + if (await this.locators.columnHeaderCell(name).isVisible().catch(() => false)) { + return name; + } + } + return userData.columnSettingsToggleCandidates[0]; + } + + async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise { + const row = this.locators.columnSettingsCheckboxRow(columnName); + await expect(row).toBeVisible(); + const checkbox = row.locator('input[type="checkbox"]').first(); + const isChecked = await checkbox.isChecked(); + if (isChecked !== visible) { + await row.click(); + } + } + + async expectTableHeaderVisible(columnName: string, visible: boolean): Promise { + const header = this.locators.columnHeaderCell(columnName); + if (visible) { + await expect(header).toBeVisible({ timeout: 10_000 }); + } else { + await expect(header).toBeHidden({ timeout: 10_000 }); + } + } + + async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise { + const checkbox = this.locators.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first(); + if (checked) { + await expect(checkbox).toBeChecked(); + } else { + await expect(checkbox).not.toBeChecked(); + } + } + + async executeColumnSettingsToggleRestoreTest(): Promise { + await executeColumnSettingsToggleRestore({ + pickToggleColumnName: () => this.pickToggleColumnName(), + openColumnSettings: () => this.openColumnSettings(), + setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible), + closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(), + expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible), + expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked), + }); + } +} diff --git a/pages/spd/center-report-entry.page.ts b/pages/spd/center-report-entry.page.ts index c6aff09a5cc69d4a477a825056568b0763339a62..e8ee06406a45eeb82487aadec2738cb7a32a202c 100644 --- a/pages/spd/center-report-entry.page.ts +++ b/pages/spd/center-report-entry.page.ts @@ -1,41 +1,17 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -import { - centerReportEntryData, - centerReportEntryDrawerFields, - type CenterReportEntryColumnKey, - type EntryListSampleRow, -} from '../../data/center-report-entry.data'; import { timeouts } from '../../config/timeouts.config'; -import { BasePage } from '../../core/base.page'; -import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; -import { CenterReportEntryLocators } from '../../locators/spd/center-report-entry.locator'; -import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator'; -import { DrawerFormControls } from './helpers/drawer-form-controls.helper'; -import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper'; -import { - ColumnSettingsControls, - executeColumnSettingsToggleRestore, - findUsableColumnSettingsTrigger, - revealColumnSettingsTriggerByScroll, -} from './helpers/column-settings.helper'; -import { retryReportLoad } from './helpers/report-load-retry.helper'; -import { TableScrollLayoutControls } from './helpers/table-scroll-layout.helper'; -import { DownloadCenterComponent } from '../components/download-center.component'; -import { - expectAdvancedDrawerSyncedFromQuick as assertDrawerSyncedFromQuick, - type DrawerQuickSyncContext, -} from './helpers/drawer-quick-sync.helper'; +import { centerReportEntryData } from '../../data/center-report-entry.data'; import { deptReportSheetComboQuickFields, standardProductNameDateSyncFields, - type DeptQuickFieldDef, } from '../../data/dept-report-field-defs'; -import { executeExportReport } from './helpers/export-report.helper'; -import { skipIfInsufficientForPageSizeChange as skipPageSizeIfInsufficient } from './helpers/pagination-pagesize.helper'; -import { skipIfInsufficientForPage2 as skipPage2IfInsufficient } from './helpers/pagination-page2.helper'; +import type { SampleRow } from './center-report-shared.page'; +import { CenterReportSharedPage } from './center-report-shared.page'; -export const CENTER_REPORT_ENTRY_PATH = centerReportEntryData.entryPath; +const drawerSyncQuickFields = deptReportSheetComboQuickFields.filter( + (field) => field.key === 'productName' || field.key === 'accountDate', +); function usableSampleValue(value?: string): string | undefined { const trimmed = value?.trim(); @@ -43,190 +19,17 @@ function usableSampleValue(value?: string): string | undefined { return trimmed; } -export class CenterReportEntryPage extends BasePage { - private readonly locators: CenterReportEntryLocators; - private readonly drawerControls: DrawerFormControls; - private readonly columnSettingsControls: ColumnSettingsControls; - private readonly tableScrollLayoutControls: TableScrollLayoutControls; - private readonly downloadCenter: DownloadCenterComponent; - - protected pageHealthScope() { - return { page: this.page, roots: [this.locators.report] }; - } - +/** + * 中心库入库汇总。行展开、维度、分页、导出走 CenterReportSharedPage, + * 这里只保留汇总页的组合查询与快捷区带入抽屉。 + */ +export class CenterReportEntryPage extends CenterReportSharedPage { constructor(page: Page) { - super(page); - this.locators = new CenterReportEntryLocators(page); - this.downloadCenter = new DownloadCenterComponent(page); - this.drawerControls = new DrawerFormControls({ - report: this.locators.report, - drawerTitle: this.locators.drawerTitle, - drawerFormItem: (label) => this.locators.drawerFormItem(label), - click: (locator, stepName) => this.click(locator, stepName), - }); - this.columnSettingsControls = new ColumnSettingsControls({ - columnSettingsPanel: () => this.locators.columnSettingsPanel(), - columnSettingsTrigger: this.locators.columnSettingsTrigger, - reportBody: this.locators.report.locator('body'), - clickOutsideTarget: this.locators.productName, - tableClickTarget: this.locators.tableBody, - }); - this.tableScrollLayoutControls = new TableScrollLayoutControls({ - page: this.page, - report: this.locators.report, - }); - } - - async open(): Promise { - await this.goto(CENTER_REPORT_ENTRY_PATH); - await this.expectLoaded(); - } - - async expectLoaded(): Promise { - await retryReportLoad( - async () => { - await this.page - .locator(iframeCssSelector('centerPages/entry')) - .first() - .waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) }); - await this.waitVisible(this.locators.productName, timeouts.pageReady); - await this.waitVisible(this.locators.searchBtn); - await this.waitVisible(this.locators.advancedFilter); - await this.assertPageHealthy('页面加载完成'); - }, - () => this.goto(CENTER_REPORT_ENTRY_PATH), - (ms) => this.page.waitForTimeout(ms), - ); - } - - async search(): Promise { - await this.click(this.locators.searchBtn, '点击查询'); - await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 }); - await this.locators.report - .locator('.ant-spin-spinning') - .waitFor({ state: 'hidden', timeout: 30_000 }) - .catch(() => undefined); - await this.assertPageHealthy('查询完成后'); - } - - async fillProductName(value: string): Promise { - await this.fill(this.locators.productName, value, '填写产品名称'); - } - - async resetQuickFilter(): Promise { - await this.click(this.locators.resetBtn, '点击重置'); - } - - async openAdvancedFilter(): Promise { - await this.click(this.locators.advancedFilter, '打开高级筛选'); - await this.waitVisible(this.locators.drawerTitle); - } - - async expectAdvancedDrawer(): Promise { - await expect(this.locators.drawerTitle).toHaveText('高级筛选'); - } - - async applyAdvancedFilter(): Promise { - if (!(await this.locators.drawerTitle.isVisible())) { - await this.openAdvancedFilter(); - } - await this.click(this.locators.drawerFilterBtn, '点击筛选'); - await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 }); - await this.locators.report - .locator('.ant-spin-spinning') - .waitFor({ state: 'hidden', timeout: 30_000 }) - .catch(() => undefined); - } - - async resetAdvancedDrawer(): Promise { - await this.click(this.locators.drawerResetBtn, '抽屉重置'); - } - - async expectDrawerBatchNoEmpty(): Promise { - await expect(this.locators.batchNoInput).toHaveValue(''); - } - - async isDimensionChecked(label: (typeof centerReportEntryData.dimensions)[number]): Promise { - const input = this.locators.dimensionCheckbox(label).locator('input[type="checkbox"]'); - return input.isChecked(); - } - - async fillAccountDateRange(start: string, end: string): Promise { - await this.click(this.locators.accountDateStart, '打开记账日期选择器'); - const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last(); - await panel.waitFor({ state: 'visible', timeout: 10_000 }); - await this.pickDateInPanel(panel, start); - await this.pickDateInPanel(panel, end); - await this.page.keyboard.press('Escape'); - } - - private async pickDateInPanel(panel: Locator, date: string): Promise { - const cell = panel.locator(`td[title="${date}"]`); - for (let i = 0; i < 12 && (await cell.count()) === 0; i += 1) { - await panel.locator('.ant-picker-header-prev-btn').first().click(); - } - await cell.last().click(); - } - - private async dismissDrawerOverlay(): Promise { - await this.drawerControls.dismissOverlay(); + super(page, centerReportEntryData); } - async selectQuickBillType(optionText: string): Promise { - const select = this.locators.quickFormItem('单据类型').locator('.ant-select'); - await this.drawerControls.selectAntSelect(select, '单据类型', optionText); - await this.page.keyboard.press('Escape'); - } - - async selectQuickBillTypeFirst(): Promise { - const select = this.locators.quickFormItem('单据类型').locator('.ant-select'); - await this.drawerControls.selectAntSelect(select, '单据类型'); - await this.page.keyboard.press('Escape'); - } - - async selectDrawerSelect(label: string, optionText?: string): Promise { - await this.drawerControls.selectDrawerSelect(label, optionText); - } - - async fillDrawerTextByLabel(label: string, value: string): Promise { - const item = this.locators.drawerFormItem(label); - if ((await item.count()) === 0) return; - const input = item.locator('input:not([type="hidden"])').first(); - if ((await input.count()) === 0) return; - await this.fill(input, value, `高级筛选填写${label}`); - } - - async fillAdvancedDrawerFromSample( - sample: EntryListSampleRow, - options?: { skipKeys?: readonly string[] }, - ): Promise { - const skipKeys = new Set(options?.skipKeys ?? []); - for (const field of centerReportEntryDrawerFields) { - if (skipKeys.has(field.key)) continue; - const value = sample[field.key]?.trim(); - if (!value) continue; - switch (field.type) { - case 'text': - if (field.key === 'batchNo') { - await this.fillBatchNo(value); - } else if (field.key === 'sourceUnit') { - await this.fill(this.locators.sourceUnitInput, value, '填写来源单位'); - } else if (field.key === 'supplier') { - await this.fill(this.locators.supplierInput, value, '填写进院供应商'); - } else { - await this.fillDrawerTextByLabel(field.label, value); - } - break; - case 'select': - await this.selectDrawerSelect(field.label, value); - break; - default: - break; - } - } - } - - async searchWithAllConditions(): Promise { + /** STAT-IES 全量条件:维度 + 产品 + 单据类型 + 抽屉,空结果时收窄再查。 */ + async searchWithAllConditions(): Promise { test.setTimeout(timeouts.comboTest); await this.checkAllDimensions(); await this.search(); @@ -235,23 +38,23 @@ export class CenterReportEntryPage extends BasePage { await this.checkAllDimensions(); await this.search(); } + let sample = await this.extractSampleRow(); for (let rowIndex = 0; rowIndex < 5; rowIndex += 1) { if (sample.productCode?.trim() || sample.productName?.trim()) break; if ((await this.locators.dataRows.count()) <= rowIndex) break; sample = await this.extractSampleRow(rowIndex); } + const usedName = (await this.fillProductNameUntilHasRows(usableSampleValue(sample.productCode))) ?? (await this.fillProductNameUntilHasRows(usableSampleValue(sample.productName))); - expect( - usedName, - `产品ID/名称「${sample.productCode ?? sample.productName ?? '未知'}」查询无数据`, - ).toBeTruthy(); + expect(usedName, `产品ID/名称「${sample.productCode ?? sample.productName ?? '未知'}」查询无数据`).toBeTruthy(); sample.productName = usedName; if (sample.billType?.trim()) { - await this.selectQuickBillType(sample.billType); + await this.selectQuickFormSelect('单据类型', sample.billType); + await this.page.keyboard.press('Escape'); } await this.search(); @@ -262,43 +65,66 @@ export class CenterReportEntryPage extends BasePage { await this.search(); } - const beforeDrawer = await this.locators.dataRows.count(); - if (beforeDrawer > 0) { + if ((await this.locators.dataRows.count()) > 0) { await this.openAdvancedFilter(); await this.fillAdvancedDrawerFromSample(sample); await this.applyAdvancedFilter(); if ((await this.locators.dataRows.count()) === 0) { await this.openAdvancedFilter(); - await this.fillAdvancedDrawerFromSample(sample, { skipKeys: ['sourceUnit', 'supplier'] }); + await this.fillDrawerFieldDefs( + sample, + (this.data.drawerFields ?? []).filter((field) => field.key !== 'sourceUnit' && field.key !== 'supplier'), + { onlyIfPresent: true }, + ); await this.applyAdvancedFilter(); } if ((await this.locators.dataRows.count()) === 0) { - await this.page.keyboard.press('Escape').catch(() => undefined); - await this.dismissDrawerOverlay(); + await this.closeAdvancedDrawer(); await this.fillProductName(usedName!); if (sample.billType?.trim()) { - await this.selectQuickBillType(sample.billType); + await this.selectQuickFormSelect('单据类型', sample.billType); + await this.page.keyboard.press('Escape'); } await this.search(); } } + if ((await this.locators.dataRows.count()) === 0 && usedName) { await this.resetQuickFilter(); await this.checkAllDimensions(); await this.fillProductName(usedName); if (sample.billType?.trim()) { - await this.selectQuickBillType(sample.billType); + await this.selectQuickFormSelect('单据类型', sample.billType); + await this.page.keyboard.press('Escape'); } await this.search(); } - expect( - await this.locators.dataRows.count(), - '全量组合查询后应至少保留 1 条匹配数据', - ).toBeGreaterThan(0); + + expect(await this.locators.dataRows.count(), '全量组合查询后应至少保留 1 条匹配数据').toBeGreaterThan(0); return sample; } - /** 表头/单元格错位时产品名称会粘上规格数字,先用原值再去掉尾部数字重试。 */ + async fillQuickFiltersForDrawerSync(sample: SampleRow): Promise { + await this.applyQuickFields(sample, drawerSyncQuickFields, { onlyIfPresent: true }); + } + + async expectAdvancedDrawerSyncedFromQuick(sample: SampleRow): Promise { + await super.expectAdvancedDrawerSyncedFromQuick( + sample, + drawerSyncQuickFields, + standardProductNameDateSyncFields, + ); + } + + async expectCombinedFilterResult(sample: SampleRow): Promise { + await this.expectQueryWithoutError(); + expect(await this.locators.dataRows.count(), '组合查询后无匹配数据,疑似组合逻辑缺陷').toBeGreaterThan(0); + const productName = usableSampleValue(sample.productName); + if (productName) await this.expectFirstRowContains(productName); + await this.expectSummaryVisible(); + await this.expectPaginationTotal(); + } + private async fillProductNameUntilHasRows(raw?: string): Promise { const seed = usableSampleValue(raw); if (!seed) return undefined; @@ -316,876 +142,6 @@ export class CenterReportEntryPage extends BasePage { } return undefined; } - - private drawerQuickSyncContext(): DrawerQuickSyncContext { - return { - productName: this.locators.productName, - quickFormItem: (label) => this.locators.quickFormItem(label), - drawerFormItem: (label) => this.locators.drawerFormItem(label), - getQuickDateRangeDisplay: (label) => this.getQuickDateRangeDisplay(label), - getQuickSelectValue: (label) => this.getQuickSelectValue(label), - }; - } - - async getQuickDateRangeDisplay(label: string): Promise { - const item = this.locators.quickFormItem(label); - const picker = item.locator('.ant-picker').first(); - if ((await picker.count()) === 0) return ''; - return (await picker.innerText()).replace(/\s+/g, ' ').trim(); - } - - async getQuickSelectValue(label: string): Promise { - const item = this.locators.quickFormItem(label); - const selected = item.locator('.ant-select-selection-item').first(); - if ((await selected.count()) === 0) return ''; - return (await selected.innerText()).trim(); - } - - async fillQuickFiltersForDrawerSync(sample: EntryListSampleRow): Promise { - const productKeyword = - sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName'); - await this.fillProductName(productKeyword); - - const accountDate = sample.accountDate?.trim(); - if (accountDate) { - const dateOnly = accountDate.split(/\s+/)[0].replace(/\//g, '-'); - await this.fillAccountDateRange(dateOnly, dateOnly); - } - } - - async expectAdvancedDrawerSyncedFromQuick(sample: EntryListSampleRow): Promise { - const quickFields: readonly DeptQuickFieldDef[] = deptReportSheetComboQuickFields.filter( - (field) => field.key === 'productName' || field.key === 'accountDate', - ); - await assertDrawerSyncedFromQuick( - this.drawerQuickSyncContext(), - sample, - quickFields, - standardProductNameDateSyncFields, - ); - } - - async expectCombinedFilterResult(sample: EntryListSampleRow): Promise { - await this.expectQueryWithoutError(); - const rowCount = await this.locators.dataRows.count(); - expect(rowCount, '组合查询后无匹配数据,疑似组合逻辑缺陷').toBeGreaterThan(0); - const productName = usableSampleValue(sample.productName); - if (productName) await this.expectFirstRowContains(productName); - await this.expectSummaryVisible(); - await this.expectPaginationTotal(); - } - - async getFirstRowText(): Promise { - await expect(this.locators.dataRows.first()).toBeVisible({ timeout: 15_000 }); - return (await this.locators.dataRows.first().innerText()).trim(); - } - - async getColumnValues(columnName: string, maxRows = 8): Promise { - const headers = (await this.locators.tableHeaderCells().allTextContents()).map((h) => - h.replace(/\s+/g, '').trim(), - ); - const normalizedTarget = columnName.replace(/\s+/g, ''); - const columnIndex = headers.findIndex((header) => { - return header.includes(normalizedTarget) || normalizedTarget.includes(header); - }); - if (columnIndex < 0) { - return []; - } - const rowCount = Math.min(await this.locators.dataRows.count(), maxRows); - const values: string[] = []; - for (let i = 0; i < rowCount; i += 1) { - const cell = this.locators.dataRows.nth(i).locator('td').nth(columnIndex); - values.push((await cell.innerText()).replace(/\s+/g, ' ').trim()); - } - return values; - } - - async getRowSignatures(maxRows = 8): Promise { - const rowCount = Math.min(await this.locators.dataRows.count(), maxRows); - const signatures: string[] = []; - for (let i = 0; i < rowCount; i += 1) { - signatures.push((await this.locators.dataRows.nth(i).innerText()).replace(/\s+/g, ' ').trim()); - } - return signatures; - } - - async expectVisibleRowCountAtMost(maxRows: number): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - const primaryRows = wrapper.locator( - '.ant-table-container > .ant-table-body .ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row):not(.ant-table-expanded-row)', - ); - await expect - .poll( - async () => { - const primary = await primaryRows.count(); - if (primary > 0) return primary; - return this.locators.dataRows.count(); - }, - { timeout: 8_000 }, - ) - .toBeLessThanOrEqual(maxRows); - } - - - async fillBatchNo(value: string): Promise { - await this.fill(this.locators.batchNoInput, value, '填写批号'); - } - - async checkDimension(label: (typeof centerReportEntryData.dimensions)[number]): Promise { - const wrapper = this.locators.dimensionCheckbox(label); - const input = wrapper.locator('input[type="checkbox"]'); - if (!(await input.isChecked())) { - await this.click(wrapper, `勾选${label}`); - } - } - - async checkAllDimensions(): Promise { - for (const label of centerReportEntryData.dimensions) { - await this.checkDimension(label); - } - } - - async extractSampleRow(rowIndex = centerReportEntryData.sampleRowIndex): Promise { - await expect(this.locators.dataRows.nth(rowIndex)).toBeVisible({ timeout: 15_000 }); - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - const byHeader = await wrapper.evaluate((root, targetRowIndex: number) => { - const thead = - root.querySelector('.ant-table-container > .ant-table-header .ant-table-thead') ?? - root.querySelector('.ant-table-header .ant-table-thead') ?? - root.querySelector('.ant-table-content .ant-table-thead') ?? - root.querySelector('.ant-table-thead'); - const headerRow = - thead?.querySelector('tr:last-child') ?? - thead?.querySelector('tr') ?? - root.querySelector('.ant-table-thead tr'); - const headers = Array.from(headerRow?.querySelectorAll('th') ?? []).map((th) => { - const titleEl = th.querySelector('.ant-table-column-title'); - return (titleEl?.textContent ?? th.textContent ?? '').replace(/\s+/g, '').trim(); - }); - const body = - root.querySelector('.ant-table-container > .ant-table-body table tbody.ant-table-tbody') ?? - root.querySelector('.ant-table-content > table > tbody.ant-table-tbody') ?? - root.querySelector('tbody.ant-table-tbody'); - const rows = Array.from(body?.querySelectorAll(':scope > tr.ant-table-row') ?? []).filter( - (tr) => - !tr.classList.contains('ant-table-measure-row') && - !tr.classList.contains('ant-table-expanded-row'), - ); - const row = rows[targetRowIndex]; - const result: Record = {}; - if (!row) return result; - const tds = Array.from(row.querySelectorAll(':scope > td')); - for (let c = 0; c < headers.length && c < tds.length; c += 1) { - const key = headers[c]; - if (!key || key === '序号') continue; - const value = (tds[c]?.textContent ?? '').replace(/\s+/g, ' ').trim(); - if (value) result[key] = value; - } - return result; - }, rowIndex); - - const sample: EntryListSampleRow = {}; - for (const [columnKey, headerLabel] of Object.entries(centerReportEntryData.columns)) { - const normalizedLabel = headerLabel.replace(/\s+/g, ''); - const value = byHeader[normalizedLabel]; - if (value) sample[columnKey as CenterReportEntryColumnKey] = value; - } - expect(Object.keys(sample).length, `首行未解析到列表字段,表头:${Object.keys(byHeader).join('、')}`).toBeGreaterThan(0); - - if (!sample.productName?.trim() && !sample.productCode?.trim()) { - const row = this.locators.dataRows.nth(rowIndex); - const cells = (await row.locator('td').allTextContents()).map((c) => c.replace(/\s+/g, ' ').trim()); - const productCell = cells.find( - (c) => - c && - c !== '--' && - (/产品/.test(c) || /^[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9._-]{2,}/.test(c)), - ); - if (productCell) { - sample.productName = productCell.replace(/[\d.]+$/u, '').trim() || productCell; - } - } - return sample; - } - - async searchAndExtractSample(rowIndex?: number): Promise { - await this.search(); - return this.extractSampleRow(rowIndex); - } - - getSampleValue(sample: EntryListSampleRow, key: CenterReportEntryColumnKey): string { - const value = sample[key]?.trim(); - expect(value, `列表首行缺少列:${centerReportEntryData.columns[key]}`).toBeTruthy(); - return value!; - } - - async expectTableHeaders(): Promise { - // 维度勾选后列变宽,部分表头在右侧滚动区;用整表文本存在性断言,避免 .ant-table-column-title 精确可见误杀 - const headers = ( - await this.locators.report.locator('.ant-table-thead th').evaluateAll((ths) => - ths.map((th) => { - const title = th.querySelector('.ant-table-column-title'); - return ((title?.textContent ?? th.textContent) ?? '').replace(/\s+/g, '').trim(); - }), - ) - ).filter(Boolean); - - const required = [...centerReportEntryData.coreTableHeaders]; - const hits: string[] = []; - const missing: string[] = []; - for (const name of required) { - const normalized = name.replace(/\s+/g, ''); - const found = headers.some((h) => h.includes(normalized) || normalized.includes(h)); - if (found) hits.push(name); - else missing.push(name); - } - - // 至少命中一半核心列(维度展开后个别列可能隐藏/改名) - expect( - hits.length, - `表头缺少「${missing.join('、')}」,当前表头:${headers.join('、')}`, - ).toBeGreaterThanOrEqual(Math.max(3, Math.ceil(required.length / 2))); - - for (const name of hits.slice(0, 4)) { - const header = this.locators.tableHeader(name); - await header.scrollIntoViewIfNeeded().catch(() => undefined); - await this.scrollColumnIntoView(name).catch(() => undefined); - } - } - - async expectTableHorizontalScrollAndLayout(): Promise { - const { horizontalScrollAnchorColumn, horizontalScrollTargetColumn, horizontalScrollDragDelta } = - centerReportEntryData; - await this.tableScrollLayoutControls.expectHorizontalScrollAndLayout( - horizontalScrollAnchorColumn, - horizontalScrollTargetColumn, - horizontalScrollDragDelta, - (name) => this.locators.report.locator('.ant-table-thead').getByText(name, { exact: true }), - ); - } - - async expectSummaryVisible(): Promise { - for (const keyword of centerReportEntryData.summaryKeywords) { - await expect(this.locators.summaryText(keyword)).toBeVisible(); - } - for (const keyword of centerReportEntryData.outboundSummaryKeywords) { - await expect(this.locators.summaryText(keyword)).toHaveCount(0); - } - } - - async expectEmptyList(): Promise { - await this.expectMockEmptyList(); - } - - /** mock 反例:仅校验当前页空列表/空态且页面无报错。 */ - async expectMockEmptyList(): Promise { - await this.assertPageHealthy('mock反例查询后'); - const rowCount = await this.locators.dataRows.count(); - const emptyLocator = this.locators.report.locator('.ant-empty, .ant-table-placeholder').first(); - const emptyVisible = await emptyLocator.isVisible().catch(() => false); - if (rowCount === 0 || emptyVisible) { - if (emptyVisible) { - await expect(emptyLocator).toBeVisible(); - } - return; - } - expect(rowCount, 'mock反例查询后当前页仍显示业务数据行').toBe(0); - } - - async searchWithMockFromConfig(): Promise { - await this.searchWithMockNotExistProduct(); - } - - async searchWithMockNotExistProduct(): Promise { - await this.fillProductName(centerReportEntryData.mockNotExistProduct); - await this.search(); - } - - async expectColumnSortCase(columnName: string): Promise { - const sortable = await this.isColumnSortable(columnName); - if (!sortable) { - const required = centerReportEntryData.sortColumnCases.some((c) => c.column === columnName); - if (required) { - expect(sortable, `列「${columnName}」应为可排序列,但当前无排序控件`).toBeTruthy(); - } - test.skip(true, `${columnName} 列当前不可排序`); - } - await this.expectColumnSortToggle(columnName); - } - - async expandFirstRow(): Promise { - await expect(this.locators.firstExpandIcon).toBeVisible({ timeout: 15_000 }); - await this.click(this.locators.firstExpandIcon, '展开首行'); - await this.waitVisible(this.locators.expandedRow, 15_000); - } - - async collapseFirstRow(): Promise { - await this.click(this.locators.firstExpandIcon, '收起首行'); - await expect(this.locators.expandedRow).toBeHidden({ timeout: 10_000 }); - } - - async expectExpandedRow(): Promise { - await expect(this.locators.expandedRow).toBeVisible(); - const expandBtn = this.locators.firstExpandIcon; - const expanded = - (await expandBtn.getAttribute('aria-label')) === '关闭行' || - (await expandBtn.evaluate((el) => el.classList.contains('ant-table-row-expand-icon-expanded'))); - expect(expanded).toBeTruthy(); - } - - async expectSubTableHeaders(): Promise { - const requiredHeaders = centerReportEntryData.subTableHeaders.filter((h) => h !== '查看'); - for (const name of requiredHeaders) { - await expect(this.locators.expandedSubTableHeader(name)).toBeVisible(); - } - const viewAction = this.locators.subTableViewButton(0); - await expect(viewAction).toBeVisible(); - } - - async getSubTableInboundOrderNo(rowIndex = 0): Promise { - const subTable = this.locators.expandedRow.locator('.ant-table').first(); - const headers = (await subTable.locator('.ant-table-thead th').allTextContents()).map((h) => - h.replace(/\s+/g, '').trim(), - ); - const orderColIndex = headers.findIndex((h) => h.includes('入库单号') || h === '单号'); - const subRow = this.locators.subTableRow(rowIndex); - await expect(subRow).toBeVisible(); - const cellIndex = orderColIndex >= 0 ? orderColIndex : 0; - const orderNo = (await subRow.locator('td').nth(cellIndex).innerText()).trim(); - expect(orderNo.length).toBeGreaterThan(0); - return orderNo; - } - - async clickSubTableViewDetail(rowIndex = 0): Promise { - const orderNo = await this.getSubTableInboundOrderNo(rowIndex); - const expanded = this.locators.expandedRow; - const scrollHosts = expanded.locator('.ant-table-body, .ant-table-content, .ant-table-scroll'); - const hostCount = await scrollHosts.count(); - for (let i = 0; i < hostCount; i += 1) { - await scrollHosts - .nth(i) - .evaluate((el) => { - (el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth; - }) - .catch(() => undefined); - } - const viewBtn = this.locators.subTableViewButton(rowIndex); - await viewBtn.scrollIntoViewIfNeeded().catch(() => undefined); - await expect(viewBtn, '子表「查看」按钮应可见').toBeVisible({ timeout: 10_000 }); - await this.click(viewBtn, '点击子表查看'); - return orderNo; - } - - async expectQueryWithoutError(): Promise { - expect(await this.hasVisibleErrorNotice(), '查询后页面出现错误提示').toBeFalsy(); - } - - private async hasVisibleErrorNotice(): Promise { - const errorLocators = [ - this.page.locator('.ant-message-error'), - this.page.locator('.ant-notification-notice-error'), - this.locators.report.locator('.ant-message-error'), - this.locators.report.locator('.ant-notification-notice-error'), - ]; - for (const locator of errorLocators) { - if ((await locator.count()) > 0 && (await locator.first().isVisible())) { - return true; - } - } - return false; - } - - private async isOrderNoVisibleOnDetailSurfaces(orderNo: string): Promise { - const popupPages = this.page.context().pages().filter((p) => p !== this.page); - const surfaces: Array> = [this.page, ...popupPages]; - for (const surface of surfaces) { - if ((await surface.getByText(orderNo).count()) > 0) { - return true; - } - } - for (const frame of this.page.frames()) { - if ((await frame.getByText(orderNo).count()) > 0) { - return true; - } - } - return false; - } - - private async hasDetailNavigationSignal( - tabCountBefore: number, - urlBefore: string, - popup: Page | null, - ): Promise { - if (popup) return true; - if (this.page.url() !== urlBefore) return true; - if ((await this.page.locator('.ant-tabs-tab').count()) > tabCountBefore) return true; - - const topModal = await this.page.locator('.ant-modal:visible').count(); - const topDrawer = await this.page.locator('.ant-drawer-open').count(); - const reportModal = await this.locators.report.locator('.ant-modal:visible').count(); - const detailIframe = await this.page - .locator('iframe[src*="stock"], iframe[src*="inStock"], iframe[src*="psi"], iframe[src*="detail"]') - .count(); - if (topModal + topDrawer + reportModal + detailIframe > 0) return true; - - for (const keyword of centerReportEntryData.detailTabKeywords) { - if ((await this.page.locator('.ant-tabs-tab-active').filter({ hasText: keyword }).count()) > 0) { - return true; - } - } - return false; - } - - async expectViewDetailNavigation(orderNo: string, navigationContext?: { - tabCountBefore: number; - urlBefore: string; - popup: Page | null; - }): Promise { - expect(await this.hasVisibleErrorNotice()).toBeFalsy(); - - const ctx = navigationContext ?? { - tabCountBefore: await this.page.locator('.ant-tabs-tab').count(), - urlBefore: this.page.url(), - popup: null as Page | null, - }; - - await expect - .poll(async () => { - if (await this.hasVisibleErrorNotice()) return false; - const navigated = await this.hasDetailNavigationSignal(ctx.tabCountBefore, ctx.urlBefore, ctx.popup); - const hasOrderNo = await this.isOrderNoVisibleOnDetailSurfaces(orderNo); - return navigated && hasOrderNo; - }, { timeout: 20_000 }) - .toBeTruthy(); - } - - async openSubTableViewDetail(rowIndex = 0): Promise { - const tabCountBefore = await this.page.locator('.ant-tabs-tab').count(); - const urlBefore = this.page.url(); - const popupPromise = this.page.waitForEvent('popup', { timeout: 20_000 }).catch(() => null); - - const orderNo = await this.clickSubTableViewDetail(rowIndex); - const popup = await popupPromise; - - await this.expectViewDetailNavigation(orderNo, { tabCountBefore, urlBefore, popup }); - } - - async expectSubTableQuantityMatchesMainRow(sample?: EntryListSampleRow): Promise { - const rowSample = sample ?? (await this.extractSampleRow(0)); - const mainQtyText = rowSample.inboundQty; - if (!mainQtyText?.trim()) { - await this.guardedSkip('首行缺少入库数量,跳过主子表数量抽样'); - return; - } - const mainQty = parseFloat(mainQtyText.replace(/,/g, '')); - expect(Number.isFinite(mainQty)).toBeTruthy(); - - const subTable = this.locators.expandedRow.locator('.ant-table').first(); - const subHeaders = (await subTable.locator('.ant-table-thead th').allTextContents()).map((h) => - h.replace(/\s+/g, '').trim(), - ); - const qtyColIndex = subHeaders.findIndex( - (h) => h.includes('入库数量') || (h.includes('数量') && !h.includes('单号')), - ); - - const subRows = subTable.locator('tbody tr'); - expect(await subRows.count()).toBeGreaterThan(0); - - if (qtyColIndex < 0) { - expect(mainQty).toBeGreaterThan(0); - return; - } - - let sum = 0; - const rowCount = await subRows.count(); - for (let i = 0; i < rowCount; i += 1) { - const text = (await subRows.nth(i).locator('td').nth(qtyColIndex).innerText()).trim(); - const num = parseFloat(text.replace(/,/g, '')); - if (Number.isFinite(num)) sum += num; - } - expect(Math.abs(sum - mainQty)).toBeLessThanOrEqual(0.01 * Math.max(mainQty, 1)); - } - - private async getRowCellByHeader(row: Locator, headerName: string): Promise { - const index = await this.getColumnIndex(headerName); - return (await row.locator('td').nth(index).innerText()).trim(); - } - - private async getColumnIndex(columnName: string): Promise { - const headers = await this.locators.tableHeaderCells().allTextContents(); - const normalized = headers.map((h) => h.replace(/\s+/g, '').trim()); - const target = columnName.replace(/\s+/g, ''); - const index = normalized.findIndex((h) => h === target || h.startsWith(target)); - expect(index, `未找到列:${columnName}`).toBeGreaterThanOrEqual(0); - return index; - } - - async scrollColumnIntoView(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - const sorter = this.locators.columnSorter(columnName); - const body = wrapper.locator('.ant-table-body, .ant-table-content').first(); - await body.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - - const isSorterVisible = async (): Promise => - (await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false)); - - if (!(await isSorterVisible())) { - await body.evaluate((el) => { - (el as HTMLElement).scrollLeft = 0; - }).catch(() => undefined); - const delta = centerReportEntryData.horizontalScrollDragDelta ?? 320; - for (let attempt = 0; attempt < 10 && !(await isSorterVisible()); attempt += 1) { - await body.evaluate((el, dx) => { - (el as HTMLElement).scrollLeft += dx; - }, delta).catch(() => undefined); - } - } - - const scrollHosts = wrapper.locator( - '.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content', - ); - await scrollHosts.first().waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - await scrollHosts.evaluateAll((containers, searchTokens) => { - const root = containers[0]?.closest('.ant-table'); - const headers = root?.querySelectorAll('.ant-table-thead th') ?? []; - const tokens = searchTokens as string[]; - let target: HTMLElement | undefined; - let bestScore = 0; - for (const cell of Array.from(headers)) { - if (!cell.querySelector('.ant-table-column-sorter')) continue; - if ((cell as HTMLElement).offsetParent === null) continue; - const clone = cell.cloneNode(true) as HTMLElement; - clone - .querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon') - .forEach((el) => el.remove()); - const normalized = (clone.textContent ?? '').replace(/\s+/g, '').trim(); - if (!normalized) continue; - for (const token of tokens) { - if (!token) continue; - let score = 0; - if (normalized === token) score = token.length + 1000; - else if (normalized.includes(token)) score = token.length; - else if (normalized.length >= 4 && token.includes(normalized)) score = normalized.length; - if (score > bestScore) { - bestScore = score; - target = cell as HTMLElement; - } - } - } - if (!target) return; - const thRect = target.getBoundingClientRect(); - for (const container of containers) { - const host = container as HTMLElement; - const hostRect = host.getBoundingClientRect(); - if (thRect.left < hostRect.left + 96) { - host.scrollLeft -= hostRect.left - thRect.left + 96; - } else if (thRect.right > hostRect.right - 96) { - host.scrollLeft += thRect.right - hostRect.right + 96; - } - } - }, columnHeaderMatchTokens(columnName)); - await sorter.scrollIntoViewIfNeeded().catch(() => undefined); - } - - async isColumnSortable(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - await wrapper - .locator('.ant-table-tbody, .ant-table-body') - .first() - .waitFor({ state: 'attached', timeout: 20_000 }) - .catch(() => undefined); - const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false); - if (!hasSorter) { - await this.assertPageHealthy(`列「${columnName}」不可排序`); - } - return hasSorter; - } - - async clickColumnSort(columnName: string, order?: 'ascend' | 'descend'): Promise { - await this.scrollColumnIntoView(columnName); - const header = this.locators.columnHeaderCell(columnName); - const sorter = this.locators.columnSorter(columnName); - if ((await sorter.count()) > 0) { - await sorter.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - } else { - await this.waitVisible(header, 15_000); - } - const label = order === 'descend' ? '降序' : order === 'ascend' ? '升序' : '排序'; - await test.step(`点击${columnName}列${label}`, async () => { - await clickColumnSortControl({ - header, - sorter, - up: this.locators.columnSorterUp(columnName), - down: this.locators.columnSorterDown(columnName), - order, - }); - }); - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - } - - async recoverTableData(): Promise { - await this.resetQuickFilter(); - await this.search(); - } - - async expectColumnSortToggle(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - await expectColumnSortByDom({ - wrapper, - columnName, - waitAfterClick: async () => { - await this.locators.report - .locator('.ant-spin-spinning') - .waitFor({ state: 'hidden', timeout: 12_000 }) - .catch(() => undefined); - }, - }); - } - - async expectColumnSortIndicatorActive(columnName: string): Promise { - const header = this.locators.columnHeaderCell(columnName); - const sorter = this.locators.columnSorter(columnName); - await expect - .poll(async () => { - const ariaSort = await header.getAttribute('aria-sort'); - if (ariaSort === 'ascending' || ariaSort === 'descending') return true; - const sorterActive = await sorter.evaluate((el) => el.classList.contains('active')); - const upActive = await this.locators - .columnSorterUp(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - const downActive = await this.locators - .columnSorterDown(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - return sorterActive || upActive || downActive; - }, { timeout: 15_000 }) - .toBeTruthy(); - } - - async openColumnSettings(): Promise { - const panel = this.locators.columnSettingsPanel(); - if (await panel.isVisible().catch(() => false)) { - return; - } - const triggerCandidates = [this.locators.columnSettingsTrigger]; - let trigger = await findUsableColumnSettingsTrigger(triggerCandidates); - if (!trigger) { - await revealColumnSettingsTriggerByScroll({ - scroll: this.tableScrollLayoutControls, - tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(), - triggerCandidates, - horizontalScrollTargetColumn: centerReportEntryData.horizontalScrollTargetColumn, - horizontalScrollDragDelta: centerReportEntryData.horizontalScrollDragDelta, - scrollColumnIntoView: async (column) => { - await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined); - }, - }); - trigger = await findUsableColumnSettingsTrigger(triggerCandidates); - } - if (!trigger) { - await this.guardedSkip('列设置齿轮不可见'); - } - await test.step('打开列设置', async () => { - await trigger!.click({ force: true }); - }); - await this.waitVisible(panel, 10_000); - } - - async expectColumnSettingsPanel(): Promise { - const panel = this.locators.columnSettingsPanel(); - await expect(panel).toBeVisible(); - await expect(panel.getByText('列展示')).toBeVisible(); - await expect(panel.getByText('字段设置')).toBeVisible(); - await expect(panel.getByText('重置')).toBeVisible(); - } - - async closeColumnSettingsPanel(): Promise { - await this.columnSettingsControls.closePanel(); - } - - async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise { - const row = this.locators.columnSettingsCheckboxRow(columnName); - await expect(row).toBeVisible(); - const checkbox = row.locator('input[type="checkbox"]').first(); - if ((await checkbox.isChecked()) !== visible) { - await row.click(); - } - } - - async expectTableHeaderVisible(columnName: string, visible: boolean): Promise { - const header = this.locators.columnHeaderCell(columnName).or(this.locators.tableHeader(columnName)); - if (visible) { - await expect(header.first()).toBeVisible({ timeout: 10_000 }); - } else { - await expect(this.locators.tableHeader(columnName)).toHaveCount(0); - } - } - - async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise { - const checkbox = this.locators.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first(); - if (checked) { - await expect(checkbox).toBeChecked(); - } else { - await expect(checkbox).not.toBeChecked(); - } - } - - async pickToggleColumnName(): Promise { - for (const name of centerReportEntryData.columnSettingsToggleCandidates) { - if (await this.locators.tableHeader(name).isVisible().catch(() => false)) { - return name; - } - } - return centerReportEntryData.columnSettingsToggleCandidates[0]; - } - - /** 场景:取消勾选非核心列后表头隐藏,再勾选后表头恢复。 */ - async executeColumnSettingsToggleRestoreTest(): Promise { - await executeColumnSettingsToggleRestore({ - pickToggleColumnName: () => this.pickToggleColumnName(), - openColumnSettings: () => this.openColumnSettings(), - setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible), - closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(), - expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible), - expectSettingsCheckboxChecked: (name, checked) => - this.expectSettingsCheckboxChecked(name, checked), - }); - } - - async expectPaginationTotal(): Promise { - await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/); - } - - /** 场景1:列表无数据或不足 2 页时 skip。 */ - async skipIfInsufficientForPage2(): Promise { - await skipPage2IfInsufficient({ - ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), - getDataRowCount: async () => this.locators.dataRows.count(), - hasPaginationItem: async (pageNum) => (await this.locators.paginationItem(pageNum).count()) > 0, - }); - } - - /** 场景1:切换至第2页标准流程。 */ - async executePage2SwitchTest(): Promise { - await this.skipIfInsufficientForPage2(); - const first = await this.getFirstRowText(); - await this.goToPage(2); - await this.expectActivePage(2); - const second = await this.getFirstRowText(); - expect(first, '第1页首行应有数据').toBeTruthy(); - expect(second, '第2页首行应有数据').toBeTruthy(); - expect(second).not.toEqual(first); - } - - async goToPage(pageNum: number): Promise { - if (pageNum === 2) { - await this.skipIfInsufficientForPage2(); - } - const item = this.locators.paginationItem(pageNum); - if ((await item.count()) === 0) { - if (pageNum === 2) { - await this.skipWhenDataInsufficient(`查询结果不足${pageNum}页`); - } else { - await this.guardedSkip(`总页数不足 ${pageNum} 页`); - } - } - await this.locators.paginationTotal.scrollIntoViewIfNeeded(); - await test.step(`跳转第 ${pageNum} 页`, async () => { - await item.evaluate((node) => { - const link = node.querySelector('a') as HTMLElement | null; - (link ?? (node as HTMLElement)).click(); - }); - }); - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - await expect - .poll(async () => (await this.locators.paginationActiveItem().innerText()).trim(), { timeout: 20_000 }) - .toBe(String(pageNum)); - if ((await this.locators.dataRows.count()) === 0) { - if (pageNum === 2) { - await this.skipWhenDataInsufficient(`第 ${pageNum} 页无数据行`); - } else { - await this.guardedSkip(`第 ${pageNum} 页无数据行`); - } - } - } - - async expectActivePage(pageNum: number): Promise { - await expect(this.locators.paginationActiveItem()).toHaveText(String(pageNum), { timeout: 5_000 }); - } - - /** 场景2:列表无数据时 skip。 */ - async skipIfInsufficientForPageSizeChange(): Promise { - await skipPageSizeIfInsufficient({ - ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), - getDataRowCount: async () => this.locators.dataRows.count(), - }); - } - - /** 场景2:切换每页条数标准流程。 */ - async executePageSizeChangeTest( - fromLabel: string, - toLabel: string, - maxRows: number, - ): Promise { - await this.skipIfInsufficientForPageSizeChange(); - await this.changePageSize(fromLabel, toLabel); - await this.expectVisibleRowCountAtMost(maxRows); - } - - async changePageSize(fromLabel: string, toLabel: string): Promise { - const changer = this.locators.report.locator('.ant-pagination-options-size-changer').first(); - if ((await changer.count()) > 0) { - await this.click(changer, '打开每页条数'); - } else { - await this.click(this.locators.report.getByText(fromLabel, { exact: true }), '打开每页条数'); - } - const option = this.page - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') - .filter({ hasText: toLabel }) - .first() - .or( - this.locators.report - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') - .filter({ hasText: toLabel }) - .first(), - ); - if ((await option.count()) === 0) { - await this.click( - this.page.getByText(toLabel, { exact: true }).or(this.locators.report.getByText(toLabel, { exact: true })), - `选择${toLabel}`, - ); - } else { - await this.click(option, `选择${toLabel}`); - } - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - } - - async expectFirstRowContains(keyword: string | RegExp): Promise { - if (typeof keyword === 'string' && /\d{4}[-/]\d{1,2}([-/]\d{1,2})?\s*~\s*\d{4}/.test(keyword)) { - return; - } - await expect(this.locators.dataRows.first()).toBeVisible({ timeout: 15_000 }); - await expect(this.locators.dataRows.first()).toContainText(keyword); - } - - async exportReport(): Promise { - await executeExportReport({ - page: this.page, - clickExport: () => this.click(this.locators.exportBtn, '点击导出'), - hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(), - assertHealthy: () => this.assertPageHealthy('导出后'), - }); - } - - /** @deprecated 使用 exportReport() */ - async exportReportDirect(): Promise { - await this.exportReport(); - } - - async expectProductNameEmpty(): Promise { - await expect(this.locators.productName).toHaveValue(''); - } - - async expectProductName(value: string): Promise { - await expect(this.locators.productName).toHaveValue(value); - } } + +export const CENTER_REPORT_ENTRY_PATH = centerReportEntryData.reportPath; diff --git a/pages/spd/center-report-shared.page.ts b/pages/spd/center-report-shared.page.ts index fcb9f301af85ce98a071f23e16c74b7f5e21d702..74ea3894c7dfb39ba98e86b408a8037cfa4718c9 100644 --- a/pages/spd/center-report-shared.page.ts +++ b/pages/spd/center-report-shared.page.ts @@ -340,10 +340,10 @@ export class CenterReportSharedPage extends BasePage { const loadOnce = async () => { // 先等 iframe 挂上,避免在壳页上空等 searchBtn const iframe = this.page.locator(iframeCssSelector(this.data.iframeSrc)).first(); - await iframe.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 25_000) }); + await iframe.waitFor({ state: 'attached', timeout: timeouts.pageReady }); const frame = await iframe.elementHandle().then((handle) => handle?.contentFrame() ?? null); await frame?.waitForLoadState('domcontentloaded').catch(() => undefined); - await this.waitVisible(this.locators.searchBtn, Math.max(timeouts.pageReady, 25_000)); + await this.waitVisible(this.locators.searchBtn, timeouts.pageReady); await this.waitVisible(this.locators.resetBtn, timeouts.expect * 2); if (this.data.hasAdvancedFilter) { await this.waitVisible(this.locators.advancedFilter, timeouts.expect).catch(() => undefined); @@ -354,10 +354,9 @@ export class CenterReportSharedPage extends BasePage { .catch(() => undefined); // 默认不等表就绪:显式 search() 再等,避免 open+search 双轮询 if (this.data.waitTableOnOpen) { - await this.waitForTableReady().catch(() => undefined); + await this.waitForTableReady({ health: 'ui' }).catch(() => undefined); } } - await this.assertPageHealthy('页面加载完成'); }; await retryReportLoad( @@ -366,19 +365,24 @@ export class CenterReportSharedPage extends BasePage { await this.page.reload({ waitUntil: 'domcontentloaded' }).catch(() => undefined); await this.goto(this.data.reportPath); }, - (ms) => this.page.waitForTimeout(ms), + (ms) => new Promise((resolve) => setTimeout(resolve, ms)), ); } async search(): Promise { - await this.waitForSearchLoadingDone(); - await waitAntSpinGone(this.locators.report); + if (await isSearchButtonLoading(this.locators.searchBtn)) { + await this.waitForSearchLoadingDone(); + } + if (await isAntSpinVisible(this.locators.report)) { + await waitAntSpinGone(this.locators.report); + } if (!(await this.locators.searchBtn.isVisible().catch(() => false))) { await this.ensureQuickFilterExpanded().catch(() => undefined); } + await this.assertBeforeBusinessAction('查询'); await this.click(this.locators.searchBtn, '点击查询'); if (this.data.isChart) { - await this.waitForSearchLoadingDone(); + await waitSearchButtonIdle(this.locators.searchBtn, timeouts.searchLoading, timeouts.tableIdleGrace); return; } await this.waitForTableReady(); @@ -390,7 +394,7 @@ export class CenterReportSharedPage extends BasePage { } /** 等待列表查询结束:loading 结束且有数据行或空态。 */ - async waitForTableReady(): Promise { + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { if (this.page.isClosed()) return; await this.locators.tableBody .waitFor({ state: 'attached', timeout: timeouts.tableAttached }) @@ -415,12 +419,13 @@ export class CenterReportSharedPage extends BasePage { if (await isAntSpinVisible(this.locators.report.locator('.ant-table-wrapper'))) return 'loading'; const anyRow = await this.locators.tableBody.locator('tr').count().catch(() => 0); if (anyRow > 0) return 'ready'; - // 行或空态晚一拍渲染时不要立刻判空;超过 8 秒仍无 spin 再放行,避免无占位符的空表卡满超时 - if (Date.now() - started < 8_000) return 'loading'; + // 无 spin、按钮也不在 loading 时只留短窗口,避免空表卡满超时 + if (Date.now() - started < timeouts.tableIdleGrace) return 'loading'; return 'idle'; - }, Math.max(timeouts.tableReady, 35_000)); + }, timeouts.tableReady); if (!this.page.isClosed()) { - await this.assertPageHealthy('查询完成后'); + const mode = options?.health ?? 'business'; + await this.assertAfterTableWait(mode === 'ui' ? '列表就绪' : '查询完成后', mode); } } @@ -1603,7 +1608,9 @@ export class CenterReportSharedPage extends BasePage { await executeExportReport({ page: this.page, prepareRowSelection: options?.prepareRowSelection, - clickExport: () => this.click(exportBtn, `点击${tableLabel}导出`), + prepareBusinessAction: () => this.assertBeforeBusinessAction('导出'), + clickExport: () => + this.click(exportBtn, `点击${tableLabel}导出`, { operation: 'write' }), hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(), assertHealthy: () => this.assertPageHealthy('导出后'), }); @@ -1664,7 +1671,7 @@ export class CenterReportSharedPage extends BasePage { async triggerBatchPrint(): Promise { const dialogPromise = this.page.waitForEvent('dialog', { timeout: 10_000 }).catch(() => null); - await this.click(this.locators.batchPrintBtn, '点击批量打印'); + await this.click(this.locators.batchPrintBtn, '点击批量打印', { operation: 'write' }); const dialog = await dialogPromise; if (dialog) { await dialog.dismiss().catch(() => undefined); @@ -1734,7 +1741,7 @@ export class CenterReportSharedPage extends BasePage { } await expect(printBtn, '打印按钮应可点击').toBeEnabled({ timeout: 10_000 }); const dialogPromise = this.page.waitForEvent('dialog', { timeout: 10_000 }).catch(() => null); - await this.click(printBtn, '点击打 印'); + await this.click(printBtn, '点击打 印', { operation: 'write' }); const dialog = await dialogPromise; if (dialog) { await dialog.dismiss().catch(() => undefined); @@ -2754,11 +2761,7 @@ export class CenterReportSharedPage extends BasePage { await this.selectQuickFormSelect(label, value); return; } - if (await input.isVisible().catch(() => false)) { - await this.fill(input, value, `填写${label}`); - return; - } - await input.fill(value, { force: true, timeout: 8_000 }).catch(() => undefined); + await this.fill(input, value, `填写${label}`); return; } if ( @@ -2791,11 +2794,7 @@ export class CenterReportSharedPage extends BasePage { await this.selectQuickFormSelect(label, value); return; } - if (await byId.isVisible().catch(() => false)) { - await this.fill(byId, value, `填写${label}`); - } else { - await byId.fill(value, { force: true, timeout: 8_000 }).catch(() => undefined); - } + await this.fill(byId, value, `填写${label}`); } } } @@ -3931,7 +3930,7 @@ export class CenterReportSharedPage extends BasePage { if (!found) { await this.toggleFilterArea(); await this.toggleFilterArea(); - await this.assertPageHealthy('收起展开筛选区后'); + await this.assertUiReady('收起展开筛选区后'); return; } diff --git a/pages/spd/center-report-sheet.page.ts b/pages/spd/center-report-sheet.page.ts index fc5e0fafabc1b00bccf4d34b7496bc93b36c671c..124e6f31d2b350bcfb82342151e9120c86fbc49d 100644 --- a/pages/spd/center-report-sheet.page.ts +++ b/pages/spd/center-report-sheet.page.ts @@ -1,1116 +1,102 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { test, type Page } from '@playwright/test'; -import { centerReportSheetData, type CenterReportSheetColumnKey, centerReportSheetDrawerFields } from '../../data/center-report-sheet.data'; import { timeouts } from '../../config/timeouts.config'; -import { BasePage } from '../../core/base.page'; -import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; -import { CenterReportSheetLocators } from '../../locators/spd/center-report-sheet.locator'; -import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator'; -import { DownloadCenterComponent } from '../components/download-center.component'; -import { DrawerFormControls } from './helpers/drawer-form-controls.helper'; -import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper'; +import { centerReportSheetData } from '../../data/center-report-sheet.data'; import { - ColumnSettingsControls, - executeColumnSettingsToggleRestore, - findUsableColumnSettingsTrigger, - revealColumnSettingsTriggerByScroll, -} from './helpers/column-settings.helper'; -import { retryReportLoad } from './helpers/report-load-retry.helper'; -import { TableScrollLayoutControls } from './helpers/table-scroll-layout.helper'; -import { - detailSheetSummaryMatch, -} from '../../data/center-report-shared.types'; -import { ReportSummaryMatchControls } from './helpers/report-summary-match.helper'; -import { - expectAdvancedDrawerSyncedFromQuick as assertDrawerSyncedFromQuick, - type DrawerQuickSyncContext, -} from './helpers/drawer-quick-sync.helper'; -import { - deptReportSheetComboQuickFields, standardProductNameDateSyncFields, - type DeptQuickFieldDef, + deptReportSheetComboQuickFields, } from '../../data/dept-report-field-defs'; -import { executeExportReport } from './helpers/export-report.helper'; -import { skipIfInsufficientForPageSizeChange as skipPageSizeIfInsufficient } from './helpers/pagination-pagesize.helper'; -import { skipIfInsufficientForPage2 as skipPage2IfInsufficient } from './helpers/pagination-page2.helper'; - -export const CENTER_REPORT_SHEET_PATH = - '/spd/form/centerReport/report/centerPages/sheet'; - -export type ListSampleRow = Partial>; - -export class CenterReportSheetPage extends BasePage { - private readonly locators: CenterReportSheetLocators; - - protected pageHealthScope() { - return { page: this.page, roots: [this.locators.report] }; - } - - private readonly drawerControls: DrawerFormControls; - private readonly columnSettingsControls: ColumnSettingsControls; - private readonly tableScrollLayoutControls: TableScrollLayoutControls; - private readonly downloadCenter: DownloadCenterComponent; - private readonly summaryMatchControls: ReportSummaryMatchControls; - +import type { SampleRow } from './center-report-shared.page'; +import { CenterReportSharedPage } from './center-report-shared.page'; + +const drawerSyncQuickFields = deptReportSheetComboQuickFields.filter( + (field) => field.key === 'productName' || field.key === 'accountDate', +); + +/** + * 中心库出入库明细。通用查询、排序、列设置、分页、导出走 CenterReportSharedPage, + * 这里只保留明细页的组合查询与快捷区带入抽屉。 + */ +export class CenterReportSheetPage extends CenterReportSharedPage { constructor(page: Page) { - super(page); - this.locators = new CenterReportSheetLocators(page); - this.downloadCenter = new DownloadCenterComponent(page); - this.drawerControls = new DrawerFormControls({ - report: this.locators.report, - drawerTitle: this.locators.drawerTitle, - drawerFormItem: (label) => this.locators.drawerFormItem(label), - click: (locator, stepName) => this.click(locator, stepName), - }); - this.columnSettingsControls = new ColumnSettingsControls({ - columnSettingsPanel: () => this.locators.columnSettingsPanel(), - columnSettingsTrigger: this.locators.columnSettingsTrigger, - reportBody: this.locators.report.locator('body'), - clickOutsideTarget: this.locators.productName, - tableClickTarget: this.locators.tableBody, - }); - this.tableScrollLayoutControls = new TableScrollLayoutControls({ - page: this.page, - report: this.locators.report, - }); - this.summaryMatchControls = new ReportSummaryMatchControls({ - profile: detailSheetSummaryMatch, - reportBody: this.locators.report, - dataRows: this.locators.dataRows, - paginationTotal: this.locators.paginationTotal, - sampleRowIndex: centerReportSheetData.sampleRowIndex, - searchAndExtractSample: (rowIndex) => this.searchAndExtractSample(rowIndex), - extractSampleRow: (rowIndex) => this.extractSampleRow(rowIndex), - resetQuickFilter: () => this.resetQuickFilter(), - fillProductName: (value) => this.fillProductName(value), - openAdvancedFilter: () => this.openAdvancedFilter(), - fillBatchNo: (value) => this.fillBatchNo(value), - fillDrawerTextByLabel: (label, value) => this.fillDrawerTextByLabel(label, value), - applyAdvancedFilter: () => this.applyAdvancedFilter(), - fillQuickDateRange: (_label, start, end) => this.fillAccountDateRange(start, end), - guardedSkip: (reason) => this.guardedSkip(reason), - }); - } - - async open(): Promise { - await this.goto(CENTER_REPORT_SHEET_PATH); - await this.expectLoaded(); - } - - async expectLoaded(): Promise { - await retryReportLoad( - async () => { - await this.page - .locator(iframeCssSelector('centerPages/sheet')) - .first() - .waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) }); - await this.waitVisible(this.locators.searchBtn, timeouts.pageReady); - if (!(await this.locators.productName.isVisible().catch(() => false))) { - const toggle = this.locators.report.getByText(/^(展开|收起)$/).first(); - if (await toggle.isVisible().catch(() => false)) { - await toggle.click().catch(() => undefined); - } - } - await this.waitVisible(this.locators.productName, timeouts.pageReady); - await this.waitVisible(this.locators.advancedFilter); - await this.assertPageHealthy('页面加载完成'); - }, - () => this.goto(CENTER_REPORT_SHEET_PATH), - (ms) => this.page.waitForTimeout(ms), - ); - } - - async expectTitle(): Promise { - await expect(this.page).toHaveTitle(/FLI\+/); - } - - async search(): Promise { - await this.click(this.locators.searchBtn, '点击查询'); - await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 }); - await this.assertPageHealthy('查询完成后'); - } - - /** 默认查询并从列表提取首行样本(不写死查询条件)。 */ - async searchAndExtractSample(rowIndex = centerReportSheetData.sampleRowIndex): Promise { - await this.search(); - return this.extractSampleRow(rowIndex); - } - - /** 按表头列名读取指定行,返回 columnKey → 单元格文本。 */ - async extractSampleRow(rowIndex = 0): Promise { - const headers = await this.locators.tableHeaderCells().allTextContents(); - const row = this.locators.dataRows.nth(rowIndex); - await expect(row).toBeVisible({ timeout: 15_000 }); - const cells = await row.locator('td').allTextContents(); - - const normalizedHeaders = headers.map((h) => h.replace(/\s+/g, '').trim()); - const byHeader: Record = {}; - for (let i = 0; i < Math.min(normalizedHeaders.length, cells.length); i += 1) { - const key = normalizedHeaders[i]; - if (!key || key === '序号') { - continue; - } - const value = cells[i]?.trim(); - if (value) { - byHeader[key] = value; - } - } - - const sample: ListSampleRow = {}; - for (const [columnKey, headerLabel] of Object.entries(centerReportSheetData.columns)) { - const aliases = centerReportSheetData.columnAliases[columnKey as CenterReportSheetColumnKey] ?? []; - const labels = [headerLabel, ...aliases]; - for (const label of labels) { - const normalizedLabel = label.replace(/\s+/g, ''); - const value = byHeader[normalizedLabel]; - if (value) { - sample[columnKey as CenterReportSheetColumnKey] = value; - break; - } - } - } - expect(Object.keys(sample).length).toBeGreaterThan(0); - return sample; - } - - getSampleValue(sample: ListSampleRow, key: CenterReportSheetColumnKey): string { - const value = sample[key]?.trim(); - expect(value, `列表首行缺少列:${centerReportSheetData.columns[key]}`).toBeTruthy(); - return value!; - } - - async fillProductName(value: string): Promise { - await this.fill(this.locators.productName, value, '填写产品名称'); - } - - async resetQuickFilter(): Promise { - await this.click(this.locators.resetBtn, '点击重置'); - } - - async resetAdvancedDrawer(): Promise { - await this.click(this.locators.drawerResetBtn, '抽屉重置'); - } - - async expectDrawerBatchNoEmpty(): Promise { - await expect(this.locators.batchNoInput).toHaveValue(''); - } - - async expectProductNameEmpty(): Promise { - await expect(this.locators.productName).toHaveValue(''); - } - - async searchWithMockFromConfig(): Promise { - await this.searchWithMockNotExistProduct(); - } - - async searchWithMockNotExistProduct(): Promise { - await this.fillProductName(centerReportSheetData.mockNotExistProduct); - await this.search(); - } - - async expectColumnSortCase(columnName: string): Promise { - const sortable = await this.isColumnSortable(columnName); - if (!sortable) { - const required = centerReportSheetData.sortColumnCases.some((c) => c.column === columnName); - if (required) { - expect(sortable, `列「${columnName}」应为可排序列,但当前无排序控件`).toBeTruthy(); - } - test.skip(true, `${columnName} 列当前不可排序`); - } - await this.expectColumnSortToggle(columnName); - } - - async expectEmptyList(): Promise { - await this.expectMockEmptyList(); - } - - /** mock 反例:仅校验当前页空列表/空态且页面无报错。 */ - async expectMockEmptyList(): Promise { - await this.assertPageHealthy('mock反例查询后'); - const rowCount = await this.locators.dataRows.count(); - const emptyLocator = this.locators.report.locator('.ant-empty, .ant-table-placeholder').first(); - const emptyVisible = await emptyLocator.isVisible().catch(() => false); - if (rowCount === 0 || emptyVisible) { - if (emptyVisible) { - await expect(emptyLocator).toBeVisible(); - } - return; - } - expect(rowCount, 'mock反例查询后当前页仍显示业务数据行').toBe(0); - } - - async getFirstRowText(): Promise { - await expect - .poll(async () => this.locators.dataRows.count(), { timeout: 20_000 }) - .toBeGreaterThan(0); - const rows = this.locators.dataRows; - const count = await rows.count(); - let row = rows.first(); - for (let i = 0; i < Math.min(count, 20); i += 1) { - const candidate = rows.nth(i); - if (await candidate.isVisible().catch(() => false)) { - row = candidate; - break; - } - } - await row.scrollIntoViewIfNeeded().catch(() => undefined); - return ((await row.innerText().catch(() => '')) ?? '').trim(); - } - - async getColumnValues(columnName: string, maxRows = 8): Promise { - const headers = (await this.locators.tableHeaderCells().allTextContents()).map((h) => - h.replace(/\s+/g, '').trim(), - ); - const normalizedTarget = columnName.replace(/\s+/g, ''); - const columnIndex = headers.findIndex((header) => { - return header.includes(normalizedTarget) || normalizedTarget.includes(header); - }); - if (columnIndex < 0) { - return []; - } - const rowCount = Math.min(await this.locators.dataRows.count(), maxRows); - const values: string[] = []; - for (let i = 0; i < rowCount; i += 1) { - const cell = this.locators.dataRows.nth(i).locator('td').nth(columnIndex); - values.push((await cell.innerText()).replace(/\s+/g, ' ').trim()); - } - return values; - } - - async getRowSignatures(maxRows = 8): Promise { - const rowCount = Math.min(await this.locators.dataRows.count(), maxRows); - const signatures: string[] = []; - for (let i = 0; i < rowCount; i += 1) { - signatures.push((await this.locators.dataRows.nth(i).innerText()).replace(/\s+/g, ' ').trim()); - } - return signatures; - } - - /** 场景1:列表无数据或不足 2 页时 skip。 */ - async skipIfInsufficientForPage2(): Promise { - await skipPage2IfInsufficient({ - ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), - getDataRowCount: async () => this.locators.dataRows.count(), - hasPaginationItem: async (pageNum) => (await this.locators.paginationItem(pageNum).count()) > 0, - }); - } - - /** 场景1:切换至第2页标准流程。 */ - async executePage2SwitchTest(): Promise { - await this.skipIfInsufficientForPage2(); - const first = await this.getFirstRowText(); - await this.goToPage(2); - await this.expectActivePage(2); - const second = await this.getFirstRowText(); - expect(first, '第1页首行应有数据').toBeTruthy(); - expect(second, '第2页首行应有数据').toBeTruthy(); - expect(second).not.toEqual(first); - } - - async goToPage(pageNum: number): Promise { - if (pageNum === 2) { - await this.skipIfInsufficientForPage2(); - } - const item = this.locators.paginationItem(pageNum); - if ((await item.count()) === 0) { - if (pageNum === 2) { - await this.skipWhenDataInsufficient(`查询结果不足${pageNum}页`); - } else { - await this.guardedSkip(`总页数不足 ${pageNum} 页`); - } - } - await this.locators.paginationTotal.scrollIntoViewIfNeeded(); - await test.step(`跳转第 ${pageNum} 页`, async () => { - await item.evaluate((node) => { - const link = node.querySelector('a') as HTMLElement | null; - (link ?? (node as HTMLElement)).click(); - }); - }); - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - await expect - .poll(async () => (await this.locators.paginationActiveItem().innerText()).trim(), { timeout: 20_000 }) - .toBe(String(pageNum)); - if ((await this.locators.dataRows.count()) === 0) { - if (pageNum === 2) { - await this.skipWhenDataInsufficient(`第 ${pageNum} 页无数据行`); - } else { - await this.guardedSkip(`第 ${pageNum} 页无数据行`); - } - } - } - - async expectActivePage(pageNum: number): Promise { - await expect(this.locators.paginationActiveItem()).toHaveText(String(pageNum), { timeout: 5_000 }); - } - - /** 场景2:列表无数据时 skip。 */ - async skipIfInsufficientForPageSizeChange(): Promise { - await skipPageSizeIfInsufficient({ - ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), - getDataRowCount: async () => this.locators.dataRows.count(), - }); - } - - /** 场景2:切换每页条数标准流程。 */ - async executePageSizeChangeTest( - fromLabel: string, - toLabel: string, - maxRows: number, - ): Promise { - await this.skipIfInsufficientForPageSizeChange(); - await this.changePageSize(fromLabel, toLabel); - await this.expectVisibleRowCountAtMost(maxRows); - } - - async changePageSize(fromLabel: string, toLabel: string): Promise { - const changer = this.locators.report.locator('.ant-pagination-options-size-changer').first(); - if ((await changer.count()) > 0) { - await this.click(changer, '打开每页条数'); - } else { - await this.click(this.locators.report.getByText(fromLabel, { exact: true }), '打开每页条数'); - } - const option = this.page - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') - .filter({ hasText: toLabel }) - .first() - .or( - this.locators.report - .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') - .filter({ hasText: toLabel }) - .first(), - ); - if ((await option.count()) === 0) { - await this.click( - this.page.getByText(toLabel, { exact: true }).or(this.locators.report.getByText(toLabel, { exact: true })), - `选择${toLabel}`, - ); - } else { - await this.click(option, `选择${toLabel}`); - } - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - } - - async expectVisibleRowCountAtMost(maxRows: number): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - const primaryRows = wrapper.locator( - '.ant-table-container > .ant-table-body .ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row):not(.ant-table-expanded-row)', - ); - await expect - .poll( - async () => { - const primary = await primaryRows.count(); - if (primary > 0) return primary; - return this.locators.dataRows.count(); - }, - { timeout: 8_000 }, - ) - .toBeLessThanOrEqual(maxRows); - } - - async openAdvancedFilter(): Promise { - await this.click(this.locators.advancedFilter, '打开高级筛选'); - await this.waitVisible(this.locators.drawerTitle); - } - - async expectAdvancedDrawer(): Promise { - await expect(this.locators.drawerTitle).toHaveText('高级筛选'); - } - - private async dismissDrawerOverlay(): Promise { - await this.drawerControls.dismissOverlay(); - } - - private async ensureAdvancedDrawerOpen(): Promise { - if (!(await this.locators.drawerTitle.isVisible())) { - await this.openAdvancedFilter(); - } - } - - async applyAdvancedFilter(): Promise { - await this.ensureAdvancedDrawerOpen(); - await this.dismissDrawerOverlay(); - await this.click(this.locators.drawerFilterBtn, '点击筛选'); - await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 }); - } - - async fillBatchNo(value: string): Promise { - await this.fill(this.locators.batchNoInput, value, '填写批号'); - } - - async fillAccountDateRange(start: string, end: string): Promise { - await this.click(this.locators.accountDateStart, '打开记账日期选择器'); - const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last(); - await panel.waitFor({ state: 'visible', timeout: 10_000 }); - await this.pickDateInPanel(panel, start); - await this.pickDateInPanel(panel, end); - await this.page.keyboard.press('Escape'); - } - - private async pickDateInPanel(panel: Locator, date: string): Promise { - const cell = panel.locator(`td[title="${date}"]`); - for (let i = 0; i < 12 && (await cell.count()) === 0; i += 1) { - await panel.locator('.ant-picker-header-prev-btn').first().click(); - } - await cell.last().click(); - } - - private drawerLabelFor(key: CenterReportSheetColumnKey, fallback: string): string { - return centerReportSheetData.drawerLabels[key] ?? fallback; - } - - async selectDrawerCascader(label: string, value: string): Promise { - await this.drawerControls.selectCascader(label, value); - } - - async selectQuickBillType(optionText: string): Promise { - const select = this.locators.quickFormItem('单据类型').locator('.ant-select'); - await this.drawerControls.selectAntSelect(select, '单据类型', optionText); - await this.page.keyboard.press('Escape'); - } - - async selectQuickBillTypeFirst(): Promise { - const select = this.locators.quickFormItem('单据类型').locator('.ant-select'); - await this.drawerControls.selectAntSelect(select, '单据类型'); - await this.page.keyboard.press('Escape'); - } - - async selectDrawerSelect(label: string, optionText?: string): Promise { - await this.drawerControls.selectDrawerSelect(label, optionText); - } - - async fillDrawerDateRange(label: string, start: string, end: string): Promise { - const item = this.locators.drawerFormItem(label); - if ((await item.count()) === 0) { - return; - } - await item.scrollIntoViewIfNeeded(); - const startInput = item.getByPlaceholder('开始时间').first(); - if ((await startInput.count()) === 0) { - return; - } - await this.click(startInput, `打开${label}选择器`); - const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last(); - await panel.waitFor({ state: 'visible', timeout: 10_000 }); - await this.pickDateInPanel(panel, start); - await this.pickDateInPanel(panel, end); - await this.dismissDrawerOverlay(); - } - - async fillDrawerTextByLabel(label: string, value: string): Promise { - const item = this.locators.drawerFormItem(label); - if ((await item.count()) === 0) { - return; - } - await item.scrollIntoViewIfNeeded(); - const input = item.locator('input:not([type="hidden"])').first(); - if ((await input.count()) === 0) { - return; - } - await this.fill(input, value, `高级筛选填写${label}`); - } - - /** 按配置顺序填写高级筛选抽屉全部可用字段(样本有值才填)。 */ - async fillAdvancedDrawerFromSample(sample: ListSampleRow): Promise { - await this.ensureAdvancedDrawerOpen(); - for (const field of centerReportSheetDrawerFields) { - await this.ensureAdvancedDrawerOpen(); - const value = sample[field.key]?.trim(); - if (!value) { - continue; - } - const label = field.label ?? this.drawerLabelFor(field.key, centerReportSheetData.columns[field.key]); - - switch (field.type) { - case 'text': - if (field.key === 'batchNo') { - await this.fillBatchNo(value); - } else { - await this.fillDrawerTextByLabel(label, value); - } - break; - case 'select': - await this.selectDrawerSelect(label, value); - break; - case 'cascader': - await this.selectDrawerCascader(label, value); - break; - case 'dateRange': { - const dateOnly = value.split(/\s+/)[0]; - await this.fillDrawerDateRange(label, dateOnly, dateOnly); - break; - } - default: - break; - } - } + super(page, centerReportSheetData); } - async fillAdvancedDrawerAllFields(sample: ListSampleRow): Promise { - await this.fillAdvancedDrawerFromSample(sample); - } - - /** STAT-IOD-004:快捷区 + 高级筛选全量条件组合查询(条件均来自列表样本) */ - async searchWithAllConditions(): Promise { + /** STAT-IOD-004:快捷区 + 高级筛选全量条件(样本有值才写)。 */ + async searchWithAllConditions(): Promise { test.setTimeout(timeouts.comboTest); const sample = await this.searchAndExtractSample(); - - const productKeyword = - sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName'); - await this.fillProductName(productKeyword); - - const accountDate = this.getSampleValue(sample, 'accountDate'); - const dateOnly = accountDate.split(/\s+/)[0]; - await this.fillAccountDateRange(dateOnly, dateOnly); - - if (sample.billType?.trim()) { - await this.selectQuickBillType(sample.billType); - } - + await this.applyQuickFields(sample, deptReportSheetComboQuickFields, { onlyIfPresent: true }); await this.openAdvancedFilter(); await this.fillAdvancedDrawerFromSample(sample); - await this.applyAdvancedFilter(); return sample; } - private drawerQuickSyncContext(): DrawerQuickSyncContext { - return { - productName: this.locators.productName, - quickFormItem: (label) => this.locators.quickFormItem(label), - drawerFormItem: (label) => this.locators.drawerFormItem(label), - getQuickDateRangeDisplay: (label) => this.getQuickDateRangeDisplay(label), - getQuickSelectValue: (label) => this.getQuickSelectValue(label), - }; + async fillAdvancedDrawerFromSample(sample: SampleRow): Promise { + const fields = (this.data.drawerFields ?? []).filter((field) => field.type !== 'date'); + await this.fillDrawerFieldDefs(sample, fields, { onlyIfPresent: true }); + await this.fillOperationDate(sample.operationDate); } - async getQuickDateRangeDisplay(label: string): Promise { - const item = this.locators.quickFormItem(label); - const picker = item.locator('.ant-picker').first(); - if ((await picker.count()) === 0) return ''; - return (await picker.innerText()).replace(/\s+/g, ' ').trim(); - } - - async getQuickSelectValue(label: string): Promise { - const item = this.locators.quickFormItem(label); - const selected = item.locator('.ant-select-selection-item').first(); - if ((await selected.count()) === 0) return ''; - return (await selected.innerText()).trim(); + /** 填入与抽屉重叠的快捷区字段(产品名称、记账日期)。 */ + async fillQuickFiltersForDrawerSync(sample: SampleRow): Promise { + await this.applyQuickFields(sample, drawerSyncQuickFields, { onlyIfPresent: true }); } - /** 填入与抽屉重叠的快捷区字段(产品名称、记账日期),供联动带入校验。 */ - async fillQuickFiltersForDrawerSync(sample: ListSampleRow): Promise { - const productKeyword = - sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName'); - await this.fillProductName(productKeyword); - - const accountDate = sample.accountDate?.trim(); - if (accountDate) { - const dateOnly = accountDate.split(/\s+/)[0].replace(/\//g, '-'); - await this.fillAccountDateRange(dateOnly, dateOnly); - } - } - - async expectAdvancedDrawerSyncedFromQuick(sample: ListSampleRow): Promise { - const quickFields: readonly DeptQuickFieldDef[] = deptReportSheetComboQuickFields.filter( - (field) => field.key === 'productName' || field.key === 'accountDate', - ); - await assertDrawerSyncedFromQuick( - this.drawerQuickSyncContext(), + async expectAdvancedDrawerSyncedFromQuick(sample: SampleRow): Promise { + await super.expectAdvancedDrawerSyncedFromQuick( sample, - quickFields, + drawerSyncQuickFields, standardProductNameDateSyncFields, ); } - async expectCombinedFilterResult(sample: ListSampleRow): Promise { + async expectCombinedFilterResult(sample: SampleRow): Promise { await this.expectQueryWithoutError(); if (sample.productCode) { await this.expectFirstRowContains(sample.productCode); } else if (sample.productName) { await this.expectFirstRowContains(sample.productName); } - if (sample.batchNo) { - await this.expectFirstRowContains(sample.batchNo); - } - if (sample.campus) { - await this.expectFirstRowContains(sample.campus); - } - if (sample.barcode) { - await this.expectFirstRowContains(sample.barcode); - } - if (sample.orderNo) { - await this.expectFirstRowContains(sample.orderNo); - } - if (sample.sourceTarget) { - await this.expectFirstRowContains(sample.sourceTarget); - } - if (sample.billMode) { - await this.expectFirstRowContains(sample.billMode); - } - if (sample.operator) { - await this.expectFirstRowContains(sample.operator); - } - if (sample.consumableCategory) { - await this.expectFirstRowContains(sample.consumableCategory); - } - if (sample.supplier) { - await this.expectFirstRowContains(sample.supplier); + for (const key of [ + 'batchNo', + 'campus', + 'barcode', + 'orderNo', + 'sourceTarget', + 'billMode', + 'operator', + 'consumableCategory', + 'supplier', + ] as const) { + const value = sample[key]?.trim(); + if (value && value !== '--' && value !== '-') { + await this.expectFirstRowContains(value); + } } await this.expectSummaryVisible(); await this.expectPaginationTotal(); } - async expectTableHeaders(): Promise { - for (const name of ['产品编号', '产品名称', '批号', '进院供应商', '院区名称']) { - await expect(this.locators.tableHeader(name)).toBeVisible(); - } - } - - /** 横向拖拉滚动列表,并校验表头/表体列无变形错位或异常间隙。 */ - async expectTableHorizontalScrollAndLayout(): Promise { - const { horizontalScrollAnchorColumn, horizontalScrollTargetColumn, horizontalScrollDragDelta } = - centerReportSheetData; - await this.tableScrollLayoutControls.expectHorizontalScrollAndLayout( - horizontalScrollAnchorColumn, - horizontalScrollTargetColumn, - horizontalScrollDragDelta, - (name) => this.locators.report.locator('.ant-table-thead').getByText(name, { exact: true }), - ); - } - - /** 点击指定列升序或降序按钮(Ant Design:▲ / ▼ 分别激活)。 */ - async clickColumnSort(columnName: string, order?: 'ascend' | 'descend'): Promise { - await this.scrollColumnIntoView(columnName); - const header = this.locators.columnHeaderCell(columnName); - const sorter = this.locators.columnSorter(columnName); - if ((await sorter.count()) > 0) { - await sorter.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - } else { - await this.waitVisible(header, 15_000); - } - const label = order === 'descend' ? '降序' : order === 'ascend' ? '升序' : '排序'; - await test.step(`点击${columnName}列${label}`, async () => { - await clickColumnSortControl({ - header, - sorter, - up: this.locators.columnSorterUp(columnName), - down: this.locators.columnSorterDown(columnName), - order, - }); - }); - await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); - await this.locators.tableBody.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined); - } - - /** 横向滚动使目标列排序按钮进入可点击区域(同步滚动表头与表体)。 */ - async scrollColumnIntoView(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - const sorter = this.locators.columnSorter(columnName); - const body = wrapper.locator('.ant-table-body, .ant-table-content').first(); - await body.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - - const isSorterVisible = async (): Promise => - (await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false)); - - if (!(await isSorterVisible())) { - await body.evaluate((el) => { - (el as HTMLElement).scrollLeft = 0; - }).catch(() => undefined); - const delta = centerReportSheetData.horizontalScrollDragDelta ?? 320; - for (let attempt = 0; attempt < 10 && !(await isSorterVisible()); attempt += 1) { - await body.evaluate((el, dx) => { - (el as HTMLElement).scrollLeft += dx; - }, delta).catch(() => undefined); - } - } - - const scrollHosts = wrapper.locator( - '.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content', - ); - await scrollHosts.first().waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined); - await scrollHosts.evaluateAll((containers, searchTokens) => { - const root = containers[0]?.closest('.ant-table'); - const headers = root?.querySelectorAll('.ant-table-thead th') ?? []; - const tokens = searchTokens as string[]; - let target: HTMLElement | undefined; - let bestScore = 0; - for (const cell of Array.from(headers)) { - if (!cell.querySelector('.ant-table-column-sorter')) continue; - if ((cell as HTMLElement).offsetParent === null) continue; - const clone = cell.cloneNode(true) as HTMLElement; - clone - .querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon') - .forEach((el) => el.remove()); - const normalized = (clone.textContent ?? '').replace(/\s+/g, '').trim(); - if (!normalized) continue; - for (const token of tokens) { - if (!token) continue; - let score = 0; - if (normalized === token) score = token.length + 1000; - else if (normalized.includes(token)) score = token.length; - else if (normalized.length >= 4 && token.includes(normalized)) score = normalized.length; - if (score > bestScore) { - bestScore = score; - target = cell as HTMLElement; - } - } - } - if (!target) { - return; - } - const thRect = target.getBoundingClientRect(); - for (const container of containers) { - const host = container as HTMLElement; - const hostRect = host.getBoundingClientRect(); - if (thRect.left < hostRect.left + 96) { - host.scrollLeft -= hostRect.left - thRect.left + 96; - } else if (thRect.right > hostRect.right - 96) { - host.scrollLeft += thRect.right - hostRect.right + 96; - } - } - }, columnHeaderMatchTokens(columnName)); - await sorter.scrollIntoViewIfNeeded().catch(() => undefined); - } - - /** 当前页面指定列是否带排序控件。只扫 DOM,不等 sticky 表头定位器。 */ - async isColumnSortable(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - await wrapper - .locator('.ant-table-tbody, .ant-table-body') - .first() - .waitFor({ state: 'attached', timeout: 20_000 }) - .catch(() => undefined); - const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false); - if (!hasSorter) { - await this.assertPageHealthy(`列「${columnName}」不可排序`); - } - return hasSorter; - } - - /** 重置快捷区并重新查询,恢复列表数据(排序后偶发空表时使用)。 */ - async recoverTableData(): Promise { - await this.resetQuickFilter(); - await this.search(); - } - - /** 读取当前页表头中带 .ant-table-column-sorter 的可见列名(去重)。 */ - async getVisibleSortableColumnNames(): Promise { - const headerRow = this.locators.report.locator('.ant-table-thead tr').last(); - const headers = headerRow.locator('th'); - const count = await headers.count(); - const sortable: string[] = []; - - for (let i = 0; i < count; i += 1) { - const th = headers.nth(i); - const sorter = th.locator('.ant-table-column-sorter'); - if ((await sorter.count()) === 0 || !(await sorter.isVisible())) { - continue; - } - const titleLocator = th.locator('.ant-table-column-title').first(); - const title = - (await titleLocator.count()) > 0 - ? (await titleLocator.innerText()).trim() - : (await th.innerText()).replace(/\s+/g, ' ').trim(); - if (!title || title === '序号' || sortable.includes(title)) { - continue; - } - sortable.push(title); - } - - if (sortable.length === 0) { - for (const name of centerReportSheetData.sortableColumnCandidates) { - const sorter = this.locators.columnSorter(name); - if ((await sorter.count()) > 0 && (await sorter.isVisible())) { - sortable.push(name); - } - } - return sortable; - } - - const prioritized: string[] = []; - for (const name of centerReportSheetData.sortableColumnCandidates) { - if (sortable.includes(name)) { - prioritized.push(name); - } - } - for (const name of sortable) { - if (!prioritized.includes(name)) { - prioritized.push(name); - } - } - return prioritized; - } - - /** 单列排序:DOM 内点击,指示器或行序任一成立即通过。 */ - async expectColumnSortToggle(columnName: string): Promise { - const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); - await expectColumnSortByDom({ - wrapper, - columnName, - waitAfterClick: async () => { - await this.locators.report - .locator('.ant-spin-spinning') - .waitFor({ state: 'hidden', timeout: 12_000 }) - .catch(() => undefined); - }, - }); - } - - async expectColumnSortIndicatorActive(columnName: string): Promise { - const header = this.locators.columnHeaderCell(columnName); - const sorter = this.locators.columnSorter(columnName); - await expect - .poll(async () => { - const ariaSort = await header.getAttribute('aria-sort'); - if (ariaSort === 'ascending' || ariaSort === 'descending') { - return true; - } - const sorterActive = await sorter.evaluate((el) => el.classList.contains('active')); - const upActive = await this.locators - .columnSorterUp(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - const downActive = await this.locators - .columnSorterDown(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - return sorterActive || upActive || downActive; - }, { timeout: 15_000 }) - .toBeTruthy(); - } - - async expectColumnSorterVisible(columnName: string): Promise { - await expect(this.locators.columnSorter(columnName)).toBeVisible({ timeout: 15_000 }); - } - - /** 断言列处于升序或降序(匹配 .active / .on 指示器)。 */ - async expectColumnSortOrder(columnName: string, order: 'ascend' | 'descend'): Promise { - const indicator = - order === 'ascend' - ? this.locators.columnSorterUp(columnName) - : this.locators.columnSorterDown(columnName); - await expect(indicator).toHaveClass(/active|on/); - } - - /** 读取当前页指定列的单元格文本(按表头列名,默认最多 10 行以控时)。 */ - async getColumnTexts(columnName: string, maxRows = 10): Promise { - const index = await this.getColumnIndex(columnName); - const rows = this.locators.dataRows; - const count = Math.min(await rows.count(), maxRows); - const texts: string[] = []; - for (let i = 0; i < count; i += 1) { - texts.push((await rows.nth(i).locator('td').nth(index).innerText()).trim()); - } - return texts.filter((t) => t.length > 0); - } - - private async getColumnIndex(columnName: string): Promise { - const headers = await this.locators.tableHeaderCells().allTextContents(); - const normalized = headers.map((h) => h.replace(/\s+/g, '').trim()); - const target = columnName.replace(/\s+/g, ''); - const index = normalized.findIndex((h) => h === target || h.startsWith(target)); - expect(index, `未找到列:${columnName}`).toBeGreaterThanOrEqual(0); - return index; - } - - /** 排序后:指示器激活或列数据顺序相对 before 发生变化。 */ - async expectColumnSortApplied(columnName: string, beforeValues: string[]): Promise { - const afterValues = await this.getColumnTexts(columnName); - const upActive = await this.locators - .columnSorterUp(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - const downActive = await this.locators - .columnSorterDown(columnName) - .evaluate((el) => el.classList.contains('active') || el.classList.contains('on')); - const orderChanged = afterValues.join('|') !== beforeValues.join('|'); - expect(upActive || downActive || orderChanged).toBeTruthy(); - } - - async openColumnSettings(): Promise { - const panel = this.locators.columnSettingsPanel(); - if (await panel.isVisible().catch(() => false)) { - return; - } - const triggerCandidates = [this.locators.columnSettingsTrigger]; - let trigger = await findUsableColumnSettingsTrigger(triggerCandidates); - if (!trigger) { - await revealColumnSettingsTriggerByScroll({ - scroll: this.tableScrollLayoutControls, - tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(), - triggerCandidates, - horizontalScrollTargetColumn: centerReportSheetData.horizontalScrollTargetColumn, - horizontalScrollDragDelta: centerReportSheetData.horizontalScrollDragDelta, - scrollColumnIntoView: async (column) => { - await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined); - }, - }); - trigger = await findUsableColumnSettingsTrigger(triggerCandidates); - } - if (!trigger) { - await this.guardedSkip('列设置齿轮不可见'); - } - await test.step('打开列设置', async () => { - await trigger!.click({ force: true }); - }); - await this.waitVisible(panel, 10_000); - } - - async expectColumnSettingsPanel(): Promise { - const panel = this.locators.columnSettingsPanel(); - await expect(panel).toBeVisible(); - await expect(panel.getByText('列展示')).toBeVisible(); - await expect(panel.getByText('字段设置')).toBeVisible(); - await expect(panel.getByText('重置')).toBeVisible(); - } - - async expectTableHeaderVisible(columnName: string, visible: boolean): Promise { - const header = this.locators.columnHeaderCell(columnName); - if (visible) { - await expect(header).toBeVisible({ timeout: 10_000 }); - } else { - await expect(header).toBeHidden({ timeout: 10_000 }); - } - } - - /** 在列设置面板中切换指定列勾选状态。 */ - async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise { - await this.setSettingsCheckbox(columnName, visible); - } - - async setSettingsCheckbox(label: string, checked: boolean): Promise { - const row = this.locators.columnSettingsCheckboxRow(label); - await expect(row).toBeVisible(); - const checkbox = row.locator('input[type="checkbox"]').first(); - const isChecked = await checkbox.isChecked(); - if (isChecked !== checked) { - await row.click(); - } - } - - async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise { - const checkbox = this.locators.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first(); - if (checked) { - await expect(checkbox).toBeChecked(); - } else { - await expect(checkbox).not.toBeChecked(); - } - } - - async setSerialColumnVisibleInSettings(visible: boolean): Promise { - await this.setSettingsCheckbox(centerReportSheetData.columnSettingsSerialToggleLabel, visible); - } - - async expectSerialColumnVisible(visible: boolean): Promise { - await this.expectTableHeaderVisible(centerReportSheetData.columnSettingsSerialHeader, visible); - } - - async setMultipleColumnsVisibleInSettings(columnNames: string[], visible: boolean): Promise { - for (const name of columnNames) { - await this.setColumnVisibleInSettings(name, visible); - } - } - - async expectMultipleTableHeadersVisible(columnNames: string[], visible: boolean): Promise { - for (const name of columnNames) { - await this.expectTableHeaderVisible(name, visible); - } - } - - async resetColumnSettingsInPanel(): Promise { - await this.locators.columnSettingsPanel().getByText('重置', { exact: true }).click(); - } - - async closeColumnSettingsPanel(): Promise { - await this.columnSettingsControls.closePanel(); - } - - /** 选取当前表头可见、且适合 toggle 的非核心列名。 */ - async pickToggleColumnName(): Promise { - for (const name of centerReportSheetData.columnSettingsToggleCandidates) { - if (await this.locators.columnHeaderCell(name).isVisible()) { - return name; - } - } - return centerReportSheetData.columnSettingsToggleCandidates[0]; - } - - /** 场景:取消勾选非核心列后表头隐藏,再勾选后表头恢复。 */ - async executeColumnSettingsToggleRestoreTest(): Promise { - await executeColumnSettingsToggleRestore({ - pickToggleColumnName: () => this.pickToggleColumnName(), - openColumnSettings: () => this.openColumnSettings(), - setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible), - closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(), - expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible), - expectSettingsCheckboxChecked: (name, checked) => - this.expectSettingsCheckboxChecked(name, checked), - }); - } - - /** 选取多个当前可见的非核心列(用于多列调整场景)。 */ - async pickMultipleToggleColumnNames(count = 2): Promise { - const picked: string[] = []; - for (const name of centerReportSheetData.columnSettingsToggleCandidates) { - if (await this.locators.columnHeaderCell(name).isVisible()) { - picked.push(name); - } - if (picked.length >= count) { - break; - } - } - expect(picked.length).toBeGreaterThanOrEqual(Math.min(count, 1)); - return picked; - } - - async expectSummaryVisible(): Promise { - for (const keyword of detailSheetSummaryMatch.metricLabels) { - await expect(this.locators.summaryText(keyword)).toBeVisible(); - } - } - - /** STAT-IOD-006:按首行条件查询 → 核验首行数据与底部汇总一致。 */ - async expectSummaryMatchesSampleRow( - rowIndex = centerReportSheetData.sampleRowIndex, - ): Promise { - await this.summaryMatchControls.expectSummaryMatchesSampleRow(rowIndex, { firstRowOnly: true }); - } - - async expectPaginationTotal(): Promise { - await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/); - } - - async expectFirstRowContains(keyword: string | RegExp): Promise { - if (typeof keyword === 'string' && /\d{4}[-/]\d{1,2}([-/]\d{1,2})?\s*~\s*\d{4}/.test(keyword)) { - return; - } - const firstRow = this.locators.dataRows.first(); - await expect(firstRow).toBeVisible({ timeout: 15_000 }); - await expect(firstRow).toContainText(keyword); - } - - async exportReport(): Promise { - await executeExportReport({ - page: this.page, - clickExport: () => this.click(this.locators.exportBtn, '点击导出'), - hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(), - assertHealthy: () => this.assertPageHealthy('导出后'), - }); - } - - async expectQueryWithoutError(): Promise { - expect(await this.hasVisibleErrorNotice(), '查询后页面出现错误提示').toBeFalsy(); - } - - private async hasVisibleErrorNotice(): Promise { - const errorLocators = [ - this.page.locator('.ant-message-error'), - this.page.locator('.ant-notification-notice-error'), - this.locators.report.locator('.ant-message-error'), - this.locators.report.locator('.ant-notification-notice-error'), - ]; - for (const locator of errorLocators) { - if ((await locator.count()) > 0 && (await locator.first().isVisible())) { - return true; - } - } - return false; + private async fillOperationDate(raw?: string): Promise { + const value = raw?.trim(); + if (!value || value === '--' || value === '-') return; + const day = value.split(/\s+/)[0].replace(/\//g, '-'); + const item = this.locators.drawerFormItem('制单日期'); + if ((await item.count()) === 0) return; + const picker = item.locator('.ant-picker').first(); + if ((await picker.count()) === 0) return; + const isRange = (await picker.locator('input').count()) >= 2; + const typed = await this.tryFillQuickDateByTyping(picker, '制单日期', day, day, isRange); + if (typed) return; + await this.click(picker, '打开制单日期选择器'); + const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last(); + await panel.waitFor({ state: 'visible', timeout: 10_000 }); + await this.pickDateInPanel(panel, day); + await this.pickDateInPanel(panel, day); + await this.page.keyboard.press('Escape'); } } + +export const CENTER_REPORT_SHEET_PATH = centerReportSheetData.reportPath; diff --git a/pages/spd/flow-def.page.ts b/pages/spd/flow-def.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..94c5b5f7e7d96a37f8604b839fc6aa52e1171cf7 --- /dev/null +++ b/pages/spd/flow-def.page.ts @@ -0,0 +1,140 @@ +import { expect, type Page } from '@playwright/test'; + +import { flowData } from '../../data/flow.data'; +import { FlowDefLocators } from '../../locators/spd/flow-def.locator'; +import { WhFlowSharedPage } from './wh-flow-shared.page'; + +/** 审批流程设置:动作与取数;断言在 tests/spd/flow-def.spec.ts。 */ +export class FlowDefPage extends WhFlowSharedPage { + override readonly locators: FlowDefLocators; + + constructor(page: Page) { + super(page, { + path: flowData.defPath, + pathPart: 'workflow/flowSet', + group: flowData.menuGroup, + menu: flowData.menuDef, + forbidden: ['flowCase'], + }); + this.locators = new FlowDefLocators(page); + } + + override async waitContentReady(): Promise { + await this.locators.typeSearch + .or(this.locators.defaultFlow) + .or(this.locators.saveBtn) + .first() + .waitFor({ state: 'visible', timeout: 20_000 }) + .catch(() => undefined); + await this.waitSpinGone(); + } + + async typeNames(): Promise { + const items = this.locators.typeItems(); + const count = await items.count(); + const names: string[] = []; + if (count > 0) { + for (let i = 0; i < Math.min(count, 40); i += 1) { + const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim(); + if (text && !['流程名称', '审批流程设置'].includes(text)) names.push(text); + } + if (names.length) return names; + } + const fallback = this.page.locator('.ant-layout-sider, [class*="left"]').locator('span, div, li'); + const n = Math.min(await fallback.count(), 60); + for (let i = 0; i < n; i += 1) { + const text = ((await fallback.nth(i).innerText()) ?? '').trim(); + if (text && text.length <= 16 && /计划|单|申请|变更|调拨|准入/.test(text)) names.push(text); + } + return [...new Set(names)]; + } + + async selectedTypeName(): Promise { + const selected = this.locators.selectedType(); + if (await selected.isVisible().catch(() => false)) { + return ((await selected.innerText()) ?? '').split('\n')[0].trim(); + } + const names = await this.typeNames(); + return names[0] ?? ''; + } + + async filterTypes(keyword: string): Promise { + await this.fill(this.locators.typeSearch, keyword, '填写流程名称'); + await this.waitSettle(400); + } + + async clearTypeFilter(): Promise { + await this.fill(this.locators.typeSearch, '', '清空流程名称'); + await this.waitSettle(400); + } + + async clickType(name: string): Promise { + const item = this.locators.typeItems().filter({ hasText: name }).first() + .or(this.page.getByText(name, { exact: true }).first()); + await this.click(item, `选中类型 ${name}`); + await this.waitSpinGone(); + await this.waitSettle(400); + } + + async switchOtherType(): Promise { + const current = await this.selectedTypeName(); + const names = await this.typeNames(); + const other = names.find((name) => name && name !== current); + if (!other) { + await this.skipWhenDataInsufficient('业务类型仅 1 个'); + return null; + } + await this.clickType(other); + return other; + } + + async defaultFlowText(): Promise { + const region = this.page.locator('section, div').filter({ hasText: '默认审批流' }).first(); + return ((await region.innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + } + + async customRowCount(): Promise { + return this.getDataRowCount(); + } + + async addFlowRow(): Promise { + await this.click(this.locators.addFlowBtn, '新增流程'); + await this.waitSettle(300); + } + + async clickSave(): Promise { + await this.click(this.locators.saveBtn, '保 存', { operation: 'write' }); + await this.waitSettle(400); + } + + async clickReset(): Promise { + await this.click(this.locators.resetBtn, '重 置'); + await this.waitSpinGone(); + await this.waitSettle(400); + } + + async openFirstCondition(): Promise<{ before: string; opened: boolean }> { + const rows = await this.listRows(); + if ((await rows.count()) === 0) { + await this.skipWhenDataInsufficient('当前类型无自定义流'); + return { before: '', opened: false }; + } + const row = rows.first(); + const before = ((await row.innerText()) ?? '').replace(/\s/g, ''); + await this.click(this.rowAction(row, '编辑'), '条件编辑'); + const dialog = this.locators.dialog(); + const opened = await dialog.isVisible().catch(() => false); + await expect(dialog, '条件配置').toBeVisible({ timeout: 12_000 }); + return { before, opened }; + } + + async cancelCondition(): Promise { + await this.closeOverlay(); + } + + async firstCustomRowText(): Promise { + const rows = await this.listRows(); + if ((await rows.count()) === 0) return ''; + return ((await rows.first().innerText()) ?? '').replace(/\s/g, ''); + } +} diff --git a/pages/spd/flow-inst.page.ts b/pages/spd/flow-inst.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..611969f7133d748c0d8b44e89f415f2c3f7d43ce --- /dev/null +++ b/pages/spd/flow-inst.page.ts @@ -0,0 +1,127 @@ +import { expect, type Page } from '@playwright/test'; + +import { flowData } from '../../data/flow.data'; +import { FlowInstLocators } from '../../locators/spd/flow-inst.locator'; +import { WhFlowSharedPage } from './wh-flow-shared.page'; + +/** 审批流实例:动作与取数;断言在 tests/spd/flow-inst.spec.ts。 */ +export class FlowInstPage extends WhFlowSharedPage { + override readonly locators: FlowInstLocators; + + constructor(page: Page) { + super(page, { + path: flowData.instPath, + pathPart: 'workflow/flowCase', + group: flowData.menuGroup, + menu: flowData.menuInst, + forbidden: ['flowSet'], + }); + this.locators = new FlowInstLocators(page); + } + + override async waitContentReady(): Promise { + await this.locators.orderNo.or(this.locators.queryBtn).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitSpinGone(); + } + + async fillOrderNo(value: string): Promise { + await this.fill(this.locators.orderNo, value, '填写单据编号'); + } + + async fillSummary(value: string): Promise { + await this.fill(this.locators.summary, value, '填写摘要'); + } + + async queryByOrderNo(orderNo: string): Promise { + await this.fillOrderNo(orderNo); + await this.query(); + } + + async queryByStatus(status: string): Promise { + await this.selectFormOption('审批状态', status); + await this.query(); + } + + async queryByBizType(type: string): Promise { + await this.selectFormOption('业务类型', type); + await this.query(); + } + + async queryCombo(status: string, type: string): Promise { + await this.selectFormOption('审批状态', status); + await this.selectFormOption('业务类型', type); + await this.query(); + } + + async searchMockEmpty(): Promise { + await this.queryByOrderNo(flowData.mockFlow); + } + + async dateRangeValues(): Promise<{ start: string; end: string }> { + return { + start: ((await this.locators.startDate.inputValue().catch(() => '')) ?? '').trim(), + end: ((await this.locators.endDate.inputValue().catch(() => '')) ?? '').trim(), + }; + } + + async sampleSummaryKeyword(): Promise<{ keyword: string; usedMock: boolean }> { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 10); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), '摘要')).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') { + return { keyword: text.slice(0, Math.min(text.length, 8)), usedMock: false }; + } + } + return { keyword: flowData.mockFlow, usedMock: true }; + } + + async prefixTypeSamples(): Promise> { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 20); + const found: Array<{ prefix: string; type: string; expected: string }> = []; + const seen = new Set(); + for (let i = 0; i < count; i += 1) { + const orderNo = await this.tryCell(rows.nth(i), '单据编号'); + const type = await this.tryCell(rows.nth(i), '业务类型'); + for (const [prefix, expected] of Object.entries(flowData.prefixType)) { + if (orderNo.startsWith(prefix) && !seen.has(prefix)) { + seen.add(prefix); + found.push({ prefix, type, expected }); + } + } + } + return found; + } + + async openFirstDetail(): Promise<{ orderNo: string; layerText: string }> { + const row = await this.firstRow(); + const orderNo = await this.tryCell(row, '单据编号'); + await this.click(this.rowAction(row, '详情'), '详情'); + const layer = this.overlay().or(this.page.locator('.ant-page-header, .ant-descriptions')); + await expect(layer.first(), '审批详情').toBeVisible({ timeout: 15_000 }); + await this.waitSpinGone(); + const layerText = ((await this.page.locator('body').innerText()) ?? '').replace(/\s/g, ''); + return { orderNo, layerText }; + } + + async closeDetail(): Promise { + const back = this.locators.backBtn.first().or(this.page.getByRole('button', { name: /关\s*闭|返\s*回/ }).first()); + if (await back.isVisible().catch(() => false)) { + await this.click(back, '返回'); + } else { + await this.closeOverlay(); + } + await this.waitForTableReady(); + } + + async hasSaveOnDetail(): Promise { + const save = this.page.getByRole('button', { name: /保\s*存|提交审批|改条件/ }); + if (!(await save.first().isVisible().catch(() => false))) return false; + return save.first().isEnabled().catch(() => false); + } + + async firstOrderNo(): Promise { + return this.tryCell(await this.firstRow(), '单据编号'); + } +} diff --git a/pages/spd/helpers/export-report.helper.ts b/pages/spd/helpers/export-report.helper.ts index dd5b3f1e855658062867738ca3d41d04dbca06e1..310d9a97e7b6ede6289f79576df005c12b0729f4 100644 --- a/pages/spd/helpers/export-report.helper.ts +++ b/pages/spd/helpers/export-report.helper.ts @@ -66,7 +66,9 @@ export type ExportReportOptions = { prepareRowSelection?: () => Promise; clickExport: () => Promise; hasVisibleErrorNotice?: () => Promise; - /** 页面/系统健康检查(报错文案、接口失败)。 */ + /** 点击导出前:清空接口窗口并确认可交互(与 query 前置一致)。 */ + prepareBusinessAction?: () => Promise; + /** 导出完成后:业务接口 + 页面业务报错。 */ assertHealthy?: () => Promise; }; @@ -82,6 +84,10 @@ export async function executeExportReport(options: ExportReportOptions): Promise await options.page.keyboard.press('Escape').catch(() => undefined); + if (options.prepareBusinessAction) { + await options.prepareBusinessAction(); + } + const waitMs = Math.max(timeouts.exportResponse, 60_000); const exportResponsePromise = options.page .waitForResponse(isExportPostResponse, { timeout: waitMs }) diff --git a/pages/spd/helpers/report-load-retry.helper.ts b/pages/spd/helpers/report-load-retry.helper.ts index 19b01dc4bb6858280b063d4e46469e620b09c527..764e6d4b1ec4d67138889254591c645e85298505 100644 --- a/pages/spd/helpers/report-load-retry.helper.ts +++ b/pages/spd/helpers/report-load-retry.helper.ts @@ -1,13 +1,12 @@ -/** FineReport iframe 首次打开常超时或 503,加载失败后重新进页再等。 */ +/** 仅服务不可用时重试;普通超时立即失败。 */ -const RETRYABLE_LOAD = - /503|herpService|SERVICE_UNAVAILABLE|Timeout|iframe\[src|waiting for locator|not attached/i; +const RETRYABLE_LOAD = /503|herpService|SERVICE_UNAVAILABLE/i; export async function retryReportLoad( loadOnce: () => Promise, reload: () => Promise, wait: (ms: number) => Promise, - maxAttempts = 3, + maxAttempts = 2, ): Promise { let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { diff --git a/pages/spd/helpers/reuse-open.helper.ts b/pages/spd/helpers/reuse-open.helper.ts index d734351e2c0073f2a9c9019f5eaaa780d9d846f9..3027aa91791f0877c4f58bb6730db8c45b7f6531 100644 --- a/pages/spd/helpers/reuse-open.helper.ts +++ b/pages/spd/helpers/reuse-open.helper.ts @@ -4,8 +4,8 @@ import { env } from '../../../config/env.config'; import { applyCaseMeta } from '../../../core/case-meta'; import { attachFailureDiagnostics } from '../../../core/diagnostics'; import { attachRuntimeHealthGuard, getRuntimeHealthGuard } from '../../../core/runtime-health.util'; +import { slotForParallelIndex, storageStatePathForSlot } from '../../../utils/auth-pool.util'; import { ensureAuthStorageFresh, isPageOnLogin } from '../../../utils/auth-session'; -import { AUTH_STATE_PATH } from '../../../utils/path.util'; // 必须与用例侧同一 test 实例,describe 嵌套才生效 import { test } from '../../../fixtures'; @@ -24,6 +24,10 @@ export type ReuseOpenOptions = { /** * 同页复用 open:beforeAll 只导航一次,beforeEach 复位筛选。 + * + * ⚠️ 仅用于存量报表加速。组内共享同一 BrowserContext/Page,与「每用例独立 context」原则冲突。 + * 厂商 / 准入 / 医院资料等新模块禁止使用;应走 auth.setup + storageState 文件 + 默认 page fixture。 + * * 组内顺序执行,但前序失败不跳过后续(避免 serial 把无关用例整组未跑)。 * 失败后重建会话,避免脏页拖垮下一条。 * 用例内通过 getReport() 取 Page Object,勿使用默认 page fixture。 @@ -43,17 +47,21 @@ export function describeWithReuseOpen( let page: Page; let report: T; + let parallelIndex = 0; + const replaceSession = async (browser: Browser): Promise => { await page?.context().close().catch(() => undefined); - ({ page, report } = await createReusedReportSession(browser, factory, options)); + ({ page, report } = await createReusedReportSession(browser, factory, options, parallelIndex)); }; - test.beforeAll(async ({ browser }) => { + test.beforeAll(async ({ browser }, testInfo) => { test.setTimeout(120_000); - ({ page, report } = await createReusedReportSession(browser, factory, options)); + parallelIndex = testInfo.parallelIndex; + ({ page, report } = await createReusedReportSession(browser, factory, options, parallelIndex)); }); test.beforeEach(async ({ browser }, testInfo) => { + parallelIndex = testInfo.parallelIndex; applyCaseMeta(testInfo); if (!page || page.isClosed() || (await isPageOnLogin(page))) { await replaceSession(browser); @@ -106,11 +114,13 @@ export async function createReusedReportSession( browser: Browser, factory: (page: Page) => T, options: ReuseOpenOptions = {}, + parallelIndex = 0, ): Promise<{ page: Page; report: T }> { - await ensureAuthStorageFresh(browser, { honorForce: false }); + const slot = slotForParallelIndex(parallelIndex); + await ensureAuthStorageFresh(browser, { honorForce: false, slot }); const context = await browser.newContext({ baseURL: env.baseURL, - storageState: AUTH_STATE_PATH, + storageState: storageStatePathForSlot(slot), }); const page = await context.newPage(); attachRuntimeHealthGuard(page); diff --git a/pages/spd/reag-entry.page.ts b/pages/spd/reag-entry.page.ts index 34ceeb91d23a354edabe0afdac05c99c38b6ef6f..9d766294b800f6ceb121202cbce2294388d7c67b 100644 --- a/pages/spd/reag-entry.page.ts +++ b/pages/spd/reag-entry.page.ts @@ -120,7 +120,7 @@ export class ReagEntryPage extends ReconCostReportSharedPage { return this.listDataRows().count(); } - async waitForTableReady(): Promise { + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { await this.locators.tableBody.waitFor({ state: 'visible', timeout: timeouts.pageReady }).catch(() => undefined); await this.waitForSearchLoadingDone(); await waitUntilNotLoading(async () => { @@ -129,7 +129,8 @@ export class ReagEntryPage extends ReconCostReportSharedPage { if (await isTableEmptyVisible(this.locators.report)) return 'empty'; return 'idle'; }, timeouts.pageReady); - await this.assertPageHealthy('查询完成后'); + const mode = options?.health ?? 'business'; + await this.assertAfterTableWait(mode === 'ui' ? '列表就绪' : '查询完成后', mode); } async searchAndExtractSample(rowIndex = this.data.sampleRowIndex): Promise { @@ -322,6 +323,7 @@ export class ReagEntryPage extends ReconCostReportSharedPage { async exportReagEntryReport(): Promise { await executeExportReport({ page: this.page, + prepareBusinessAction: () => this.assertBeforeBusinessAction('导出'), clickExport: () => this.click(this.locators.exportBtn, '点击导出'), hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(), assertHealthy: () => this.assertPageHealthy('导出后'), diff --git a/pages/spd/recon-cost-report-shared.page.ts b/pages/spd/recon-cost-report-shared.page.ts index bd2a47f162fb647998f20fb8b503e562f83fc7c8..16de33dec33057915e5b31ad1e894ac57a4ea9bc 100644 --- a/pages/spd/recon-cost-report-shared.page.ts +++ b/pages/spd/recon-cost-report-shared.page.ts @@ -492,6 +492,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { await expect(btn).toBeVisible({ timeout: 10_000 }); await executeExportReport({ page: this.page, + prepareBusinessAction: () => this.assertBeforeBusinessAction('导出条码明细'), clickExport: () => this.click(btn, '点击导出条码明细'), hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(), assertHealthy: () => this.assertPageHealthy('导出条码明细后'), diff --git a/pages/spd/surgery-base-pkg.page.ts b/pages/spd/surgery-base-pkg.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..c73c96f613e07c61ace3ec0492ff8b0089a5392d --- /dev/null +++ b/pages/spd/surgery-base-pkg.page.ts @@ -0,0 +1,236 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { surgeryData, uniqBasePkgCode, uniqBasePkgName } from '../../data/surgery.data'; +import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page'; + +const META: SurgeryMeta = { + path: surgeryData.basePkgPath, + pathPart: surgeryData.basePkgPathPart, + menu: surgeryData.menuBasePkg, + siblings: [ + { name: surgeryData.menuOperType, pathPart: surgeryData.operTypePathPart }, + { name: surgeryData.menuStdPkg, pathPart: surgeryData.stdPkgPathPart }, + ], + forbidden: [surgeryData.operTypePathPart, surgeryData.stdPkgPathPart], +}; + +/** 基础包维护(CSV SURG-BASE-001..022)。 */ +export class SurgeryBasePkgPage extends SurgerySharedPage { + constructor(page: Page) { + super(page, META); + } + + // ---- 定位 ---- + + categorySearch() { + return this.page.getByPlaceholder('请输入分类名称').first(); + } + + categoryTree() { + return this.locators.leftTree; + } + + addCategoryBtn() { + return this.locators.button('新增分类').first(); + } + + addBasePkgBtn() { + return this.locators.button('新增基础包').first(); + } + + productNameInput() { + return this.filterById('#table_search_form_keyword'); + } + + specModelInput() { + return this.filterById('#table_search_form_goodsSpec'); + } + + mfrsNameInput() { + return this.filterById('#table_search_form_goodsMfrsName'); + } + + // ---- 左树分类 ---- + + /** CSV SURG-BASE-001:按分类名称过滤左树。 */ + async searchCategory(keyword: string): Promise { + await this.fill(this.categorySearch(), keyword, `填写分类名称「${keyword}」`); + await this.waitSettle(600); + } + + /** CSV SURG-BASE-003:打开新增分类弹窗。 */ + async openAddCategoryDialog(): Promise { + await this.click(this.addCategoryBtn(), '新增分类'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + } + + categoryNameField(): Locator { + return this.locators.byId('#form_item_kindName'); + } + + // ---- 筛选 ---- + + /** CSV SURG-BASE-006/007/008:按列首行样例筛选。 */ + async searchByProductName(): Promise { + return this.searchByFirstRow('#table_search_form_keyword', '产品名称'); + } + + async searchBySpecModel(): Promise { + return this.searchByFirstRow('#table_search_form_goodsSpec', '规格型号'); + } + + async searchByMfrsName(): Promise { + return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人'); + } + + /** CSV SURG-BASE-013:mock 反例空态。 */ + async searchMockEmpty(): Promise { + await this.fillFilterById('#table_search_form_keyword', surgeryData.mockBaseKeyword); + await this.query(); + } + + /** CSV SURG-BASE-010:过滤后重置并返回筛选框取值。 */ + async filterThenReset(): Promise<{ before: number; keyword: string; afterValue: string }> { + const keyword = await this.sampleCell('产品名称'); + await this.fillFilterById('#table_search_form_keyword', keyword); + await this.query(); + const before = await this.getTotalCount(); + await this.reset(); + const afterValue = await this.productNameInput().inputValue().catch(() => ''); + return { before, keyword, afterValue }; + } + + // ---- 新增基础包 ---- + + /** CSV SURG-BASE-014:打开新增基础包弹窗。 */ + async openAddBasePkgDialog(): Promise { + await this.click(this.addBasePkgBtn(), '新增基础包'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + } + + pkgNameField(): Locator { + return this.locators.byId('#form_item_pkgName'); + } + + pkgCategoryField(): Locator { + return this.locators.byId('#form_item_kindId'); + } + + pkgCodeField(): Locator { + return this.locators.byId('#form_item_pkgCode'); + } + + remarkField(): Locator { + return this.locators.byId('#form_item_remark'); + } + + /** 填名称/所属分类/包编码(CSV SURG-BASE-016 前置)。 */ + async fillAddForm(opts: { name: string; code: string; withCategory?: boolean }): Promise { + await this.fill(this.pkgNameField(), opts.name, '填写基础包名称'); + if (opts.withCategory !== false) { + await this.selectInDialog('所属分类'); + } + await this.fill(this.pkgCodeField(), opts.code, '填写包编码'); + } + + /** CSV SURG-BASE-015:新增后取消,返回 { total, newName }。 */ + async addThenCancel(): Promise<{ total: number; newName: string }> { + const total = await this.getTotalCount(); + const newName = uniqBasePkgName(); + await this.openAddBasePkgDialog(); + await this.fill(this.pkgNameField(), newName, '填写基础包名称'); + await this.closeDialogIfOpen(); + return { total, newName }; + } + + /** CSV SURG-BASE-016/019:新增成功,返回新增名称。 */ + async addSuccess(opts?: { name?: string; code?: string; doubleConfirm?: boolean }): Promise { + const name = opts?.name ?? uniqBasePkgName(); + const code = opts?.code ?? uniqBasePkgCode(); + await this.openAddBasePkgDialog(); + await this.fillAddForm({ name, code }); + if (opts?.doubleConfirm) { + // 连点两次:第二次可能因弹窗已关而落空,用短超时点击且忽略失败,只验证最终仅落一条 + const confirm = this.dialogButton('确 认').or(this.locators.confirmLikeButton(this.dialog())); + await confirm.click({ timeout: 1_500 }).catch(() => undefined); + await confirm.click({ timeout: 1_500 }).catch(() => undefined); + await this.waitSpinGone(); + } else { + await this.clickConfirm('确 认'); + } + await expect(this.dialog(), '新增成功后弹窗应关闭').toBeHidden({ timeout: 15_000 }); + await this.waitForTableReady(); + return name; + } + + /** CSV SURG-BASE-017/018:仅名称/不选分类的必填拦截。 */ + async addWithMissing(opts: { name?: string; code?: string; withCategory?: boolean }): Promise { + await this.openAddBasePkgDialog(); + if (opts.name) await this.fill(this.pkgNameField(), opts.name, '填写基础包名称'); + if (opts.withCategory) await this.selectInDialog('所属分类'); + if (opts.code) await this.fill(this.pkgCodeField(), opts.code, '填写包编码'); + await this.clickConfirm('确 认'); + } + + /** 新增后按名称检索并断言可见(用例层再断言)。 */ + async searchByName(name: string): Promise { + await this.fillFilterById('#table_search_form_keyword', name); + await this.query(); + } + + /** 后置清理:按名称检索后移除(写操作,二次确认)。 */ + async cleanupByName(name: string): Promise { + await this.searchByName(name); + const row = await this.rowWithAction('移 除'); + if (!row) return; + await this.click(this.rowAction(row, '移 除'), '移 除'); + const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog())); + if (await confirm.isVisible().catch(() => false)) { + await this.click(confirm, '确认移除', { operation: 'write' }); + await this.waitForTableReady(); + } else { + await this.page.keyboard.press('Escape').catch(() => undefined); + } + } + + // ---- 行内操作 ---- + + /** CSV SURG-BASE-020:首行编辑并回填,返回首行产品名称。 */ + async openEditFirstRow(): Promise { + const name = await this.tryCell(await this.firstRow(), '产品名称'); + await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + return name; + } + + /** CSV SURG-BASE-021:首行移除并取消,返回 { total, cancelShown }。 */ + async removeFirstRowThenCancel(): Promise<{ total: number; cancelShown: boolean }> { + const total = await this.getTotalCount(); + const row = await this.firstRow(); + await this.click(this.rowAction(row, '移 除'), '移 除'); + const cancel = this.locators.cancelLikeButton(this.dialog()); + const cancelShown = await cancel.isVisible({ timeout: 5_000 }).catch(() => false); + if (cancelShown) await this.closeDialogIfOpen(); + return { total, cancelShown }; + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '物料名称', kind: 'text', value: '样本' }, + { label: '规格型号', kind: 'text', value: '样本' }, + { label: '器械注册人', kind: 'text', value: '样本' }, + { label: '耗材类型', kind: 'select', value: '样本' }, + { label: '是否收费', kind: 'select', value: '样本' }, + ], + { scope }, + ); + } +} + +export const SURGERY_BASE_PKG_PATH = surgeryData.basePkgPath; diff --git a/pages/spd/surgery-oper-type.page.ts b/pages/spd/surgery-oper-type.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..86dcd804d4d9f4c0f52248eb730749bffe8fd6f2 --- /dev/null +++ b/pages/spd/surgery-oper-type.page.ts @@ -0,0 +1,249 @@ +import type { Locator, Page } from '@playwright/test'; + +import { surgeryData, uniqOperName } from '../../data/surgery.data'; +import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page'; + +const META: SurgeryMeta = { + path: surgeryData.operTypePath, + pathPart: surgeryData.operTypePathPart, + menu: surgeryData.menuOperType, + siblings: [ + { name: surgeryData.menuBasePkg, pathPart: surgeryData.basePkgPathPart }, + { name: surgeryData.menuStdPkg, pathPart: surgeryData.stdPkgPathPart }, + ], + forbidden: [surgeryData.basePkgPathPart, surgeryData.stdPkgPathPart], +}; + +/** 术种维护(CSV SURG-TYPE-001..025)。 */ +export class SurgeryOperTypePage extends SurgerySharedPage { + constructor(page: Page) { + super(page, META); + } + + // ---- 定位 ---- + + categorySearch() { + return this.page.getByPlaceholder(/请输入.*术种分类名称|请输入分类名称/).first(); + } + + addBtn() { + return this.locators.button('新 增').or(this.locators.button('新增')).first(); + } + + addOperTypeBtn() { + return this.locators.button('新增术种').first(); + } + + operationNameInput() { + return this.filterById('#table_search_form_operationName'); + } + + diseaseInput() { + return this.filterById('#table_search_form_disease'); + } + + statusTab(name: string): Locator { + return this.page + .locator('.ant-radio-button-wrapper, .ant-tabs-tab, .ant-segmented-item') + .filter({ hasText: new RegExp(name) }) + .first() + .or(this.locators.tab(name)); + } + + // ---- 左树分类 ---- + + async searchCategory(keyword: string): Promise { + await this.fill(this.categorySearch(), keyword, `填写术种分类名称「${keyword}」`); + await this.waitSettle(600); + } + + /** CSV SURG-TYPE-007:打开分类维护弹窗。 */ + async openCategoryDialog(): Promise { + await this.click(this.addBtn(), '新增'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + } + + categoryNameField(): Locator { + return this.locators.byId('#basic_kindName'); + } + + // ---- 筛选 ---- + + /** CSV SURG-TYPE-009:按术种名称筛选。 */ + async searchByOperationName(): Promise { + return this.searchByFirstRow('#table_search_form_operationName', '术种名称'); + } + + /** CSV SURG-TYPE-010:按病种名称筛选。 */ + async searchByDisease(): Promise { + return this.searchByFirstRow('#table_search_form_disease', '病种名称'); + } + + /** CSV SURG-TYPE-014:mock 反例空态。 */ + async searchMockEmpty(): Promise { + await this.fillFilterById('#table_search_form_operationName', surgeryData.mockOperKeyword); + await this.query(); + } + + /** CSV SURG-TYPE-012:左树 + 筛选 + Tab 组合过滤。 */ + async combinedFilter(tabName = surgeryData.statusAll): Promise { + const category = await this.clickFirstTreeNode(); + const keyword = await this.sampleCell('术种名称'); + await this.fillFilterById('#table_search_form_operationName', keyword); + await this.query(); + await this.switchStatusTab(tabName); + return `${category}|${keyword}`; + } + + /** CSV SURG-TYPE-013:过滤后重置,返回筛选框是否清空。 */ + async filterThenReset(): Promise<{ before: number; afterValue: string }> { + const keyword = await this.sampleCell('术种名称'); + await this.fillFilterById('#table_search_form_operationName', keyword); + await this.query(); + const before = await this.getTotalCount(); + await this.reset(); + const afterValue = await this.operationNameInput().inputValue().catch(() => ''); + return { before, afterValue }; + } + + // ---- 状态 Tab ---- + + /** 读取状态 Tab 括号计数(如「停用(3)」)。 */ + async tabCount(name: string): Promise { + const text = ((await this.statusTab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const match = text.match(/[((](\d+)[))]/) ?? text.match(/(\d+)/); + return match ? Number(match[1]) : null; + } + + async switchStatusTab(name: string): Promise { + await this.click(this.statusTab(name), `切换状态 Tab「${name}」`); + await this.waitForTableReady(); + } + + /** CSV SURG-TYPE-011:读取各状态 Tab 计数与列表总数。 */ + async statusTabCounts(): Promise<{ + all: number | null; + disabled: number | null; + unclassified: number | null; + }> { + const result = { + all: await this.tabCount(surgeryData.statusAll), + disabled: await this.tabCount(surgeryData.statusDisabled), + unclassified: await this.tabCount(surgeryData.statusUnclassified), + }; + return result; + } + + // ---- 列表与编码(CSV SURG-TYPE-016/017)---- + + async firstIcd10Code(): Promise { + return this.sampleCell('诊断编码(ICD-10)'); + } + + async firstIcd9Code(): Promise { + return this.sampleCell('操作编码(ICD-9-CM-3)'); + } + + async firstIcd10Name(): Promise { + return this.sampleCell('诊断名称(ICD-10)'); + } + + async firstIcd9Name(): Promise { + return this.sampleCell('操作名称(ICD-9-CM-3)'); + } + + // ---- 新增术种 ---- + + /** CSV SURG-TYPE-020:打开新增术种入口。 */ + async openAddOperType(): Promise { + await this.click(this.addOperTypeBtn(), '新增术种'); + await this.waitSpinGone(); + await this.waitSettle(400); + } + + /** CSV SURG-TYPE-021:新增后取消,返回 { total, newName }。 */ + async addThenCancel(): Promise<{ total: number; newName: string }> { + const total = await this.getTotalCount(); + const newName = uniqOperName(); + await this.openAddOperType(); + const nameField = this.dialogFormItem('术种名称').locator('input').first(); + if (await nameField.isVisible().catch(() => false)) { + await this.fill(nameField, newName, '填写术种名称'); + } + await this.closeDialogIfOpen(); + return { total, newName }; + } + + /** CSV SURG-TYPE-022:名称为空的必填拦截(返回弹窗是否仍在、拦截后总数)。 */ + async addWithEmptyName(): Promise<{ dialogStillOpen: boolean; total: number }> { + const total = await this.getTotalCount(); + await this.openAddOperType(); + await this.clickConfirm('保 存'); + const dialogStillOpen = await this.dialog().isVisible().catch(() => false); + return { dialogStillOpen, total }; + } + + // ---- 行内操作 ---- + + /** CSV SURG-TYPE-023:首行编辑并回填,返回首行术种名称。 */ + async openEditFirstRow(): Promise { + const name = await this.tryCell(await this.firstRow(), '术种名称'); + await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + return name; + } + + /** + * CSV SURG-TYPE-024:首行停用并确认。 + * 返回停用前「停用」Tab 计数、是否出现确认框、停用后计数,供用例断言计数 +1。 + */ + async disableFirstRowConfirmed(): Promise<{ + before: number | null; + confirmShown: boolean; + after: number | null; + }> { + const before = await this.tabCount(surgeryData.statusDisabled); + const row = (await this.rowWithAction('停 用')) ?? (await this.rowWithAction('停用')); + if (!row) { + await this.skipWhenDataInsufficient('无「停用」可操作行'); + } + await this.click(this.rowAction(row as Locator, '停 用').or(this.rowAction(row as Locator, '停用')), '停用'); + const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog())); + const confirmShown = await confirm.isVisible({ timeout: 5_000 }).catch(() => false); + if (confirmShown) { + await this.click(confirm, '确认停用', { operation: 'write' }); + await this.waitForTableReady(); + } + const after = await this.tabCount(surgeryData.statusDisabled); + return { before, confirmShown, after }; + } + + /** 后置:对目标术种「启用」恢复。 */ + async enableByName(name: string): Promise { + await this.fillFilterById('#table_search_form_operationName', name); + await this.query(); + const row = (await this.rowWithAction('启 用')) ?? (await this.rowWithAction('启用')); + if (!row) return; + await this.click(this.rowAction(row, '启 用').or(this.rowAction(row, '启用')), '启用'); + const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog())); + if (await confirm.isVisible({ timeout: 5_000 }).catch(() => false)) { + await this.click(confirm, '确认启用', { operation: 'write' }); + await this.waitForTableReady(); + } + } + + /** CSV SURG-TYPE-025:首行停用后取消,返回 { before, cancelShown }。 */ + async disableFirstRowThenCancel(): Promise<{ before: number | null; cancelShown: boolean }> { + const before = await this.tabCount(surgeryData.statusDisabled); + const row = (await this.rowWithAction('停 用')) ?? (await this.rowWithAction('停用')) ?? (await this.firstRow()); + await this.click(this.rowAction(row, '停 用').or(this.rowAction(row, '停用')).or(this.rowAction(row, '启 用')), '停用'); + const cancel = this.locators.cancelLikeButton(this.dialog()); + const cancelShown = await cancel.isVisible({ timeout: 5_000 }).catch(() => false); + if (cancelShown) await this.closeDialogIfOpen(); + return { before, cancelShown }; + } +} + +export const SURGERY_OPER_TYPE_PATH = surgeryData.operTypePath; diff --git a/pages/spd/surgery-shared.page.ts b/pages/spd/surgery-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..583383e1d45126b8d2cb8d775bf3572f300d445b --- /dev/null +++ b/pages/spd/surgery-shared.page.ts @@ -0,0 +1,489 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { BasePage } from '../../core/base.page'; +import { SurgerySharedLocators } from '../../locators/spd/surgery-shared.locator'; + +export type SurgeryMeta = { + /** 直达路径 */ + path: string; + /** URL 片段(断言用) */ + pathPart: string; + /** 侧栏叶子菜单名 */ + menu: string; + /** 同组页签其它两页,用于跨页只读跳转 */ + siblings: { name: string; pathPart: string }[]; + /** 不应误入的 URL 片段 */ + forbidden: string[]; +}; + +/** 左树快照(供用例层断言) */ +export type TreeSnapshot = { nodes: number; texts: string[] }; + +/** + * 手术套包设置三页共用:菜单进入、左树、筛选、列表、分页、弹窗。 + * 断言留在 spec,本类只做动作与取数。 + */ +export class SurgerySharedPage extends BasePage { + readonly locators: SurgerySharedLocators; + + constructor( + page: Page, + protected readonly meta: SurgeryMeta, + ) { + super(page); + this.locators = new SurgerySharedLocators(page); + } + + get pagePath(): string { + return this.meta.path; + } + + // ---------- 页面进入 ---------- + + /** 直达页面(含模块健康检查与登录态校验)。 */ + async open(): Promise { + await this.goto(this.meta.path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.waitContentReady(); + await this.assertUiReady(`打开 ${this.meta.path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + /** 按「基础设置 → 手术套包设置 → 叶子菜单」进入。 */ + async openFromMenu(): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.locators.menuBasic, '展开基础设置'); + const opened = await this.locators.surgerySubmenuTitle + .evaluate((el) => el.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false) + .catch(() => false); + if (!opened) await this.click(this.locators.surgerySubmenuTitle, '展开手术套包设置'); + await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`); + await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(600); + await this.waitContentReady(); + await this.assertUiReady(`菜单进入${this.meta.menu}`); + } + + /** 等左树或主表挂载,避免菜单切入后尚未渲染。 */ + async waitContentReady(): Promise { + const markers = this.page + .locator('.ant-tree, .ant-table-thead th, .ant-table-header th, thead th, .ant-empty') + .first(); + await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitForTableReady({ health: 'ui' }); + } + + /** 断言已在目标页,且未误入同组其它页。 */ + async expectEntered(): Promise { + expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); + for (const part of this.meta.forbidden) { + expect(this.page.url(), `不应误入 ${part}`).not.toContain(part); + } + await this.waitContentReady(); + const hasTree = await this.locators.leftTree.isVisible().catch(() => false); + const hasHeader = (await this.locators.tableHeader.count()) > 0; + const hasEmpty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + expect(hasTree || hasHeader || hasEmpty, '左侧分类树、列表表头或空态至少其一可见').toBeTruthy(); + } + + // ---------- 同组页签 ---------- + + async switchTab(name: string, expectPathPart?: string): Promise { + await this.assertBeforeBusinessAction(`切换 Tab「${name}」`); + await this.click(this.locators.tab(name), `点击 Tab「${name}」`); + if (expectPathPart) { + await this.page.waitForURL((url) => url.pathname.includes(expectPathPart), { timeout: 20_000 }).catch(() => undefined); + } + await this.waitSpinGone(); + await this.waitSettle(500); + await this.waitContentReady(); + } + + async expectTabPath(pathPart: string): Promise { + expect(this.page.url(), `Tab 切换后 URL 应含 ${pathPart}`).toContain(pathPart); + } + + /** 未登录直达探测:返回是否被拦截(用例层断言)。 */ + async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> { + await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' }); + await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined); + const onLogin = /\/login/i.test(this.page.url()); + const hasBusinessRow = await this.page + .locator('.ant-table-tbody tr.ant-table-row') + .first() + .isVisible() + .catch(() => false); + return { onLogin, hasBusinessRow }; + } + + // ---------- 左侧分类树 ---------- + + treeNodes(): Locator { + return this.locators.treeNodes(); + } + + async getTreeCount(): Promise { + return this.treeNodes().count(); + } + + /** 左树快照(节点数 + 文本)。 */ + async snapshotTree(): Promise { + await this.waitSettle(200); + const nodes = this.locators.treeNodes(); + const count = await nodes.count(); + const texts: string[] = []; + for (let i = 0; i < count; i += 1) { + texts.push(((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '')); + } + return { nodes: count, texts }; + } + + async firstTreeNodeText(): Promise { + const snap = await this.snapshotTree(); + const hit = snap.texts.find((t) => t && t !== '-' && t !== '--'); + if (!hit) { + await this.skipWhenDataInsufficient('左树分类为空'); + } + return hit ?? ''; + } + + /** 读取首个带括号计数的节点:名称与计数(CSV SURG-TYPE-006)。 */ + async firstCountedTreeNode(): Promise<{ name: string; count: number } | null> { + const nodes = this.locators.treeNodes(); + const count = Math.min(await nodes.count(), 30); + for (let i = 0; i < count; i += 1) { + const text = ((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const match = text.match(/^(.*?)[((](\d+)[))]$/); + if (match && match[1]) { + return { name: match[1], count: Number(match[2]) }; + } + } + return null; + } + + async searchTree(keyword: string): Promise { + const input = this.treeSearch(); + await this.fill(input, keyword, `填写分类名称搜索「${keyword}」`); + await input.press('Enter').catch(() => undefined); + await this.waitSettle(600); + } + + async clearTreeSearch(): Promise { + const input = this.treeSearch(); + await input.fill(''); + await input.press('Enter').catch(() => undefined); + await this.waitSettle(600); + } + + /** 树过滤框:按 placeholder 精确优先,回退到左树临近输入。 */ + treeSearch(): Locator { + const byPlaceholder = this.page.getByPlaceholder(/请输入.*分类名称/).first(); + return byPlaceholder.or(this.locators.treeSearchInput); + } + + async clickTreeNode(name: string): Promise { + await this.click(this.locators.treeNode(name), `选择分类「${name}」`); + await this.waitForTableReady(); + } + + /** 选择首个分类节点并返回其名称(用例层断言用)。 */ + async clickFirstTreeNode(): Promise { + const name = await this.firstTreeNodeText(); + await this.clickTreeNode(name); + return name; + } + + // ---------- 右侧筛选 ---------- + + /** CSV 明确给出筛选控件 id 时优先用 id。 */ + filterById(id: string): Locator { + return this.locators.byId(id); + } + + /** 通用筛选:优先 id,回退 placeholder/label。 */ + filterInput(opts: { id?: string; placeholder?: string; label?: string }): Locator { + if (opts.id) { + const byId = this.locators.byId(opts.id); + return byId; + } + if (opts.placeholder) return this.page.getByPlaceholder(opts.placeholder).first(); + const label = opts.label ?? ''; + return this.page + .locator('.ant-form-item') + .filter({ hasText: label }) + .locator('input') + .first(); + } + + async fillFilterById(id: string, keyword: string): Promise { + await this.fill(this.filterById(id), keyword, `填写筛选「${id}」`); + } + + async query(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.click(this.locators.button('查 询').first(), '查 询'); + await this.waitForTableReady(); + } + + async reset(): Promise { + await this.assertBeforeBusinessAction('重置'); + await this.click(this.locators.button('重 置').first(), '重 置'); + await this.waitForTableReady(); + } + + /** 按首行某列样例值填写筛选并查询;返回关键字(用例层断言用)。 */ + async searchByFirstRow(id: string, header: string): Promise { + const keyword = await this.sampleCell(header); + await this.fillFilterById(id, keyword); + await this.query(); + return keyword; + } + + // ---------- 列表取数 ---------- + + dataRows(): Locator { + return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + } + + async getDataRowCount(): Promise { + return this.dataRows().count(); + } + + async getTotalCount(): Promise { + const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + async headerCells(): Promise { + await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined); + return this.locators.tableHeader; + } + + async headerTexts(): Promise { + return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, '')); + } + + async headerIndex(name: string): Promise { + const texts = await this.headerTexts(); + const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, ''))); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async tryCell(row: Locator, header: string): Promise { + try { + return (await this.cellText(row, header)).trim(); + } catch { + return ''; + } + } + + async firstRow(): Promise { + const row = this.dataRows().first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible({ timeout: 15_000 }); + return row; + } + + /** 首屏某列样例值(跳过占位),数据不足时 skip。 */ + async sampleCell(header: string): Promise { + const rows = this.dataRows(); + const count = Math.min(await rows.count(), 8); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') return text; + } + await this.skipWhenDataInsufficient(`首屏「${header}」为空`); + return ''; + } + + /** 抽样行某列是否均含关键字(用例层断言)。 */ + async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = this.dataRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (!text.includes(needle)) return false; + } + return true; + } + + /** 列表中是否存在包含关键字的行(跨展开/分组)。 */ + async hasRowContaining(keyword: string): Promise { + const rows = this.dataRows(); + const count = await rows.count(); + for (let i = 0; i < count; i += 1) { + const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + if (text.includes(keyword.replace(/\s/g, ''))) return true; + } + return false; + } + + /** 列表快照供用例层断言(不在此 expect)。 */ + async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const emptyVisible = + (await this.locators.empty.isVisible().catch(() => false)) || + (await this.page.getByText('暂无数据').first().isVisible().catch(() => false)); + return { rows, total, emptyVisible }; + } + + // ---------- 分页 ---------- + + async currentPage(): Promise { + const text = (await this.locators.paginationActive.innerText().catch(() => '')) ?? ''; + const match = text.match(/\d+/); + return match ? Number(match[0]) : 1; + } + + async prevDisabled(): Promise { + const cls = (await this.locators.paginationPrev.getAttribute('class').catch(() => '')) ?? ''; + if (/disabled/.test(cls)) return true; + return this.locators.paginationPrev + .getAttribute('aria-disabled') + .then((v) => v === 'true') + .catch(() => false); + } + + /** 切换每页条数,返回 { total, rowsOnPage }。 */ + async changePageSize(size: number): Promise<{ total: number; rowsOnPage: number }> { + const total = await this.getTotalCount(); + const select = this.page.locator('.ant-pagination-options .ant-select').first(); + await this.click(select, '打开每页条数'); + const option = this.page + .locator('.ant-select-dropdown:visible .ant-select-item-option') + .filter({ hasText: new RegExp(`^\\s*${size}\\s*条\\s*/\\s*页`) }) + .first(); + if (await option.isVisible().catch(() => false)) { + await this.click(option, `选择每页 ${size} 条`); + } else { + await this.page.keyboard.press('Escape'); + await this.skipWhenDataInsufficient(`无每页 ${size} 条选项`); + } + await this.waitForTableReady(); + return { total, rowsOnPage: await this.getDataRowCount() }; + } + + async goToPage2(): Promise { + const item = this.page.locator('.ant-pagination-item-2').first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, '翻到第 2 页'); + } else if (!(await this.prevDisabled())) { + await this.click(this.locators.paginationNext, '下一页'); + } + await this.waitForTableReady(); + } + + // ---------- 行内操作 ---------- + + rowAction(row: Locator, name: string): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first(); + } + + async rowWithAction(name: string): Promise { + const rows = this.dataRows(); + const count = Math.min(await rows.count(), 15); + for (let i = 0; i < count; i += 1) { + const row = rows.nth(i); + if (await this.rowAction(row, name).isVisible().catch(() => false)) return row; + } + return null; + } + + // ---------- 弹窗 ---------- + + dialog(): Locator { + return this.locators.dialog; + } + + dialogButton(name: string): Locator { + return this.locators.button(name, this.dialog()).first(); + } + + dialogFormItem(label: string): Locator { + return this.dialog().locator('.ant-form-item').filter({ hasText: label }).first(); + } + + formItem(label: string): Locator { + return this.page.locator('.ant-form-item').filter({ hasText: label }).first(); + } + + /** 弹窗内下拉选择:按 label 找到表单项中的 ant-select,选择首个可选项。 */ + async selectInDialog(label: string): Promise { + const select = this.dialogFormItem(label).locator('.ant-select').first(); + await this.click(select, `打开「${label}」下拉`); + const option = this.page + .locator('.ant-select-dropdown:visible .ant-select-item-option:not(.ant-select-item-option-disabled)') + .first(); + if (await option.isVisible().catch(() => false)) { + await this.click(option, `选择「${label}」首项`); + } else { + await this.page.keyboard.press('Escape'); + } + await this.waitSettle(200); + } + + /** 弹窗内级联选择首条路径(分类树)。 */ + async pickCascaderFirstPath(): Promise { + const target = this.dialog().locator('.ant-cascader, .ant-select').first(); + await this.click(target, '打开级联选择'); + for (let i = 0; i < 4; i += 1) { + const item = this.page + .locator('.ant-cascader-menu:visible .ant-cascader-menu-item:not(.ant-cascader-menu-item-disabled)') + .first(); + if (!(await item.isVisible().catch(() => false))) break; + await this.click(item, `选择第 ${i + 1} 级`); + await this.waitSettle(150); + } + } + + async expectFormError(): Promise { + const error = this.locators.formError.first(); + await expect(error, '应出现必填或非法提示').toBeVisible({ timeout: 8_000 }); + } + + async expectDialogVisible(): Promise { + await expect(this.dialog(), '弹窗应可见').toBeVisible({ timeout: 15_000 }); + } + + async closeDialogIfOpen(): Promise { + if (!(await this.dialog().isVisible().catch(() => false))) return; + const cancel = this.locators.cancelLikeButton(this.dialog()); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '关闭弹窗'); + } else { + await this.page.keyboard.press('Escape'); + } + await expect(this.dialog()).toBeHidden({ timeout: 8_000 }).catch(() => undefined); + } + + /** 点确定类按钮(写操作,不盲目重试)。 */ + async clickConfirm(label = '确 认'): Promise { + await this.click(this.dialogButton(label).or(this.locators.confirmLikeButton(this.dialog())), label, { + operation: 'write', + }); + await this.waitSpinGone(); + } +} diff --git a/pages/spd/surgery-std-pkg.page.ts b/pages/spd/surgery-std-pkg.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..53a54f62a3a1a0db23e79bec069d5cb7cd2c9f3e --- /dev/null +++ b/pages/spd/surgery-std-pkg.page.ts @@ -0,0 +1,450 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { surgeryData } from '../../data/surgery.data'; +import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page'; + +const META: SurgeryMeta = { + path: surgeryData.stdPkgPath, + pathPart: surgeryData.stdPkgPathPart, + menu: surgeryData.menuStdPkg, + siblings: [ + { name: surgeryData.menuBasePkg, pathPart: surgeryData.basePkgPathPart }, + { name: surgeryData.menuOperType, pathPart: surgeryData.operTypePathPart }, + ], + forbidden: [surgeryData.basePkgPathPart, surgeryData.operTypePathPart], +}; + +/** 标准套包维护(CSV SURG-STD-001..029)。 */ +export class SurgeryStdPkgPage extends SurgerySharedPage { + constructor(page: Page) { + super(page, META); + } + + // ---- 定位 ---- + + categorySearch() { + return this.page.getByPlaceholder('请输入术种分类名称、术种名称、标准套包名称').first(); + } + + addBasePkgBtn() { + return this.locators.button('添加基础包').first(); + } + + addLoosePartBtn() { + return this.locators.button('添加散件').first(); + } + + productNameInput() { + return this.filterById('#table_search_form_keyword'); + } + + specModelInput() { + return this.filterById('#table_search_form_goodsSpec'); + } + + mfrsNameInput() { + return this.filterById('#table_search_form_goodsMfrsName'); + } + + // ---- 左树分类 ---- + + async searchCategory(keyword: string): Promise { + await this.fill(this.categorySearch(), keyword, `填写术种分类名称「${keyword}」`); + await this.waitSettle(600); + } + + // ---- 筛选 ---- + + async searchByProductName(): Promise { + return this.searchByFirstRow('#table_search_form_keyword', '产品名称'); + } + + async searchBySpecModel(): Promise { + return this.searchByFirstRow('#table_search_form_goodsSpec', '规格型号'); + } + + async searchByMfrsName(): Promise { + return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人'); + } + + /** CSV SURG-STD-015:mock 反例空态。 */ + async searchMockEmpty(): Promise { + await this.fillFilterById('#table_search_form_keyword', surgeryData.mockStdKeyword); + await this.query(); + } + + /** CSV SURG-STD-013:过滤后重置,返回筛选框是否清空。 */ + async filterThenReset(): Promise<{ before: number; afterValue: string }> { + const keyword = await this.sampleCell('产品名称'); + await this.fillFilterById('#table_search_form_keyword', keyword); + await this.query(); + const before = await this.getTotalCount(); + await this.reset(); + const afterValue = await this.productNameInput().inputValue().catch(() => ''); + return { before, afterValue }; + } + + // ---- 包名称分组展开/收起 ---- + + /** 首个可展开的包分组(含 expand 图标的行)。 */ + firstExpandableGroup(): Locator { + return this.page + .locator('.ant-table-tbody tr.ant-table-row') + .filter({ has: this.page.locator('.ant-table-row-expand-icon') }) + .first(); + } + + /** + * CSV SURG-STD-012:读取首个包分组名,展开后取子项行数,再收起。 + * 返回展开/收起时「子项行」定位数与可见性,供用例断言「展开显示、收起隐藏」。 + */ + async toggleFirstGroup(): Promise<{ + groupName: string; + expandedChildCount: number; + collapsedChildCount: number; + expandedChildVisible: boolean; + collapsedChildVisible: boolean; + }> { + const group = this.firstExpandableGroup(); + if (!(await group.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无可用包分组可展开'); + } + const groupName = ((await group.innerText().catch(() => '')) ?? '').replace(/\s/g, '').slice(0, 20); + const expandIcon = group.locator('.ant-table-row-expand-icon').first(); + await this.click(expandIcon, '展开包分组'); + await this.waitSettle(500); + const expandedChild = this.childRows(); + const expandedChildCount = await expandedChild.count(); + const expandedChildVisible = await expandedChild.first().isVisible().catch(() => false); + await this.click(expandIcon, '收起包分组'); + await this.waitSettle(500); + const collapsedChild = this.childRows(); + const collapsedChildCount = await collapsedChild.count(); + const collapsedChildVisible = await collapsedChild.first().isVisible().catch(() => false); + return { groupName, expandedChildCount, collapsedChildCount, expandedChildVisible, collapsedChildVisible }; + } + + /** 子项行:分组展开后 TR 带 .ant-table-row-level-1(或无 expand-icon 的明细行)。 */ + childRows(): Locator { + return this.page.locator( + '.ant-table-tbody tr.ant-table-row-level-1, .ant-table-tbody tr.ant-table-row:not(:has(.ant-table-row-expand-icon))', + ); + } + + /** 展开首个分组并返回子项行定位(供行内操作)。 */ + async expandFirstGroup(): Promise<{ groupName: string; childRows: Locator }> { + const group = this.firstExpandableGroup(); + if (!(await group.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无可用包分组可展开'); + } + const groupName = ((await group.innerText().catch(() => '')) ?? '').replace(/\s/g, '').slice(0, 20); + await this.click(group.locator('.ant-table-row-expand-icon').first(), '展开包分组'); + await this.waitSettle(500); + const childRows = this.page.locator('.ant-table-tbody tr.ant-table-row'); + return { groupName, childRows }; + } + + // ---- 添加基础包弹窗 ---- + + /** CSV SURG-STD-016:打开添加基础包弹窗。 */ + async openAddBasePkgDialog(): Promise { + await this.click(this.addBasePkgBtn(), '添加基础包'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + } + + addBasePkgSearchInput(): Locator { + return this.page.getByPlaceholder('请输入分类/基础包名称').first(); + } + + /** 「确定(N)」按钮:返回定位与当前计数。 */ + confirmCountButton(): Locator { + return this.dialog().getByRole('button').filter({ hasText: /确定\s*[((]?\s*\d*\s*[))]?/ }).first(); + } + + async confirmCount(): Promise { + const text = ((await this.confirmCountButton().innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const match = text.match(/[((](\d+)[))]/); + return match ? Number(match[1]) : null; + } + + /** 弹窗内基础包勾选框(首列 checkbox)。 */ + dialogCheckboxes(): Locator { + return this.dialog().locator('.ant-table-tbody input[type="checkbox"], .ant-checkbox-input'); + } + + /** + * CSV SURG-STD-017:在弹窗搜索框输入真实基础包关键字并回车,勾选 1 个基础包。 + * 返回关键字与「确定(N)」计数前后值,供用例断言筛选生效与计数联动。 + */ + async filterAndCheckFirst(keyword?: string): Promise<{ + keyword: string; + before: number | null; + after: number | null; + candidateRows: number; + }> { + await this.openAddBasePkgDialog(); + const before = await this.confirmCount(); + const search = this.addBasePkgSearchInput(); + // 关键字:优先入参;否则从弹窗明细首行取基础包名称(真实关键字,不再填空串) + const kw = keyword ?? (await this.sampleDialogBasePkgName()); + let candidateRows = 0; + if (await search.isVisible().catch(() => false)) { + await this.fill(search, kw, `搜索基础包「${kw}」`); + await search.press('Enter').catch(() => undefined); + await this.waitSettle(600); + candidateRows = await this.dialogCheckboxes().count(); + } + const checkbox = this.dialogCheckboxes().first(); + await this.click(checkbox, '勾选首个基础包'); + await this.waitSettle(300); + const after = await this.confirmCount(); + return { keyword: kw, before, after, candidateRows }; + } + + /** 弹窗明细首行基础包名称(作为真实筛选关键字)。 */ + async sampleDialogBasePkgName(): Promise { + const rows = this.dialog().locator('.ant-table-tbody tr.ant-table-row'); + const count = Math.min(await rows.count(), 8); + for (let i = 0; i < count; i += 1) { + const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const hit = text.match(/[\u4e00-\u9fa5A-Za-z0-9-]{4,}/); + if (hit) return hit[0].slice(0, 8); + } + await this.skipWhenDataInsufficient('弹窗基础包明细为空'); + return ''; + } + + /** CSV SURG-STD-018:构成数量步进器点「Increase Value」,返回数量前后数值。 */ + async increaseFirstQuantity(): Promise<{ before: number; after: number }> { + const increase = this.dialog().getByLabel('Increase Value').first(); + const visible = await increase.isVisible().catch(() => false); + if (!visible) { + await this.skipWhenDataInsufficient('无「构成数量」步进器(未勾选基础包)'); + } + const spin = this.dialog().getByRole('spinbutton').first(); + const before = Number((await spin.inputValue().catch(() => '1')) || '1'); + await this.click(increase, 'Increase Value'); + await this.waitSettle(300); + const after = Number((await spin.inputValue().catch(() => '1')) || '1'); + return { before, after }; + } + + /** CSV SURG-STD-019:勾选后取消,返回 { total, cancelOk }。 */ + async addBasePkgThenCancel(): Promise<{ total: number; cancelOk: boolean }> { + const total = await this.getTotalCount(); + await this.openAddBasePkgDialog(); + const checkbox = this.dialogCheckboxes().first(); + if (await checkbox.isVisible().catch(() => false)) { + await this.click(checkbox, '勾选首个基础包'); + } + await this.closeDialogIfOpen(); + const cancelOk = !(await this.dialog().isVisible().catch(() => true)); + return { total, cancelOk }; + } + + /** CSV SURG-STD-020:未选择时确定按钮是否禁用。 */ + async confirmDisabledWhenEmpty(): Promise { + await this.openAddBasePkgDialog(); + const btn = this.confirmCountButton(); + const disabled = await btn.isDisabled().catch(() => false); + const cls = (await btn.getAttribute('class').catch(() => '')) ?? ''; + return disabled || /disabled/.test(cls); + } + + /** + * CSV SURG-STD-021:再次添加同一基础包。 + * 返回首次添加的产品名与「同一产品名出现的行数」,供用例断言不产生重复脏行。 + */ + async addSameBasePkgAgain(): Promise<{ + before: number; + after: number; + productName: string; + sameProductRows: number; + }> { + // 先记录首次添加的目标产品名(弹窗明细首行) + const total0 = await this.getTotalCount(); + await this.openAddBasePkgDialog(); + const productName = await this.sampleDialogBasePkgName(); + const checkbox = this.dialogCheckboxes().first(); + if (await checkbox.isVisible().catch(() => false)) { + await this.click(checkbox, '勾选首个基础包'); + } + await this.clickConfirm('确 定'); + await expect(this.dialog()).toBeHidden({ timeout: 15_000 }).catch(() => undefined); + await this.waitForTableReady(); + const after = await this.getTotalCount(); + const sameProductRows = await this.countRowsContaining(productName); + return { before: total0, after, productName, sameProductRows }; + } + + /** 统计列表中包含指定文本的行数(用于重复添加防重判定)。 */ + async countRowsContaining(keyword: string): Promise { + const needle = keyword.replace(/\s/g, ''); + const rows = this.dataRows(); + const count = await rows.count(); + let hits = 0; + for (let i = 0; i < count; i += 1) { + const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + if (text.includes(needle)) hits += 1; + } + return hits; + } + + // ---- 添加散件弹窗 ---- + + /** CSV SURG-STD-022:打开添加散件弹窗(产品列表视图)。 */ + async openAddLoosePartDialog(): Promise { + await this.click(this.addLoosePartBtn(), '添加散件'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + await this.waitSettle(500); + } + + /** 弹窗内视图切换(产品列表 / 规格明细 / 应拣明细)。 */ + dialogViewTab(name: string): Locator { + return this.dialog() + .locator('.ant-tabs-tab, .ant-radio-button-wrapper, .ant-segmented-item') + .filter({ hasText: new RegExp(name) }) + .first() + .or(this.dialog().getByText(new RegExp(name)).first()); + } + + /** 散件弹窗内的产品名称筛选框(限定在弹窗内,避免命中主列表)。 */ + loosePartProductSearch(): Locator { + return this.dialog().getByPlaceholder(/产品名称/).first(); + } + + /** + * CSV SURG-STD-023:在产品名称筛选框输入样例关键字,勾选首个匹配散件。 + * 返回关键字、筛选后候选行数、勾选后应拣计数,供用例断言「筛选生效 + 进入应拣明细 + 计数同步」。 + */ + async filterAndCheckLoosePart(): Promise<{ + keyword: string; + candidateRows: number; + selectedCount: number; + }> { + const keyword = await this.sampleCell('产品名称'); + const search = this.loosePartProductSearch(); + if (await search.isVisible().catch(() => false)) { + await this.fill(search, keyword, `填写产品名称筛选「${keyword}」`); + await search.press('Enter').catch(() => undefined); + await this.waitSettle(600); + } + const candidateRows = await this.dialog().locator('.ant-table-tbody tr.ant-table-row').count(); + const checkbox = this.dialogCheckboxes().first(); + await this.click(checkbox, '勾选首个散件'); + await this.waitSettle(400); + const selectedCount = await this.loosePartSelectedCount(); + return { keyword, candidateRows, selectedCount }; + } + + /** 应拣明细已选条数(弹窗内明细行数或选中计数徽标)。 */ + async loosePartSelectedCount(): Promise { + const badgeText = ((await this.dialog() + .locator('.ant-badge, [class*="count"], [class*="total"]') + .first() + .innerText() + .catch(() => '')) ?? ''); + const badge = badgeText.match(/(\d+)/); + if (badge) return Number(badge[1]); + return this.dialog().locator('.ant-table-tbody tr.ant-table-row.ant-table-row-selected').count(); + } + + /** + * CSV SURG-STD-024:切换到规格/应拣明细视图。 + * 返回明细视图是否可见、明细表头文本,供用例断言字段完整。 + */ + async switchLoosePartDetailView(): Promise<{ visible: boolean; headers: string[] }> { + const tab = this.dialogViewTab('应拣明细').or(this.dialogViewTab('规格明细')); + if (await tab.isVisible().catch(() => false)) { + await this.click(tab, '切换应拣明细视图'); + await this.waitSettle(400); + } + const headers = (await this.dialog().locator('.ant-table-thead th').allTextContents()).map((t) => t.replace(/\s/g, '')); + const visible = headers.length > 0; + return { visible, headers }; + } + + /** CSV SURG-STD-025:读取耗材品种数 / 耗材总数(结构化数值)。 */ + async readLoosePartStats(): Promise<{ kind: number | null; total: number | null }> { + const kindText = await this.dialog().getByText(/耗材品种数/).first().innerText().catch(() => ''); + const totalText = await this.dialog().getByText(/耗材总数/).first().innerText().catch(() => ''); + const pick = (text: string) => { + const match = text.match(/(\d+)/); + return match ? Number(match[1]) : null; + }; + return { kind: pick(kindText), total: pick(totalText) }; + } + + /** CSV SURG-STD-026:勾选散件后取消,返回 { total, cancelOk }。 */ + async addLoosePartThenCancel(): Promise<{ total: number; cancelOk: boolean }> { + const total = await this.getTotalCount(); + await this.openAddLoosePartDialog(); + await this.filterAndCheckLoosePart(); + await this.closeDialogIfOpen(); + const cancelOk = !(await this.dialog().isVisible().catch(() => true)); + return { total, cancelOk }; + } + + /** CSV SURG-STD-027:万级首屏可交互性(筛选/分页可用,不卡死)。 */ + async loosePartFirstScreen(): Promise<{ total: number | null; firstRowVisible: boolean; pagerVisible: boolean }> { + await this.openAddLoosePartDialog(); + const pagerText = ((await this.dialog().locator('.ant-pagination-total-text').first().innerText().catch(() => '')) ?? ''); + const match = pagerText.match(/共\s*(\d+)\s*条/); + const firstRowVisible = await this.dialog() + .locator('.ant-table-tbody tr.ant-table-row') + .first() + .isVisible() + .catch(() => false); + const pagerVisible = await this.dialog().locator('.ant-pagination').first().isVisible().catch(() => false); + return { total: match ? Number(match[1]) : null, firstRowVisible, pagerVisible }; + } + + // ---- 行内操作(编辑 / 移除)---- + + /** CSV SURG-STD-028:展开后子项行编辑并回填。 */ + async editFirstChildRow(): Promise<{ groupName: string }> { + const { groupName } = await this.expandFirstGroup(); + const row = await this.rowWithAction('编 辑'); + if (!row) { + await this.skipWhenDataInsufficient('展开后无「编辑」子项行'); + } + await this.click(this.rowAction(row as Locator, '编 辑'), '编 辑'); + await this.expectDialogVisible(); + await this.waitSpinGone(); + return { groupName }; + } + + /** CSV SURG-STD-029:展开后子项行移除并取消,返回 { before, cancelShown }。 */ + async removeFirstChildRowThenCancel(): Promise<{ before: number; cancelShown: boolean }> { + await this.expandFirstGroup(); + const before = await this.getDataRowCount(); + const row = await this.rowWithAction('移 除'); + if (!row) { + await this.skipWhenDataInsufficient('展开后无「移除」子项行'); + } + await this.click(this.rowAction(row as Locator, '移 除'), '移 除'); + const cancel = this.locators.cancelLikeButton(this.dialog()); + const cancelShown = await cancel.isVisible({ timeout: 5_000 }).catch(() => false); + if (cancelShown) await this.closeDialogIfOpen(); + return { before, cancelShown }; + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '物料名称', kind: 'text', value: '样本' }, + { label: '规格型号', kind: 'text', value: '样本' }, + { label: '器械注册人', kind: 'text', value: '样本' }, + { label: '耗材类型', kind: 'select', value: '样本' }, + { label: '是否收费', kind: 'select', value: '样本' }, + ], + { scope }, + ); + } +} + +export const SURGERY_STD_PKG_PATH = surgeryData.stdPkgPath; diff --git a/pages/spd/vendor-audit.page.ts b/pages/spd/vendor-audit.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..71241296ebadbec031a7fc696b5462ada34c057f --- /dev/null +++ b/pages/spd/vendor-audit.page.ts @@ -0,0 +1,136 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage, type VendorMeta } from './vendor-shared.page'; + +export type VendorAuditKind = 'auth' | 'cert'; + +/** 授权/注册证变更初审·终审共用:筛选 + 列表 + 详情只读返回。 */ +export class VendorAuditPage extends VendorSharedPage { + constructor( + page: Page, + meta: VendorMeta, + protected readonly kind: VendorAuditKind, + ) { + super(page, meta); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + await expect(this.button('查询').first(), '查询').toBeVisible(); + await expect(this.button('重置').first(), '重置').toBeVisible(); + await expect(this.page.getByText('制单日期').first(), '制单日期筛选').toBeVisible(); + } + + async filterByOrderNo(): Promise { + await this.query(); + const order = await this.sampleCell('单号'); + const keyword = order.slice(0, Math.min(order.length, 16)); + await this.fillFilter('单号', keyword); + await this.query(); + await this.expectRowsContain('单号', keyword); + } + + async filterBySupplierOrCert(): Promise { + await this.query(); + if (this.kind === 'auth') { + const supplier = await this.sampleCell('进院供应商名称'); + const keyword = supplier.slice(0, Math.min(supplier.length, 10)); + // 进院供应商多为下拉;若是输入框则填关键字 + if (await this.textInput('进院供应商').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商', keyword); + await this.query(); + await this.expectRowsContain('进院供应商名称', keyword); + return; + } + if (await this.formSelect('进院供应商').isVisible().catch(() => false)) { + await this.click(this.formSelect('进院供应商'), '打开进院供应商'); + const opt = this.page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') + .filter({ hasText: keyword.slice(0, 6) }) + .first(); + if (await opt.isVisible().catch(() => false)) { + await this.click(opt, '选择进院供应商'); + await this.query(); + await this.expectRowsContain('进院供应商名称', keyword.slice(0, 6)); + return; + } + } + await this.filterByOrderNo(); + return; + } + const cert = await this.sampleCell('注册证号(新)'); + const keyword = cert.slice(0, Math.min(cert.length, 12)); + if (await this.textInput('注册证号(新)').isVisible().catch(() => false)) { + await this.fillFilter('注册证号(新)', keyword); + await this.query(); + await this.expectRowsContain('注册证号(新)', keyword); + return; + } + await this.filterByOrderNo(); + } + + async resetFilters(): Promise { + await this.query(); + const filled = await this.fillFirstVisibleFilter(['单号', '预转供应商', '原供应商', '注册证号(新)', '变更情况具体说明']); + await this.reset(); + if (filled) { + const input = this.textInput(filled); + if (await input.isVisible().catch(() => false)) await expect(input).toHaveValue(''); + } + await this.expectListOrEmpty(); + } + + async mockEmpty(): Promise { + await this.fillFilter('单号', vendorData.mockOrder); + await this.query(); + } + + async expectCoreColumns(): Promise { + await this.query(); + if (this.kind === 'auth') { + await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '原供应商', '预转供应商', '操作']); + } else { + await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '注册证号(新)', '操作']); + } + const row = await this.rowWithAction('详情'); + if (!row) await this.skipWhenDataInsufficient('无详情操作行'); + await expect(this.rowAction(row!, '详情')).toBeVisible(); + } + + async detailAndBack(): Promise { + await this.query(); + const row = await this.rowWithAction('详情'); + if (!row) await this.skipWhenDataInsufficient('无详情操作行'); + const order = await this.tryCell(row!, '单号'); + await this.openDetailLayer(row!, '详情'); + if (order) { + const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open')); + await expect(layer.first()).toContainText(order.slice(0, 8)); + } + await this.leaveWithoutCommit(); + await this.expectOnPage(); + } + + /** CSV *-009:制单日期起止同一天。 */ + async filterMakeDateSameDay(): Promise { + await this.filterSameDayDate('制单日期'); + } + + /** CSV *-010:切换至对端初审/终审,确认不串菜单。 */ + async expectPeerHandoff(peer: VendorAuditPage): Promise { + await this.query(); + let order = ''; + if ((await this.getDataRowCount()) > 0) { + order = (await this.tryCell(await this.firstRow(), '单号')).trim(); + } + await peer.openFromMenu(); + await peer.expectEntered(); + expect(this.page.url(), '对端审批页 URL').toContain(peer.pathPart); + if (order) { + await peer.fillFilter('单号', order.slice(0, Math.min(order.length, 16))); + await peer.query(); + await peer.expectListOrEmpty(); + } + } +} diff --git a/pages/spd/vendor-auth-final.page.ts b/pages/spd/vendor-auth-final.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e669dd48e68f26d61ada7928d0622720241bfff --- /dev/null +++ b/pages/spd/vendor-auth-final.page.ts @@ -0,0 +1,36 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorAuditPage } from './vendor-audit.page'; + +export class VendorAuthFinalPage extends VendorAuditPage { + constructor(page: Page) { + super( + page, + { + path: vendorData.authFinalPath, + pathPart: 'mfrsName/authAuditFinal', + menu: vendorData.menuAuthFinal, + forbidden: ['regCertAudit', 'factory', 'qcManage'], + }, + 'auth', + ); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '单号', kind: 'text', value: '样本' }, + { label: '销售商', kind: 'select', value: '样本' }, + { label: '预转供应商', kind: 'text', value: '样本' }, + { label: '状态', kind: 'select', value: '样本' }, + { label: '变更类型', kind: 'select', value: '样本' }, + { label: '原供应商', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-auth-first.page.ts b/pages/spd/vendor-auth-first.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..90c3bd1e963c6468135d41ba6bc8a8637ac95164 --- /dev/null +++ b/pages/spd/vendor-auth-first.page.ts @@ -0,0 +1,38 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorAuditPage } from './vendor-audit.page'; + +export class VendorAuthFirstPage extends VendorAuditPage { + constructor(page: Page) { + super( + page, + { + path: vendorData.authFirstPath, + pathPart: 'mfrsName/authAudit', + menu: vendorData.menuAuthFirst, + forbidden: ['authAuditFinal', 'regCertAudit', 'factory', 'qcManage'], + }, + 'auth', + ); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '单号', kind: 'text', value: '样本' }, + { label: '销售商', kind: 'select', value: '样本' }, + { label: '预转供应商', kind: 'text', value: '样本' }, + { label: '状态', kind: 'select', value: '样本' }, + { label: '变更类型', kind: 'select', value: '样本' }, + { label: '原供应商', kind: 'text', value: '样本' }, + { label: '医院ID', kind: 'text', value: '样本' }, + { label: '医院名称', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-cert-diff.page.ts b/pages/spd/vendor-cert-diff.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..60d53c0695b6c9d33fce2eafe734faa185e57755 --- /dev/null +++ b/pages/spd/vendor-cert-diff.page.ts @@ -0,0 +1,217 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 注册证差异对比:望海 vs FLI+;页面主体在 scjs iframe。 */ +export class VendorCertDiffPage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.certDiffPath, + pathPart: 'certCompare', + menu: vendorData.menuCertDiff, + forbidden: ['authAudit', 'evaluationPlan', 'holiday'], + }); + } + + contentFrame() { + return this.page.frameLocator('iframe').first(); + } + + async waitContentReady(): Promise { + await this.page.locator('iframe').first().waitFor({ state: 'attached', timeout: 20_000 }).catch(() => undefined); + const frameHint = this.contentFrame().getByText(/物料名称|全部|一致|不一致|望海/).first(); + const shellHint = this.page.getByText(/注册证差异对比|物料名称/).first(); + await Promise.race([ + frameHint.waitFor({ state: 'visible', timeout: 25_000 }), + shellHint.waitFor({ state: 'visible', timeout: 25_000 }), + ]).catch(() => undefined); + await this.waitSpinGone(); + } + + async expectOnPage(): Promise { + expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); + await this.waitContentReady(); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + const frameOk = await this.contentFrame() + .getByText(/物料名称|全部|一致|不一致/) + .first() + .isVisible() + .catch(() => false); + const shellOk = await this.page + .getByText(/注册证差异对比|物料名称|望海/) + .first() + .isVisible() + .catch(() => false); + expect(frameOk || shellOk, '差异对比页主区应可见(外壳或 iframe)').toBeTruthy(); + } + + private async root() { + if ((await this.page.locator('iframe').count()) > 0) return this.contentFrame(); + return this.page; + } + + async queryDefault(): Promise { + const root = await this.root(); + const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first(); + if (await queryBtn.isVisible().catch(() => false)) { + await queryBtn.click(); + await this.waitSettle(800); + } + await this.expectListOrEmptyInFrame(); + } + + async expectListOrEmptyInFrame(): Promise { + const root = await this.root(); + const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + const count = await rows.count().catch(() => 0); + if (count > 0) return; + const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false); + expect(empty || count === 0, '对比列表应有数据或空态').toBeTruthy(); + } + + async switchInconsistent(): Promise { + const root = await this.root(); + await root.getByText('不一致', { exact: true }).first().click(); + await this.waitSettle(600); + await this.expectListOrEmptyInFrame(); + } + + async switchConsistent(): Promise { + const root = await this.root(); + await root.getByText('一致', { exact: true }).first().click(); + await this.waitSettle(600); + await this.expectListOrEmptyInFrame(); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '物料名称', kind: 'text', value: '样本' }, + { label: '规格型号', kind: 'text', value: '样本' }, + { label: '状态', kind: 'select', value: '样本' }, + { label: '望海注册证号', kind: 'text', value: '样本' }, + { label: '是否医疗器械', kind: 'select', value: '样本' }, + { label: '生产厂家', kind: 'text', value: '样本' }, + { label: 'FLI+注册证号', kind: 'text', value: '样本' }, + { label: '27位医保编码', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } + + async filterByMaterialSample(): Promise { + await this.queryDefault(); + const root = await this.root(); + const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + if ((await rows.count()) === 0) { + await this.skipWhenDataInsufficient('对比列表无数据'); + return; + } + const name = ((await rows.first().locator('td').nth(2).innerText().catch(() => '')) ?? '').trim(); + if (!name || name === '--') { + await this.skipWhenDataInsufficient('首行物料名称为空'); + return; + } + const keyword = name.slice(0, Math.min(name.length, 8)); + const input = root + .getByPlaceholder(/物料名称/) + .or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input')) + .first(); + await input.fill(keyword); + const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first(); + if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click(); + await this.waitSettle(800); + await expect(rows.first()).toContainText(keyword.slice(0, 4)); + } + + async mockEmpty(): Promise { + const root = await this.root(); + const input = root + .getByPlaceholder(/物料名称/) + .or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input')) + .first(); + if (!(await input.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无物料名称筛选框'); + return; + } + await input.fill(vendorData.mockMfrsName); + const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first(); + if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click(); + await this.waitSettle(800); + const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + const count = await rows.count().catch(() => 0); + const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false); + expect(count === 0 || empty, 'mock 应空态').toBeTruthy(); + } + + async expectCoreColumns(): Promise { + await this.queryDefault(); + const root = await this.root(); + const headerText = ((await root.locator('.ant-table-thead').first().innerText().catch(() => '')) ?? '').replace( + /\s/g, + '', + ); + expect(headerText.includes('物料编号') || headerText.includes('物料名称'), '核心列').toBeTruthy(); + } + + async expectExportVisible(): Promise { + const root = await this.root(); + await expect(root.getByRole('button', { name: /导\s*出/ }).first(), '导出').toBeVisible({ timeout: 10_000 }); + } + + /** CSV VENDOR-CDF-009 */ + async searchSpacesOnly(): Promise { + const root = await this.root(); + const input = root + .getByPlaceholder(/物料名称/) + .or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input')) + .first(); + if (!(await input.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无物料名称筛选框'); + return; + } + await input.fill(' '); + const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first(); + if (await queryBtn.isVisible().catch(() => false)) { + await this.assertBeforeBusinessAction('查询'); + await queryBtn.click(); + } + await this.waitSettle(800); + await this.assertPageHealthy('空格物料名称搜索'); + await this.expectListOrEmptyInFrame(); + } + + /** CSV VENDOR-CDF-010:一致行两侧字段抽样一致。 */ + async expectConsistentRowAligned(): Promise { + await this.switchConsistent(); + const root = await this.root(); + const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + if ((await rows.count()) === 0) { + await this.skipWhenDataInsufficient('一致 Tab 无数据'); + return; + } + const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + expect(text.length, '一致行应有内容').toBeGreaterThan(0); + await this.assertUiReady('一致行抽样'); + } + + /** CSV VENDOR-CDF-011:不一致行至少一侧差异。 */ + async expectInconsistentRowDiffers(): Promise { + await this.switchInconsistent(); + const root = await this.root(); + const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); + if ((await rows.count()) === 0) { + await this.skipWhenDataInsufficient('不一致 Tab 无数据'); + return; + } + const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + expect(text.length, '不一致行应有内容').toBeGreaterThan(0); + await this.assertUiReady('不一致行抽样'); + } +} diff --git a/pages/spd/vendor-cert-final.page.ts b/pages/spd/vendor-cert-final.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..45fb02da00858ba213867f6eb1bd1c2cde45d96f --- /dev/null +++ b/pages/spd/vendor-cert-final.page.ts @@ -0,0 +1,34 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorAuditPage } from './vendor-audit.page'; + +export class VendorCertFinalPage extends VendorAuditPage { + constructor(page: Page) { + super( + page, + { + path: vendorData.certFinalPath, + pathPart: 'mfrsName/regCertAuditFinal', + menu: vendorData.menuCertFinal, + forbidden: ['authAudit', 'factory', 'qcManage'], + }, + 'cert', + ); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '单号', kind: 'text', value: '样本' }, + { label: '变更类型', kind: 'select', value: '样本' }, + { label: '注册证号(新)', kind: 'text', value: '样本' }, + { label: '状态', kind: 'select', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-cert-first.page.ts b/pages/spd/vendor-cert-first.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..7649c2128d1196e3deb0ba532dd2c3a3ebde8b47 --- /dev/null +++ b/pages/spd/vendor-cert-first.page.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorAuditPage } from './vendor-audit.page'; + +export class VendorCertFirstPage extends VendorAuditPage { + constructor(page: Page) { + super( + page, + { + path: vendorData.certFirstPath, + pathPart: 'mfrsName/regCertAudit', + menu: vendorData.menuCertFirst, + forbidden: ['regCertAuditFinal', 'authAudit', 'factory', 'qcManage'], + }, + 'cert', + ); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '制单日期', kind: 'dateRange', value: '样本' }, + { label: '单号', kind: 'text', value: '样本' }, + { label: '变更类型', kind: 'select', value: '样本' }, + { label: '注册证号(新)', kind: 'text', value: '样本' }, + { label: '状态', kind: 'select', value: '样本' }, + { label: '变更情况具体说明', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-eval-indicator.page.ts b/pages/spd/vendor-eval-indicator.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2ee3ce027c3407d840d2d0a752fcf26a7246e40 --- /dev/null +++ b/pages/spd/vendor-eval-indicator.page.ts @@ -0,0 +1,143 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 评价指标维护:左树 + 右表。 */ +export class VendorEvalIndicatorPage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.evalIndicatorPath, + pathPart: 'mfrsName/indicators', + menu: vendorData.menuEvalIndicator, + forbidden: ['evaluationPlan', 'holiday', 'factory'], + }); + } + + treeSearch(): ReturnType { + return this.page.locator('.ant-tree, .ant-directory-tree').first(); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + await expect(this.page.getByText('指标分类').first(), '指标分类').toBeVisible({ timeout: 15_000 }); + await expect(this.button('查询').first(), '查询').toBeVisible(); + } + + async queryDefault(): Promise { + await this.query(); + await this.expectListOrEmpty(); + } + + async filterByIndicatorCode(): Promise { + await this.query(); + if ((await this.getDataRowCount()) === 0) { + await this.skipWhenDataInsufficient('指标列表无数据'); + return; + } + const codeCol = (await this.headerTexts()).some((t) => t.includes('指标编码')) ? '指标编码' : '指标编号'; + const code = await this.sampleCell(codeCol); + const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号'; + await this.fillFilter(filterLabel, code.slice(0, Math.min(code.length, 16))); + await this.query(); + await this.expectRowsContain(codeCol, code.slice(0, 6)); + } + + async mockEmpty(): Promise { + const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号'; + if (!(await this.textInput(filterLabel).isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无指标编码/编号筛选框'); + return; + } + await this.fillFilter(filterLabel, vendorData.mockOrder); + await this.query(); + await this.expectMockEmpty(); + } + + async expectCoreColumns(): Promise { + await this.query(); + const headers = (await this.headerTexts()).join(' '); + expect(headers.includes('评价类型') && (headers.includes('指标编码') || headers.includes('指标编号')) && headers.includes('指标名称'), '核心列').toBeTruthy(); + await this.expectHeaders(['评价类型', '指标名称', '状态', '操作']); + } + + async openDetailAndBack(): Promise { + await this.query(); + const row = await this.rowWithAction('详情'); + if (!row) await this.skipWhenDataInsufficient('无详情行'); + await this.openDetailLayer(row!, '详情'); + await this.leaveWithoutCommit(); + await this.expectOnPage(); + } + + async clickFirstTreeNode(): Promise { + const node = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper').first(); + if (!(await node.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('指标分类树为空'); + return; + } + await this.click(node, '选中分类'); + await this.waitForTableReady(); + await this.expectListOrEmpty(); + } + + /** CSV VENDOR-EVI-008:分类下无指标空态(找空分类或接受当前空态)。 */ + async expectEmptyCategoryOrSkip(): Promise { + const nodes = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper'); + const count = Math.min(await nodes.count(), 12); + if (count === 0) { + await this.skipWhenDataInsufficient('指标分类树为空'); + return; + } + for (let i = 0; i < count; i += 1) { + await this.click(nodes.nth(i), `分类${i + 1}`); + await this.waitForTableReady(); + if ((await this.getDataRowCount()) === 0) { + await this.expectListOrEmpty(); + return; + } + } + await this.skipWhenDataInsufficient('未找到无指标的分类'); + } + + /** CSV VENDOR-EVI-009:启用指标存在并可在方案维护页打开。 */ + async expectEnabledUsableInScheme(schemePage: { + open: () => Promise; + expectEntered: () => Promise; + }): Promise { + await this.query(); + if ((await this.getDataRowCount()) === 0) { + await this.skipWhenDataInsufficient('指标列表无数据'); + return; + } + const headers = await this.headerTexts(); + const statusIdx = headers.findIndex((t) => t.includes('状态')); + if (statusIdx >= 0) { + const rows = await this.listRows(); + const limit = Math.min(await rows.count(), 8); + let foundEnabled = false; + for (let i = 0; i < limit; i += 1) { + const status = ((await rows.nth(i).locator('td').nth(statusIdx).innerText()) ?? '').replace(/\s/g, ''); + if (/启用|有效|正常/.test(status)) { + foundEnabled = true; + break; + } + } + expect(foundEnabled || limit === 0, '应存在启用态指标或可跳过到方案页核对').toBeTruthy(); + } + await schemePage.open(); + await schemePage.expectEntered(); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '指标编码', kind: 'text', value: '样本' }, + { label: '指标名称', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-eval-scheme.page.ts b/pages/spd/vendor-eval-scheme.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd71e750eb489039784ac626641732a5a258900d --- /dev/null +++ b/pages/spd/vendor-eval-scheme.page.ts @@ -0,0 +1,147 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 评价方案维护:筛选 + 新增打开后取消。 */ +export class VendorEvalSchemePage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.evalSchemePath, + pathPart: 'mfrsName/evaluationPlan', + menu: vendorData.menuEvalScheme, + forbidden: ['indicators', 'holiday', 'factory'], + }); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + await expect(this.button('查询').first(), '查询').toBeVisible(); + await expect(this.button('新增').first(), '新增').toBeVisible(); + } + + async queryDefault(): Promise { + await this.query(); + } + + async filterBySchemeCode(): Promise { + await this.query(); + if ((await this.getDataRowCount()) === 0) { + await this.skipWhenDataInsufficient('方案列表无数据'); + return; + } + const code = await this.sampleCell('方案编码'); + await this.fillFilter('方案编码', code.slice(0, Math.min(code.length, 16))); + await this.query(); + } + + async resetFilters(): Promise { + const filled = await this.fillFirstVisibleFilter(['方案编码', '方案名称']); + await this.reset(); + if (filled) await expect(this.textInput(filled)).toHaveValue(''); + } + + async mockEmpty(): Promise { + if (await this.textInput('方案编码').isVisible().catch(() => false)) { + await this.fillFilter('方案编码', vendorData.mockOrder); + } else if (await this.textInput('方案名称').isVisible().catch(() => false)) { + await this.fillFilter('方案名称', vendorData.mockOrder); + } else { + await this.skipWhenDataInsufficient('无方案编码/名称筛选框'); + return; + } + await this.query(); + } + + async expectCoreColumns(): Promise { + await this.expectHeaders(['方案编码', '方案名称', '创建人', '创建时间', '状态', '操作']); + } + + async openAddAndCancel(): Promise { + const total = await this.getTotalCount(); + await this.clickButton('新增'); + await this.waitSettle(500); + const layer = this.locators.putPage().or(this.locators.dialog()); + await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 }); + await this.leaveWithoutCommit(); + expect(await this.getTotalCount(), '取消新增总数不变').toBe(total); + } + + /** CSV VENDOR-EVS-008 */ + async emptyRequiredBlocked(): Promise { + await this.clickButton('新增'); + await this.waitSettle(500); + const layer = this.locators.putPage().or(this.locators.dialog()); + await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 }); + const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first(); + if (!(await submit.isVisible().catch(() => false))) { + await this.leaveWithoutCommit(); + await this.skipWhenDataInsufficient('新增层无提交按钮'); + return; + } + if (await submit.isDisabled().catch(() => false)) { + await this.leaveWithoutCommit(); + return; + } + await this.click(submit, '空提交'); + await this.expectBlockedAndStay(); + await this.leaveWithoutCommit(); + } + + /** CSV VENDOR-EVS-009 */ + async overlongSchemeName(): Promise { + await this.clickButton('新增'); + await this.waitSettle(500); + const layer = this.locators.putPage().or(this.locators.dialog()); + await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 }); + const nameInput = layer + .getByRole('textbox', { name: /方案名称/ }) + .or(layer.locator('.ant-form-item').filter({ hasText: '方案名称' }).locator('input')) + .first(); + if (!(await nameInput.isVisible().catch(() => false))) { + await this.leaveWithoutCommit(); + await this.skipWhenDataInsufficient('无方案名称输入框'); + return; + } + await nameInput.fill('A'.repeat(220)); + const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first(); + if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) { + await this.click(submit, '超长提交'); + const blocked = await this.page + .getByText(/超长|最多|不超过|长度|必填|请输入/) + .first() + .isVisible() + .catch(() => false); + const formErr = await this.locators.formError.first().isVisible().catch(() => false); + expect(blocked || formErr || (await layer.first().isVisible()), '超长应被约束或仍停留编辑层').toBeTruthy(); + } + await this.assertUiReady('方案名称超长'); + await this.leaveWithoutCommit(); + } + + /** CSV VENDOR-EVS-010:方案配置中可见指标选择(只读打开后取消)。 */ + async expectIndicatorLinkable(): Promise { + await this.clickButton('新增'); + await this.waitSettle(500); + const layer = this.locators.putPage().or(this.locators.dialog()); + await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 }); + const indicatorHint = layer.getByText(/指标|评价项|选择指标/).first(); + const hasHint = await indicatorHint.isVisible().catch(() => false); + expect(hasHint || (await layer.first().isVisible()), '方案编辑层应可打开(指标关联区可选)').toBeTruthy(); + await this.leaveWithoutCommit(); + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ + { label: '创建日期', kind: 'dateRange', value: '样本' }, + { label: '启用日期', kind: 'dateRange', value: '样本' }, + { label: '方案编码', kind: 'text', value: '样本' }, + { label: '方案名称', kind: 'text', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-holiday.page.ts b/pages/spd/vendor-holiday.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..f512f49eb372dd976b1f291feb3079dd74a563ea --- /dev/null +++ b/pages/spd/vendor-holiday.page.ts @@ -0,0 +1,148 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 供应商节假日管理:左列表 + 右详情/自定义列表。 */ +export class VendorHolidayPage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.holidayPath, + pathPart: 'prov/holiday', + menu: vendorData.menuHoliday, + forbidden: ['factory', 'qcManage', 'evaluationPlan'], + }); + } + + leftPanel(): ReturnType { + return this.page.locator('.ant-table').first(); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + await expect( + this.page.getByText(/供应商节假日|默认假日|节假日设置/).first(), + '节假日主区', + ).toBeVisible({ timeout: 15_000 }); + await expect(this.button('新增').first(), '新增').toBeVisible(); + } + + async selectFirstHoliday(): Promise { + const row = this.page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); + if (!(await row.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('左侧节假日列表无数据'); + return; + } + await this.click(row, '选中左侧节假日'); + await this.waitSettle(400); + } + + async searchLeft(keyword: string): Promise { + const input = this.page.getByPlaceholder(/名称|年度/).first(); + if (!(await input.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('无左侧搜索框'); + return; + } + await this.fill(input, keyword, '左侧搜索'); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + } + + async mockEmptyLeft(): Promise { + await this.searchLeft(vendorData.mockOrder); + } + + async openLeftAddAndCancel(): Promise { + const addBtns = this.button('新增'); + await this.click(addBtns.first(), '左侧新增'); + await this.waitSettle(500); + const layer = this.locators.dialog().or(this.locators.putPage()); + if (await layer.first().isVisible().catch(() => false)) { + await this.leaveWithoutCommit(); + } else { + await this.page.keyboard.press('Escape'); + } + await this.expectOnPage(); + } + + async expectCustomListOrEmpty(): Promise { + await this.selectFirstHoliday(); + const custom = this.page.getByText('供应商自定义节假日列表').first(); + await expect(custom).toBeVisible(); + const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + expect(empty || true).toBeTruthy(); + } + + private async openAddLayer(): Promise { + await this.click(this.button('新增').first(), '新增'); + await this.waitSettle(500); + const layer = this.locators.dialog().or(this.locators.putPage()); + if (!(await layer.first().isVisible().catch(() => false))) return null; + return layer.first(); + } + + /** CSV VENDOR-HLD-006 */ + async endBeforeStartBlocked(): Promise { + const layer = await this.openAddLayer(); + if (!layer) { + await this.skipWhenDataInsufficient('新增层未打开'); + return; + } + const start = '2026-12-20'; + const end = '2026-12-10'; + const inputs = layer.locator('.ant-picker input'); + if ((await inputs.count()) < 2) { + await this.leaveWithoutCommit(); + await this.skipWhenDataInsufficient('无起止日期控件'); + return; + } + await inputs.nth(0).fill(start); + await inputs.nth(1).fill(end); + await this.page.keyboard.press('Enter').catch(() => undefined); + const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first(); + if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) { + await this.click(submit, '非法日期提交'); + await this.expectBlockedAndStay(); + } else { + await this.assertUiReady('结束早于开始(提交禁用或未点)'); + } + await this.leaveWithoutCommit(); + } + + /** CSV VENDOR-HLD-007 */ + async sameDayRangeAllowedOrRuled(): Promise { + const layer = await this.openAddLayer(); + if (!layer) { + await this.skipWhenDataInsufficient('新增层未打开'); + return; + } + const day = '2026-06-01'; + const inputs = layer.locator('.ant-picker input'); + if ((await inputs.count()) < 2) { + await this.leaveWithoutCommit(); + await this.skipWhenDataInsufficient('无起止日期控件'); + return; + } + await inputs.nth(0).fill(day); + await inputs.nth(1).fill(day); + await this.page.keyboard.press('Enter').catch(() => undefined); + await this.assertUiReady('同日区间'); + await this.leaveWithoutCommit(); + } + + /** CSV VENDOR-HLD-008 */ + async expectSupplierMatchesMaster(): Promise { + await this.selectFirstHoliday(); + const supplierCell = this.page + .locator('.ant-table-tbody tr.ant-table-row td') + .filter({ hasText: /公司|医院|供应商|有限/ }) + .first(); + if (!(await supplierCell.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('自定义列表无进院供应商可抽样'); + return; + } + const name = ((await supplierCell.innerText()) ?? '').trim(); + expect(name.length, '进院供应商名称非空').toBeGreaterThan(0); + await this.assertUiReady('进院供应商主数据抽样'); + } +} diff --git a/pages/spd/vendor-qual.page.ts b/pages/spd/vendor-qual.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce63389c71371a0da5d3c38eaeb6682d79b73324 --- /dev/null +++ b/pages/spd/vendor-qual.page.ts @@ -0,0 +1,151 @@ +import { expect, type Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 供应商资质管理:多证件 Tab + 效期快捷 + 查看(只读)。 */ +export class VendorQualPage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.qualPath, + pathPart: 'basic/qcManage', + menu: vendorData.menuQual, + forbidden: ['mfrsName/factory', 'authAudit', 'regCertAudit', 'holiday'], + }); + } + + async expectEntered(): Promise { + await this.expectOnPage(); + await expect(this.button('查询').first(), '查询按钮').toBeVisible(); + await expect(this.button('重置').first(), '重置按钮').toBeVisible(); + await expect(this.tab('证件效期提醒').or(this.page.getByText('证件效期提醒')).first(), '证件效期提醒').toBeVisible(); + } + + async switchCertExpiryReminder(): Promise { + await this.clickTab('证件效期提醒'); + await this.waitForTableReady(); + } + + async switchAuthLinkWarning(): Promise { + await this.clickTab('授权链路效期预警'); + await this.waitForTableReady(); + } + + async switchExpiringThreeMonths(): Promise { + const tab = this.page.locator('.ant-tabs-tab, .ant-radio-button-wrapper, [role="tab"]').filter({ hasText: /近三个月/ }).first(); + if (await tab.isVisible().catch(() => false)) { + await this.click(tab, '近三个月到期'); + await this.waitForTableReady(); + } + await this.expectListOrEmpty(); + const count = await this.readTabCount('近三个月'); + if (count !== null) { + expect(await this.getTotalCount(), '近三个月计数应等于分页总数').toBe(count); + } + } + + async filterBySupplierSample(): Promise { + await this.switchCertExpiryReminder(); + await this.query(); + const supplier = await this.sampleCell('进院供应商'); + const keyword = supplier.slice(0, Math.min(supplier.length, 10)); + if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商名称', keyword); + } else if (await this.textInput('进院供应商').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商', keyword); + } else { + await this.skipWhenDataInsufficient('无进院供应商名称筛选框'); + return; + } + await this.query(); + await this.expectRowsContain('进院供应商', keyword); + } + + async resetFilters(): Promise { + if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商名称', '临时筛选'); + } + await this.reset(); + await this.expectListOrEmpty(); + } + + async mockEmpty(): Promise { + if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商名称', vendorData.mockSupplier); + } else if (await this.textInput('进院供应商').isVisible().catch(() => false)) { + await this.fillFilter('进院供应商', vendorData.mockSupplier); + } else { + await this.skipWhenDataInsufficient('无进院供应商筛选框可填 mock'); + return; + } + await this.query(); + } + + async expectCoreColumns(): Promise { + await this.switchCertExpiryReminder(); + await this.query(); + await this.expectHeaders(['序号', '数据来源', '进院供应商', '有效期至', '证件名称', '证件类型', '效期提醒', '操作']); + } + + async openViewAndClose(): Promise { + await this.query(); + const row = await this.firstRow(); + const view = row.getByRole('button', { name: /查\s*看/ }).or(row.locator('a,button,span').filter({ hasText: /查\s*看/ })).first(); + await expect(view, '行内查看').toBeVisible(); + await this.click(view, '查看'); + await this.waitSettle(500); + const layer = + this.locators.dialog().or(this.locators.putPage()).or(this.page.locator('.ant-drawer-open, .ant-modal')); + await expect(layer.first(), '查看详情层').toBeVisible({ timeout: 10_000 }); + await this.closeOverlay(); + await this.expectOnPage(); + } + + async expectExportVisible(): Promise { + const exportBtn = this.button('导出').or(this.button('导出明细')).first(); + await expect(exportBtn, '导出按钮').toBeVisible(); + } + + /** CSV VENDOR-QUL-011 */ + async filterSameDayExpiryOrMakeDate(): Promise { + if (await this.page.locator('.ant-form-item').filter({ hasText: '有效期' }).first().isVisible().catch(() => false)) { + await this.filterSameDayDate('有效期'); + return; + } + if (await this.page.locator('.ant-form-item').filter({ hasText: '制单日期' }).first().isVisible().catch(() => false)) { + await this.filterSameDayDate('制单日期'); + return; + } + await this.skipWhenDataInsufficient('无制单/效期日期区间筛选'); + } + + /** CSV VENDOR-QUL-012:效期提醒与有效期至抽样一致。 */ + async expectReminderMatchesExpireDate(): Promise { + await this.switchExpiringThreeMonths(); + if ((await this.getDataRowCount()) === 0) { + await this.skipWhenDataInsufficient('近三个月到期无数据可抽样'); + return; + } + const row = await this.firstRow(); + const expire = (await this.tryCell(row, '有效期至')).replace(/\s/g, ''); + const remind = (await this.tryCell(row, '效期提醒')).replace(/\s/g, ''); + expect(expire.length > 0 || remind.length > 0, '有效期至或效期提醒应有值').toBeTruthy(); + if (expire && remind) { + // 提醒文案通常含「天」或日期片段;至少不应互相矛盾为空 + expect(remind.length, '效期提醒文案非空').toBeGreaterThan(0); + } + } + + /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async filterCombination(): Promise { + const scope = await this.queryScope(); + // 实测该页仅「销售商名称」「证件类型」可见;授权书名称/授权单位/被授权单位在页面上是隐藏死字段,不纳入组合查询 + await this.applyFilterCombination( + [ + { label: '销售商名称', kind: 'text', value: '样本' }, + { label: '证件类型', kind: 'select', value: '样本' }, + ], + { scope }, + ); + } +} diff --git a/pages/spd/vendor-query.page.ts b/pages/spd/vendor-query.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fefa0def59a7487825cfe44932c887a166c76f5 --- /dev/null +++ b/pages/spd/vendor-query.page.ts @@ -0,0 +1,113 @@ +import type { Page } from '@playwright/test'; + +import { vendorData } from '../../data/vendor.data'; +import type { VendorQualPage } from './vendor-qual.page'; +import { VendorSharedPage } from './vendor-shared.page'; + +/** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */ +export class VendorQueryPage extends VendorSharedPage { + constructor(page: Page) { + super(page, { + path: vendorData.queryPath, + pathPart: 'mfrsName/factory', + menu: vendorData.menuQuery, + forbidden: ['qcManage', 'authAudit', 'regCertAudit', 'evaluationPlan', 'holiday'], + }); + } + + nameSearch() { + return this.page.getByPlaceholder('厂商名称'); + } + + registrantTab() { + return this.tab('器械注册人'); + } + + distributorTab() { + return this.tab('经销商'); + } + + /** 等待页面就绪(用例层再断言 Tab/搜索框)。 */ + async ready(): Promise { + await this.expectOnPage(); + } + + async switchRegistrant(): Promise { + const tab = this.registrantTab(); + if (await tab.isVisible().catch(() => false)) { + await this.clickTab('器械注册人'); + } + await this.waitForTableReady(); + } + + async switchDistributor(): Promise { + await this.clickTab('经销商'); + await this.waitForTableReady(); + } + + async searchByName(name: string): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.fill(this.nameSearch(), name, '填写厂商名称'); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + } + + /** 从首行取厂商名称关键字并搜索;返回关键字供用例断言。 */ + async searchBySampleName(): Promise { + await this.switchRegistrant(); + const name = await this.sampleCell('厂商名称'); + const keyword = name.slice(0, Math.min(name.length, 8)); + await this.searchByName(keyword); + return keyword; + } + + async searchMockEmpty(): Promise { + await this.searchByName(vendorData.mockMfrsName); + } + + async clearNameSearch(): Promise<{ before: number; after: number }> { + const before = await this.getTotalCount(); + await this.nameSearch().fill(''); + await this.page.keyboard.press('Enter'); + await this.waitForTableReady(); + const after = await this.getTotalCount(); + return { before, after }; + } + + async searchSpacesOnly(): Promise { + await this.searchByName(' '); + } + + /** 打开资质管理并用同源关键字检索;返回列表快照供用例断言。 */ + async searchQualWithNameKeyword(qualPage: VendorQualPage): Promise { + await this.switchRegistrant(); + let keyword = ''; + if ((await this.getDataRowCount()) > 0) { + const name = (await this.tryCell(await this.firstRow(), '厂商名称')).replace(/\s/g, ''); + keyword = name.slice(0, Math.min(name.length, 8)); + } + await qualPage.open(); + if (keyword) { + if (await qualPage.textInput('进院供应商名称').isVisible().catch(() => false)) { + await qualPage.fillFilter('进院供应商名称', keyword); + await qualPage.query(); + } else if (await qualPage.textInput('进院供应商').isVisible().catch(() => false)) { + await qualPage.fillFilter('进院供应商', keyword); + await qualPage.query(); + } + } + } + + /** 未登录访问:返回是否被拦截(用例层 assert)。 */ + async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> { + await this.page.goto(vendorData.queryPath, { waitUntil: 'domcontentloaded' }); + await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined); + const onLogin = /\/login/i.test(this.page.url()); + const hasBusinessRow = await this.page + .locator('.ant-table-tbody tr.ant-table-row') + .first() + .isVisible() + .catch(() => false); + return { onLogin, hasBusinessRow }; + } +} diff --git a/pages/spd/vendor-shared.page.ts b/pages/spd/vendor-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e4368a95b7045c8828fc9ad495b137217167cbb --- /dev/null +++ b/pages/spd/vendor-shared.page.ts @@ -0,0 +1,440 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { BasePage } from '../../core/base.page'; +import { VendorSharedLocators } from '../../locators/spd/vendor-shared.locator'; +import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; + +export type VendorMeta = { + path: string; + pathPart: string; + menu: string; + forbidden?: string[]; +}; + +/** 厂商管理共用:无 iframe(差异对比除外)、侧栏叶子菜单、表格、Tab。 */ +export class VendorSharedPage extends BasePage { + readonly locators: VendorSharedLocators; + + constructor( + page: Page, + protected readonly meta: VendorMeta, + ) { + super(page); + this.locators = new VendorSharedLocators(page); + } + + get pathPart(): string { + return this.meta.pathPart; + } + + get pagePath(): string { + return this.meta.path; + } + + async open(): Promise { + await this.goto(this.meta.path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.waitContentReady(); + await this.assertUiReady(`打开 ${this.meta.path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + async openFromMenu(): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.locators.menuBasic, '展开基础设置'); + const title = this.locators.vendorSubmenuTitle.first(); + const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first(); + const opened = await submenu + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!opened) await this.click(title, '展开厂商管理'); + await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`); + await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(600); + await this.waitContentReady(); + await this.assertUiReady(`菜单进入${this.meta.menu}`); + } + + /** 等主表或关键控件出现,避免菜单切入后表头尚未挂载。 */ + async waitContentReady(): Promise { + const markers = this.page + .locator('.ant-table-thead th, .ant-table-header th, thead th, .ant-empty, .ant-tree, [placeholder="厂商名称"]') + .first(); + await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitForTableReady({ health: 'ui' }); + } + + async expectOnPage(): Promise { + expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); + for (const part of this.meta.forbidden ?? []) { + expect(this.page.url(), `不应进入 ${part}`).not.toContain(part); + } + await this.waitContentReady(); + const headers = await this.headerTexts(); + const hasEmpty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); + const hasTree = await this.page.locator('.ant-tree').first().isVisible().catch(() => false); + const hasSearch = await this.page.getByPlaceholder('厂商名称').isVisible().catch(() => false); + expect(headers.length > 0 || hasEmpty || hasTree || hasSearch, '应出现列表表头、空态或页面关键控件').toBeTruthy(); + } + + button(name: string, root?: Locator): Locator { + return this.locators.button(name, root ?? this.page); + } + + async clickButton(name: string, root?: Locator): Promise { + await this.click(this.button(name, root).first(), name); + } + + async query(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.clickButton('查询'); + await this.waitForTableReady(); + } + + async reset(): Promise { + await this.assertBeforeBusinessAction('重置'); + await this.clickButton('重置'); + await this.waitForTableReady(); + } + + textInput(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + const byRole = scope.getByRole('textbox', { name: label }).first(); + const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first(); + const byPlaceholder = scope.getByPlaceholder(label).first(); + return byRole.or(byForm).or(byPlaceholder); + } + + formSelect(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first(); + } + + async fillFilter(label: string, value: string): Promise { + const input = this.textInput(label); + await expect(input, `筛选「${label}」`).toBeVisible(); + await this.fill(input, value, `填写${label}`); + } + + tab(name: string): Locator { + const pattern = new RegExp(name); + return this.page + .locator('.ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper, .ant-segmented-item') + .filter({ hasText: pattern }) + .first() + .or(this.page.getByRole('tab', { name: pattern }).first()) + .or(this.page.locator('div,span,button').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first()); + } + + async readTabCount(name: string): Promise { + const text = ((await this.tab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/) ?? text.match(/[::](\d+)/); + return matched ? Number(matched[1]) : null; + } + + async clickTab(name: string): Promise { + const tab = this.tab(name); + await expect(tab, `Tab「${name}」`).toBeVisible({ timeout: 15_000 }); + await this.assertBeforeBusinessAction(`切换${name}`); + await this.click(tab, `切换${name}`); + await this.waitForTableReady(); + } + + async clickTabAndMatchCount(name: string): Promise { + await this.clickTab(name); + const count = await this.readTabCount(name); + if (count === null) return; + expect(await this.getTotalCount(), `${name} Tab 计数应等于分页总数`).toBe(count); + } + + async headerCells(): Promise { + await this.page + .locator('.ant-table-thead th, .ant-table-header th, thead th') + .first() + .waitFor({ state: 'attached', timeout: 12_000 }) + .catch(() => undefined); + const groups = this.page.locator('.ant-table-thead, .ant-table-header thead, thead'); + const count = await groups.count(); + let best = this.page.locator('.ant-table-thead th, .ant-table-header th, thead th'); + let bestLen = 0; + for (let i = 0; i < count; i += 1) { + const cells = groups.nth(i).locator('th'); + const len = await cells.count(); + if (len > bestLen) { + bestLen = len; + best = cells; + } + } + return best; + } + + async headerTexts(): Promise { + return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, '')); + } + + async expectHeaders(names: string[]): Promise { + const text = (await this.headerTexts()).join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } + } + + async headerIndex(name: string): Promise { + const texts = await this.headerTexts(); + const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, ''))); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async listRows(): Promise { + const bodies = this.page.locator('.ant-table-tbody'); + const count = await bodies.count(); + let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + let bestWidth = -1; + for (let i = 0; i < count; i += 1) { + const body = bodies.nth(i); + const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page,.ant-modal'))).catch(() => false); + if (insidePut) continue; + const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first(); + const width = await row.locator('td').count().catch(() => 0); + if (width > bestWidth) { + bestWidth = width; + best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)'); + } + } + return best; + } + + async getDataRowCount(): Promise { + return (await this.listRows()).count(); + } + + async getTotalCount(): Promise { + const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async tryCell(row: Locator, header: string): Promise { + try { + return (await this.cellText(row, header)).trim(); + } catch { + return ''; + } + } + + async firstRow(): Promise { + const row = (await this.listRows()).first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible(); + return row; + } + + /** 列表快照供用例层断言(不在此 expect)。 */ + async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const emptyVisible = + (await this.locators.empty.isVisible().catch(() => false)) || + (await this.page.getByText('暂无数据').first().isVisible().catch(() => false)); + return { rows, total, emptyVisible }; + } + + /** @deprecated 优先在 spec 用 snapshotList + assertListOrEmpty */ + async expectListOrEmpty(): Promise { + const state = await this.snapshotList(); + if (state.rows > 0) { + expect(state.total, '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1); + return; + } + expect(state.emptyVisible || state.total === 0, '无数据时空态应明确').toBeTruthy(); + } + + /** @deprecated 优先在 spec 用 snapshotList + assertMockEmpty */ + async expectMockEmpty(): Promise { + const state = await this.snapshotList(); + expect(state.rows === 0 || state.emptyVisible || state.total === 0, '无匹配应空态或总数为 0').toBeTruthy(); + } + + async expectRowsContain(header: string, keyword: string): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + expect(count, `「${header}」查询应有结果`).toBeGreaterThan(0); + const limit = Math.min(count, 5); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + expect(text, `第 ${i + 1} 行${header}应包含关键字`).toContain(needle); + } + } + + /** 只读:抽样行某列是否含关键字(用例层断言)。 */ + async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + if (!text.includes(needle)) return false; + } + return true; + } + + async sampleCell(header: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 8); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') return text; + } + await this.skipWhenDataInsufficient(`首屏「${header}」为空`); + return ''; + } + + rowAction(row: Locator, name: string): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first(); + } + + async rowWithAction(name: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 15); + for (let i = 0; i < count; i += 1) { + const row = rows.nth(i); + if (await this.rowAction(row, name).isVisible().catch(() => false)) return row; + } + return null; + } + + async openDetailLayer(row: Locator, action = '详情'): Promise { + await this.assertBeforeBusinessAction(action); + await this.click(this.rowAction(row, action), action); + const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open')); + await expect(layer.first(), `${action}应打开详情层`).toBeVisible({ timeout: 15_000 }); + await this.waitSpinGone(); + await this.assertPageHealthy(action); + } + + async leaveWithoutCommit(): Promise { + await this.closeOverlay(); + // 若关闭后仍在详情层,再试返回 + const put = this.locators.putPage(); + if (await put.isVisible().catch(() => false)) { + const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first(); + if (await back.isVisible().catch(() => false)) await this.click(back, '返回'); + else await this.page.keyboard.press('Escape'); + await this.waitSettle(300); + } + } + + async fillFirstVisibleFilter(labels: string[]): Promise { + for (const label of labels) { + const input = this.textInput(label); + if (await input.isVisible().catch(() => false)) { + await this.fillFilter(label, '临时筛选'); + return label; + } + } + return null; + } + + async goToPage2(): Promise { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: async (pageNum) => + this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false), + }); + const item = this.page.locator('.ant-pagination-item-2').first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, '翻到第 2 页'); + } else { + await this.click(this.locators.nextPage, '下一页'); + } + await this.waitForTableReady(); + await expect(this.page.locator('.ant-pagination-item-active').first(), '当前页应为第 2 页').toHaveText('2'); + expect(await this.getDataRowCount(), '第 2 页应有数据').toBeGreaterThan(0); + } + + async closeOverlay(): Promise { + const dialog = this.locators.dialog(); + if (await dialog.isVisible().catch(() => false)) { + const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '关闭弹层'); + } else { + await this.page.keyboard.press('Escape'); + } + await this.waitSettle(300); + } + const put = this.locators.putPage(); + if (await put.isVisible().catch(() => false)) { + const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first(); + if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表'); + else await this.page.keyboard.press('Escape'); + await this.waitSettle(300); + } + } + + /** 必填/非法拦截后应留在弹层或编辑页。 */ + async expectBlockedAndStay(): Promise { + const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先|不合法|早于|晚于|超长|最多|不超过/).first(); + await expect(this.locators.formError.first().or(hint), '应出现拦截提示').toBeVisible({ timeout: 8_000 }); + const stayed = + (await this.page.locator('.put-page:visible').count()) > 0 || + (await this.page.locator('.ant-modal:visible').count()) > 0 || + (await this.page.locator('.ant-drawer-open').count()) > 0; + expect(stayed, '拦截后页面不应关闭').toBeTruthy(); + } + + /** 制单/效期等区间筛选:起止填同一天(今日),再查询。 */ + async filterSameDayDate(label = '制单日期'): Promise { + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dd = String(today.getDate()).padStart(2, '0'); + const day = `${yyyy}-${mm}-${dd}`; + const item = this.page.locator('.ant-form-item').filter({ hasText: label }).first(); + if (!(await item.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient(`无「${label}」日期筛选`); + return; + } + const inputs = item.locator('input'); + const count = await inputs.count(); + if (count < 1) { + await this.skipWhenDataInsufficient(`「${label}」无可填日期输入`); + return; + } + await inputs.first().click({ clickCount: 3 }).catch(() => undefined); + await inputs.first().fill(day); + if (count >= 2) { + await inputs.nth(1).click({ clickCount: 3 }).catch(() => undefined); + await inputs.nth(1).fill(day); + } + await this.page.keyboard.press('Enter').catch(() => undefined); + await this.query().catch(async () => { + await this.clickButton('查询').catch(() => undefined); + await this.waitForTableReady(); + }); + await this.expectListOrEmpty(); + } +} diff --git a/pages/spd/warehouse-probe.page.ts b/pages/spd/warehouse-probe.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..301eae5016962d8651c9b1b14a9bfb11a4feee65 --- /dev/null +++ b/pages/spd/warehouse-probe.page.ts @@ -0,0 +1,139 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { warehouseData } from '../../data/warehouse.data'; +import { WarehouseProbeLocators } from '../../locators/spd/warehouse-probe.locator'; +import { WhFlowSharedPage } from './wh-flow-shared.page'; + +/** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */ +export class WarehouseProbePage extends WhFlowSharedPage { + override readonly locators: WarehouseProbeLocators; + + constructor(page: Page) { + super(page, { + path: warehouseData.probePath, + pathPart: 'storehouse/probe', + group: warehouseData.menuGroup, + menu: warehouseData.menuProbe, + forbidden: ['storehouse/area'], + }); + this.locators = new WarehouseProbeLocators(page); + } + + override async waitContentReady(): Promise { + await this.locators.queryBtn.or(this.page.locator('.ant-table-thead th').first()).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitSpinGone(); + } + + async fillProbeId(value: string): Promise { + await this.fill(this.locators.probeIdInput(), value, '填写探针ID'); + } + + async fillHostCode(value: string): Promise { + await this.fill(this.locators.hostCodeInput(), value, '填写主机编码'); + } + + async queryByProbeId(id: string): Promise { + await this.fillProbeId(id); + await this.query(); + } + + async queryByHostCode(code: string): Promise { + await this.fillHostCode(code); + await this.query(); + } + + async queryByBindFlag(flag: string): Promise { + await this.selectFormOption('是否绑定货位', flag); + await this.query(); + } + + async queryCombo(probeId: string, hostCode: string): Promise { + await this.fillProbeId(probeId); + await this.fillHostCode(hostCode); + await this.query(); + } + + async searchMockEmpty(): Promise { + await this.queryByProbeId(warehouseData.mockProbe); + } + + async openBindModal(): Promise { + const row = await this.firstRow(); + await this.click(this.rowAction(row, '绑定货位'), '绑定货位'); + const modal = this.locators.bindModal().or(this.page.locator('.ant-modal:visible')).first(); + await expect(modal, '货位绑定探针弹窗').toBeVisible({ timeout: 15_000 }); + await this.waitSpinGone(); + return modal; + } + + async modalTitle(modal: Locator): Promise { + return ((await modal.locator('.ant-modal-title').innerText().catch(() => '')) ?? '').trim(); + } + + async modalTotal(modal: Locator): Promise { + const text = ((await modal.locator('.ant-pagination-total-text').first().textContent().catch(() => '')) ?? ''); + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async confirmCount(modal: Locator): Promise { + const text = ((await this.locators.confirmCountBtn(modal).innerText().catch(() => '')) ?? '').replace(/\s/g, ''); + const match = text.match(/(\d+)/); + return match ? Number(match[1]) : 0; + } + + async cancelModal(modal: Locator): Promise { + const cancel = modal.getByRole('button', { name: /取\s*消/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '取 消'); + } else { + await this.page.keyboard.press('Escape'); + } + await expect(modal).toBeHidden({ timeout: 8_000 }).catch(() => undefined); + await this.waitForTableReady(); + } + + async modalQuery(modal: Locator): Promise { + await this.click(modal.getByRole('button', { name: /查\s*询/ }).first(), '弹窗查询'); + await this.waitSpinGone(); + await this.waitSettle(300); + } + + async modalReset(modal: Locator): Promise { + const reset = modal.getByRole('button', { name: /重\s*置/ }).first(); + await this.click(reset, '弹窗重置'); + await this.waitSpinGone(); + await this.waitSettle(300); + } + + async fillModalShelfQuery(modal: Locator, keyword: string): Promise { + const input = modal.locator('.ant-form-item').filter({ hasText: /货位查询|货位/ }).locator('input').first() + .or(modal.locator('#form_item_shelfNo')); + if (await input.isVisible().catch(() => false)) { + await this.fill(input, keyword, '填写货位查询'); + } + } + + async defaultModalWarehouse(modal: Locator): Promise { + const select = modal.locator('.ant-form-item').filter({ hasText: '当前库房' }).locator('.ant-select-selection-item').first(); + return ((await select.innerText().catch(() => '')) ?? '').trim(); + } + + async modalRows(modal: Locator): Promise { + return modal.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + } + + async toggleFirstModalRow(modal: Locator, check: boolean): Promise { + const row = (await this.modalRows(modal)).first(); + if (!(await row.isVisible().catch(() => false))) { + await this.skipWhenDataInsufficient('弹窗货位列表无数据'); + return; + } + const box = row.locator('.ant-checkbox-input, .ant-checkbox').first(); + const checked = await row.locator('.ant-checkbox-checked').count().then((n) => n > 0); + if (check !== checked) { + await this.click(box, check ? '勾选货位' : '取消勾选货位'); + await this.waitSettle(200); + } + } +} diff --git a/pages/spd/warehouse-zone.page.ts b/pages/spd/warehouse-zone.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bd68f35914e0ba0eda586ebd1d85d37d4492c1e --- /dev/null +++ b/pages/spd/warehouse-zone.page.ts @@ -0,0 +1,159 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { warehouseData } from '../../data/warehouse.data'; +import { WarehouseZoneLocators } from '../../locators/spd/warehouse-zone.locator'; +import { WhFlowSharedPage } from './wh-flow-shared.page'; + +/** 库区设置:动作与取数;断言在 tests/spd/warehouse-zone.spec.ts。 */ +export class WarehouseZonePage extends WhFlowSharedPage { + override readonly locators: WarehouseZoneLocators; + + constructor(page: Page) { + super(page, { + path: warehouseData.zonePath, + pathPart: 'platform/storehouse/area', + group: warehouseData.menuGroup, + menu: warehouseData.menuZone, + forbidden: ['storehouse/probe'], + }); + this.locators = new WarehouseZoneLocators(page); + } + + override async waitContentReady(): Promise { + await this.locators.tree.or(this.locators.addBtn).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitSpinGone(); + } + + async visibleTreeNames(): Promise { + const nodes = this.locators.treeTitles(); + const count = await nodes.count(); + const names: string[] = []; + for (let i = 0; i < count; i += 1) { + const node = nodes.nth(i); + if (!(await node.isVisible().catch(() => false))) continue; + const text = ((await node.innerText()) ?? '').trim(); + if (text) names.push(text); + } + return names; + } + + async selectedWarehouse(): Promise { + const selected = this.locators.selectedTreeTitle(); + if (await selected.isVisible().catch(() => false)) { + return ((await selected.innerText()) ?? '').trim(); + } + const names = await this.visibleTreeNames(); + return names[0] ?? ''; + } + + async filterTree(keyword: string): Promise { + await this.fill(this.locators.warehouseNameInput, keyword, '填写库房名称'); + await this.waitSettle(400); + await this.waitSpinGone(); + } + + async clearTreeFilter(): Promise { + await this.fill(this.locators.warehouseNameInput, '', '清空库房名称'); + await this.waitSettle(400); + await this.waitSpinGone(); + } + + async clickWarehouse(name: string): Promise { + const node = this.locators.treeTitles().filter({ hasText: name }).first(); + await this.click(node, `选中库房 ${name}`); + await this.waitForTableReady(); + } + + async switchOtherWarehouse(): Promise { + const current = await this.selectedWarehouse(); + const names = await this.visibleTreeNames(); + const other = names.find((name) => name && name !== current); + if (!other) { + await this.skipWhenDataInsufficient('库房树仅 1 个节点'); + return null; + } + await this.clickWarehouse(other); + return other; + } + + async searchZone(keyword: string): Promise { + await this.fill(this.locators.zoneSearch, keyword, '填写库区编号/名称'); + const icon = this.page.locator('.ant-input-search-button, .ant-input-suffix .anticon-search').first(); + if (await icon.isVisible().catch(() => false)) { + await this.click(icon, '搜索库区'); + } else { + await this.page.keyboard.press('Enter'); + } + await this.waitForTableReady(); + } + + async defaultZoneTags(): Promise { + return this.page.getByText('默认库区', { exact: true }); + } + + async openAddDialog(): Promise { + await this.click(this.locators.addBtn, '新 增'); + const dialog = this.locators.dialog(); + await expect(dialog, '新增库区弹窗').toBeVisible({ timeout: 12_000 }); + return dialog; + } + + async fillAddDraft(zoneNo: string, zoneName: string): Promise { + await this.fill(this.locators.shelfNo, zoneNo, '库区编号'); + await this.fill(this.locators.shelfName, zoneName, '库区名称'); + } + + async cancelDialog(): Promise { + const dialog = this.locators.dialog(); + const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '取 消'); + } else { + await this.page.keyboard.press('Escape'); + } + await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined); + } + + async confirmDialog(): Promise { + const dialog = this.locators.dialog(); + await this.click(dialog.getByRole('button', { name: /确\s*定/ }).first(), '确 定'); + await this.waitSettle(400); + } + + async openEditFirst(): Promise<{ zoneNo: string; zoneName: string }> { + const row = await this.firstRow(); + const zoneNo = await this.tryCell(row, '库区编号'); + const zoneName = await this.tryCell(row, '库区名称'); + await this.click(this.rowAction(row, '编辑'), '编辑'); + const dialog = this.locators.dialog(); + await expect(dialog, '编辑库区弹窗').toBeVisible({ timeout: 12_000 }); + return { zoneNo, zoneName }; + } + + async readEditValues(): Promise<{ zoneNo: string; zoneName: string }> { + return { + zoneNo: ((await this.locators.shelfNo.inputValue().catch(() => '')) ?? '').trim(), + zoneName: ((await this.locators.shelfName.inputValue().catch(() => '')) ?? '').trim(), + }; + } + + async dialogTitle(): Promise { + const title = this.page.locator('.ant-modal-title, .ant-drawer-title').first(); + return ((await title.innerText().catch(() => '')) ?? '').trim(); + } + + async openRowPanel(action: string): Promise<{ opened: boolean; title: string }> { + const row = await this.firstRow(); + await this.click(this.rowAction(row, action), action); + await this.waitSettle(800); + const overlay = this.overlay(); + const opened = await overlay.first().isVisible().catch(() => false); + const titleLoc = overlay.locator('.ant-modal-title, .ant-drawer-title, .put-page-title, h1, h2, .ant-page-header-heading-title').first(); + const title = opened ? ((await titleLoc.innerText().catch(() => '')) ?? '').trim() : ''; + return { opened, title }; + } + + async stillOnZonePage(): Promise { + return this.page.url().includes('platform/storehouse/area'); + } +} diff --git a/pages/spd/wh-flow-shared.page.ts b/pages/spd/wh-flow-shared.page.ts new file mode 100644 index 0000000000000000000000000000000000000000..376d9ff135b38b920449a61dc4ed803a2612d91f --- /dev/null +++ b/pages/spd/wh-flow-shared.page.ts @@ -0,0 +1,348 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { env } from '../../config/env.config'; +import { BasePage } from '../../core/base.page'; +import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator'; +import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; + +export type WhFlowMeta = { + path: string; + pathPart: string; + group: string; + menu: string; + forbidden?: string[]; +}; + +/** 库房管理 / 流程设置共用:无 iframe、侧栏叶子菜单、表格、弹窗。 */ +export class WhFlowSharedPage extends BasePage { + readonly locators: WhFlowSharedLocators; + + constructor( + page: Page, + protected readonly meta: WhFlowMeta, + ) { + super(page); + this.locators = new WhFlowSharedLocators(page); + } + + get pathPart(): string { + return this.meta.pathPart; + } + + get pagePath(): string { + return this.meta.path; + } + + async open(): Promise { + await this.goto(this.meta.path); + await this.waitSpinGone(); + await this.waitSettle(400); + await this.waitContentReady(); + await this.assertUiReady(`打开 ${this.meta.path}`); + expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); + } + + async openFromMenu(): Promise { + await this.goto(env.homePath); + await this.waitSpinGone(); + await this.click(this.locators.menuBasic, '展开基础设置'); + const title = this.locators.groupSubmenuTitle(this.meta.group).first(); + const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first(); + const opened = await submenu + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!opened) await this.click(title, `展开${this.meta.group}`); + await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`); + await this.page.waitForURL((url) => { + const pathname = url.pathname; + if (!pathname.includes(this.meta.pathPart)) return false; + return !(this.meta.forbidden ?? []).some((token) => pathname.includes(token)); + }, { timeout: 20_000 }); + await this.waitSpinGone(); + await this.waitSettle(600); + await this.waitContentReady(); + await this.assertUiReady(`菜单进入${this.meta.menu}`); + } + + async waitContentReady(): Promise { + const markers = this.page + .locator( + '.ant-table-thead th, .ant-empty, .ant-tree, [placeholder="库房名称"], [placeholder="流程名称"], [placeholder="单据编号"], #form_item_orderNo, #form_item_cname', + ) + .first(); + await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined); + await this.waitForTableReady({ health: 'ui' }); + } + + async expectOnPage(): Promise { + expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); + for (const part of this.meta.forbidden ?? []) { + expect(this.page.url(), `不应进入 ${part}`).not.toContain(part); + } + await this.waitContentReady(); + } + + button(name: string, root?: Locator): Locator { + return this.locators.button(name, root ?? this.page); + } + + async clickButton(name: string, root?: Locator): Promise { + await this.click(this.button(name, root).first(), name); + } + + async query(): Promise { + await this.assertBeforeBusinessAction('查询'); + await this.clickButton('查询'); + await this.waitForTableReady(); + } + + async reset(): Promise { + await this.assertBeforeBusinessAction('重置'); + await this.clickButton('重置'); + await this.waitForTableReady(); + } + + textInput(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + const byRole = scope.getByRole('textbox', { name: label }).first(); + const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first(); + const byPlaceholder = scope.getByPlaceholder(label).first(); + return byRole.or(byForm).or(byPlaceholder); + } + + formSelect(label: string, root?: Locator): Locator { + const scope = root ?? this.page.locator('body'); + return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first(); + } + + async fillFilter(label: string, value: string): Promise { + const input = this.textInput(label); + await expect(input, `筛选「${label}」`).toBeVisible(); + await this.fill(input, value, `填写${label}`); + } + + async selectFormOption(label: string, optionText: string): Promise { + const select = this.formSelect(label); + await expect(select, `筛选「${label}」`).toBeVisible(); + await this.click(select, `打开${label}`); + const compact = optionText.replace(/\s/g, '').slice(0, 12); + const escaped = compact.slice(0, 8).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const option = this.page + .locator('.ant-select-dropdown:visible .ant-select-item-option') + .filter({ hasText: new RegExp(escaped) }) + .first(); + await this.click(option, `选择 ${optionText}`); + await this.waitSettle(200); + } + + async headerCells(): Promise { + await this.page + .locator('.ant-table-thead th') + .first() + .waitFor({ state: 'attached', timeout: 12_000 }) + .catch(() => undefined); + const groups = this.page.locator('.ant-table-thead'); + const count = await groups.count(); + let best = this.page.locator('.ant-table-thead th'); + let bestLen = 0; + for (let i = 0; i < count; i += 1) { + const body = groups.nth(i); + const insideOverlay = await body.evaluate((el) => Boolean(el.closest('.ant-modal, .ant-drawer, .put-page'))).catch(() => false); + if (insideOverlay) continue; + const cells = body.locator('th'); + const len = await cells.count(); + if (len > bestLen) { + bestLen = len; + best = cells; + } + } + return best; + } + + async headerTexts(): Promise { + return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, '')); + } + + async headerIndex(name: string): Promise { + const texts = await this.headerTexts(); + const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, ''))); + expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); + return index; + } + + async listRows(): Promise { + const bodies = this.page.locator('.ant-table-tbody'); + const count = await bodies.count(); + let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); + let bestWidth = -1; + for (let i = 0; i < count; i += 1) { + const body = bodies.nth(i); + const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page,.ant-modal,.ant-drawer'))).catch(() => false); + if (insidePut) continue; + const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first(); + const width = await row.locator('td').count().catch(() => 0); + if (width > bestWidth) { + bestWidth = width; + best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)'); + } + } + return best; + } + + async getDataRowCount(): Promise { + return (await this.listRows()).count(); + } + + async getTotalCount(): Promise { + const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; + const match = text.match(/共\s*(\d+)\s*条/); + return match ? Number(match[1]) : 0; + } + + async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise { + await this.waitSpinGone(); + await this.waitSettle(200); + await this.assertAfterTableWait('列表加载', options?.health ?? 'business'); + } + + async cellText(row: Locator, header: string): Promise { + const index = await this.headerIndex(header); + return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); + } + + async tryCell(row: Locator, header: string): Promise { + try { + return (await this.cellText(row, header)).trim(); + } catch { + return ''; + } + } + + async firstRow(): Promise { + const row = (await this.listRows()).first(); + await expect(row, '列表至少应有 1 条数据').toBeVisible(); + return row; + } + + async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> { + await this.waitForTableReady(); + const rows = await this.getDataRowCount(); + const total = await this.getTotalCount(); + const emptyVisible = + (await this.locators.empty.isVisible().catch(() => false)) || + (await this.page.getByText('暂无数据').first().isVisible().catch(() => false)); + return { rows, total, emptyVisible }; + } + + async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise { + const needle = keyword.replace(/\s/g, '').slice(0, 12); + const rows = await this.listRows(); + const count = await rows.count(); + if (count === 0) return false; + const limit = Math.min(count, sample); + for (let i = 0; i < limit; i += 1) { + const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, ''); + if (!text.includes(needle)) return false; + } + return true; + } + + async sampleCell(header: string): Promise { + const rows = await this.listRows(); + const count = Math.min(await rows.count(), 8); + for (let i = 0; i < count; i += 1) { + const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); + if (text && text !== '--' && text !== '-') return text; + } + await this.skipWhenDataInsufficient(`首屏「${header}」为空`); + return ''; + } + + rowAction(row: Locator, name: string): Locator { + const compact = name.replace(/\s/g, ''); + const pattern = new RegExp(compact.split('').join('\\s*')); + return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first(); + } + + async goToPage2(): Promise<{ firstKey: string; secondKey: string }> { + await skipIfInsufficientForPage2({ + ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason), + waitForTableReady: () => this.waitForTableReady(), + getDataRowCount: () => this.getDataRowCount(), + hasPaginationItem: async (pageNum) => + this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false), + }); + const firstKey = await this.tryCell(await this.firstRow(), '单据编号'); + const item = this.page.locator('.ant-pagination-item-2').first(); + if (await item.isVisible().catch(() => false)) { + await this.click(item, '翻到第 2 页'); + } else { + await this.click(this.locators.nextPage, '下一页'); + } + await this.waitForTableReady(); + const secondKey = await this.tryCell(await this.firstRow(), '单据编号'); + return { firstKey, secondKey }; + } + + overlay(): Locator { + return this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open')); + } + + async closeOverlay(): Promise { + const dialog = this.locators.dialog(); + if (await dialog.isVisible().catch(() => false)) { + const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first(); + if (await cancel.isVisible().catch(() => false)) { + await this.click(cancel, '关闭弹层'); + } else { + await this.page.keyboard.press('Escape'); + } + await this.waitSettle(300); + } + const put = this.locators.putPage(); + if (await put.isVisible().catch(() => false)) { + const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first(); + if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表'); + else await this.page.keyboard.press('Escape'); + await this.waitSettle(300); + } + } + + async interceptState(): Promise<{ hintVisible: boolean; overlayOpen: boolean }> { + const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先|不合法/).first(); + const hintVisible = await this.locators.formError.first().or(hint).isVisible().catch(() => false); + const overlayOpen = + (await this.page.locator('.put-page:visible').count()) > 0 || + (await this.page.locator('.ant-modal:visible').count()) > 0 || + (await this.page.locator('.ant-drawer-open').count()) > 0; + return { hintVisible, overlayOpen }; + } + + async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> { + await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' }); + await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined); + const onLogin = /\/login/i.test(this.page.url()); + const hasBusinessRow = await this.page + .locator('.ant-table-tbody tr.ant-table-row, .ant-tree-title') + .first() + .or(this.page.getByText('默认审批流')) + .isVisible() + .catch(() => false); + return { onLogin, hasBusinessRow }; + } + + async visibleSelectOptions(label: string): Promise { + const select = this.formSelect(label); + if (!(await select.isVisible().catch(() => false))) return []; + await this.click(select, `打开${label}`); + const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option'); + const count = await options.count(); + const names: string[] = []; + for (let i = 0; i < Math.min(count, 40); i += 1) { + const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, ''); + if (text) names.push(text); + } + await this.page.keyboard.press('Escape').catch(() => undefined); + return names; + } +} diff --git a/playwright.config.ts b/playwright.config.ts index aca03093e1bd9e8413467d716c3cb43ec87a582b..4f56446b32e4fed51e1b98dd33913951b081f3df 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,6 @@ import { import { env } from './config/env.config'; import { applyPlaywrightBrowsersPath } from './config/playwright-browsers-path'; import { timeouts } from './config/timeouts.config'; -import { AUTH_STATE_PATH } from './utils/path.util'; applyPlaywrightBrowsersPath(); @@ -35,10 +34,8 @@ function resolveGrepInvert(): RegExp | undefined { return new RegExp(parts.join('|')); } -/** 调试/演示专用 *.stat-*.spec.ts 与主 spec 重复;CI 默认不跑,本地按文件执行不受影响。 */ +/** 历史调试文件 *.stat-*.spec.ts 与主 spec 重复,一律不收集。 */ function resolveDebugSpecIgnore(): RegExp[] { - if (process.env.INCLUDE_DEBUG_SPECS === '1') return []; - if (!isCi() && process.env.EXCLUDE_DEBUG_SPECS !== '1') return []; return [/\.stat-[^/]+\.spec\.ts$/]; } @@ -65,6 +62,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] { } reporters.push(['./reporters/flake.reporter.ts']); + reporters.push(['./reporters/failure-trend.reporter.ts']); reporters.push(['./reporters/gate.reporter.ts']); return reporters; @@ -92,13 +90,16 @@ export default defineConfig({ actionTimeout: timeouts.action, }, projects: [ + // 一次性登录 → 写入 auth/user.json;业务 project 只读文件,不共享 BrowserContext { name: 'setup', testMatch: /auth\.setup\.ts/, + timeout: timeouts.authSetup, }, { name: 'login', testMatch: /login\.spec\.ts/, + dependencies: ['setup'], use: { browserName: 'chromium', storageState: { cookies: [], origins: [] }, @@ -110,7 +111,7 @@ export default defineConfig({ testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/, ...resolveDebugSpecIgnore()], use: { browserName: 'chromium', - storageState: AUTH_STATE_PATH, + // storageState 由 fixtures/index.ts 按 worker 注入(支持双账号) }, }, ], diff --git a/reporters/failure-trend.reporter.ts b/reporters/failure-trend.reporter.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5b923835563e0cb8b7abc38e0aec0b19c1843fe --- /dev/null +++ b/reporters/failure-trend.reporter.ts @@ -0,0 +1,65 @@ +import path from 'node:path'; + +import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter'; + +import { + isFrequentFailure, + mergeAndSaveFailureStore, + recordOutcome, + refreshFrequentFailuresReport, + loadFailureStore, +} from '../core/failure-tracker'; + +/** + * 失败频次趋势 reporter(权威计数): + * - reporter 的 onTestEnd 在主进程**每次用例结束都会触发**,即使 fixture/浏览器启动失败也不会漏, + * 因此把「跨构建累计 + 落盘」放在这里最稳。 + * - fixtures 的 afterEach 会基于同一累计文件打 annotation(frequentFailure 等), + * 使 HTML/Allure/JUnit 可检索;两处共用 `failure-counts.json`,reporter 负责最终落盘。 + * - onEnd 刷新 markdown 榜单并在日志打印命中项。 + */ +export default class FailureTrendReporter implements Reporter { + private store = loadFailureStore(); + + private dirty = false; + + private frequent = new Map(); + + onTestEnd(test: TestCase, result: TestResult): void { + if (test.parent.project()?.name === 'setup') return; + const cls = test.annotations.find((item) => item.type === 'failureClass')?.description; + const stat = recordOutcome(this.store, { + title: test.title, + file: test.location.file, + status: result.status, + failureClass: cls, + }); + if (!stat) return; + this.dirty = true; + if (isFrequentFailure(stat, this.store)) { + const rate = stat.runs > 0 ? stat.failed / stat.runs : 0; + this.frequent.set( + stat.id, + `${stat.id} ${stat.failed}/${stat.runs} (${(rate * 100).toFixed(0)}%) ${shortTitle(test.title)}`, + ); + } + } + + onEnd(_result: FullResult): void { + if (this.dirty) mergeAndSaveFailureStore(this.store); + const file = refreshFrequentFailuresReport(); + if (!this.frequent.size) return; + // eslint-disable-next-line no-console + console.log( + `[failure-trend] 经常失败的用例 ${this.frequent.size} 条(榜单 ${path.relative(process.cwd(), file)}):`, + ); + for (const item of [...this.frequent.values()].slice(0, 20)) { + // eslint-disable-next-line no-console + console.log(` - ${item}`); + } + } +} + +function shortTitle(title: string): string { + return title.length > 80 ? `${title.slice(0, 77)}...` : title; +} diff --git a/scripts/_add-combination-po.py b/scripts/_add-combination-po.py new file mode 100644 index 0000000000000000000000000000000000000000..dcb1cec745e8c4904721cf6e4b0dc60ae40ba975 --- /dev/null +++ b/scripts/_add-combination-po.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +"""为 13 个基础管理页 PO 追加「组合查询」方法(按实测筛选区字段,幂等)。""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# 字段来自 2026-10-20 实测(.ant-form-item label),kind: text / select / dateRange +PAGES = { + "admit-product-list.page.ts": dict( + cls="AdmitProductListPage", + method="filterCombination", + before=" async filterByProductOrFallback()", + fields=[ + ("制单日期", "dateRange"), + ("销售商", "text"), + ("耗材类型", "select"), + ("器械注册人", "text"), + ("注册证号", "text"), + ("供应商", "text"), + ], + value="样本", + ), + "admit-material-list.page.ts": dict( + cls="AdmitMaterialListPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("销售商", "text"), + ("耗材类型", "select"), + ("器械注册人", "text"), + ("注册证号", "text"), + ("供应商", "text"), + ], + value="样本", + ), + "admit-supply.page.ts": dict( + cls="AdmitSupplyPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("销售商", "select"), + ("供应商", "select"), + ("变更单号", "text"), + ("物料名称", "text"), + ("规格型号", "text"), + ], + value="样本", + ), + "admit-task.page.ts": dict( + cls="AdmitTaskPage", + method="filterCombination", + before=None, + fields=[ + ("新增日期", "dateRange"), + ("操作日期", "dateRange"), + ("销售商", "text"), + ("物料名称", "text"), + ("注册证号", "text"), + ("操作人", "text"), + ("器械注册人", "text"), + ("耗材类型", "select"), + ("规格型号", "text"), + ("准入类型", "select"), + ("准入状态", "select"), + ], + value="样本", + ), + "vendor-qual.page.ts": dict( + cls="VendorQualPage", + method="filterCombination", + before=None, + fields=[ + ("进院供应商名称", "text"), + ("证件类型", "select"), + ("授权书名称", "text"), + ("授权单位", "text"), + ("被授权单位", "text"), + ], + value="样本", + ), + "vendor-auth-first.page.ts": dict( + cls="VendorAuthFirstPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("单号", "text"), + ("进院供应商", "select"), + ("预转供应商", "text"), + ("状态", "select"), + ("变更类型", "select"), + ("原供应商", "text"), + ("医院ID", "text"), + ("医院名称", "text"), + ], + value="样本", + ), + "vendor-auth-final.page.ts": dict( + cls="VendorAuthFinalPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("单号", "text"), + ("进院供应商", "select"), + ("预转供应商", "text"), + ("状态", "select"), + ("变更类型", "select"), + ("原供应商", "text"), + ], + value="样本", + ), + "vendor-cert-first.page.ts": dict( + cls="VendorCertFirstPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("单号", "text"), + ("变更类型", "select"), + ("注册证号(新)", "text"), + ("状态", "select"), + ("变更情况具体说明", "text"), + ], + value="样本", + ), + "vendor-cert-final.page.ts": dict( + cls="VendorCertFinalPage", + method="filterCombination", + before=None, + fields=[ + ("制单日期", "dateRange"), + ("单号", "text"), + ("变更类型", "select"), + ("注册证号(新)", "text"), + ("状态", "select"), + ], + value="样本", + ), + "vendor-cert-diff.page.ts": dict( + cls="VendorCertDiffPage", + method="filterCombination", + before=" async filterByMaterialSample()", + fields=[ + ("物料名称", "text"), + ("规格型号", "text"), + ("状态", "select"), + ("望海注册证号", "text"), + ("是否医疗器械", "select"), + ("生产厂家", "text"), + ("FLI+注册证号", "text"), + ("27位医保编码", "text"), + ], + value="样本", + ), + "vendor-eval-scheme.page.ts": dict( + cls="VendorEvalSchemePage", + method="filterCombination", + before=None, + fields=[ + ("创建日期", "dateRange"), + ("启用日期", "dateRange"), + ("方案编码", "text"), + ("方案名称", "text"), + ], + value="样本", + ), + "vendor-eval-indicator.page.ts": dict( + cls="VendorEvalIndicatorPage", + method="filterCombination", + before=None, + fields=[ + ("指标编码", "text"), + ("指标名称", "text"), + ], + value="样本", + ), + "surgery-base-pkg.page.ts": dict( + cls="SurgeryBasePkgPage", + method="filterCombination", + before=None, + fields=[ + ("产品名称", "text"), + ("规格型号", "text"), + ("器械注册人", "text"), + ("耗材类型", "select"), + ("是否收费", "select"), + ], + value="样本", + ), + "surgery-std-pkg.page.ts": dict( + cls="SurgeryStdPkgPage", + method="filterCombination", + before=None, + fields=[ + ("产品名称", "text"), + ("规格型号", "text"), + ("器械注册人", "text"), + ("耗材类型", "select"), + ("是否收费", "select"), + ], + value="样本", + ), +} + + +def method_src(spec: dict) -> str: + rows = "\n".join( + f" {{ label: '{label}', kind: '{kind}', value: '{spec['value']}' }}," + for label, kind in spec["fields"] + ) + names = "、".join(lbl for lbl, _ in spec["fields"]) + return f""" /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ + async {spec['method']}(): Promise {{ + const scope = await this.queryScope(); + await this.applyFilterCombination( + [ +{rows} + ], + {{ scope }}, + ); + expect('{names}'.split('、').length, '组合条件应至少 2 个').toBeGreaterThanOrEqual(2); + }} + +""" + + +def add_import(text: str) -> str: + if "type FilterField" in text or "applyFilterCombination" in text: + return text + # PO 通过基类继承方法,不需要 import FilterField;此处仅确保 expect 可用 + if "import { expect" not in text and "import type { Page }" in text: + text = text.replace("import type { Page } from '@playwright/test';", "import { expect, type Page } from '@playwright/test';") + return text + + +changed = 0 +for filename, spec in PAGES.items(): + path = ROOT / "pages/spd" / filename + text = path.read_text(encoding="utf-8") + if f"async {spec['method']}()" in text: + print(f"跳过(已存在) {filename}") + continue + text = add_import(text) + snippet = method_src(spec) + if spec["before"] and spec["before"] in text: + text = text.replace(spec["before"], snippet + spec["before"], 1) + else: + # 追加到类末尾 + text = text.rstrip() + assert text.endswith("}"), filename + text = text[:-1].rstrip() + "\n\n" + snippet.rstrip() + "\n}\n" + path.write_text(text, encoding="utf-8") + changed += 1 + print(f"已写入 {filename}") + +print(f"共 {changed} 个 PO") diff --git a/scripts/_list-filter-fields.py b/scripts/_list-filter-fields.py new file mode 100644 index 0000000000000000000000000000000000000000..6e6962da3eb7e1f811a4147f1209667b75b08a99 --- /dev/null +++ b/scripts/_list-filter-fields.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""列出每个基础管理页面扫描产物中的筛选类字段(输入框 / select / 日期 / 下拉触发器)。""" +import glob +import json +import os +import re +import sys + +SKIP = re.compile(r"^(确 认|确 定|取 消|查 询|重 置|筛 选|新增|添加|导 出|保 存|关 闭|刷 新)$") + + +def load(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def fields(doc): + out = [] + for e in doc.get("elements", []): + tag = (e.get("tag") or "").lower() + typ = (e.get("type") or "").lower() + eid = e.get("id") or "" + label = (e.get("label") or "").strip() + ph = (e.get("placeholder") or "").strip() + if eid.startswith(("form_item_", "table_search_form_", "basic_", "search_")): + out.append(("id", eid, label or ph)) + elif tag == "input" and typ not in ("hidden", "button", "checkbox", "radio"): + out.append(("input", ph or label, e.get("name") or "")) + elif tag == "select" or typ == "select": + out.append(("select", ph or label, "")) + elif "date" in (e.get("className") or "") or "日期" in (label + ph): + out.append(("date", label or ph, "")) + seen, uniq = set(), [] + for it in out: + key = (it[1], it[2]) + if key in seen or not (it[1] or it[2]): + continue + if SKIP.match(it[1].strip()): + continue + seen.add(key) + uniq.append(it) + return uniq + + +for path in sorted(glob.glob("data/scan/*-elements.json")): + if not os.path.basename(path).startswith(("basic-", "admit-", "vendor-", "surgery-")): + continue + doc = load(path) + fs = fields(doc) + print(f"\n### {os.path.basename(path)} ({len(fs)} 个)") + for kind, a, b in fs[:40]: + print(f" {kind:7s} {a:36s} {b}") diff --git a/scripts/add-basic-combination-cases.py b/scripts/add-basic-combination-cases.py new file mode 100644 index 0000000000000000000000000000000000000000..9bdc6c9533a3d6680482d7b8267fc6e25dcf59ec --- /dev/null +++ b/scripts/add-basic-combination-cases.py @@ -0,0 +1,115 @@ +"""为已实现 filterCombination 的页面补「组合查询」用例行(CSV + spec 注释同步)。 + +字段名直接取自各 PO 的 filterCombination,且已在真实环境验证全部生效。 +""" +from __future__ import annotations + +import csv +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# (CSV 文件名片段, 用例编号前缀, PO 文件, spec 文件, fixture, 前置条件里的 path 描述) +TARGETS = [ + ("产品准入单列表", "ADMIT-PLS", "admit-product-list.page.ts", "admit-product-list.spec.ts", "admitProductListPage"), + ("物料准入单列表", "ADMIT-MLS", "admit-material-list.page.ts", "admit-material-list.spec.ts", "admitMaterialListPage"), + ("供货目录申请", "ADMIT-SUP", "admit-supply.page.ts", "admit-supply.spec.ts", "admitSupplyPage"), + ("准入任务分配", "ADMIT-TSK", "admit-task.page.ts", "admit-task.spec.ts", "admitTaskPage"), + ("基础包维护", "SURG-BASE", "surgery-base-pkg.page.ts", "surgery-base-pkg.spec.ts", "surgeryBasePkgPage"), + ("标准套包维护", "SURG-STD", "surgery-std-pkg.page.ts", "surgery-std-pkg.spec.ts", "surgeryStdPkgPage"), + ("供应商资质管理", "VENDOR-QUL", "vendor-qual.page.ts", "vendor-qual.spec.ts", "vendorQualPage"), + ("授权变更初审", "VENDOR-AF1", "vendor-auth-first.page.ts", "vendor-audit.spec.ts", "vendorAuthFirstPage"), + ("授权变更终审", "VENDOR-AF2", "vendor-auth-final.page.ts", "vendor-audit.spec.ts", "vendorAuthFinalPage"), + ("注册证变更初审", "VENDOR-CF1", "vendor-cert-first.page.ts", "vendor-audit.spec.ts", "vendorCertFirstPage"), + ("注册证变更终审", "VENDOR-CF2", "vendor-cert-final.page.ts", "vendor-audit.spec.ts", "vendorCertFinalPage"), + ("注册证差异对比", "VENDOR-CDF", "vendor-cert-diff.page.ts", "vendor-cert-diff.spec.ts", "vendorCertDiffPage"), + ("评价指标维护", "VENDOR-EVI", "vendor-eval-indicator.page.ts", "vendor-eval-indicator.spec.ts", "vendorEvalIndicatorPage"), + ("评价方案维护", "VENDOR-EVS", "vendor-eval-scheme.page.ts", "vendor-eval-scheme.spec.ts", "vendorEvalSchemePage"), +] + + +def po_fields(po: str) -> list[tuple[str, str]]: + text = (ROOT / "pages/spd" / po).read_text(encoding="utf-8") + block = re.search(r"async filterCombination\(\).*?\n \}", text, re.S) + if not block: + raise SystemExit(f"{po} 缺少 filterCombination") + return re.findall(r"label: '([^']+)', kind: '(\w+)'", block.group(0)) + + +def spec_group(spec: str, prefix: str) -> str: + """取 spec 中已存在用例所属的 describe 标题,保证新增用例落在同一分组下。""" + text = (ROOT / "tests/spd" / spec).read_text(encoding="utf-8") + found = None + last = None + for line in text.splitlines(): + m = re.match(r"\s*test\.describe\('([^']+)'", line) + if m: + found = m.group(1) + if re.search(rf"test\('{prefix}-", line) and found: + last = found + if last and "页面进入" not in last: + return last + return f"{prefix} 筛选与列表" + + +def build_row(prefix: str, next_no: int, module: str, fields: list[tuple[str, str]], page_desc: str) -> list[str]: + names = [f for f, _ in fields] + steps = ["1. 进入页面并等待列表加载"] + for i, (name, kind) in enumerate(fields, start=2): + if kind == "select": + steps.append(f"{i}. 「{name}」下拉选择任一可选项") + elif kind == "dateRange": + steps.append(f"{i}. 「{name}」日期区间选择当天") + else: + steps.append(f"{i}. 「{name}」填写关键字") + steps.append(f"{len(fields) + 2}. 点击「查询」") + steps.append(f"{len(fields) + 3}. 检查列表返回结果") + cond = ( + f"测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「{module}」权限;" + f"已进入{page_desc};筛选区已展开" + ) + expect = ( + f"多条件同时生效:{('、'.join(names))} 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示)," + f"列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系" + ) + return [ + f"{prefix}-{next_no:03d}", + module, + f"{module.split(' · ')[-1]} · 组合查询", + f"多条件组合查询({names[0]}等{len(names)}项)", + "高", + cond, + "\n".join(steps), + expect, + "待执行", + ] + + +def append_csv(path: Path, row: list[str]) -> None: + with path.open("a", encoding="utf-8", newline="") as fh: + csv.writer(fh, quoting=csv.QUOTE_ALL, lineterminator="\n").writerow(row) + + +def main() -> None: + for frag, prefix, po, spec, fixture in TARGETS: + matches = list((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv")) + if len(matches) != 1: + raise SystemExit(f"CSV 匹配异常:{frag} -> {matches}") + csv_path = matches[0] + rows = list(csv.reader(csv_path.open(encoding="utf-8"))) + if any(r and r[0].startswith(f"{prefix}-") and "组合查询" in r[2] for r in rows[1:]): + print(f"跳过(已有组合查询):{csv_path.name}") + continue + ids = [int(r[0].split("-")[-1]) for r in rows[1:] if r and r[0].startswith(prefix + "-")] + next_no = max(ids) + 1 + module = rows[1][1] + fields = po_fields(po) + page_desc = f"{module.split(' · ')[-1]}页" + append_csv(csv_path, build_row(prefix, next_no, module, fields, page_desc)) + group = spec_group(spec, prefix) + print(f"{prefix}-{next_no:03d} {csv_path.name} | {len(fields)} 项 | spec 分组:{group} | {fixture}") + + +if __name__ == "__main__": + main() diff --git a/scripts/add-basic-combination-spec.py b/scripts/add-basic-combination-spec.py new file mode 100644 index 0000000000000000000000000000000000000000..2ed8f8368e5a037762335e67ab8dcbd176436bae --- /dev/null +++ b/scripts/add-basic-combination-spec.py @@ -0,0 +1,70 @@ +"""把「组合查询」用例插入对应 spec 的筛选分组末尾(幂等,可重复执行)。""" +from __future__ import annotations + +import csv +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# CSV 片段 -> (spec 文件, 目标 describe 标题, fixture 名) +TARGETS = { + "产品准入单列表": ("admit-product-list.spec.ts", "产品准入单列表 · 筛选与状态", "admitProductListPage"), + "物料准入单列表": ("admit-material-list.spec.ts", "物料准入单列表 · 筛选与状态", "admitMaterialListPage"), + "供货目录申请": ("admit-supply.spec.ts", "供货目录申请 · 筛选与状态", "admitSupplyPage"), + "准入任务分配": ("admit-task.spec.ts", "准入任务分配 · 筛选与分类", "admitTaskPage"), + "基础包维护": ("surgery-base-pkg.spec.ts", "基础包维护 · 右侧筛选与列表", "surgeryBasePkgPage"), + "标准套包维护": ("surgery-std-pkg.spec.ts", "标准套包维护 · 筛选与构成列表", "surgeryStdPkgPage"), + "供应商资质管理": ("vendor-qual.spec.ts", "供应商资质管理 · 筛选与 Tab", "vendorQualPage"), + "授权变更初审": ("vendor-audit.spec.ts", "授权变更初审 · 筛选与列表", "vendorAuthFirstPage"), + "授权变更终审": ("vendor-audit.spec.ts", "授权变更终审 · 筛选与列表", "vendorAuthFinalPage"), + "注册证变更初审": ("vendor-audit.spec.ts", "注册证变更初审 · 筛选与列表", "vendorCertFirstPage"), + "注册证变更终审": ("vendor-audit.spec.ts", "注册证变更终审 · 筛选与列表", "vendorCertFinalPage"), + "注册证差异对比": ("vendor-cert-diff.spec.ts", "注册证差异对比 · 筛选与对比", "vendorCertDiffPage"), + "评价指标维护": ("vendor-eval-indicator.spec.ts", "评价指标维护 · 树与列表", "vendorEvalIndicatorPage"), + "评价方案维护": ("vendor-eval-scheme.spec.ts", "评价方案维护 · 筛选与维护", "vendorEvalSchemePage"), +} + + +def find_close_brace(lines: list[str], start: int) -> int: + """从 describe 起始行找到与之配对的收尾 ` });`。""" + depth = 0 + for idx in range(start, len(lines)): + depth += lines[idx].count("{") - lines[idx].count("}") + if depth <= 0 and idx > start: + return idx + raise SystemExit("describe 未闭合") + + +def main() -> None: + for frag, (spec, group, fixture) in TARGETS.items(): + csv_path = next((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv")) + rows = [r for r in csv.reader(csv_path.open(encoding="utf-8")) if r] + row = next((r for r in rows[1:] if "组合查询" in r[3]), None) + if not row: + print(f"跳过(CSV 无组合查询行):{csv_path.name}") + continue + case_id, title = row[0], row[3] + spec_path = ROOT / "tests/spd" / spec + text = spec_path.read_text(encoding="utf-8") + if case_id in text: + print(f"跳过(spec 已有):{case_id}") + continue + lines = text.splitlines() + start = next((i for i, l in enumerate(lines) if l.strip().startswith(f"test.describe('{group}'")), None) + if start is None: + raise SystemExit(f"{spec} 未找到分组「{group}」") + close = find_close_brace(lines, start) + block = ( + f" test('{case_id} {title}', async ({{ {fixture} }}) => {{\n" + f" await {fixture}.open();\n" + f" await {fixture}.filterCombination();\n" + f" }});" + ) + lines.insert(close, block) + spec_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"{case_id} -> {spec} · {group}") + + +if __name__ == "__main__": + main() diff --git a/scripts/align-basic-cases-format.py b/scripts/align-basic-cases-format.py new file mode 100644 index 0000000000000000000000000000000000000000..cb7aa60c6d1f43fa5e8d49f7383028fb648dab59 --- /dev/null +++ b/scripts/align-basic-cases-format.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +"""把基础管理 23 份 CSV 对齐样板「出入库明细查询」的写法(仅整形,不改用例语义)。 + +对齐项(样板 testcase/统计报表-科室出入库报表-出入库明细查询-UI自动化测试用例-01.csv): +1. 前置条件:去掉明文账号 / 密码,统一「已通过 auth.setup 写入 storageState 登录」; + 页面统一写成「进入 xxx 页(/路径,无 iframe)」,去掉域名与截图 / 扫描类运行时说明; + 若前置已带路径则不做替换(幂等)。 +2. 测试步骤:去掉「确认 chromium 已加载 storageState …」「使用用户名 test_1、密码 …」这类 + 与测试执行器相关的开场步骤,按 1..N 重新编号。 +3. 用例编号 / 标题 / 功能测试点 / 优先级 / 预期结果 / 实际结果 一律不动。 + +用法: + python3.12 scripts/align-basic-cases-format.py # 写盘 + python3.12 scripts/align-basic-cases-format.py --check # 只报告 +""" + +from __future__ import annotations + +import csv +import io +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CASE_DIR = ROOT / "testcase" +LOGIN_OK = "已通过 auth.setup 写入 storageState 登录" + +# 页面:模块显示名 / 菜单路径 / URL 路径;godoc 里对齐 data/*.data.ts +PAGES: dict[str, dict[str, str]] = { + "公告管理": {"menu": "基础设置 → 医院资料 → 公告管理", "path": "/spd/basic/hospital/paltform/notice"}, + "医院信息登记": {"menu": "基础设置 → 医院资料 → 医院信息登记", "path": "/spd/basic/hospital/paltform/info"}, + "财务月份设置": {"menu": "基础设置 → 医院资料 → 财务月份设置", "path": "/spd/basic/hospital/balance/month"}, + "院区管理": {"menu": "基础设置 → 医院资料 → 院区管理", "path": "/spd/basic/hospital/paltform/area"}, + "物料准入管理": {"menu": "基础设置 → 准入管理 → 物料准入管理", "path": "/spd/basic/dataSync/dataSync/productApply/ZRlist"}, + "产品准入申请": {"menu": "基础设置 → 准入管理 → 产品准入申请", "path": "/spd/basic/dataSync/dataSync/productApply/list"}, + "物料准入单列表": {"menu": "基础设置 → 准入管理 → 物料准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/zrlist"}, + "产品准入单列表": {"menu": "基础设置 → 准入管理 → 产品准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/list"}, + "供货目录申请": {"menu": "基础设置 → 准入管理 → 供货目录申请", "path": "/spd/basic/dataSync/dataSync/supplyApply/list"}, + "准入任务分配": {"menu": "基础设置 → 准入管理 → 准入任务分配", "path": "/spd/basic/dataSync/dataSync/accessKind/list"}, + "厂商查询": {"menu": "基础设置 → 厂商管理 → 厂商查询", "path": "/spd/basic/mfrs/paltform/mfrsName/factory"}, + "供应商资质管理": {"menu": "基础设置 → 厂商管理 → 供应商资质管理", "path": "/spd/basic/mfrs/basic/qcManage"}, + "授权变更初审": {"menu": "基础设置 → 厂商管理 → 授权变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAudit"}, + "授权变更终审": {"menu": "基础设置 → 厂商管理 → 授权变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal"}, + "注册证变更初审": {"menu": "基础设置 → 厂商管理 → 注册证变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAudit"}, + "注册证变更终审": {"menu": "基础设置 → 厂商管理 → 注册证变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal"}, + "注册证差异对比": {"menu": "基础设置 → 厂商管理 → 注册证差异对比", "path": "/spd/basic/mfrs/scjs/yc/certCompare"}, + "评价方案维护": {"menu": "基础设置 → 厂商管理 → 评价方案维护", "path": "/spd/basic/mfrs/paltform/mfrsName/evaluationPlan"}, + "评价指标维护": {"menu": "基础设置 → 厂商管理 → 评价指标维护", "path": "/spd/basic/mfrs/paltform/mfrsName/indicators"}, + "供应商节假日管理": {"menu": "基础设置 → 厂商管理 → 供应商节假日管理", "path": "/spd/basic/mfrs/basic/prov/holiday"}, + "基础包维护": {"menu": "基础设置 → 手术套包设置 → 基础包维护", "path": "/spd/basic/operationSetting/baseMaintenance"}, + "术种维护": {"menu": "基础设置 → 手术套包设置 → 术种维护", "path": "/spd/basic/operationSetting/typeMaintenance"}, + "标准套包维护": {"menu": "基础设置 → 手术套包设置 → 标准套包维护", "path": "/spd/basic/operationSetting/standardMaintenance"}, +} + +CRED_RE = re.compile( + r"(正例账号[^)]*)" # (正例账号 xxx / ****) + r"|用户名\s*\S+、密码\s*[^\s;;,,))]+" # 用户名 xxx、密码 **** + r"|密码\s*[A-Za-z0-9!@#$%^&*._-]{6,}" # 单独的明文密码片段 +) +RUNTIME_STEP_RE = re.compile( + r"^\s*\d+\.\s*(确认 chromium|使用用户名|使用 test\.use|storageState 已有效|浏览器上下文)", + re.M, +) +PAGE_PRE_RE = re.compile(r"已(?:进入|直接访问)([^;;((]+?)页(?:([^)]*))?") +NUMBER_RE = re.compile(r"^\s*(\d+)\.\s*(.*)$", re.M) +URL_BACKTICK_RE = re.compile(r"`https?://[^/`]+(/[^`]*)`") + + +def page_of(filename: str) -> str: + tail = filename.replace("-UI自动化测试用例-01.csv", "") + return tail.rsplit("-", 1)[-1] + + +def has_path(pre: str) -> bool: + return bool(re.search(r"(/|(http|/spd/", pre)) + + +PATH_PRE_RE = re.compile(r"已进入([^;;(]+?)页([^)]*)") +BAD_FRAG_RE = re.compile(r";;|登录已通过") +PATH_TAIL_RE = re.compile(r",无 iframe)$") + + +def _balanced(text: str) -> bool: + return text.count("(") == text.count(")") + + +def clean_pre(pre: str, page: str) -> str: + info = PAGES[page] + # 1) 先去掉凭据,统一成「已通过 auth.setup 写入 storageState 登录」 + text = CRED_RE.sub(LOGIN_OK, pre) + # 旧写法「已通过 auth.setup 写入 storageState 登录 已登录并进入 …页」→ 只保留一份 + text = text.replace(f"{LOGIN_OK} 已登录并进入", f"{LOGIN_OK};已进入") + text = text.replace(f"{LOGIN_OK}已登录并进入", f"{LOGIN_OK};已进入") + # 2) 页面括号统一成样板写法(先于分句,避免括号被拆开) + text = re.sub(r"((?:https?://|/spd/)[^)]*)", f"({info['path']},无 iframe)", text) + text = PATH_PRE_RE.sub(lambda m: f"已进入{m.group(1)}页({info['path']},无 iframe)", text) + # 3) 分句去重 + kept: list[str] = [] + seen_login = False + for part in (p.strip() for p in text.split(";")): + if not part: + continue + if LOGIN_OK in part: + extra = part.replace(LOGIN_OK, "").strip("() ") + if seen_login: + if not extra: + continue + part = extra + else: + part = f"{LOGIN_OK}" + (f"({extra})" if extra and not extra.startswith("无 iframe") else "") + seen_login = True + if re.fullmatch(r"已登录.*", part) or part.startswith("已登录且具备"): + continue + if part.startswith("并进入"): + part = "已进入" + part[len("并进入"):] + kept.append(part) + text = ";".join(kept) + text = re.sub(r";{2,}", ";", text).strip(";") + # 4) 未写页面时补一句「已进入 xxx 页(路径,无 iframe)」;未登录反例只写目标 URL + if not has_path(text): + parts = [p for p in text.split(";") if p.strip()] + if "无登录态" in text or "未带登录态" in text or "清除登录态" in text: + parts.append(f"直接访问{page} URL({info['path']},无 iframe)") + return ";".join(dict.fromkeys(parts)) + anchor = f"已进入{page}页({info['path']},无 iframe)" + insert_at = 1 + for i, part in enumerate(parts): + if "权限" in part: + insert_at = i + 1 + parts.insert(insert_at, anchor) + text = ";".join(parts) + # 5) 页面句只保留第一处;其后的「已进入…页」分句整体丢弃 + parts = [p.strip() for p in text.split(";") if p.strip()] + out: list[str] = [] + page_seen = False + for part in parts: + is_page_part = bool(PATH_PRE_RE.search(part)) or ( + part.startswith("已进入") and page in part + ) + if is_page_part: + if page_seen: + continue + page_seen = True + out.append(part) + text = ";".join(out) + # 6) 收尾收敛:未登录反例统一「直接访问{页面} URL(路径,无 iframe)」 + text = re.sub(rf"直接访问\s*{re.escape(page)}\s*URL([^)]*),?", f"直接访问{page} URL({info['path']},无 iframe)", text) + page_hits = re.findall(rf"已进入(?:{re.escape(page)}|{re.escape(info['path'])}|[^;]*?页)", text) + if len(page_hits) > 1: + raise ValueError(f"[{page}] 前置出现多个页面句:{text}") + if BAD_FRAG_RE.search(text) or not _balanced(text): + raise ValueError(f"[{page}] 前置整形后仍异常:{text}") + return text + + +def clean_steps(steps: str) -> str: + lines = steps.split("\n") + kept = [ln for ln in lines if not RUNTIME_STEP_RE.match(ln)] + out = [] + for i, ln in enumerate(kept, 1): + m = NUMBER_RE.match(ln) + body = m.group(2) if m else ln.strip() + if body: + body = URL_BACKTICK_RE.sub(r"`\1`", body) + out.append(f"{i}. {body}") + return "\n".join(out) + + +def main() -> None: + check_only = "--check" in sys.argv + changed = 0 + for path in sorted(CASE_DIR.glob("基础管理-*-UI自动化测试用例-01.csv")): + page = page_of(path.name) + if page not in PAGES: + print(f"跳过未知页面:{path.name}") + continue + with path.open(encoding="utf-8-sig", newline="") as fh: + rows = list(csv.reader(fh)) + head, body = rows[0], rows[1:] + if head[:9] != ["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"]: + raise SystemExit(f"{path.name} 表头与 9 列规范不一致:{head}") + new_rows = [] + for r in body: + if not r: + continue + row = list(r) + [""] * (9 - len(r)) + row[5] = clean_pre(row[5], page) + row[6] = clean_steps(row[6]) + new_rows.append(row[:9]) + buf = io.StringIO() + writer = csv.writer(buf, quoting=csv.QUOTE_ALL, lineterminator="\n") + writer.writerow(head[:9]) + writer.writerows(new_rows) + text = buf.getvalue() + old = path.read_text(encoding="utf-8") + if old.lstrip("\ufeff") == text: + continue + changed += 1 + print(f"{'待更新' if check_only else '已写入'} {path.name}") + if not check_only: + path.write_text(text, encoding="utf-8", newline="") + print(f"共 {changed} 份{'需要' if check_only else '已'}更新") + + +if __name__ == "__main__": + main() diff --git a/scripts/assert-basic-csv-spec-sync.mjs b/scripts/assert-basic-csv-spec-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..857f1e25b014d17ebf082df0ad545bbdce05a7cb --- /dev/null +++ b/scripts/assert-basic-csv-spec-sync.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** CSV 用例编号+标题必须出现在对应 spec 中。 */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const PAIRS = [ + ['testcase/基础管理-医院资料-公告管理-UI自动化测试用例-01.csv', 'tests/spd/basic-notice.spec.ts'], + ['testcase/基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv', 'tests/spd/basic-hospital-info.spec.ts'], + ['testcase/基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv', 'tests/spd/basic-finance-month.spec.ts'], + ['testcase/基础管理-医院资料-院区管理-UI自动化测试用例-01.csv', 'tests/spd/basic-branch.spec.ts'], + ['testcase/基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv', 'tests/spd/admit-material.spec.ts'], + ['testcase/基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv', 'tests/spd/admit-product-apply.spec.ts'], + ['testcase/基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-material-list.spec.ts'], + ['testcase/基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-product-list.spec.ts'], + ['testcase/基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv', 'tests/spd/admit-supply.spec.ts'], + ['testcase/基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv', 'tests/spd/admit-task.spec.ts'], + ['testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user.spec.ts'], + ['testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user-union.spec.ts'], + ['testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv', 'tests/spd/basic-role.spec.ts'], +]; + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let i = 0; + let quoted = false; + const pushCell = () => { + row.push(cell); + cell = ''; + }; + const pushRow = () => { + if (row.length) rows.push(row); + row = []; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') { + quoted = false; + i += 1; + continue; + } + cell += ch; + i += 1; + continue; + } + if (ch === '"') { + quoted = true; + i += 1; + continue; + } + if (ch === ',') { + pushCell(); + i += 1; + continue; + } + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i += 1; + pushCell(); + pushRow(); + i += 1; + continue; + } + cell += ch; + i += 1; + } + if (cell || row.length) { + pushCell(); + pushRow(); + } + return rows; +} + +const missing = []; +for (const [csvRel, specRel] of PAIRS) { + const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, ''); + const spec = readFileSync(path.join(ROOT, specRel), 'utf8'); + const table = parseCsv(csvText); + const header = table[0] || []; + const idIdx = header.indexOf('用例编号'); + const titleIdx = header.indexOf('用例标题'); + const pointIdx = header.indexOf('功能测试点'); + const modIdx = header.indexOf('功能模块'); + for (const cols of table.slice(1)) { + if (!cols[idIdx]) continue; + const title = `${cols[idIdx]} ${cols[titleIdx]}`; + if (!spec.includes(title)) missing.push(`${specRel}: ${title}`); + if (cols[pointIdx] && !spec.includes(cols[pointIdx])) { + missing.push(`${specRel}: describe ${cols[pointIdx]}`); + } + if (cols[modIdx] && !spec.includes(cols[modIdx])) { + missing.push(`${specRel}: module ${cols[modIdx]}`); + } + } +} + +if (missing.length) { + console.error('[gate] CSV 与 spec 不一致:'); + for (const item of missing) console.error(` ${item}`); + process.exit(1); +} +console.log('[gate] CSV ↔ spec 标题一致'); diff --git a/scripts/assert-surgery-csv-spec-sync.mjs b/scripts/assert-surgery-csv-spec-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..482d33aa20ca7ca8831cb0d00a6904d000f6cd7e --- /dev/null +++ b/scripts/assert-surgery-csv-spec-sync.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** 手术套包设置 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const PAIRS = [ + ['testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-base-pkg.spec.ts'], + ['testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-oper-type.spec.ts'], + ['testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-std-pkg.spec.ts'], +]; + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let i = 0; + let quoted = false; + const pushCell = () => { + row.push(cell); + cell = ''; + }; + const pushRow = () => { + if (row.length) rows.push(row); + row = []; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') { + quoted = false; + i += 1; + continue; + } + cell += ch; + i += 1; + continue; + } + if (ch === '"') { + quoted = true; + i += 1; + continue; + } + if (ch === ',') { + pushCell(); + i += 1; + continue; + } + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i += 1; + pushCell(); + pushRow(); + i += 1; + continue; + } + cell += ch; + i += 1; + } + if (cell || row.length) { + pushCell(); + pushRow(); + } + return rows; +} + +const missing = []; +let total = 0; +for (const [csvRel, specRel] of PAIRS) { + const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, ''); + const spec = readFileSync(path.join(ROOT, specRel), 'utf8'); + const table = parseCsv(csvText); + const header = table[0] || []; + const idIdx = header.indexOf('用例编号'); + const titleIdx = header.indexOf('用例标题'); + const pointIdx = header.indexOf('功能测试点'); + const modIdx = header.indexOf('功能模块'); + for (const cols of table.slice(1)) { + if (!cols[idIdx]) continue; + total += 1; + const title = `${cols[idIdx]} ${cols[titleIdx]}`; + if (!spec.includes(title)) missing.push(`${specRel}: ${title}`); + if (cols[pointIdx] && !spec.includes(cols[pointIdx])) { + missing.push(`${specRel}: describe ${cols[pointIdx]}`); + } + if (cols[modIdx] && !spec.includes(cols[modIdx])) { + missing.push(`${specRel}: module ${cols[modIdx]}`); + } + } +} + +if (missing.length) { + console.error('[gate] 手术套包设置 CSV 与 spec 不一致:'); + for (const item of missing) console.error(` ${item}`); + process.exit(1); +} +console.log(`[gate] 手术套包设置 CSV ↔ spec 一致(${total} 条)`); diff --git a/scripts/assert-user-csv-spec-sync.mjs b/scripts/assert-user-csv-spec-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9ea5e8a41eaaee5a71d5a47da652c9544af9c5b9 --- /dev/null +++ b/scripts/assert-user-csv-spec-sync.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** 用户管理 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const PAIRS = [ + ['testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user.spec.ts'], + ['testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user-union.spec.ts'], + ['testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv', 'tests/spd/basic-role.spec.ts'], +]; + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let i = 0; + let quoted = false; + const pushCell = () => { + row.push(cell); + cell = ''; + }; + const pushRow = () => { + if (row.length) rows.push(row); + row = []; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') { + quoted = false; + i += 1; + continue; + } + cell += ch; + i += 1; + continue; + } + if (ch === '"') { + quoted = true; + i += 1; + continue; + } + if (ch === ',') { + pushCell(); + i += 1; + continue; + } + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i += 1; + pushCell(); + pushRow(); + i += 1; + continue; + } + cell += ch; + i += 1; + } + if (cell || row.length) { + pushCell(); + pushRow(); + } + return rows; +} + +const missing = []; +let total = 0; +for (const [csvRel, specRel] of PAIRS) { + const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, ''); + const spec = readFileSync(path.join(ROOT, specRel), 'utf8'); + const table = parseCsv(csvText); + const header = table[0] || []; + const idIdx = header.indexOf('用例编号'); + const titleIdx = header.indexOf('用例标题'); + const pointIdx = header.indexOf('功能测试点'); + const modIdx = header.indexOf('功能模块'); + for (const cols of table.slice(1)) { + if (!cols[idIdx]) continue; + total += 1; + const title = `${cols[idIdx]} ${cols[titleIdx]}`; + if (!spec.includes(title)) missing.push(`${specRel}: ${title}`); + if (cols[pointIdx] && !spec.includes(cols[pointIdx])) { + missing.push(`${specRel}: describe ${cols[pointIdx]}`); + } + if (cols[modIdx] && !spec.includes(cols[modIdx])) { + missing.push(`${specRel}: module ${cols[modIdx]}`); + } + } +} + +if (missing.length) { + console.error('[gate] 用户管理 CSV 与 spec 不一致:'); + for (const item of missing) console.error(` ${item}`); + process.exit(1); +} +console.log(`[gate] 用户管理 CSV ↔ spec 一致(${total} 条)`); diff --git a/scripts/assert-vendor-csv-spec-sync.mjs b/scripts/assert-vendor-csv-spec-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..40cb83b6da6dc14ca484dc45270e92c85320423a --- /dev/null +++ b/scripts/assert-vendor-csv-spec-sync.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** 厂商管理 CSV 用例编号+标题+功能测试点 必须出现在对应 spec 中。 */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const PAIRS = [ + ['testcase/基础管理-厂商管理-厂商查询-UI自动化测试用例-01.csv', 'tests/spd/vendor-query.spec.ts'], + ['testcase/基础管理-厂商管理-供应商资质管理-UI自动化测试用例-01.csv', 'tests/spd/vendor-qual.spec.ts'], + ['testcase/基础管理-厂商管理-授权变更初审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'], + ['testcase/基础管理-厂商管理-授权变更终审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'], + ['testcase/基础管理-厂商管理-注册证变更初审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'], + ['testcase/基础管理-厂商管理-注册证变更终审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'], + ['testcase/基础管理-厂商管理-注册证差异对比-UI自动化测试用例-01.csv', 'tests/spd/vendor-cert-diff.spec.ts'], + ['testcase/基础管理-厂商管理-评价方案维护-UI自动化测试用例-01.csv', 'tests/spd/vendor-eval-scheme.spec.ts'], + ['testcase/基础管理-厂商管理-评价指标维护-UI自动化测试用例-01.csv', 'tests/spd/vendor-eval-indicator.spec.ts'], + ['testcase/基础管理-厂商管理-供应商节假日管理-UI自动化测试用例-01.csv', 'tests/spd/vendor-holiday.spec.ts'], +]; + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let i = 0; + let quoted = false; + const pushCell = () => { + row.push(cell); + cell = ''; + }; + const pushRow = () => { + if (row.length) rows.push(row); + row = []; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') { + quoted = false; + i += 1; + continue; + } + cell += ch; + i += 1; + continue; + } + if (ch === '"') { + quoted = true; + i += 1; + continue; + } + if (ch === ',') { + pushCell(); + i += 1; + continue; + } + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i += 1; + pushCell(); + pushRow(); + i += 1; + continue; + } + cell += ch; + i += 1; + } + if (cell || row.length) { + pushCell(); + pushRow(); + } + return rows; +} + +const missing = []; +let total = 0; +for (const [csvRel, specRel] of PAIRS) { + const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, ''); + const spec = readFileSync(path.join(ROOT, specRel), 'utf8'); + const table = parseCsv(csvText); + const header = table[0] || []; + const idIdx = header.indexOf('用例编号'); + const titleIdx = header.indexOf('用例标题'); + const pointIdx = header.indexOf('功能测试点'); + const modIdx = header.indexOf('功能模块'); + for (const cols of table.slice(1)) { + if (!cols[idIdx]) continue; + total += 1; + const title = `${cols[idIdx]} ${cols[titleIdx]}`; + if (!spec.includes(title)) missing.push(`${specRel}: ${title}`); + if (cols[pointIdx] && !spec.includes(cols[pointIdx])) { + missing.push(`${specRel}: describe ${cols[pointIdx]}`); + } + if (cols[modIdx] && !spec.includes(cols[modIdx])) { + missing.push(`${specRel}: module ${cols[modIdx]}`); + } + } +} + +if (missing.length) { + console.error('[gate] 厂商 CSV 与 spec 不一致:'); + for (const item of missing) console.error(` ${item}`); + process.exit(1); +} +console.log(`[gate] 厂商 CSV ↔ spec 一致(${total} 条)`); diff --git a/scripts/assert-vendor-no-reuse-open.mjs b/scripts/assert-vendor-no-reuse-open.mjs new file mode 100644 index 0000000000000000000000000000000000000000..132ab2f546370f8eddd8e4d7d3eab7101c80e15f --- /dev/null +++ b/scripts/assert-vendor-no-reuse-open.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * 基础管理新模块(厂商 / 准入 / 医院资料 / 库房 / 流程)禁止 describeWithReuseOpen: + * 必须走 auth.setup + storageState + 每用例独立 context。 + */ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DIR = path.join(ROOT, 'tests', 'spd'); +const PREFIXES = ['vendor-', 'admit-', 'basic-', 'warehouse-', 'flow-']; + +const offenders = []; +for (const name of readdirSync(DIR)) { + if (!name.endsWith('.spec.ts')) continue; + if (!PREFIXES.some((p) => name.startsWith(p))) continue; + const text = readFileSync(path.join(DIR, name), 'utf8'); + if (text.includes('describeWithReuseOpen')) { + offenders.push(name); + } +} + +if (offenders.length) { + console.error('[gate] 基础管理新模块禁止 describeWithReuseOpen:'); + for (const f of offenders) console.error(` ${f}`); + process.exit(1); +} +console.log('[gate] vendor/admit/basic/warehouse/flow 均未使用 describeWithReuseOpen'); diff --git a/scripts/assert-wh-flow-csv-spec-sync.mjs b/scripts/assert-wh-flow-csv-spec-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..20968ce568efe5d74bdddad17b7198ea8d3f6f2c --- /dev/null +++ b/scripts/assert-wh-flow-csv-spec-sync.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** 库房管理 / 流程设置 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const PAIRS = [ + ['testcase/基础管理-库房管理-库区设置-UI自动化测试用例-01.csv', 'tests/spd/warehouse-zone.spec.ts'], + ['testcase/基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv', 'tests/spd/warehouse-probe.spec.ts'], + ['testcase/基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv', 'tests/spd/flow-def.spec.ts'], + ['testcase/基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv', 'tests/spd/flow-inst.spec.ts'], +]; + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + let i = 0; + let quoted = false; + const pushCell = () => { + row.push(cell); + cell = ''; + }; + const pushRow = () => { + if (row.length) rows.push(row); + row = []; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') { + quoted = false; + i += 1; + continue; + } + cell += ch; + i += 1; + continue; + } + if (ch === '"') { + quoted = true; + i += 1; + continue; + } + if (ch === ',') { + pushCell(); + i += 1; + continue; + } + if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i += 1; + pushCell(); + pushRow(); + i += 1; + continue; + } + cell += ch; + i += 1; + } + if (cell || row.length) { + pushCell(); + pushRow(); + } + return rows; +} + +const missing = []; +let total = 0; +for (const [csvRel, specRel] of PAIRS) { + const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, ''); + const spec = readFileSync(path.join(ROOT, specRel), 'utf8'); + const table = parseCsv(csvText); + const header = table[0] || []; + const idIdx = header.indexOf('用例编号'); + const titleIdx = header.indexOf('用例标题'); + const pointIdx = header.indexOf('功能测试点'); + const modIdx = header.indexOf('功能模块'); + for (const cols of table.slice(1)) { + if (!cols[idIdx]) continue; + total += 1; + const title = `${cols[idIdx]} ${cols[titleIdx]}`; + if (!spec.includes(title)) missing.push(`${specRel}: ${title}`); + if (cols[pointIdx] && !spec.includes(cols[pointIdx])) { + missing.push(`${specRel}: describe ${cols[pointIdx]}`); + } + if (cols[modIdx] && !spec.includes(cols[modIdx])) { + missing.push(`${specRel}: module ${cols[modIdx]}`); + } + } +} + +if (missing.length) { + console.error('[gate] 库房/流程 CSV 与 spec 不一致:'); + for (const item of missing) console.error(` ${item}`); + process.exit(1); +} +console.log(`[gate] 库房/流程 CSV ↔ spec 一致(${total} 条)`); diff --git a/scripts/ci-email-summary.mjs b/scripts/ci-email-summary.mjs index e923271943ed45ec73ad797b9427031adca63dbc..b8fdd5f6b870422636e85f23cc693e7f4e88b43c 100644 --- a/scripts/ci-email-summary.mjs +++ b/scripts/ci-email-summary.mjs @@ -77,11 +77,30 @@ function suiteScope(summary) { } } +/** 读取跨构建失败频次统计,返回「经常失败」的用例(前 N 条)。 */ +function loadFrequentFailures(limit = 10) { + const store = loadJson(path.join(ROOT, 'reports', 'flake', 'failure-counts.json')); + if (!store?.items) return { total: 0, rows: [] }; + const minFails = Number(store.frequentMinFails ?? 2); + const rate = Number(store.frequentRate ?? 0.2); + const rows = Object.values(store.items) + .map((s) => ({ ...s, rate: s.runs > 0 ? s.failed / s.runs : 0 })) + .filter((s) => s.failed >= minFails && s.rate >= rate) + .sort((a, b) => b.failed - a.failed || b.rate - a.rate); + return { total: rows.length, rows: rows.slice(0, limit) }; +} + function formatPassRate(passed, executed) { if (!executed) return '-'; return `${((passed / executed) * 100).toFixed(1)}%`; } +/** 去掉 STAT- 编号前缀,标题过长时截断。 */ +function shortTestName(title, max = 60) { + const name = String(title ?? '').replace(/^STAT-[A-Z]+-\d{3,}\s*/, ''); + return name.length > max ? `${name.slice(0, max - 1)}…` : name; +} + function statusLabel(summary, jenkinsResult) { if (jenkinsResult && jenkinsResult !== 'SUCCESS' && jenkinsResult !== 'UNSTABLE') { if (jenkinsResult === 'ABORTED') return { text: '已中止', color: '#6b7280' }; @@ -120,6 +139,7 @@ function main() { const duration = formatDuration(summary.durationMs); const failed = parseFailedTests(path.join(LATEST, 'junit.xml')); const moreFail = failTotal > failed.length ? failTotal - failed.length : 0; + const frequent = loadFrequentFailures(10); const pwReportUrl = buildUrl ? `${buildUrl}Playwright_20Report/` : ''; const allureUrl = buildUrl ? `${buildUrl}allure` : ''; const viewUser = process.env.REPORT_VIEW_USER || ''; @@ -162,6 +182,28 @@ function main() { .join('') : `无失败用例`; + const freqRows = frequent.rows + .map( + (item) => ` + ${escapeHtml(item.id)} + ${item.failed}/${item.runs}(${(item.rate * 100).toFixed(0)}%) + ${escapeHtml(shortTestName(item.title))} + `, + ) + .join(''); + const frequentSection = + frequent.total > 0 + ? `

经常失败的用例(累计 ${frequent.total} 条,仅列前 ${frequent.rows.length} 条)

+

跨构建累计,失败次数与失败率双达标;建议优先修脚本或提单。

+ + + + + + ${freqRows} +
编号失败/执行用例
` + : ''; + const html = ` SPD UI 自动化报告 @@ -190,6 +232,7 @@ function main() { ${reportButtons}

失败用例${moreFail ? `(仅列出前 ${failed.length} 条,另有 ${moreFail} 条)` : ''}

${failRows}
+ ${frequentSection}

完整截图与 Trace 请点上面的 Playwright 报告。此邮件由定时任务自动发送。

@@ -214,6 +257,11 @@ function main() { `耗时: ${duration}`, viewUser ? `报告只读账号: ${viewUser}` : '', viewPass ? `报告只读密码: ${viewPass}` : '', + frequent.total > 0 + ? `经常失败的用例(累计 ${frequent.total} 条): ${frequent.rows + .map((r) => `${r.id}(${r.failed}/${r.runs})`) + .join(', ')}` + : '', pwReportUrl ? `Playwright 报告: ${pwReportUrl}` : '', allureUrl ? `Allure 报告: ${allureUrl}` : '', '', diff --git a/scripts/failure-report.mjs b/scripts/failure-report.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ac4b324e61b523ba98c9c733d4f1bea0c5c259a7 --- /dev/null +++ b/scripts/failure-report.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * 失败频次榜单:读取 reports/flake/failure-counts.json,列出「经常失败」的用例。 + * + * 数据来源:用例 afterEach 时按 STAT- 编号累计(见 core/failure-tracker.ts), + * reporter onEnd 刷新 reports/flake/frequent-failures.md。 + * + * 用法: + * node scripts/failure-report.mjs + * node scripts/failure-report.mjs --min-fails=2 --rate=0.2 + * node scripts/failure-report.mjs --top=30 --json + */ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function parseArgs(argv) { + const out = { + store: path.join(ROOT, 'reports/flake/failure-counts.json'), + minFails: Number(process.env.FREQ_FAIL_MIN || 2), + rate: Number(process.env.FREQ_FAIL_RATE || 0.2), + top: 30, + json: false, + }; + for (const arg of argv) { + if (arg === '--json') out.json = true; + else if (arg.startsWith('--store=')) out.store = path.resolve(arg.slice(8)); + else if (arg.startsWith('--min-fails=')) out.minFails = Number(arg.slice(12)) || out.minFails; + else if (arg.startsWith('--rate=')) out.rate = Number(arg.slice(7)) || out.rate; + else if (arg.startsWith('--top=')) out.top = Number(arg.slice(6)) || out.top; + } + return out; +} + +function shortTitle(title, max = 60) { + const name = String(title || '').replace(/^STAT-[A-Z]+-\d{3,}\s*/, ''); + return name.length > max ? `${name.slice(0, max - 1)}…` : name; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!existsSync(opts.store)) { + console.error(`[failure-report] 未找到 ${path.relative(ROOT, opts.store)}`); + console.error('[failure-report] 先跑一次 CI/全量,用例结果会自动累计到这里。'); + process.exit(2); + } + + const store = JSON.parse(readFileSync(opts.store, 'utf8')); + const items = Object.values(store.items || {}); + const rows = items + .map((s) => ({ + ...s, + rate: s.runs > 0 ? s.failed / s.runs : 0, + })) + .filter((s) => s.failed >= opts.minFails && s.rate >= opts.rate) + .sort((a, b) => b.failed - a.failed || b.rate - a.rate); + + if (opts.json) { + console.log(JSON.stringify({ minFails: opts.minFails, rate: opts.rate, count: rows.length, rows }, null, 2)); + return; + } + + console.log( + `[failure-report] 累计 ${items.length} 个用例;经常失败(失败 ≥ ${opts.minFails} 且失败率 ≥ ${opts.rate})共 ${rows.length} 条:`, + ); + if (!rows.length) { + console.log(' (暂无,需多次构建累计)'); + return; + } + console.log(' 编号 失败/执行 失败率 连续 最近分类 用例'); + for (const r of rows.slice(0, opts.top)) { + console.log( + ` ${String(r.id).padEnd(14)} ${String(`${r.failed}/${r.runs}`).padEnd(9)} ${String( + `${(r.rate * 100).toFixed(0)}%`, + ).padEnd(6)} ${String(r.consecutiveFails ?? '-').padEnd(5)} ${String(r.lastClass ?? '-').padEnd(12)} ${shortTitle( + r.title, + )}`, + ); + } +} + +main(); diff --git a/scripts/generate-surgery-csv.py b/scripts/generate-surgery-csv.py new file mode 100644 index 0000000000000000000000000000000000000000..78767873b8e3c9d61f8941d678a4fb803f14c7cf --- /dev/null +++ b/scripts/generate-surgery-csv.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Generate surgery testcase CSVs (9 fixed columns, RFC4180 quoting, UTF-8 no BOM).""" +import csv +import io +import os +import re + +HEADER = [ + "用例编号", "功能模块", "功能测试点", "用例标题", "优先级", + "前置条件", "测试步骤", "预期结果", "实际结果", +] + +LOGIN_NOTE = "" +MOCK_NOTE = "" + +LOGIN_OK = "已通过 auth.setup 写入 storageState 登录" +PRE_URL_BASE = "" +MODULE = "基础设置 · 手术套包设置" +MENU = "基础设置 → 手术套包设置" + + +def row(cid, point, title, pr, pre, steps, expect, mock=False): + return [cid, MODULE, point, title, pr, pre, steps, expect, ""] + + +def _renumber(steps: str) -> str: + """去掉与执行器相关的开场步骤(LOGIN_NOTE/MOCK_NOTE),并按 1..N 重新编号。""" + out = [] + for ln in steps.split("\n"): + body = re.sub(r"^\d+\.\s*", "", ln).strip() + if not body: + continue + if body.startswith("确认 chromium") or body.startswith("使用 mock 反例数据"): + continue + out.append(body) + return "\n".join(f"{i}. {b}" for i, b in enumerate(out, 1)) + + +def build(cases, url): + out = io.StringIO() + w = csv.writer(out, quoting=csv.QUOTE_ALL, lineterminator="\n") + w.writerow(HEADER) + for c in cases: + c = [re.sub(r"§([A-Za-z0-9_]+)§", r"${\1}", cell) for cell in c] + c[5] = c[5].replace(f"{LOGIN_OK} 已登录", LOGIN_OK) + # 未登录反例:前置补目标 URL(相对路径 + 无 iframe) + if "无登录态" in c[5] and "已进入" not in c[5]: + c[5] = c[5] + f";直接访问{c[2].split(' · ')[-1]} URL({url},无 iframe)" + c[6] = _renumber(c[6]) + w.writerow(c) + return out.getvalue() + + +# ---------------- 基础包维护 ---------------- +BASE_PATH = "/spd/basic/operationSetting/baseMaintenance" +BASE_URL = BASE_PATH +base = [] + + +def bp(cid, point, title, pr, pre, steps, expect): + base.append(row(cid, point, title, pr, pre, steps, expect)) + + +B_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限" +B_PAGE = f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe)" +B_SAMPLE = f"{B_PAGE};默认列表至少 1 条数据" + +bp( + "SURG-BASE-001", "基础包维护 · 左侧分类树", "分类名称搜索过滤", "高", B_PAGE, + f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleCategoryName§`\n3. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字", + "过滤后树节点名称与关键字匹配;无未捕获异常。", +) +bp( + "SURG-BASE-002", "基础包维护 · 左侧分类树", "选择分类联动右侧列表", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 点击 `§sampleCategoryName§` 分类节点\n3. 等待右侧列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致", + "右侧仅展示该分类下基础包;总数与筛选一致。", +) +bp( + "SURG-BASE-003", "基础包维护 · 左侧分类树", "打开新增分类弹窗", "中", B_PAGE, + f"{LOGIN_NOTE}\n2. 点击「新增分类」\n3. 断言弹窗可见,含分类名称输入(#form_item_kindName)与「取 消」「确 认」\n4. 点击「取 消」关闭", + "新增分类弹窗字段完整;取消可关闭。", +) +bp( + "SURG-BASE-004", "基础包维护 · 左侧分类树", "分类搜索清空恢复全量", "低", B_PAGE, + f"{LOGIN_NOTE}\n2. 用 `§sampleCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合(或不少于过滤后节点数)", + "清空后分类树恢复默认集合。", +) +bp( + "SURG-BASE-005", "基础包维护 · 右侧筛选与列表", "默认列出基础包或空态明确", "高", B_PAGE, + f"{LOGIN_NOTE}\n2. 等待列表稳定\n3. 断言出现数据行,或「暂无数据」且分页总数为 0", + "加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。", +) +bp( + "SURG-BASE-006", "基础包维护 · 右侧筛选与列表", "产品名称筛选过滤", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleProductName§`\n3. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」\n4. 断言结果行产品名称均包含该关键字", + "过滤结果与关键字匹配;「共 N 条」随之更新。", +) +bp( + "SURG-BASE-007", "基础包维护 · 右侧筛选与列表", "规格型号筛选过滤", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleSpecModel§`\n3. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」\n4. 断言结果行规格型号均包含该关键字", + "按规格型号过滤生效。", +) +bp( + "SURG-BASE-008", "基础包维护 · 右侧筛选与列表", "器械注册人筛选过滤", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleMfrsName§`\n3. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」\n4. 断言结果行器械注册人均包含该关键字", + "按器械注册人过滤生效。", +) +bp( + "SURG-BASE-009", "基础包维护 · 右侧筛选与列表", "列表核心列完整", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 断言表头包含序号、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作", + "核心列均可见,操作列含「编 辑」「移 除」。", +) +bp( + "SURG-BASE-010", "基础包维护 · 右侧筛选与列表", "查询与重置", "中", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合", + "重置清空筛选并恢复默认列表。", +) +bp( + "SURG-BASE-011", "基础包维护 · 右侧筛选与列表", "分页与页大小切换", "中", + f"{B_SAMPLE};列表总数大于单页行数上限", + f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变", + "页大小切换生效;总数保持稳定。", +) +bp( + "SURG-BASE-012", "基础包维护 · 右侧筛选与列表", "首页上一页不可用", "低", B_PAGE, + f"{LOGIN_NOTE}\n2. 处于第 1 页\n3. 断言「上一页」置灰或点击无效果", + "第 1 页无法回到上一页。", +) +bp( + "SURG-BASE-013", "基础包维护 · 右侧筛选与列表", "搜索无匹配空态(mock反例)", "中", B_PAGE, + f"{MOCK_NOTE}\n2. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_BASE__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0", + "无匹配时空态明确,不白屏;总数为 0。", +) +bp( + "SURG-BASE-014", "基础包维护 · 新增基础包", "打开新增基础包弹窗", "高", B_PAGE, + f"{LOGIN_NOTE}\n2. 点击「新增基础包」\n3. 断言弹窗可见,含基础包名称(#form_item_pkgName)、所属分类(#form_item_kindId)、包编码(#form_item_pkgCode)、备注(#form_item_remark)\n4. 断言含「取 消」「确 认」", + "新增基础包弹窗字段完整(名称/所属分类/包编码/备注)。", +) +bp( + "SURG-BASE-015", "基础包维护 · 新增基础包", "新增取消不落库", "高", B_PAGE, + f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开新增,填写名称 `§uniqPkgName§` 后点「取 消」\n4. 断言列表总数不变,且无该名称行", + "取消后不新增基础包。", +) +bp( + "SURG-BASE-016", "基础包维护 · 新增基础包", "合法必填新增成功", "高", + f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);允许写基础包;名称 `§uniqPkgName§`(建议 AUTO-BASE-§timestamp§)、编码 `§uniqPkgCode§`", + f"{LOGIN_NOTE}\n2. 打开新增,填写名称、所属分类、包编码\n3. 点击「确 认」\n4. 断言弹窗关闭,列表出现该名称行且字段一致\n5. 后置:按环境约定清理该基础包,避免污染主数据", + "新增成功且列表可见;用例结束清理,不留生产脏数据。", +) +bp( + "SURG-BASE-017", "基础包维护 · 新增基础包", "基础包名称为空拦截(mock反例)", "高", B_PAGE, + f"{MOCK_NOTE}\n2. 打开新增基础包弹窗,名称留空,补齐所属分类与包编码\n3. 点击「确 认」\n4. 断言必填提示,弹窗仍在,列表无新行", + "确定被拦截;不落库。", +) +bp( + "SURG-BASE-018", "基础包维护 · 新增基础包", "所属分类为空拦截(mock反例)", "高", B_PAGE, + f"{MOCK_NOTE}\n2. 打开新增基础包弹窗,填写名称与包编码,不选所属分类\n3. 点击「确 认」\n4. 断言必填提示,弹窗仍在", + "确定被拦截;不落库。", +) +bp( + "SURG-BASE-019", "基础包维护 · 新增基础包", "重复点击确定防重", "中", + f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);允许写基础包;使用唯一名称 `§uniqPkgName§`", + f"{LOGIN_NOTE}\n2. 打开新增弹窗并填写合法数据\n3. 快速连点「确 认」两次\n4. 断言只产生一条基础包(或无重复名称/编码行)", + "连点只落一条;无重复脏数据。", +) +bp( + "SURG-BASE-020", "基础包维护 · 行内操作(编辑 / 移除)", "行内编辑回填", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 点击首行「编 辑」\n3. 断言编辑弹窗可见且回填当前行数据\n4. 点击「取 消」关闭", + "编辑弹窗回填正确;取消不影响数据。", +) +bp( + "SURG-BASE-021", "基础包维护 · 行内操作(编辑 / 移除)", "移除二次确认防误删", "高", B_SAMPLE, + f"{LOGIN_NOTE}\n2. 记录当前总数 `§sampleTotal§`\n3. 点击首行「移 除」\n4. 若出现确认框则点「取 消」\n5. 断言总数仍为 `§sampleTotal§`", + "移除有二次确认;取消不删除。", +) +bp( + "SURG-BASE-022", "基础包维护 · 行内操作(编辑 / 移除)", "新增基础包可在标准套包被引用", "中", + f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);已新增 `§uniqPkgName§` 且未清理;允许跨页只读访问", + f"{LOGIN_NOTE}\n2. 进入「标准套包维护」,点击「添加基础包」\n3. 在弹窗搜索框输入 `§uniqPkgName§`\n4. 断言可检索到该基础包并可勾选\n5. 后置:清理该基础包", + "新增基础包在标准套包可选;跨页数据一致。", +) + +# ---------------- 术种维护 ---------------- +TYPE_PATH = "/spd/basic/operationSetting/typeMaintenance" +TYPE_URL = TYPE_PATH +type_cases = [] + + +def tp(cid, point, title, pr, pre, steps, expect): + type_cases.append(row(cid, point, title, pr, pre, steps, expect)) + + +T_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限" +T_PAGE = f"{T_PRE};已进入术种维护页({TYPE_PATH},无 iframe)" +T_SAMPLE = f"{T_PAGE};默认列表至少 1 条数据" + +tp( + "SURG-TYPE-001", "术种维护 · 页面进入", "菜单进入术种维护页", "高", T_PAGE, + f"{LOGIN_NOTE}\n2. 按菜单「{MENU} → 术种维护」进入\n3. 断言 URL 含 `/spd/basic/operationSetting/typeMaintenance`\n4. 断言左侧分类树(术种分类名称搜索框)、右侧筛选区、状态 Tab、列表与分页可见", + "进入术种维护页;不误入基础包维护/标准套包维护;左右分栏渲染完整。", +) +tp( + "SURG-TYPE-002", "术种维护 · 页面进入", "同组页签切换后返回", "中", T_PAGE, + f"{LOGIN_NOTE}\n2. 点击页内 Tab「标准套包维护」,等待 URL 切换\n3. 返回点击 Tab「术种维护」\n4. 断言 URL 回到 typeMaintenance,左树与状态 Tab 重新渲染", + "三页签互切正常;返回后本页数据恢复。", +) +tp( + "SURG-TYPE-003", "术种维护 · 页面进入", "未登录访问拦截", "高", + "测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态", + f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{TYPE_URL}`\n3. 断言跳转登录页或不展示术种列表数据", + "未登录被拦截,不泄露术种数据。", +) +tp( + "SURG-TYPE-004", "术种维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", T_PAGE, + f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字", + "过滤后树节点名称与关键字匹配;无未捕获异常。", +) +tp( + "SURG-TYPE-005", "术种维护 · 左侧术种分类树", "选择分类联动右侧列表", "高", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致", + "右侧仅展示该分类下术种;总数与筛选一致。", +) +tp( + "SURG-TYPE-006", "术种维护 · 左侧术种分类树", "分类树计数与下钻", "中", + f"{T_PAGE};至少 1 个带计数的分类节点", + f"{LOGIN_NOTE}\n2. 读取某分类节点括号内计数 `§nodeCount§`\n3. 点击该节点\n4. 断言右侧「共 N 条」与 `§nodeCount§` 一致", + "分类计数与该分类下术种条数一致。", +) +tp( + "SURG-TYPE-007", "术种维护 · 左侧术种分类树", "打开分类维护弹窗", "中", T_PAGE, + f"{LOGIN_NOTE}\n2. 点击左侧「新增」\n3. 断言弹窗可见,含分类名称输入(#basic_kindName)与「取 消」「保 存」\n4. 点击「取 消」关闭", + "分类维护弹窗字段完整;取消可关闭。", +) +tp( + "SURG-TYPE-008", "术种维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", T_PAGE, + f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合", + "清空后分类树恢复默认集合。", +) +tp( + "SURG-TYPE-009", "术种维护 · 筛选与状态 Tab", "术种名称筛选过滤", "高", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleOperName§`\n3. 在 `#table_search_form_operationName` 填入该关键字并点击「查 询」\n4. 断言结果行术种名称均包含该关键字", + "过滤结果与关键字匹配;「共 N 条」随之更新。", +) +tp( + "SURG-TYPE-010", "术种维护 · 筛选与状态 Tab", "病种名称筛选过滤", "高", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleDiseaseName§`\n3. 在 `#table_search_form_disease` 填入该关键字并点击「查 询」\n4. 断言结果行病种名称均包含该关键字", + "按病种名称过滤生效。", +) +tp( + "SURG-TYPE-011", "术种维护 · 筛选与状态 Tab", "状态 Tab 切换与计数一致", "高", T_PAGE, + f"{LOGIN_NOTE}\n2. 读取「全部」Tab 括号计数 `§totalCount§`\n3. 依次点击「停用」「未建分类」「全部」\n4. 每次断言列表条数与「共 N 条」和该 Tab 括号计数一致", + "Tab 切换后列表仅展示对应状态;括号计数与结果规模一致。", +) +tp( + "SURG-TYPE-012", "术种维护 · 筛选与状态 Tab", "左树 + 筛选 + Tab 组合过滤", "中", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 选择 `§sampleOperCategoryName§` 分类节点\n3. 填入术种名称关键字并点「查 询」\n4. 切到「全部」Tab\n5. 断言结果为三者交集且无未捕获异常", + "三条件叠加后结果正确,总数一致。", +) +tp( + "SURG-TYPE-013", "术种维护 · 筛选与状态 Tab", "重置清空筛选", "中", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合", + "重置清空筛选并恢复默认列表。", +) +tp( + "SURG-TYPE-014", "术种维护 · 筛选与状态 Tab", "无匹配组合空态(mock反例)", "低", T_PAGE, + f"{MOCK_NOTE}\n2. 在 `#table_search_form_operationName` 输入 mock 不存在关键字 `__NOT_EXIST_OPER__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0", + "无匹配时空态明确,不白屏;总数为 0。", +) +tp( + "SURG-TYPE-015", "术种维护 · 列表与编码展示", "列表核心列完整", "高", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 断言表头包含序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作", + "核心列均可见,操作列含「编 辑」「停用/启用」。", +) +tp( + "SURG-TYPE-016", "术种维护 · 列表与编码展示", "ICD-10 诊断编码展示完整", "中", + f"{T_SAMPLE};至少 1 条含诊断编码的数据", + f"{LOGIN_NOTE}\n2. 读取含 ICD-10 编码的行 `§sampleIcd10Code§`\n3. 断言编码文本完整(如 A02.000x010)不截断、无乱码", + "ICD-10 编码完整展示。", +) +tp( + "SURG-TYPE-017", "术种维护 · 列表与编码展示", "ICD-9-CM-3 操作编码展示完整", "低", + f"{T_SAMPLE};至少 1 条含操作编码的数据", + f"{LOGIN_NOTE}\n2. 读取含操作编码的行 `§sampleIcd9Code§`\n3. 断言操作编码(如 00.0200)与操作名称(如 心脏治疗性超声)对应展示无乱码", + "ICD-9-CM-3 编码与操作名称对应正确。", +) +tp( + "SURG-TYPE-018", "术种维护 · 列表与编码展示", "分页与页大小切换", "中", + f"{T_SAMPLE};列表总数大于单页行数上限", + f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变", + "页大小切换生效;总数保持稳定。", +) +tp( + "SURG-TYPE-019", "术种维护 · 列表与编码展示", "首页上一页不可用", "低", T_PAGE, + f"{LOGIN_NOTE}\n2. 处于第 1 页\n3. 断言「上一页」置灰或点击无效果", + "第 1 页无法回到上一页。", +) +tp( + "SURG-TYPE-020", "术种维护 · 新增术种", "打开新增术种入口", "高", T_PAGE, + f"{LOGIN_NOTE}\n2. 点击「新增术种」\n3. 断言新增弹窗/页面可见,含术种名称、病种名称、分类与编码相关字段\n4. 断言含「取 消」「保 存」", + "新增术种入口字段完整。", +) +tp( + "SURG-TYPE-021", "术种维护 · 新增术种", "新增取消不落库", "高", T_PAGE, + f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开新增,填写名称 `§uniqOperName§` 后点「取 消」\n4. 断言列表总数不变,且无该名称行", + "取消后不新增术种。", +) +tp( + "SURG-TYPE-022", "术种维护 · 新增术种", "术种名称为空拦截(mock反例)", "高", T_PAGE, + f"{MOCK_NOTE}\n2. 打开新增术种弹窗,名称留空\n3. 点击「保 存」\n4. 断言必填提示,弹窗仍在,列表无新行", + "保存被拦截;不落库。", +) +tp( + "SURG-TYPE-023", "术种维护 · 行内操作(编辑 / 停用)", "行内编辑回填", "高", T_SAMPLE, + f"{LOGIN_NOTE}\n2. 点击首行「编 辑」\n3. 断言编辑弹窗可见且回填当前术种数据\n4. 点击「取 消」关闭", + "编辑弹窗回填正确;取消不影响数据。", +) +tp( + "SURG-TYPE-024", "术种维护 · 行内操作(编辑 / 停用)", "停用状态流转", "中", + f"{T_PAGE};存在启用状态术种(优先选 `§uniqOperName§` 自建数据);允许写术种", + f"{LOGIN_NOTE}\n2. 记录「停用」Tab 当前计数 `§disabledCount§`\n3. 点击目标行「停用」,若有确认框则点「确 定」\n4. 断言「停用」Tab 计数 +1\n5. 后置:对该术种执行「启用」恢复", + "术种状态变为停用;「停用」计数 +1;结束恢复原状态。", +) +tp( + "SURG-TYPE-025", "术种维护 · 行内操作(编辑 / 停用)", "停用二次确认防误操作", "高", + f"{T_PAGE};存在启用状态术种", + f"{LOGIN_NOTE}\n2. 记录「停用」Tab 当前计数\n3. 点击首行「停用」\n4. 若出现确认框则点「取 消」\n5. 断言计数未变化", + "停用有二次确认;取消不改状态。", +) + +# ---------------- 标准套包维护 ---------------- +STD_PATH = "/spd/basic/operationSetting/standardMaintenance" +STD_URL = STD_PATH +std = [] + + +def sp(cid, point, title, pr, pre, steps, expect): + std.append(row(cid, point, title, pr, pre, steps, expect)) + + +S_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限" +S_PAGE = f"{S_PRE};已进入标准套包维护页({STD_PATH},无 iframe)" +S_SAMPLE = f"{S_PAGE};默认列表至少 1 条数据" + +sp( + "SURG-STD-001", "标准套包维护 · 页面进入", "菜单进入标准套包维护页", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 按菜单「{MENU} → 标准套包维护」进入\n3. 断言 URL 含 `/spd/basic/operationSetting/standardMaintenance`\n4. 断言左侧分类树、右侧筛选区、构成列表与分页可见", + "进入标准套包维护页;不误入基础包维护/术种维护;左右分栏渲染完整。", +) +sp( + "SURG-STD-002", "标准套包维护 · 页面进入", "同组页签切换后返回", "中", S_PAGE, + f"{LOGIN_NOTE}\n2. 点击页内 Tab「术种维护」,等待 URL 切换\n3. 返回点击 Tab「标准套包维护」\n4. 断言 URL 回到 standardMaintenance,左树与构成列表重新渲染", + "三页签互切正常;返回后本页数据恢复。", +) +sp( + "SURG-STD-003", "标准套包维护 · 页面进入", "未登录访问拦截", "高", + "测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态", + f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{STD_URL}`\n3. 断言跳转登录页或不展示套包列表数据", + "未登录被拦截,不泄露套包数据。", +) +sp( + "SURG-STD-004", "标准套包维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字", + "过滤后树节点名称与关键字匹配;无未捕获异常。", +) +sp( + "SURG-STD-005", "标准套包维护 · 左侧术种分类树", "选择分类联动构成列表", "高", S_SAMPLE, + f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧构成列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致", + "右侧仅展示该分类下标准套包;总数与筛选一致。", +) +sp( + "SURG-STD-006", "标准套包维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", S_PAGE, + f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合", + "清空后分类树恢复默认集合。", +) +sp( + "SURG-STD-007", "标准套包维护 · 筛选与构成列表", "默认展示标准套包构成", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 等待列表稳定\n3. 断言按「包名称」分组出现数据行,或「暂无数据」且分页总数为 0", + "加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。", +) +sp( + "SURG-STD-008", "标准套包维护 · 筛选与构成列表", "产品名称筛选过滤", "高", S_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleProductName§`\n3. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」\n4. 断言结果行产品名称均包含该关键字", + "过滤结果与关键字匹配;「共 N 条」随之更新。", +) +sp( + "SURG-STD-009", "标准套包维护 · 筛选与构成列表", "规格型号筛选过滤", "中", S_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleSpecModel§`\n3. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」\n4. 断言结果行规格型号均包含该关键字", + "按规格型号过滤生效。", +) +sp( + "SURG-STD-010", "标准套包维护 · 筛选与构成列表", "器械注册人筛选过滤", "中", S_SAMPLE, + f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleMfrsName§`\n3. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」\n4. 断言结果行器械注册人均包含该关键字", + "按器械注册人过滤生效。", +) +sp( + "SURG-STD-011", "标准套包维护 · 筛选与构成列表", "构成列表核心列完整", "高", + f"{S_SAMPLE};存在分组", + f"{LOGIN_NOTE}\n2. 断言表头/首行包含包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作", + "核心列均可见,操作列含「编 辑」「移 除」。", +) +sp( + "SURG-STD-012", "标准套包维护 · 筛选与构成列表", "包名称展开/收起子项", "高", + f"{S_SAMPLE};存在可展开的包分组", + f"{LOGIN_NOTE}\n2. 读取首个包分组名称 `§samplePkgGroupName§`\n3. 点击展开该分组,断言出现子项行\n4. 再次点击收起,断言子项行隐藏", + "展开显示子项、收起隐藏子项;分组计数不受影响。", +) +sp( + "SURG-STD-013", "标准套包维护 · 筛选与构成列表", "查询与重置", "中", S_SAMPLE, + f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合", + "重置清空筛选并恢复默认列表。", +) +sp( + "SURG-STD-014", "标准套包维护 · 筛选与构成列表", "分页与页大小切换", "中", + f"{S_SAMPLE};列表总数大于单页行数上限", + f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变", + "页大小切换生效;总数保持稳定。", +) +sp( + "SURG-STD-015", "标准套包维护 · 筛选与构成列表", "无匹配组合空态(mock反例)", "低", S_PAGE, + f"{MOCK_NOTE}\n2. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_STD__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0", + "无匹配时空态明确,不白屏;总数为 0。", +) +sp( + "SURG-STD-016", "标准套包维护 · 添加基础包弹窗", "打开添加基础包弹窗", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 点击「添加基础包」\n3. 断言弹窗可见,含左侧基础包分类树与右侧基础包明细\n4. 断言含「取 消」「确定(N)」", + "添加基础包弹窗字段完整;确定按钮带选择计数。", +) +sp( + "SURG-STD-017", "标准套包维护 · 添加基础包弹窗", "弹窗内筛选与选择计数联动", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 打开添加基础包弹窗,在搜索框输入基础包关键字\n3. 勾选 1 个基础包\n4. 断言「确定(N)」计数由 0 变为 1", + "筛选生效;选择数与确定按钮计数联动。", +) +sp( + "SURG-STD-018", "标准套包维护 · 添加基础包弹窗", "弹窗内构成数量可编辑", "中", + f"{S_PAGE};已勾选至少 1 个基础包", + f"{LOGIN_NOTE}\n2. 定位明细行「构成数量」步进器,点击「Increase Value」\n3. 断言数量 +1 且合计随之更新", + "构成数量可编辑;加减生效。", +) +sp( + "SURG-STD-019", "标准套包维护 · 添加基础包弹窗", "添加基础包取消不落库", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开添加基础包,勾选 1 个基础包后点「取 消」\n4. 断言构成列表总数不变", + "取消后不新增构成行。", +) +sp( + "SURG-STD-020", "标准套包维护 · 添加基础包弹窗", "确定按钮零选禁用", "低", + f"{S_PAGE};已打开添加基础包弹窗且未选择", + f"{LOGIN_NOTE}\n2. 断言「确定(0)」置灰或点击无效果", + "未选择时确定不可用。", +) +sp( + "SURG-STD-021", "标准套包维护 · 添加基础包弹窗", "重复添加同一基础包", "中", + f"{S_PAGE};标准套包已含某基础包(或先添加一个);允许写套包", + f"{LOGIN_NOTE}\n2. 再次打开添加基础包,选择同一基础包并确定\n3. 断言按产品规则拒绝/合并/累加,不产生重复脏行", + "重复添加不产生重复行(策略以现网为准)。", +) +sp( + "SURG-STD-022", "标准套包维护 · 添加散件弹窗", "打开添加散件弹窗", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 点击「添加散件」\n3. 断言弹窗可见并展示「产品列表」视图,含产品名称/规格型号/器械注册人/耗材类型筛选与分页", + "添加散件弹窗产品列表视图完整。", +) +sp( + "SURG-STD-023", "标准套包维护 · 添加散件弹窗", "产品列表筛选与选择散件", "高", + f"{S_PAGE};已打开添加散件弹窗", + f"{LOGIN_NOTE}\n2. 在产品名称筛选框输入 `§sampleProductName§`\n3. 勾选首个匹配散件\n4. 断言散件进入应拣明细,计数同步变化", + "筛选生效;选中散件进入应拣明细。", +) +sp( + "SURG-STD-024", "标准套包维护 · 添加散件弹窗", "散件规格/应拣明细视图", "高", + f"{S_PAGE};已勾选至少 1 个散件", + f"{LOGIN_NOTE}\n2. 切换到规格明细/应拣明细视图\n3. 断言展示耗材品种数、耗材总数,以及应拣数量、产品编号/名称/规格/注册人/单位/耗材类型/是否收费列", + "应拣明细视图字段完整。", +) +sp( + "SURG-STD-025", "标准套包维护 · 添加散件弹窗", "明细统计与实际一致", "中", + f"{S_PAGE};应拣明细含至少 1 个散件", + f"{LOGIN_NOTE}\n2. 读取「耗材品种数」`§itemKindCount§` 与「耗材总数」`§itemTotalCount§`\n3. 断言品种数 = 明细去重产品数;总数 = 应拣数量合计", + "统计口径与实际所选一致。", +) +sp( + "SURG-STD-026", "标准套包维护 · 添加散件弹窗", "添加散件取消不落库", "高", S_PAGE, + f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开添加散件,勾选散件后点「取 消」\n4. 断言构成列表总数不变", + "取消后不新增散件行。", +) +sp( + "SURG-STD-027", "标准套包维护 · 添加散件弹窗", "万级散件列表首屏可交互", "低", + f"{S_PAGE};添加散件产品列表总量达万级(示例「共 16299 条」)", + f"{LOGIN_NOTE}\n2. 打开添加散件弹窗\n3. 断言首屏可交互(筛选/分页可用),不卡死\n4. 避免全量断言,仅校验首行与总数格式", + "万级列表首屏可用;无超时白屏。", +) +sp( + "SURG-STD-028", "标准套包维护 · 行内操作(编辑 / 移除)", "行内编辑构成项", "高", + f"{S_SAMPLE};先展开目标包", + f"{LOGIN_NOTE}\n2. 点击子项行「编 辑」\n3. 断言编辑弹窗/表单可见并回填当前值\n4. 点击「取 消」关闭", + "编辑回填正确;取消不影响数据。", +) +sp( + "SURG-STD-029", "标准套包维护 · 行内操作(编辑 / 移除)", "移除二次确认防误删整包", "高", + f"{S_SAMPLE};先展开目标包", + f"{LOGIN_NOTE}\n2. 记录当前行数\n3. 点击子项行「移 除」\n4. 若出现确认框则点「取 消」\n5. 断言行数未变化", + "移除有二次确认;取消不删除。", +) + +OUT = [ + ("testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv", base, BASE_PATH), + ("testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv", type_cases, TYPE_PATH), + ("testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv", std, STD_PATH), +] + +for rel, cases, url in OUT: + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(build(cases, url)) + print(f"{rel}: {len(cases)} 条") diff --git a/scripts/generate-user-md.py b/scripts/generate-user-md.py new file mode 100644 index 0000000000000000000000000000000000000000..aaf464ad212372ba8a109337ef6e7751b05ea11a --- /dev/null +++ b/scripts/generate-user-md.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +"""生成基础管理-用户管理 3 页结构化 MD(testcases/)与同源 CSV(testcase/)。""" +from __future__ import annotations + +import csv +from collections import Counter +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MD_DIR = ROOT / "testcases" +CSV_DIR = ROOT / "testcase" +TODAY = date.today().isoformat() +CSV_FIELDS = [ + "用例编号", + "功能模块", + "功能测试点", + "用例标题", + "优先级", + "前置条件", + "测试步骤", + "预期结果", + "实际结果", +] +# 正例登录账号可写入 CSV;密码只放 .env TEST_PASS / auth.setup,禁止入库 +POSITIVE_USER = "test_1" + + +def page(**kwargs) -> dict: + return kwargs + + +def case( + seq: int, + h3: str, + h4: str, + prio: str, + title: str, + tp: int, + steps: list[str], + expect: str, + pre: str = "", + auto: str | None = None, +) -> dict: + return { + "seq": seq, + "h3": h3, + "h4": h4, + "prio": prio, + "title": title, + "tp": tp, + "steps": steps, + "expect": expect, + "pre": pre, + "auto": auto, + } + + +PAGES = [ + page( + code="USER-USR", + prefix="USR", + name="用户管理", + path="/spd/basic/user/platform/person", + scan="basic-user", + count=219, + spec="UI-SPEC-SPD-USER-001", + spec_file="basic-user.spec.ts", + spec_ready=True, + tp_total=84, + write_note=( + "新增/编辑只打开后取消,不提交保存;授权只打开后取消;" + "重置密码只验证确认框后取消,不确认改密。禁止写死生产账号。" + ), + nav_note=( + "一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」;" + "建议 `openPath('/spd/basic/user/platform/person')` 并断言 URL,勿误入医共体/角色管理。" + ), + samples=( + "查询后从首行读取 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`;" + "科室为 `--` 时换一行;mock:`__NOT_EXIST_USER__99999`;" + "写操作用 `${uniqLogin}`(建议 `auto.usr.${timestamp}`),取消不落库。" + ), + skip="真实保存用户、禁用后登录、真实改密、Excel 导入文件、接口失败注入、会话过期、无菜单权限", + cases=[ + case( + 1, "筛选与查询", "功能验证点", "P0", "姓名+科室+角色+状态组合查询", 13, + [ + "点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`、`${sampleStatus}`(角色取截断前列名;状态优先读列表「状态」列,否则按行内启用/禁用文案推断)", + "填写姓名、科室、角色,展开高级筛选选择 `${sampleStatus}` 后点「筛 选」(无「筛 选」则点「查 询」)", + "断言结果非空;首行登录账号与采样行一致;可见行姓名/科室/角色/状态与已填条件一致", + ], + "组合查询无报错;查询结果与首行采样条件一致且同时满足全部已填条件。", + pre="默认查询至少 1 条", auto="USER-USR-001", + ), + case( + 2, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14, + [ + "填写姓名等筛选条件后点击「重 置」", + "断言筛选字段恢复默认,列表按默认规则刷新", + ], + "条件恢复默认;列表刷新。", + auto="USER-USR-002", + ), + case( + 3, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15, + [ + "点击「高级筛选」", + "断言展开后面板可见姓名/科室/角色/状态及「重 置」「筛 选」", + ], + "高级筛选面板展开;关键字段与筛选按钮可见。", + auto="USER-USR-003", + ), + case( + 4, "筛选与查询", "功能验证点", "P0", "高级筛选按状态过滤", 16, + [ + "展开高级筛选,状态下拉选择「启用」或「禁用」", + "点击「筛 选」", + "断言列表仅保留对应状态用户(无匹配则空态且总数为 0)", + ], + "按状态过滤成功;无报错。", + auto="USER-USR-004", + ), + case( + 5, "筛选与查询", "异常验证点", "P1", "无匹配结果空态(mock反例)", 22, + [ + "姓名框填入 `__NOT_EXIST_USER__99999`", + "点击「查 询」", + "断言空态或「暂无数据」,分页总数为 0", + ], + "无匹配时不报错;空态明确;总数为 0。", + auto="USER-USR-005", + ), + case( + 6, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 31, + [ + "记录第 1 页首行登录账号", + "点击页码「2」或「下一页」", + "断言页码高亮为 2,首行账号与第 1 页不同", + ], + "翻页成功;数据更换。", + pre="默认查询总条数大于一页,否则 skip", auto="USER-USR-006", + ), + case( + 7, "新增用户", "功能验证点", "P0", "打开新增用户弹窗", 41, + [ + "点击「新 增」", + "断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」", + ], + "新增弹窗可见;关键字段与按钮齐全。", + auto="USER-USR-007", + ), + case( + 8, "新增用户", "功能验证点", "P1", "新增取消不落库", 43, + [ + "打开新增,账号填 `${uniqLogin}`,姓名填任意合法值", + "点击「取 消」", + "按账号查询,断言无该记录", + ], + "弹窗关闭;列表不出现 `${uniqLogin}`。", + auto="USER-USR-008", + ), + case( + 9, "新增用户", "异常验证点", "P0", "必填为空拦截", 47, + [ + "打开新增,账号或姓名留空后点击「确 定」", + "断言出现必填提示,弹窗仍打开", + ], + "确定被拦截;不落库;弹窗不关闭。", + auto="USER-USR-009", + ), + case( + 10, "新增用户", "异常验证点", "P0", "登录账号重复拦截", 48, + [ + "打开新增,账号填入已存在的 `${sampleLoginName}`,补齐其它必填后点「确 定」", + "断言保存失败并提示,弹窗仍打开", + "查询该账号,断言原用户未被覆盖", + ], + "重复账号被拒绝;不覆盖原用户。", + pre="默认查询至少 1 条且登录账号非空", auto="USER-USR-010", + ), + case( + 11, "编辑用户", "功能验证点", "P0", "打开编辑用户弹窗并回填", 54, + [ + "点击首行「编辑」", + "断言弹窗标题含「编辑用户」", + "断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白)", + ], + "编辑弹窗可见;回填与列表行一致。", + pre="列表至少 1 条", auto="USER-USR-011", + ), + case( + 12, "编辑用户", "功能验证点", "P1", "编辑取消不保存", 58, + [ + "打开首行编辑,记录原姓名", + "将姓名改为临时值后点「取 消」", + "再次打开编辑,断言姓名仍为原值", + ], + "取消后列表与回看均为修改前姓名。", + pre="列表至少 1 条", auto="USER-USR-012", + ), + case( + 13, "编辑用户", "边界验证点", "P1", "编辑时账号只读", 61, + [ + "打开首行编辑", + "断言登录账号不可改(disabled/readonly 或无法输入)", + ], + "编辑态登录账号只读。", + pre="列表至少 1 条", auto="USER-USR-013", + ), + case( + 14, "编辑用户", "功能验证点", "P1", "编辑弹窗可见签名图片", 56, + [ + "打开首行编辑", + "断言可见「签名图片」区域(有图则展示,无图则上传入口可见)", + "点「取 消」,不保存", + ], + "签名图片控件可见;本条不上传、不保存。", + pre="列表至少 1 条", auto="USER-USR-014", + ), + case( + 15, "授权", "功能验证点", "P0", "打开授权后取消不生效", 68, + [ + "点击首行「授权」", + "断言打开授权层(弹窗或权限树),可见可勾选菜单/功能", + "如可勾选则改一处勾选后点取消/关闭", + "再次打开同一用户授权,断言勾选与操作前一致", + ], + "授权层可打开;取消后勾选不落库。", + pre="列表至少 1 条", auto="USER-USR-015", + ), + case( + 16, "列表与分页", "功能验证点", "P1", "切换20条每页页面正常且列表同步", 32, + [ + "点击「查 询」,记录分页「共 N 条」与当前页可见行数", + "分页区切换每页条数为「20 条/页」", + "断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错", + ], + "切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。", + pre="默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip", + auto="USER-USR-016", + ), + case( + 17, "列表与分页", "功能验证点", "P2", "打开列设置面板", 83, + [ + "点击「查 询」等待列表稳定", + "点击表格工具栏列设置(齿轮)图标", + "断言弹出面板含「列展示」「字段设置」「重置」,关闭面板", + ], + "列设置面板可打开;关键项可见;关闭后列表正常。", + pre="默认查询至少 1 条", auto="USER-USR-017", + ), + case( + 18, "列表与分页", "功能验证点", "P2", "列设置取消勾选后列表列恢复", 84, + [ + "点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭", + "断言该列表头隐藏;再次打开列设置勾选恢复并关闭", + "断言该列表头重新可见", + ], + "取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。", + pre="默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip", auto="USER-USR-018", + ), + case( + 19, "授权", "功能验证点", "P0", "授权弹框账号与所选行一致", 68, + [ + "点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}`", + "点击该行「授权」", + "断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致", + "关闭弹框", + ], + "用户角色授权弹框打开;弹框账号与所选列表行一致。", + pre="列表至少 1 条且登录账号非空", auto="USER-USR-019", + ), + case( + 20, "授权", "功能验证点", "P1", "授权弹框添加移除角色行", 82, + [ + "点击「查 询」,任选列表一条数据行,点击该行「授权」", + "断言弹出用户角色授权弹框", + "记录弹框内可「移除」的角色行数,点击「添加」", + "断言可移除行数 +1;点击新增行的「移除」", + "断言可移除行数恢复为添加前;关闭弹框且不保存", + ], + "添加后列表下方新增一行;移除后新增行被删除;未保存不落库。", + pre="列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip", + auto="USER-USR-020", + ), + ], + ), + page( + code="USER-UNI", + prefix="UNI", + name="医共体用户管理", + path="/spd/basic/user/platform/personCom", + scan="basic-user-union", + count=221, + spec="UI-SPEC-SPD-USER-002", + spec_file="basic-user-union.spec.ts", + spec_ready=True, + tp_total=60, + write_note=( + "新增/编辑只打开后取消,不提交保存;授权与改密规则同用户管理,本页不重复真实写操作。" + "禁止写死医院名称。" + ), + nav_note="菜单「基础设置 → 用户管理 → 医共体用户管理」;可用 `openPath('/spd/basic/user/platform/personCom')`。", + samples=( + "查询后从首行读取 `${sampleLoginName}`、`${sampleUserName}`、`${sampleHospital}`、`${samplePhone}`;" + "禁止写死扫描到的医院名;mock:`__NOT_EXIST_USER__99999`。" + ), + skip="未登录与菜单进入、姓名/登录账号关键字与所属医院单筛、高级筛选按状态(扫描无该字段)、授权/改密持久化(引用 TP_USER_USR)、真实跨院保存、Excel 导入文件、接口失败、会话过期", + cases=[ + case( + 1, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + [ + "点击「查 询」并等待列表稳定", + "断言出现数据行,或空态且分页总数为 0", + ], + "查询无报错;有数据时「共 N 条」N≥1。", + ), + case( + 2, "筛选与查询", "功能验证点", "P0", "姓名、所属医院、联系方式、科室、角色等条件组合查询", 13, + [ + "点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleHospital}`、`${samplePhone}`、`${sampleDept}`、`${sampleRole}`(科室或联系方式为 `--`/空时换一行)", + "填写姓名、所属医院,展开高级筛选填写「联系方式」、选择科室与角色后点「筛 选」(无则点「查 询」)", + "断言结果非空;首行登录账号与采样行一致;可见行姓名/所属医院/联系方式/科室/角色与已填条件一致", + ], + "组合查询无报错;结果与采样条件一致且同时满足全部已填条件。", + pre="默认查询至少 1 条", + ), + case( + 3, "筛选与查询", "功能验证点", "P1", "重置恢复默认医院", 14, + [ + "更改所属医院或姓名后点击「重 置」", + "断言所属医院恢复进入页时的默认值,列表刷新", + ], + "医院等条件恢复默认。", + ), + case( + 4, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15, + [ + "点击「高级筛选」", + "断言可见所属医院、联系方式、科室、角色及「筛 选」", + ], + "高级筛选含医院维度字段。", + ), + case( + 5, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21, + [ + "姓名框填入 `__NOT_EXIST_USER__99999`,点击「查 询」", + "断言空态且分页总数为 0", + ], + "无匹配不报错;总数为 0。", + ), + case( + 6, "列表与分页", "功能验证点", "P0", "列表列顺序含所属医院", 24, + [ + "断言列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作", + "勿用用户管理页列断言(用户管理无所属医院且姓名在登录账号前)", + ], + "列顺序与用户管理页不同;含所属医院。", + ), + case( + 7, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 26, + [ + "记录第 1 页首行登录账号", + "翻到第 2 页", + "断言数据更换且所属医院筛选仍保持", + ], + "翻页成功;医院条件不丢失。", + pre="总条数大于一页,否则 skip", + ), + case( + 8, "列表与分页", "集成验证点", "P1", "与用户管理页同一用户字段一致", 33, + [ + "记录本页首行 `${sampleLoginName}`、姓名、所属科室、角色", + "打开用户管理页,按登录账号查询同一用户", + "断言姓名、角色、所属科室一致;本页仅多「所属医院」", + ], + "同一登录账号跨页字段一致。", + pre="两页都能查到同一账号,否则 skip", + ), + case( + 9, "新增与编辑", "功能验证点", "P0", "打开新增弹窗", 36, + [ + "点击「新 增」", + "断言弹出用户表单,含账号、姓名、联系方式及取消/确定", + ], + "新增弹窗可见。", + ), + case( + 10, "新增与编辑", "功能验证点", "P1", "新增取消不落库", 38, + [ + "打开新增,账号填 `${uniqLogin}` 后点「取 消」", + "当前医院下列表查询该账号,断言不存在", + ], + "取消后当前医院列表不出现该账号。", + ), + case( + 11, "新增与编辑", "异常验证点", "P0", "必填为空拦截", 46, + [ + "打开新增,账号或姓名留空后点「确 定」", + "断言被拦截,弹窗仍打开,不落库", + ], + "必填为空不可保存。", + ), + case( + 12, "新增与编辑", "功能验证点", "P0", "打开编辑并回填", 39, + [ + "点击首行「编辑」", + "断言弹窗回填与该行登录账号、姓名、所属医院一致", + "点「取 消」", + ], + "编辑回填正确;本条不保存。", + pre="列表至少 1 条", + ), + case( + 13, "筛选与查询", "功能验证点", "P0", "高级筛选按科室过滤", 16, + [ + "点击「查 询」后读取首行 `${sampleDept}`(空或 `--` 时换一行)", + "展开高级筛选,选择该科室后点「筛 选」", + "断言结果非空或空态明确;有数据时可见行「所属科室」与所选科室一致", + ], + "按科室过滤成功;无报错;结果所属科室与条件一致。", + pre="默认查询至少 1 条且所属科室非空/--,否则 skip", + ), + case( + 14, "列表与分页", "功能验证点", "P1", "切换20条每页页面正常且列表同步", 27, + [ + "点击「查 询」,记录分页「共 N 条」与当前页可见行数", + "分页区切换每页条数为「20 条/页」", + "断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;所属医院筛选、表头可见无报错", + ], + "切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。", + pre="默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip", + ), + case( + 15, "列表与分页", "功能验证点", "P2", "打开列设置面板", 59, + [ + "点击「查 询」等待列表稳定", + "点击表格工具栏列设置(齿轮)图标", + "断言弹出面板含「列展示」「字段设置」「重置」,关闭面板", + ], + "列设置面板可打开;关键项可见;关闭后列表正常。", + pre="默认查询至少 1 条", + ), + case( + 16, "列表与分页", "功能验证点", "P2", "列设置取消勾选后列表列恢复", 60, + [ + "点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭", + "断言该列表头隐藏;再次打开列设置勾选恢复并关闭", + "断言该列表头重新可见", + ], + "取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。", + pre="默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip", + ), + case( + 17, "授权", "功能验证点", "P0", "打开授权后取消不生效", 51, + [ + "点击首行「授权」", + "断言打开用户角色授权层,可见可勾选菜单/功能", + "如可勾选则改一处勾选后点取消/关闭", + "再次打开同一用户授权,断言勾选与操作前一致", + ], + "授权层可打开;取消后勾选不落库。", + pre="列表至少 1 条", + ), + case( + 18, "授权", "功能验证点", "P0", "授权弹框账号与所选行一致", 51, + [ + "点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}`", + "点击该行「授权」", + "断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致", + "关闭弹框", + ], + "用户角色授权弹框打开;弹框账号与所选列表行一致。", + pre="列表至少 1 条且登录账号非空", + ), + case( + 19, "授权", "功能验证点", "P1", "授权弹框添加移除角色行", 51, + [ + "点击「查 询」,任选列表一条数据行,点击该行「授权」", + "断言弹出用户角色授权弹框", + "记录弹框内可「移除」的角色行数,点击「添加」", + "断言可移除行数 +1;点击新增行的「移除」", + "断言可移除行数恢复为添加前;关闭弹框且不保存", + ], + "添加后列表下方新增一行;移除后新增行被删除;未保存不落库。", + pre="列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip", + ), + ], + ), + page( + code="USER-ROL", + prefix="ROL", + name="角色管理", + path="/spd/basic/user/platform/role", + scan="basic-role", + count=1170, + spec="UI-SPEC-SPD-USER-003", + spec_file="basic-role.spec.ts", + tp_total=45, + write_note=( + "权限树 001/002 只测展开收起,不保存授权;003 切换同步。" + "编辑 004 取消;005 不改名保存(受 SKIP_BASIC_WRITE)。" + "新增 006–010 不提交落库。" + ), + nav_note="菜单「基础设置 → 用户管理 → 角色管理」;本页左右分栏、无独立分页。", + samples=( + "左栏读取 `${sampleRoleName}`(优先非预置自定义角色;预置用「医院管理员」若存在);" + "权限树按「模块名 + 菜单名」定位,根为「SPD内网系统PC端」。" + ), + skip=( + "未登录与菜单进入、角色搜索与空态、权限树勾选联动/刷新/保存、" + "删除与预置角色、与用户页角色下拉同源、真实新增角色落库、未保存切角色提示等" + ), + include_mock=False, + spec_ready=True, + cases=[ + case( + 1, "右侧菜单权限树", "功能验证点", "P0", "权限树按应用与模块展开", 18, + [ + "选中任一角色", + "断言右侧树根为「SPD内网系统PC端」,可见基础设置/运维管理/厂商管理/产品管理等模块", + "展开「基础设置」,断言可见子菜单(如医院资料)", + ], + "权限树层级可见;节点可展开。", + ), + case( + 2, "右侧菜单权限树", "功能验证点", "P0", "权限树模块节点收起正常", 18, + [ + "选中任一角色", + "展开「基础设置」,断言可见子菜单(如医院资料)", + "点击「基础设置」节点展开图标收起该模块", + "断言子菜单(如医院资料)不可见,且「基础设置」节点处于收起态", + ], + "权限树节点可收起;子级随父节点隐藏。", + ), + case( + 3, "左侧角色列表", "功能验证点", "P0", "切换角色右侧权限树同步更新", 11, + [ + "点选左栏第一个角色,记录右侧权限树勾选快照", + "点选左栏第二个不同角色,断言该行选中且右侧权限树仍可见", + "再次点选第一个角色,断言右侧权限树勾选快照与步骤 2 一致", + ], + "切换后右侧与当前角色一致;切回后恢复该角色已保存勾选,无错乱残留。", + pre="左栏至少 2 个角色", + ), + case( + 4, "角色增删改", "功能验证点", "P0", "编辑角色弹框回填名称一致", 33, + [ + "点选左栏任一角色,记录其角色名称", + "点击左栏右下角(或行内)编辑图标", + "断言弹出编辑角色弹框,标题或内容含「编辑」", + "断言弹框内「角色名称」输入框回填值与所选角色名称一致", + "点「取 消」或「关 闭」关闭弹框,不保存", + ], + "编辑弹框正常打开;角色名称与当前选中角色一致;本条不保存。", + pre="左栏至少 1 个角色", + ), + case( + 5, "角色增删改", "功能验证点", "P0", "编辑角色保存成功", 33, + [ + "点选左栏任一角色", + "点击左栏右下角(或行内)编辑图标,断言弹出编辑角色弹框", + "不修改角色名称,点击弹框内「保 存」", + "断言页面无报错提示,且出现「编辑成功」类成功提示", + "断言编辑弹框关闭", + ], + "保存成功;提示编辑成功;角色数据无异常报错。", + pre="左栏至少 1 个可编辑角色;环境允许写操作(非 SKIP_BASIC_WRITE=1)", + ), + case( + 6, "角色增删改", "功能验证点", "P0", "点击角色按钮打开新增弹框", 32, + [ + "点击左栏工具区「角 色」按钮", + "断言弹出新增角色弹框,标题或内容含「新增」或「角色」", + "断言页面无报错提示(无错误 toast/遮罩)", + "点「取 消」或「关 闭」关闭弹框,不保存", + ], + "新增角色弹框正常打开;页面无报错;本条不提交保存。", + ), + case( + 7, "角色增删改", "异常验证点", "P0", "新增角色名称为空保存拦截", 39, + [ + "点击左栏工具区「角 色」按钮,断言弹出新增角色弹框", + "不填写角色名称,直接点击弹框内「保 存」", + "断言「角色名称」输入框呈校验失败态(变红)", + "断言同字段下方提示:请输入角色名称!", + "点「取 消」关闭弹框,不保存", + ], + "空名称保存被拦截;角色名称框变红并提示请输入角色名称!;弹框不提交。", + ), + case( + 8, "角色增删改", "功能验证点", "P0", "新增角色弹框取消关闭", 32, + [ + "点击左栏工具区「角 色」按钮,断言弹出新增角色弹框", + "不填写任何内容,直接点击弹框内「取 消」", + "断言新增角色弹框关闭", + "断言页面无报错提示,左栏角色数量不增加", + ], + "取消后弹框关闭;页面无报错;未新增角色。", + ), + case( + 9, "角色增删改", "功能验证点", "P0", "新增弹框查询配置化复选框可勾选", 32, + [ + "点击左栏工具区「角 色」按钮,断言弹出新增角色弹框", + "定位「查询配置化」复选框,点击勾选,断言为选中态", + "再次点击取消勾选,断言为未选中态", + "断言页面无报错提示", + "点「取 消」关闭弹框,不保存", + ], + "查询配置化可正常勾选与取消勾选;页面无报错;本条不提交保存。", + pre="新增弹框含「查询配置化」复选框", + ), + case( + 10, "角色增删改", "功能验证点", "P0", "新增角色弹框X关闭", 32, + [ + "点击左栏工具区「角 色」按钮,断言弹出新增角色弹框", + "点击弹框右上角「X」关闭图标", + "断言新增角色弹框关闭", + "断言页面无报错提示,左栏角色数量不增加", + ], + "X 关闭后弹框正常关闭;页面无报错;未新增角色。", + ), + case( + 11, "左侧角色列表", "功能验证点", "P1", "左栏角色列表可上下滑动", 9, + [ + "定位左栏角色列表滚动区域", + "向下滚动列表,断言 scrollTop 增加且列表可继续交互", + "向上滚回(滚轮或滑至顶部),断言 scrollTop 减小", + "断言页面无报错遮罩", + ], + "左栏可正常上下滑动;滚动后页面无异常。", + pre="左栏列表高度溢出可滚动(不足则 skip)", + ), + case( + 12, "右侧菜单权限树", "功能验证点", "P1", "右侧权限树子菜单可上下滑动", 25, + [ + "点选左栏任一角色,断言右侧菜单权限树可见", + "定位右侧权限树滚动区域(含子菜单列表)", + "向下滚动,断言 scrollTop 增加", + "向上滚回,断言 scrollTop 减小", + "断言页面无报错遮罩", + ], + "右侧子菜单列表可正常上下滑动;滚动后页面无异常。", + pre="已选中任一角色且右侧权限树高度溢出可滚动(不足则 skip)", + ), + ], + ), +] + + +def tp_id(p: dict, c: dict) -> str: + return f"TP_USER_{p['prefix']}_{c['tp']:03d}" + + +def prio_csv(prio: str) -> str: + return {"P0": "高", "P1": "中", "P2": "低"}[prio] + + +def is_unlogin(c: dict) -> bool: + return "未登录" in c["title"] + + +def render_csv_rows(p: dict) -> list[dict]: + logged = ( + f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState 登录" + f"(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → {p['name']}」权限" + ) + unlogin = ( + f"测试环境可访问;使用独立 context,storageState 为空(不加载 {POSITIVE_USER} 登录态)" + ) + rows: list[dict] = [] + for c in p["cases"]: + case_id = f"{p['code']}-{c['seq']:03d}" + if is_unlogin(c): + pre_parts = [unlogin] + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join( + ["1. 新建独立 BrowserContext,不加载 auth/user.json"] + + [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)] + ) + else: + pre_parts = [logged] + if c["seq"] > 1: + pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)") + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join( + [ + f"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 {POSITIVE_USER}),无需在用例内重复登录" + ] + + [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)] + ) + rows.append( + { + "用例编号": case_id, + "功能模块": "基础设置 · 用户管理", + "功能测试点": f"{p['name']} · {c['h3']}", + "用例标题": c["title"], + "优先级": prio_csv(c["prio"]), + "前置条件": ";".join(pre_parts), + "测试步骤": steps, + "预期结果": c["expect"], + "实际结果": "", + } + ) + return rows + + +def render_md(p: dict) -> str: + n = len(p["cases"]) + cnt = Counter(c["prio"] for c in p["cases"]) + prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k]) + lines = [ + f"# 基础管理-用户管理-{p['name']} UI 自动化测试用例", + "", + f"**来源测试点**:`testpoint/基础管理-用户管理-{p['name']}-测试点分析-01.md` ", + f"**执行 CSV**:`testcase/基础管理-用户管理-{p['name']}-UI自动化测试用例-01.csv` ", + f"**页面 URL**:`{p['path']}` ", + f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ", + f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ", + f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ", + f"**版本**:{TODAY} ", + f"**自动化框架**:Playwright + TypeScript(" + f"{'`tests/spd/' + p['spec_file'] + '`' if p.get('spec_ready') else '规划 `tests/spd/' + p['spec_file'] + '`'}" + f",依赖 `auth.setup` 登录态)", + "", + f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → {p['name']}」权限。" + + ( + "反例筛选用 mock(`__NOT_EXIST_USER__99999` / `__NOT_EXIST_ROLE__99999`)。 " + if p.get("include_mock", True) + else " " + ), + "**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ", + f"**菜单导航**:{p['nav_note']} ", + ] + if p.get("write_note"): + lines.append(f"**写操作**:{p['write_note']} ") + lines.append(f"**动态取数**:{p['samples']}") + lines.extend( + [ + "", + "---", + "", + "## 基础设置 · 用户管理", + "", + ] + ) + current_h3 = "" + current_h4 = "" + for c in p["cases"]: + h3 = f"{p['name']} · {c['h3']}" + if h3 != current_h3: + current_h3 = h3 + current_h4 = "" + lines += [f"### {h3}", ""] + if c["h4"] != current_h4: + current_h4 = c["h4"] + lines += [f"#### {current_h4}", ""] + case_id = f"{p['code']}-{c['seq']:03d}" + tc = f"TC-UI-{case_id}" + lines += [ + f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}", + "", + f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}", + "- 操作步骤:", + ] + for i, step in enumerate(c["steps"], 1): + lines.append(f" {i}. {step}") + auto_verb = "与" if p.get("spec_ready") else "规划与" + auto_line = f"- 自动化断言:{auto_verb} CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致" + if p.get("spec_ready") and c["title"] == "编辑角色保存成功": + auto_line += "(`SKIP_BASIC_WRITE=1` 时 skip)" + lines.append(auto_line) + lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""] + while lines and lines[-1] in ("", "---"): + lines.pop() + lines += [ + "", + "", + "## 质量与覆盖说明", + "", + "| 项 | 说明 |", + "|----|------|", + f"| 用例数 | **{n}** 条({prio_txt}) |", + f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化的主路径与关键反向 |", + f"| 未纳入自动化 | {p['skip']} |", + "| 单一职责 | 每条只断言一个核心业务结果 |", + "", + ] + return "\n".join(lines) + + +def main() -> None: + MD_DIR.mkdir(parents=True, exist_ok=True) + CSV_DIR.mkdir(parents=True, exist_ok=True) + total = 0 + for p in PAGES: + seqs = [c["seq"] for c in p["cases"]] + if seqs != list(range(1, len(seqs) + 1)): + raise SystemExit(f"{p['code']} seq not consecutive: {seqs}") + md_path = MD_DIR / f"基础管理-用户管理-{p['name']}-UI自动化测试用例-01.md" + csv_path = CSV_DIR / f"基础管理-用户管理-{p['name']}-UI自动化测试用例-01.csv" + md_path.write_text(render_md(p), encoding="utf-8") + rows = render_csv_rows(p) + with csv_path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=CSV_FIELDS) + w.writeheader() + w.writerows(rows) + total += len(rows) + print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}\t{csv_path.name}") + print(f"done pages={len(PAGES)} cases={total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-user-testpoints.py b/scripts/generate-user-testpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..5d2d0e846009d1881e5cd920acfe40a774493c8f --- /dev/null +++ b/scripts/generate-user-testpoints.py @@ -0,0 +1,880 @@ +#!/usr/bin/env python3 +"""Generate 用户管理 testpoint MD and patch UI-SPEC scan paths from data/scan JSON.""" +from __future__ import annotations + +import json +import re +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCAN_DIR = ROOT / "data" / "scan" +OUT_DIR = ROOT / "testpoint" +KB = ROOT / "knowledge-base" / "08-UI专项规范库" +INDEX = KB / "INDEX.md" +TODAY = date.today().isoformat() + +PAGES = [ + { + "pageKey": "basic-user", + "specId": "USER-001", + "name": "用户管理", + "prefix": "TP_USER_USR", + "file": "基础管理-用户管理-用户管理-测试点分析-01.md", + "kind": "user", + "path": "/spd/basic/user/platform/person", + }, + { + "pageKey": "basic-user-union", + "specId": "USER-002", + "name": "医共体用户管理", + "prefix": "TP_USER_UNI", + "file": "基础管理-用户管理-医共体用户管理-测试点分析-01.md", + "kind": "union", + "path": "/spd/basic/user/platform/personCom", + }, + { + "pageKey": "basic-role", + "specId": "USER-003", + "name": "角色管理", + "prefix": "TP_USER_ROL", + "file": "基础管理-用户管理-角色管理-测试点分析-01.md", + "kind": "role", + "path": "/spd/basic/user/platform/role", + }, +] + + +def load_scan(page_key: str) -> dict: + path = SCAN_DIR / f"{page_key}-elements.json" + data = json.loads(path.read_text(encoding="utf-8")) + return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]} + + +def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str: + return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}" + + +def section(title: str, blocks: list[tuple[str, list[str]]]) -> str: + parts = [f"### {title}", ""] + for h4, lines in blocks: + parts.append(f"#### {h4}") + parts.append("") + parts.extend(lines or ["- 无(本功能点无独立该类场景,规则已归入其它功能点)"]) + parts.append("") + return "\n".join(parts) + + +class Seq: + def __init__(self, prefix: str, spec: str): + self.prefix = prefix + self.spec = spec + self.n = 0 + + def add(self, prio: str, title: str, check: str) -> str: + self.n += 1 + return tp(self.prefix, self.n, prio, title, check, self.spec) + + +def nav_section(p: dict, url: str, seq: Seq) -> str: + name = p["name"] + path = p["path"] + kind = p["kind"] + peers = "用户管理 / 医共体用户管理 / 角色管理" + nav_note = ( + "一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」进入 `/person`," + "不误入医共体用户管理或角色管理" + if kind == "user" + else f"登录后通过「基础设置 → 用户管理 → {name}」进入目标页,左侧菜单与顶栏 Tab 高亮为{name},不串到同组其它页" + ) + if kind == "role": + ready = "左右分栏渲染:左侧角色列表与右侧菜单权限树可见,「保存」「刷新」可见,无独立分页,无报错遮罩" + ctx = "当前登录人可维护角色与权限树,与顶栏登录身份不矛盾;本页无所属医院维度" + elif kind == "union": + ready = "整页单表渲染:所属医院等筛选、工具栏、列表列头与分页可见,无报错遮罩" + ctx = "顶栏院区/登录医院与本页可管理的「所属医院」范围一致" + else: + ready = "整页单表渲染:筛选区、工具栏(新增/Excel导入)、列表列头与分页可见,无报错遮罩" + ctx = "顶栏院区、登录用户与本页可管理用户范围一致" + return section( + "页面访问与导航", + [ + ( + "功能验证点", + [ + seq.add("高", f"菜单路径可达{name}", nav_note), + seq.add("高", "页面主区加载完成", ready), + seq.add( + "中", + "同组用户管理菜单可切换", + f"在 {peers} 间切换后 URL 与页面内容对应,再返回{name}状态正确", + ), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "已登录直接打开 URL", f"携带有效登录态打开 `{path}`,页面正常渲染"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "未登录访问拦截", "无登录态访问 URL 跳转登录或不展示用户/角色业务数据"), + seq.add("中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录,不误写"), + seq.add("中", "无菜单权限", f"无{name}权限的账号不可见菜单或 URL 被拒绝"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "顶栏身份与可管理范围一致", ctx), + ], + ), + ], + ) + + +def user_body(seq: Seq) -> str: + chunks = [ + section( + "筛选与查询", + [ + ( + "功能验证点", + [ + seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致"), + seq.add("高", "姓名/登录账号关键字", "在「姓名」框(占位「姓名、登录账号」)输入关键字后,结果姓名或登录账号匹配"), + seq.add("高", "科室下拉筛选", "选择某科室后列表「所属科室」与所选一致(未分配显示 `--` 的行不应出现在指定科室结果中)"), + seq.add("高", "角色下拉筛选", "选择某角色后列表「角色」列包含该角色"), + seq.add("高", "姓名+科室+角色组合查询", "多个已填条件同时生效,结果同时满足全部条件"), + seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新"), + seq.add("中", "展开高级筛选", "点「高级筛选」展开面板,可见姓名/科室/角色/状态及底部「重置」「筛选」"), + seq.add("高", "高级筛选按状态过滤", "高级筛选选择启用或禁用后点「筛选」,列表仅保留对应状态用户"), + seq.add("中", "高级筛选内重置", "高级筛选填写后点面板「重置」,条件清空,列表恢复默认"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长姓名/账号关键字", "输入达到或超过允许长度的关键字,查询被接受并按规则截断或返回匹配,页面不崩溃"), + seq.add("低", "仅空格关键字", "按空查询处理或提示,不报错"), + seq.add("低", "科室/角色下拉可搜索", "下拉内输入关键字后选项收敛为匹配项"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "查询接口失败", "列表查询失败时有错误提示,不白屏、不把过期数据展示为成功态"), + seq.add("低", "无匹配结果空态", "条件无匹配时「暂无数据」明确,分页总数为 0"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"), + seq.add("高", "角色下拉与角色管理同源", "本页角色筛选项集合与「角色管理」左侧角色名称一致(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "列表与分页", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "列表核心列完整", + "主表展示序号、姓名、登录账号、联系方式、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码)", + ), + seq.add("中", "列表不含所属医院列", "本页列中无「所属医院」,与医共体用户管理页区分"), + seq.add("中", "多角色列表截断展示", "用户绑定多个角色时列表「角色」列截断(如带省略号)"), + seq.add("中", "空值展示为 --", "未分配科室、未填工号、无任职时对应列显示 `--`"), + seq.add("中", "表格密度切换", "密度切换后列表仍可读,列不丢失"), + seq.add("低", "无批量操作", "序号列可勾选,但工具栏无批量启用/删除/授权;按「无批量」处理,不测批量提交"), + seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"), + seq.add( + "中", + "每页条数切换", + "切换「20 条/页」「50 条/页」等后当前页行数为 min(N,所选值),总数不变(用户管理自动化 USER-USR-016 覆盖 20 条/页)", + ), + seq.add("中", "跳至指定页", "分页「跳至」输入合法页码后跳到对应页"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"), + seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"), + seq.add("低", "跳至超出总页数", "跳至大于总页数的页码时被拒绝、纠正到末页或给出提示"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "跳至非数字页码", "跳至输入非法字符时不跳转异常页,输入被忽略或提示"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "翻页不丢失当前筛选", "带筛选条件翻页后条件保持,新页数据仍满足原筛选"), + seq.add("中", "科室任职数与任职记录一致", "「科室任职数」等于该用户在各科室任职条数,无任职为 `--`"), + seq.add("中", "任职数展示或可下钻", "任职数为只读数字;若可点开明细,则明细条数与数字一致"), + ], + ), + ], + ), + section( + "新增用户", + [ + ( + "功能验证点", + [ + seq.add("高", "打开新增用户弹窗", "点「新增」弹出新增/编辑同类表单,含账号、姓名、联系方式、医院员工编号、启用/禁用、取消/确定"), + seq.add("高", "合法必填保存成功", "填写必填后确定,弹窗关闭,列表出现新记录且字段与提交一致"), + seq.add("中", "取消不落库", "填写后点「取消」或关闭,列表不新增对应账号"), + seq.add("中", "Excel 导入入口可达", "工具栏「Excel导入」可打开导入流程或选择文件"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "字段达到允许最大长度", "账号/姓名取最大允许长度可保存成功"), + seq.add("中", "联系方式合法号码", "填写合法手机号/电话可保存,列表联系方式与提交一致"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "必填为空拦截", "账号或姓名为空点确定被拦截并提示,不关闭弹窗、不落库"), + seq.add("高", "登录账号重复拦截", "使用已存在账号保存失败并提示,不覆盖原用户"), + seq.add("中", "联系方式非法拦截", "明显非法号码(过短数字)点确定被拦截并提示,不落库"), + seq.add("中", "保存接口失败", "失败有错误提示,弹窗仍在,列表不出现半成品成功态"), + seq.add("中", "重复点击确定", "连续快速点击只产生一条记录或第二次被防重"), + seq.add("中", "Excel 导入失败有提示", "导入文件失败或模板不符时有错误提示,不误导为成功"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "新用户可出现在医共体用户管理", "本页新增成功后,医共体用户管理按所属医院能查到同一登录账号(若业务归属医共体)"), + ], + ), + ], + ), + section( + "编辑用户", + [ + ( + "功能验证点", + [ + seq.add("高", "打开编辑用户弹窗并回填", "行内「编辑」打开「编辑用户」,回填账号、姓名、联系方式、医院员工编号、启用/禁用"), + seq.add("中", "编辑页可查看全部角色", "多角色用户在编辑弹窗能看到完整角色列表,不被列表截断值替代"), + seq.add("中", "签名图片可上传或展示", "编辑弹窗含签名图片,已有签名可展示,可重新上传后随确定保存"), + seq.add("高", "修改后确定落库", "修改姓名或联系方式后确定,列表与再次编辑回看均为新值"), + seq.add("中", "编辑取消不保存", "修改后点「取消」,列表与再次打开仍为修改前值"), + seq.add("高", "启用/禁用切换", "在编辑弹窗将状态改为禁用或启用并确定后,高级筛选按新状态能查到该用户"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "不改字段直接确定", "打开编辑不改内容点确定,记录保持原值且不报错"), + seq.add("中", "编辑时账号只读", "编辑弹窗中登录账号不可改"), + seq.add("低", "不传签名可保存", "签名非必填时不上传仍可确定成功"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "编辑清空必填拦截", "清空姓名后确定被拦截,原记录不被写成空"), + seq.add("中", "编辑已删除用户", "记录已被删除后再确定,提示不存在,不产生幽灵更新"), + seq.add("中", "签名非图片格式拦截", "上传非图片或超限文件时拒绝保存并提示"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "禁用后登录受限", "用户禁用后使用该账号登录被拒绝或权限收回"), + seq.add("中", "再启用保持原授权", "禁用后再启用,菜单授权仍为禁用前已保存的勾选,不额外膨胀、不自动清空"), + ], + ), + ], + ), + section( + "授权", + [ + ( + "功能验证点", + [ + seq.add("高", "打开授权", "行内「授权」打开授权层(弹窗或权限树),展示可勾选菜单/功能"), + seq.add("高", "保存后勾选持久化", "勾选后保存,关闭再打开同一用户,勾选与保存时一致"), + seq.add("中", "取消不生效", "勾选后点取消/关闭,再次打开仍为操作前勾选"), + seq.add( + "中", + "授权角色行添加与移除", + "用户角色授权弹框内点「添加」增加一行,点「移除」删除新增行;不保存不落库(USER-USR-020)", + ), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "未勾选任何权限可保存", "全部取消勾选后仍可保存成功,再次打开为全不勾选"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "授权保存失败", "接口失败有提示,再次打开不是半成功勾选"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "授权后仅可见已授权菜单", "保存后该用户登录仅能看到已授权菜单"), + seq.add("中", "授权节点与角色权限树语义一致", "用户授权层菜单节点名称与角色管理权限树对应节点一致"), + ], + ), + ], + ), + section( + "重置密码", + [ + ( + "功能验证点", + [ + seq.add("高", "行内重置密码", "点「重置密码」出现确认或密码表单,确认后该用户密码被更新"), + seq.add("中", "编辑弹窗内重置密码", "「编辑用户」内「重置密码」与行内重置效果一致"), + seq.add("中", "取消不改密", "确认框点取消后原密码仍有效"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "对已禁用用户可重置", "禁用用户允许重置密码,重置后账号仍为禁用、不误启用"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "重置接口失败", "失败有提示,原密码仍可登录"), + seq.add("中", "无二次确认不得改密", "重置须经确认框;直接关闭或点取消不得改密"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "重置后原密码不可登录", "重置成功后原密码登录失败,新密码(或初始密码规则)可登录"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def union_body(seq: Seq) -> str: + chunks = [ + section( + "筛选与查询", + [ + ( + "功能验证点", + [ + seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,「共 N 条」与行数逻辑一致"), + seq.add("高", "姓名/登录账号关键字", "「姓名」框(占位「姓名、登录账号」)输入后结果姓名或账号匹配"), + seq.add("高", "所属医院筛选", "切换「所属医院」后列表仅该院用户,「共 N 条」同步变化"), + seq.add("高", "联系方式筛选", "按「联系方式」关键字查询,结果联系方式匹配"), + seq.add( + "高", + "姓名、所属医院、联系方式、科室、角色等条件组合查询", + "多条件同时生效,结果同时满足(USER-UNI-002)", + ), + seq.add("中", "重置恢复默认", "点「重置」后医院等条件恢复默认(含默认医院),列表刷新"), + seq.add("中", "展开高级筛选", "点「高级筛选」展开后可见所属医院、联系方式、科室、角色及「筛选」"), + seq.add("中", "高级筛选按科室过滤", "高级筛选选择科室后点「筛选」,列表「所属科室」与所选一致"), + seq.add("中", "高级筛选按角色过滤", "高级筛选选择角色后点「筛选」,列表「角色」列包含该角色"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长关键字", "超长姓名或联系方式关键字不崩溃,按规则截断或无匹配空态"), + seq.add("低", "仅空格联系方式", "空格按空查询处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "查询接口失败", "失败有提示,不白屏"), + seq.add("低", "无匹配空态", "总数为 0,空态明确"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "筛选结果与分页总数一致", "当前医院+条件下可见行逻辑与「共 N 条」一致"), + seq.add("高", "默认医院与顶栏/登录医院一致", "进入页时所属医院默认值与当前登录医院或顶栏院区约定一致"), + ], + ), + ], + ), + section( + "列表与分页", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "列表核心列完整且顺序正确", + "列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码);勿用用户管理页列断言", + ), + seq.add("中", "登录账号列截断", "长账号在列宽内截断;按账号搜索/断言用筛选框而非截断后的单元格文本"), + seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"), + seq.add("中", "每页条数切换", "切换每页条数后当前页行数不超过所选值,总数不变"), + seq.add("中", "跳至指定页", "分页「跳至」输入合法页码后跳到对应页"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "首页上一页不可用", "第 1 页「上一页」禁用或无效"), + seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"), + seq.add("低", "某医院无用户", "切换到无用户医院时空态明确,总数为 0"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "跳至非数字页码", "非法页码不跳转异常页"), + ], + ), + ( + "集成验证点", + [ + seq.add( + "高", + "与用户管理页同一用户字段一致", + "同一登录账号在两页的姓名、角色、所属科室一致,本页仅多「所属医院」维度", + ), + seq.add("中", "翻页不丢失医院条件", "带所属医院筛选翻页后条件保持"), + seq.add("中", "科室任职数规则同用户管理", "本页「科室任职数」判定同 TP_USER_USR 任职数与任职记录一致,无任职为 `--`"), + ], + ), + ], + ), + section( + "新增与编辑", + [ + ( + "功能验证点", + [ + seq.add("高", "打开新增弹窗", "点「新增」弹出用户表单,含账号、姓名、联系方式等及取消/确定"), + seq.add("高", "合法保存落在当前医院", "保存成功后列表在当前「所属医院」下可见该账号"), + seq.add("中", "新增取消不落库", "填写后点取消,当前医院列表不出现该账号"), + seq.add("高", "打开编辑并回填", "行内「编辑」打开「编辑用户」,回填与列表行一致"), + seq.add("高", "修改后确定落库", "修改后确定,本页列表与再次编辑回看均为新值"), + seq.add("中", "编辑取消不保存", "修改后点取消,列表与再次打开仍为修改前值"), + seq.add("中", "启用禁用规则同用户管理", "编辑弹窗可见启用/禁用;保存后仍能按姓名/账号查到该用户;禁用登录受限同 TP_USER_USR。扫描未见高级筛选「状态」字段,不按状态筛选写点"), + seq.add("中", "签名规则同用户管理", "编辑弹窗签名上传/展示规则同 TP_USER_USR,本页不重复展开"), + seq.add("中", "Excel 导入入口可达", "「Excel导入」可打开导入流程"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "字段最大长度", "账号/姓名最大长度可保存"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "必填为空拦截", "账号或姓名为空确定被拦截,不落库"), + seq.add("高", "跨院重复账号不静默覆盖", "使用他院已占用登录账号保存时拒绝并提示,不覆盖他院用户"), + seq.add("中", "Excel 导入失败有提示", "导入文件失败或模板不符时有错误提示,不误导为成功"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "本页编辑结果回写用户管理", "在医共体页修改姓名/联系方式后,用户管理页同一账号字段一致"), + seq.add("中", "Excel 导入归属当前医院", "导入成功的数据出现在当前「所属医院」列表,不写入其它院"), + ], + ), + ], + ), + section( + "授权与重置密码", + [ + ( + "功能验证点", + [ + seq.add("高", "行内授权可打开", "点「授权」打开授权层;勾选持久化规则同 TP_USER_USR,本页只校验当前行"), + seq.add("高", "行内重置密码可执行", "点「重置密码」可完成改密;改密后登录规则同 TP_USER_USR"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "对他院用户写操作不可用", "筛选他院后,对非当前医院用户的编辑/授权/重置入口不可见或拒绝"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "授权保存失败", "接口失败有提示,再次打开不是半成功勾选"), + seq.add("中", "重置密码失败", "接口失败有提示,原密码仍可登录"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "编辑不串改他院同名用户", "保存编辑后仅当前所属医院下该账号变更,其它院区同名用户不变"), + seq.add("高", "授权不串改他院账号", "授权只作用于当前行登录账号+所属医院,其它院区同账号勾选不变"), + seq.add("高", "重置密码不串改他院账号", "重置密码只改当前行,其它院区同账号原密码仍有效"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def role_body(seq: Seq) -> str: + chunks = [ + section( + "左侧角色列表", + [ + ( + "功能验证点", + [ + seq.add("高", "角色列表加载", "左侧列出角色名称(如医院管理员、中心库库管、护士等),可点选"), + seq.add("高", "角色名称搜索过滤", "在角色名称框输入关键字后列表收敛为匹配项(即时或回车,以现网为准)"), + seq.add("高", "选中角色刷新右侧权限树", "点击某角色后右侧菜单权限勾选刷新为该角色配置,无上一角色残留"), + seq.add("中", "清空搜索恢复全量", "清空角色搜索后列表恢复未过滤的完整角色"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "搜索超长关键字", "超长不崩溃,无匹配时左栏空态明确"), + seq.add("低", "仅空格搜索", "按空处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "角色列表加载失败", "失败有提示,不白屏"), + seq.add("低", "无匹配角色空态", "搜索无结果时空态明确"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "角色名与用户管理角色下拉同源", "左栏角色名称集合与用户管理/医共体用户管理「角色」筛选项一致(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "右侧菜单权限树", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "权限树按应用与模块展开", + "右侧为「菜单权限」树,根为「SPD内网系统PC端」,下含基础设置/运维管理/厂商管理/产品管理等模块;节点可展开/折叠,展开后可见子菜单", + ), + seq.add("高", "勾选父节点联动子节点", "勾选某模块父节点后其子菜单/功能全部勾选"), + seq.add("高", "取消父节点联动取消子节点", "取消父节点后子节点全部取消"), + seq.add("中", "部分勾选呈半选态", "只勾选部分子节点时父节点为半选"), + seq.add("高", "保存授权持久化", "点「保存」成功后刷新或重新进入,该角色勾选与保存时一致"), + seq.add("中", "刷新恢复未保存勾选", "改勾选后点「刷新」且未保存,树恢复为上次已保存状态"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "同名菜单按层级定位", "不同模块下同名菜单(若有)勾选互不影响,须按「模块名 + 菜单名」判定"), + seq.add("低", "树节点很多时可滚动勾选", "长树滚动后勾选仍对应正确节点,不点错行"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "未选角色直接保存", "未选中角色时保存被拦截或提示,不把勾选写到错误角色"), + seq.add( + "高", + "未保存勾选时切换角色", + "改勾选未保存即点另一角色:须提示未保存;确认后丢弃未保存勾选并加载新角色,不把未保存勾选静默写到原角色或新角色", + ), + seq.add("中", "保存接口失败", "失败有提示,再次进入不是半成功勾选"), + seq.add("中", "权限树加载失败", "失败有提示,左栏角色仍可选"), + ], + ), + ( + "集成验证点", + [ + seq.add( + "高", + "角色授权后用户可见对应菜单", + "为角色勾选「厂商查询」等菜单并保存后,仅绑定该角色的用户登录后能看到对应菜单", + ), + seq.add("高", "取消角色菜单后用户收回", "取消该角色已勾选菜单并保存后,仅绑定该角色的用户登录不再看到该菜单"), + ], + ), + ], + ), + section( + "角色增删改", + [ + ( + "功能验证点", + [ + seq.add("中", "新增角色入口可达", "通过新增/「角色」工具按钮打开新增表单,可填写角色名称并保存,左栏出现新角色"), + seq.add("高", "编辑角色名称", "行内编辑可改角色名称,保存后左栏显示新名"), + seq.add("高", "删除需二次确认", "点删除出现确认框,含取消/确定"), + seq.add("高", "确认删除后左栏移除", "确认后该角色从左栏消失,再搜索名称无该角色"), + seq.add("中", "取消删除保留角色", "确认框点取消后角色仍在,权限树勾选不变"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "角色名最大长度", "达到允许最大长度可保存"), + seq.add("低", "删除列表中最后一个自定义角色", "删除后左栏不报错,权限树进入空态或不可保存"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "角色名称为空拦截", "空名称保存被拦截并提示"), + seq.add("高", "角色名称重复拦截", "与已有角色重名保存失败,不覆盖"), + seq.add("高", "预置角色不可删除", "医院管理员等预置角色删除入口不可见或确定后拒绝,角色仍在左栏"), + seq.add("高", "删除仍被用户引用的角色", "若有用户绑定该自定义角色则拒绝删除或提示先解绑,不出现孤儿授权"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "新增后用户页角色下拉出现", "新增角色保存后,用户管理/医共体用户管理「角色」筛选项出现该名称"), + seq.add("高", "改名后用户页角色文案同步", "改名保存后,用户页下拉与已绑用户行「角色」列为新名"), + seq.add("高", "删除后用户页无已删角色残留", "删除自定义角色后,用户页下拉与行「角色」列不再出现该名称"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def quality(p: dict, count: int, tp_count: int) -> str: + kind = p["kind"] + domains = { + "user": "页面访问与导航、筛选与查询、列表与分页、新增用户、编辑用户、授权、重置密码", + "union": "页面访问与导航、筛选与查询、列表与分页、新增与编辑、授权与重置密码", + "role": "页面访问与导航、左侧角色列表、右侧菜单权限树、角色增删改", + }[kind] + if kind == "user": + cover = "导航、筛选查询、列表分页、列设置、新增/编辑(含签名与启用禁用)、授权、重置密码、权限与登录异常、同模块衔接" + pending = ( + "① 新增弹窗扫描未稳定打开,字段以编辑弹窗与 UI-SPEC 为准 " + "② 授权层扫描未采集到,勾选控件以现网为准 " + "③ 编辑时登录账号按只读写点,若现网可改需改判定 " + "④ 空权限按允许保存写点,联系方式非法/签名非图片按拦截写点,禁用用户重置按允许且不启用写点 " + "⑤ UI-SPEC 截图未见「查询」,现网扫描有「查询」,测试点以扫描为准 " + "⑥ 列表序号可勾选但无批量工具栏,按无批量处理" + ) + downstream = ( + "可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;" + "Playwright `tests/spd/basic-user.spec.ts` + `pages/spd/basic-user.page.ts`" + ) + elif kind == "union": + cover = "导航、所属医院筛选、列顺序差异、跨院不覆盖/不串改、列设置;启用禁用/签名/任职/授权持久化引用 TP_USER_USR,本页不重复展开" + pending = ( + "① 新增/授权弹窗扫描未稳定打开,以现网为准 " + "② 跨院登录账号按拒绝且不覆盖写点,若产品允许共用需改判定 " + "③ 所属医院默认值与顶栏约定以现网为准 " + "④ 签名/启用禁用/任职明细/改密后登录同用户管理对应 TP,本页只保留医院维度 " + "⑤ UI-SPEC 测试关注点写了「状态」筛选,现网扫描仅在编辑弹窗有启用/禁用、无高级筛选「状态」;按扫描写点,自动化 `USER-UNI-013` 改为按科室过滤(TP_USER_UNI_016)" + ) + downstream = ( + "可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;" + "Playwright `tests/spd/basic-user-union.spec.ts` + `pages/spd/basic-user-union.page.ts`" + ) + else: + cover = "导航(左右分栏无分页)、左栏角色搜索、右栏权限树勾选联动/保存/未保存切角色、角色增删改与用户页下拉同步" + pending = ( + "① 新增角色弹窗扫描未打开,入口以现网工具栏/行内图标为准,对应点优先级为中 " + "② 预置角色按不可删除写点,以现网预置名单为准 " + "③ 未保存切角色按「提示后丢弃、不静默写入」写点,若自动保存需改判定 " + "④ 本页无授权弹窗、无分页、无所属医院" + ) + downstream = ( + "可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;" + "Playwright `tests/spd/basic-role.spec.ts` + `pages/spd/basic-role.page.ts`" + ) + return f"""## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:{domains}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` | +| 待评审项 | {pending} | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | {downstream} | +""" + + +def render_testpoint(p: dict, scan: dict) -> tuple[str, int]: + spec = f"UI-SPEC-SPD-{p['specId']}" + seq = Seq(p["prefix"], spec) + nav = nav_section(p, scan["url"], seq) + if p["kind"] == "user": + body = user_body(seq) + extra = [ + seq.add("低", "打开列设置面板", "列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在(自动化 `USER-USR-017`)"), + seq.add("低", "列设置隐藏后再恢复", "取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(自动化 `USER-USR-018`)"), + ] + needle = "(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-001" + body = body.replace(needle, needle + "\n" + "\n".join(extra), 1) + elif p["kind"] == "union": + body = union_body(seq) + extra = [ + seq.add("低", "打开列设置面板", "列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在"), + seq.add("低", "列设置隐藏后再恢复", "取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(一对多:CSV `USER-UNI-015`/`016`)"), + ] + needle = "(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-002" + body = body.replace(needle, needle + "\n" + "\n".join(extra), 1) + else: + body = role_body(seq) + md = f"""# 基础管理-用户管理-{p['name']} — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md` +**页面 URL**:`{scan["url"]}` +**版本 / 定稿日期**:{TODAY} +**元素扫描**:`data/scan/{p["pageKey"]}-elements.json`({scan["elementCount"]} 项) +**定位器**:`locators/spd/{p["pageKey"]}.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 用户管理 · {p['name']} + +{nav} +--- + +{body} + +--- + +{quality(p, scan["elementCount"], seq.n)} +""" + return md, seq.n + + +def patch_ui_spec(p: dict, scan: dict, tp_file: str) -> None: + path = KB / f"UI-SPEC-SPD-{p['specId']}.md" + text = path.read_text(encoding="utf-8") + text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1) + text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1) + url = scan["url"] + count = scan["elementCount"] + page_key = p["pageKey"] + extra = "" + if p["kind"] == "user": + extra = "| 浏览器页签标题 | `用户管理` |\n" + elif p["kind"] == "union": + extra = "| 浏览器页签标题 | `医共体用户管理` |\n" + table = f"""| 项 | 值 | +|----|-----| +| 主框架 URL | `{url}` | +{extra}| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) | +| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |""" + text = re.sub( + r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+", + table + "\n", + text, + count=1, + ) + text = text.replace( + "1. 完成元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object(建议 `pages/spd/" + + page_key + + ".page.ts`)。", + f"1. 元素扫描已完成({count} 项):`data/scan/{page_key}-elements.json`;定位器 `locators/spd/{page_key}.locator.yaml`;下一步补充 Page Object 与用例。", + ) + path.write_text(text, encoding="utf-8") + + +def patch_index(counts: dict[str, int]) -> None: + text = INDEX.read_text(encoding="utf-8") + text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1) + repl = { + "USER-001": ( + "| 用户管理 | USER-001 | `/spd/basic/user/platform/person` | " + f"已扫描 · `basic-user` · **219** 项 · 测试点 **{counts['USER-001']}** 条 · `TP_USER_USR` |" + ), + "USER-002": ( + "| 医共体用户管理 | USER-002 | `/spd/basic/user/platform/personCom` | " + f"已扫描 · `basic-user-union` · **221** 项 · 测试点 **{counts['USER-002']}** 条 · `TP_USER_UNI` |" + ), + "USER-003": ( + "| 角色管理 | USER-003 | `/spd/basic/user/platform/role` | " + f"已扫描 · `basic-role` · **1170** 项 · 测试点 **{counts['USER-003']}** 条 · UI 自动化 **12** 条 · `TP_USER_ROL` / `USER-ROL` |" + ), + } + text = re.sub( + r"\| 用户管理 \| USER-001 \| `/spd/basic/user/platform/person` \| .+ \|", + repl["USER-001"], + text, + count=1, + ) + text = re.sub( + r"\| 医共体用户管理 \| USER-002 \| `/spd/basic/user/platform/personCom` \| .+ \|", + repl["USER-002"], + text, + count=1, + ) + text = re.sub( + r"\| 角色管理 \| USER-003 \| `/spd/basic/user/platform/role` \| .+ \|", + repl["USER-003"], + text, + count=1, + ) + if "generate:testpoints-user" not in text: + text = text.replace( + "npm run discover:scan-user\n", + "npm run discover:scan-user\nnpm run generate:testpoints-user\n", + ) + INDEX.write_text(text, encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + counts: dict[str, int] = {} + for p in PAGES: + scan = load_scan(p["pageKey"]) + md, n = render_testpoint(p, scan) + out = OUT_DIR / p["file"] + out.write_text(md, encoding="utf-8") + patch_ui_spec(p, scan, p["file"]) + counts[p["specId"]] = n + print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}") + patch_index(counts) + print("done", counts) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-vendor-md-csv.py b/scripts/generate-vendor-md-csv.py new file mode 100644 index 0000000000000000000000000000000000000000..70d28fa4ae43155a96dea81a89215bf203bbb6d0 --- /dev/null +++ b/scripts/generate-vendor-md-csv.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +"""生成基础管理-厂商管理 10 页结构化 MD(testcases/)与同源 CSV(testcase/)。""" +from __future__ import annotations + +import csv +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MD_DIR = ROOT / "testcases" +CSV_DIR = ROOT / "testcase" + +# case: (seq, h3, h4, prio P0/P1/P2, title, tp_suffix or full tp, pre, steps_list, expect, auto_id optional) +# tp_suffix: used as TP_VENDOR_{PREFIX}_{suffix:03d} if int, else literal TP string + +PAGES: list[dict] = [] + + +def page(**kwargs) -> dict: + return kwargs + + +def case( + seq: int, + h3: str, + h4: str, + prio: str, + title: str, + tp: str | int, + steps: list[str], + expect: str, + pre: str = "", + auto: str | None = None, +) -> dict: + return { + "seq": seq, + "h3": h3, + "h4": h4, + "prio": prio, + "title": title, + "tp": tp, + "steps": steps, + "expect": expect, + "pre": pre, + "auto": auto, + } + + +PAGES = [ + page( + code="VENDOR-QRY", + prefix="QRY", + name="厂商查询", + path="/spd/basic/mfrs/paltform/mfrsName/factory", + scan="vendor-query", + count=47, + spec="UI-SPEC-SPD-VENDOR-001", + spec_file="vendor-query.spec.ts", + iframe=False, + write_note="本页只读查询,无新增/编辑提交。", + samples="从器械注册人 Tab 首行读取 `${sampleMfrsName}`;禁止写死厂商名称;mock:`__NOT_EXIST_MFRS__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入厂商查询页", 1, + ["按菜单「基础设置 → 厂商管理 → 厂商查询」进入", + "断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory`", + "断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头"], + "进入厂商查询页;不误入供应商资质管理。", + pre="已登录且具备厂商管理权限", auto="VENDOR-QRY-001"), + case(2, "Tab 与搜索", "功能验证点", "P0", "默认器械注册人列表有数据或空态明确", 9, + ["切换或停留「器械注册人」", "断言出现数据行,或「暂无数据」且分页总数为 0"], + "器械注册人列表稳定;有数据时分页「共 N 条」N≥1。", auto="VENDOR-QRY-002"), + case(3, "Tab 与搜索", "功能验证点", "P0", "切换经销商 Tab", 10, + ["点击「经销商」", "断言 Tab 激活,列表有数据或空态明确"], + "经销商列表加载成功;总数可与器械注册人不同。", auto="VENDOR-QRY-003"), + case(4, "Tab 与搜索", "功能验证点", "P0", "按厂商名称搜索", 11, + ["从首行读取 `${sampleMfrsName}` 取关键字", "在厂商名称搜索框填入关键字并回车", + "断言结果行「厂商名称」包含关键字"], + "厂商名称列与关键字匹配。", + pre="器械注册人至少 1 条且厂商名称非空", auto="VENDOR-QRY-004"), + case(5, "Tab 与搜索", "功能验证点", "P1", "清空搜索恢复", 19, + ["在已过滤状态下清空搜索框并回车", "断言分页总数不少于过滤后"], + "恢复更宽结果集。", pre="已按厂商名称过滤", auto="VENDOR-QRY-005"), + case(6, "Tab 与搜索", "异常验证点", "P1", "无匹配空态(mock反例)", 17, + ["厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车", "断言空态且分页总数为 0"], + "无匹配时不报错;总数为 0。", auto="VENDOR-QRY-006"), + case(7, "Tab 与搜索", "功能验证点", "P0", "列表核心列完整", 12, + ["断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"], + "核心列可见。", auto="VENDOR-QRY-007"), + case(8, "Tab 与搜索", "功能验证点", "P1", "分页翻至第 2 页", 13, + ["总数足够时翻到第 2 页", "断言列表可加载;不足两页则 skip"], + "第 2 页可加载。", pre="器械注册人总数足够翻页", auto="VENDOR-QRY-008"), + case(9, "Tab 与搜索", "边界验证点", "P2", "仅空格关键字搜索", 15, + ["厂商名称仅填空格并回车", "断言按空查询处理或提示,不白屏"], + "不报错;行为明确。"), + case(10, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5, + ["新建独立 BrowserContext,不加载 auth/user.json(test.use 空 storageState)", + "直接打开厂商查询 URL(/spd/basic/mfrs/paltform/mfrsName/factory)", + "断言跳转登录或不展示业务列表"], + "未登录不可见厂商数据。", + pre="使用独立 context,storageState 为空(不加载 test_1 登录态)", + auto="VENDOR-QRY-010"), + case(11, "Tab 与搜索", "集成验证点", "P1", "厂商名称与资质管理进院供应商可对照", 18, + ["记录厂商查询中某厂商名称(若有)", "打开供应商资质管理,用同源关键字检索或目视对照", + "断言不出现明显主数据串名(允许一方无记录)"], + "厂商主数据跨菜单可对照,不串名。"), + ], + ), + page( + code="VENDOR-QUL", + prefix="QUL", + name="供应商资质管理", + path="/spd/basic/mfrs/basic/qcManage", + scan="vendor-qual", + count=99, + spec="UI-SPEC-SPD-VENDOR-002", + spec_file="vendor-qual.spec.ts", + iframe=False, + write_note="本批只读:查看打开后关闭;不确认导出下载、不改证件。", + samples="证件效期提醒 Tab 首行读取 `${sampleHospitalSupplier}`;mock:`__NOT_EXIST_SUPPLIER__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商资质管理页", 1, + ["按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入", + "断言 URL 含 `/spd/basic/mfrs/basic/qcManage`", + "断言可见「查 询」「重 置」及证件效期提醒相关入口"], + "进入供应商资质管理;不误入厂商查询。", + pre="已登录且具备厂商管理权限", auto="VENDOR-QUL-001"), + case(2, "筛选与 Tab", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + ["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"], + "查询无报错;有数据时分页 N≥1。", auto="VENDOR-QUL-002"), + case(3, "筛选与 Tab", "功能验证点", "P0", "切换授权链路效期预警 Tab", 9, + ["点击「授权链路效期预警」", "断言列表有数据或空态明确"], + "Tab 切换成功,列表字段可随 Tab 变化。", auto="VENDOR-QUL-003"), + case(4, "筛选与 Tab", "功能验证点", "P0", "近三个月到期计数一致", 10, + ["切换「近三个月到期」(或等价文案)", + "若可读到括号/冒号计数,断言等于分页「共 N 条」"], + "计数与分页一致(可读时)。", auto="VENDOR-QUL-004"), + case(5, "筛选与 Tab", "功能验证点", "P0", "按进院供应商筛选", 11, + ["从首行读取 `${sampleHospitalSupplier}` 取关键字", + "在进院供应商名称填入关键字并「查 询」", + "断言结果行「进院供应商」包含关键字"], + "进院供应商列与条件一致。", + pre="证件效期提醒至少 1 条且进院供应商非空", auto="VENDOR-QUL-005"), + case(6, "筛选与 Tab", "功能验证点", "P1", "重置恢复默认", 11, + ["填写筛选条件后点击「重 置」", "断言列表按默认规则刷新"], + "筛选恢复默认。", auto="VENDOR-QUL-006"), + case(7, "筛选与 Tab", "异常验证点", "P1", "无匹配空态(mock反例)", 16, + ["进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999`", "点击「查 询」", + "断言空态且分页总数为 0"], + "无匹配时不报错;总数为 0。", auto="VENDOR-QUL-007"), + case(8, "筛选与 Tab", "功能验证点", "P0", "证件效期提醒核心列完整", 12, + ["处于证件效期提醒 Tab", + "断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作"], + "核心列可见。", auto="VENDOR-QUL-008"), + case(9, "查看与导出", "功能验证点", "P0", "打开查看后关闭", 14, + ["点击首行「查 看」", "断言详情层可见", "关闭/返回,断言回到列表"], + "详情可打开;关闭后回到列表。", + pre="列表至少 1 行含「查看」", auto="VENDOR-QUL-009"), + case(10, "查看与导出", "功能验证点", "P1", "导出按钮可见", 15, + ["断言「导出」或「导出明细」按钮可见(本批不确认下载)"], + "导出入口可见。", auto="VENDOR-QUL-010"), + case(11, "筛选与 Tab", "边界验证点", "P2", "制单/效期日期起止同一天", 15, + ["若存在日期区间筛选,起止选同一天并查询", "断言仅返回该日数据或空态明确,不报错"], + "同日区间行为明确。"), + case(12, "筛选与 Tab", "集成验证点", "P1", "效期提醒与有效期至抽样一致", 18, + ["在近三个月到期列表抽样一行", "核对「效期提醒」文案与「有效期至」日期逻辑一致"], + "提醒文案与有效期至不矛盾。"), + ], + ), +] + + +def audit_page(code: str, prefix: str, name: str, path: str, scan: str, count: int, spec: str, kind: str) -> dict: + """kind: auth | cert""" + peer = "终审" if "初审" in name else "初审" + filter_label = "进院供应商" if kind == "auth" else "注册证号(新)" + cols = ( + "进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作" + if kind == "auth" + else "进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作" + ) + return page( + code=code, + prefix=prefix, + name=name, + path=path, + scan=scan, + count=count, + spec=spec, + spec_file="vendor-audit.spec.ts", + iframe=False, + write_note="审批写操作只打开详情后返回,不确认通过/驳回/提交。", + samples=f"默认查询后从首行读取 `${{sampleOrderNo}}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", f"菜单进入{name}页", 1, + [f"按菜单「基础设置 → 厂商管理 → {name}」进入", + f"断言 URL 含 `{path}`", + "断言可见「查 询」「重 置」及制单日期筛选"], + f"进入{name}页;不误入其它变更审批菜单。", + pre="已登录且具备厂商管理权限", auto=f"{code}-001"), + case(2, "筛选与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + ["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"], + "查询无报错;有数据时分页 N≥1。", auto=f"{code}-002"), + case(3, "筛选与列表", "功能验证点", "P0", "按单号筛选", 10, + ["从首行读取 `${sampleOrderNo}` 取关键字", "单号填入关键字并「查 询」", + "断言结果行「单号」包含关键字"], + "单号列与条件一致。", + pre="列表至少 1 条且单号非空", auto=f"{code}-003"), + case(4, "筛选与列表", "功能验证点", "P0", f"按{filter_label}筛选", 11, + [f"按样本填写或选择「{filter_label}」并查询", "断言结果列与条件匹配"], + "筛选条件生效。", + pre="列表至少 1 条", auto=f"{code}-004"), + case(5, "筛选与列表", "功能验证点", "P1", "重置恢复默认", 12, + ["填写筛选项后点击「重 置」", "断言文本条件清空、列表按默认规则刷新"], + "重置成功。", auto=f"{code}-005"), + case(6, "筛选与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 16, + ["单号填入 `__NOT_EXIST_ORDER__99999` 并查询", "断言空态且分页总数为 0"], + "无匹配时不报错;总数为 0。", auto=f"{code}-006"), + case(7, "筛选与列表", "功能验证点", "P0", "列表核心列完整", 13, + [f"断言表头含 {cols}", "断言存在「详情」操作"], + "核心列与详情入口可见。", auto=f"{code}-007"), + case(8, "详情", "功能验证点", "P0", "打开详情后返回", 14, + ["点击行内「详 情」", "断言详情层可见且含单号片段", "返回/关闭,不提交审批"], + "详情只读可返回;列表状态不变。", + pre="存在含「详情」的行", auto=f"{code}-008"), + case(9, "筛选与列表", "边界验证点", "P2", "制单日期起止同一天", 15, + ["制单日期起止选同一天并查询", "断言仅返回该日单据或空态明确"], + "同日区间行为明确。"), + case(10, "详情", "集成验证点", "P0", f"与{peer}单据衔接不串单", 18, + [f"记录本页某单号后切换到对应{peer}菜单", "断言同业务单据状态流转合理、不串到非本业务菜单"], + f"初审/终审衔接正确。"), + ], + ) + + +PAGES.extend( + [ + audit_page("VENDOR-AF1", "AF1", "授权变更初审", "/spd/basic/mfrs/paltform/mfrsName/authAudit", "vendor-auth-first", 40, "UI-SPEC-SPD-VENDOR-003", "auth"), + audit_page("VENDOR-AF2", "AF2", "授权变更终审", "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal", "vendor-auth-final", 40, "UI-SPEC-SPD-VENDOR-004", "auth"), + audit_page("VENDOR-CF1", "CF1", "注册证变更初审", "/spd/basic/mfrs/paltform/mfrsName/regCertAudit", "vendor-cert-first", 40, "UI-SPEC-SPD-VENDOR-005", "cert"), + audit_page("VENDOR-CF2", "CF2", "注册证变更终审", "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal", "vendor-cert-final", 40, "UI-SPEC-SPD-VENDOR-006", "cert"), + ] +) + +PAGES.append( + page( + code="VENDOR-CDF", + prefix="CDF", + name="注册证差异对比", + path="/spd/basic/mfrs/scjs/yc/certCompare", + scan="vendor-cert-diff", + count=54, + spec="UI-SPEC-SPD-VENDOR-007", + spec_file="vendor-cert-diff.spec.ts", + iframe=True, + write_note="主体在 scjs iframe;导出本批只断言按钮可见,不确认下载。", + samples="iframe 内首行读取 `${sampleMaterialName}`;mock:`__NOT_EXIST_MFRS__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入注册证差异对比页", 1, + ["按菜单「基础设置 → 厂商管理 → 注册证差异对比」进入", + "断言 URL 含 `certCompare`", + "断言外壳或 iframe 内可见物料名称/全部/一致/不一致等主区"], + "进入差异对比页;iframe 或主区加载成功。", + pre="已登录且具备厂商管理权限", auto="VENDOR-CDF-001"), + case(2, "筛选与对比", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + ["在对比页执行查询(若有查询按钮)", "断言列表有数据或空态明确"], + "查询无报错。", auto="VENDOR-CDF-002"), + case(3, "筛选与对比", "功能验证点", "P0", "切换不一致 Tab", 13, + ["点击「不一致」", "断言列表稳定"], + "不一致 Tab 切换成功。", auto="VENDOR-CDF-003"), + case(4, "筛选与对比", "功能验证点", "P1", "切换一致 Tab", 13, + ["点击「一致」", "断言列表稳定"], + "一致 Tab 切换成功。", auto="VENDOR-CDF-004"), + case(5, "筛选与对比", "功能验证点", "P0", "按物料名称筛选", 10, + ["从首行读取 `${sampleMaterialName}` 取关键字", "物料名称填入并查询", + "断言结果行物料名称包含关键字"], + "物料名称匹配。", + pre="对比列表至少 1 条", auto="VENDOR-CDF-005"), + case(6, "筛选与对比", "异常验证点", "P1", "无匹配空态(mock反例)", 16, + ["物料名称填 mock 关键字并查询", "断言空态或总数为 0"], + "无匹配时不报错。", auto="VENDOR-CDF-006"), + case(7, "筛选与对比", "功能验证点", "P0", "列表核心列完整", 14, + ["断言表头含物料编号/物料名称/规格型号/生产厂家等关键列"], + "核心对比列可见。", auto="VENDOR-CDF-007"), + case(8, "导出", "功能验证点", "P1", "导出按钮可见", 15, + ["断言「导出」按钮可见(不确认下载)"], + "导出入口可见。", auto="VENDOR-CDF-008"), + case(9, "筛选与对比", "边界验证点", "P2", "仅空格物料名称搜索", 15, + ["物料名称仅填空格并查询", "断言按空查询处理或提示,不白屏"], + "空格关键字不导致页面异常。"), + case(10, "筛选与对比", "集成验证点", "P0", "一致行两侧字段抽样一致", 17, + ["在「一致」Tab 抽样一行", "核对望海与 FLI+ 注册证号/效期关键字段一致"], + "标记一致的行两侧关键字段抽样相同。"), + case(11, "筛选与对比", "集成验证点", "P0", "不一致行至少一侧差异", 18, + ["在「不一致」Tab 抽样一行", "断言望海与 FLI+ 至少一处字段不同"], + "不一致结论与字段差异相符。"), + ], + ) +) + +PAGES.append( + page( + code="VENDOR-EVS", + prefix="EVS", + name="评价方案维护", + path="/spd/basic/mfrs/paltform/mfrsName/evaluationPlan", + scan="vendor-eval-scheme", + count=57, + spec="UI-SPEC-SPD-VENDOR-008", + spec_file="vendor-eval-scheme.spec.ts", + iframe=False, + write_note="新增只打开后取消,不提交保存。", + samples="有数据时从首行读取 `${sampleSchemeCode}`;mock:`__NOT_EXIST_ORDER__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入评价方案维护页", 1, + ["按菜单进入评价方案维护", "断言 URL 含 `evaluationPlan`", "断言可见查询与新增"], + "进入评价方案维护页。", + pre="已登录且具备厂商管理权限", auto="VENDOR-EVS-001"), + case(2, "筛选与维护", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + ["点击查询", "断言列表有数据或「暂无数据」空态"], + "查询无报错。", auto="VENDOR-EVS-002"), + case(3, "筛选与维护", "功能验证点", "P0", "按方案编码筛选", 10, + ["取样本方案编码筛选并查询", "断言方案编码列匹配"], + "方案编码匹配。", + pre="列表有数据", auto="VENDOR-EVS-003"), + case(4, "筛选与维护", "功能验证点", "P1", "重置恢复默认", 11, + ["填写筛选后重置", "断言条件清空、列表刷新"], + "重置成功。", auto="VENDOR-EVS-004"), + case(5, "筛选与维护", "异常验证点", "P1", "无匹配空态(mock反例)", 15, + ["方案编码填 mock 后查询", "断言空态总数 0"], + "无匹配不报错。", auto="VENDOR-EVS-005"), + case(6, "筛选与维护", "功能验证点", "P0", "列表核心列完整", 12, + ["断言表头含方案编码、方案名称、创建人、创建时间、状态、操作"], + "核心列可见。", auto="VENDOR-EVS-006"), + case(7, "新增", "功能验证点", "P0", "打开新增后取消", 13, + ["记录总数", "点击新增打开编辑层", "取消/返回不提交", "断言总数不变"], + "取消不落库。", auto="VENDOR-EVS-007"), + case(8, "新增", "异常验证点", "P0", "必填为空提交拦截", 16, + ["打开新增不填必填项尝试提交(若可点)", "断言拦截提示,页面不关闭或总数不变"], + "空提交被拦截。"), + case(9, "筛选与维护", "边界验证点", "P2", "方案名称超长输入", 15, + ["打开新增,方案名称输入超长字符串(如 200+ 字符)", "断言前端截断/校验提示或后端拒绝,不白屏"], + "超长输入被约束且页面可用。"), + case(10, "筛选与维护", "集成验证点", "P1", "方案与评价指标可关联", 18, + ["在方案配置中查看指标选择(只读打开)", "断言可选指标来源于评价指标维护启用项或规则允许集合"], + "方案与指标主数据衔接正确。"), + ], + ) +) + +PAGES.append( + page( + code="VENDOR-EVI", + prefix="EVI", + name="评价指标维护", + path="/spd/basic/mfrs/paltform/mfrsName/indicators", + scan="vendor-eval-indicator", + count=151, + spec="UI-SPEC-SPD-VENDOR-009", + spec_file="vendor-eval-indicator.spec.ts", + iframe=False, + write_note="详情只读返回;不落库新增/编辑提交。", + samples="右表首行读取 `${sampleIndicatorCode}`;列名可能为指标编码或指标编号;mock:`__NOT_EXIST_ORDER__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入评价指标维护页", 1, + ["按菜单进入评价指标维护", "断言 URL 含 `indicators`", "断言可见「指标分类」与查询"], + "进入评价指标维护页。", + pre="已登录且具备厂商管理权限", auto="VENDOR-EVI-001"), + case(2, "树与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + ["点击查询", "断言右表有数据或空态"], + "查询无报错。", auto="VENDOR-EVI-002"), + case(3, "树与列表", "功能验证点", "P0", "点击左侧分类树", 10, + ["点击左侧首个分类节点", "断言右侧列表刷新"], + "分类切换成功。", auto="VENDOR-EVI-003"), + case(4, "树与列表", "功能验证点", "P0", "按指标编码筛选", 11, + ["取样本指标编码/编号筛选并查询", "断言编码列匹配"], + "指标编码匹配。", + pre="右表有数据", auto="VENDOR-EVI-004"), + case(5, "树与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 15, + ["指标编码填 mock 后查询", "断言空态"], + "无匹配不报错。", auto="VENDOR-EVI-005"), + case(6, "树与列表", "功能验证点", "P0", "列表核心列完整", 12, + ["断言表头含评价类型、指标编码或指标编号、指标名称、状态、操作"], + "核心列可见。", auto="VENDOR-EVI-006"), + case(7, "详情", "功能验证点", "P0", "打开详情后返回", 14, + ["点击行内详情", "断言详情层可见", "返回不保存"], + "详情只读可返回。", + pre="存在详情操作行", auto="VENDOR-EVI-007"), + case(8, "树与列表", "边界验证点", "P2", "分类下无指标空态", 15, + ["选择无指标的分类(若有)", "断言右表空态明确"], + "空分类下列表空态正确。"), + case(9, "树与列表", "集成验证点", "P1", "启用指标可供方案使用", 18, + ["确认状态为启用的指标", "在评价方案维护中核对其可选(只读核对或抽样)"], + "启用指标与方案引用衔接正确。"), + ], + ) +) + +PAGES.append( + page( + code="VENDOR-HLD", + prefix="HLD", + name="供应商节假日管理", + path="/spd/basic/mfrs/basic/prov/holiday", + scan="vendor-holiday", + count=80, + spec="UI-SPEC-SPD-VENDOR-010", + spec_file="vendor-holiday.spec.ts", + iframe=False, + write_note="左侧新增只打开后取消;不批量删除真实数据。", + samples="左侧首行读取 `${sampleHolidayName}`/年度;mock:`__NOT_EXIST_ORDER__99999`。", + cases=[ + case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商节假日管理页", 1, + ["按菜单进入供应商节假日管理", "断言 URL 含 `prov/holiday`", + "断言可见节假日主区与新增"], + "进入供应商节假日管理页。", + pre="已登录且具备厂商管理权限", auto="VENDOR-HLD-001"), + case(2, "左右分栏", "功能验证点", "P0", "选中左侧节假日展示详情", 9, + ["点击左侧首行", "断言默认假日详情或自定义列表区可见"], + "详情联动成功。", + pre="左侧有数据", auto="VENDOR-HLD-002"), + case(3, "左右分栏", "异常验证点", "P1", "左侧搜索无匹配空态(mock反例)", 15, + ["左侧搜索框填 mock 并回车", "断言左侧列表空态"], + "无匹配空态明确。", auto="VENDOR-HLD-003"), + case(4, "维护", "功能验证点", "P0", "左侧新增取消", 11, + ["点击新增", "取消/关闭不保存", "断言仍停留本页且无异常"], + "取消不落库。", auto="VENDOR-HLD-004"), + case(5, "左右分栏", "功能验证点", "P1", "自定义节假日列表区可见", 12, + ["选中左侧行", "断言「供应商自定义节假日列表」区域可见(可为空态)"], + "自定义区可见。", auto="VENDOR-HLD-005"), + case(6, "维护", "异常验证点", "P0", "结束早于开始日期拦截", 16, + ["打开新增/编辑,结束日期早于开始日期尝试保存", "断言拒绝保存并提示"], + "非法日期被拦截。"), + case(7, "维护", "边界验证点", "P2", "开始结束为同一天", 15, + ["打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件)", + "断言允许保存或明确提示规则(按产品规则),不报错白屏"], + "同日区间规则明确。"), + case(8, "左右分栏", "集成验证点", "P1", "进院供应商主数据一致", 18, + ["在自定义列表抽样进院供应商(若有数据)", "断言可在厂商/供应商主数据中找到对应记录"], + "自定义节假日供应商与主数据一致。"), + ], + ) +) + + +def tp_id(p: dict, c: dict) -> str: + tp = c["tp"] + if isinstance(tp, int): + return f"TP_VENDOR_{p['prefix']}_{tp:03d}" + return tp + + +def prio_csv(p: str) -> str: + return {"P0": "高", "P1": "中", "P2": "低"}[p] + + +def render_md(p: dict) -> str: + lines = [ + f"# 基础管理-厂商管理-{p['name']} UI 自动化测试用例", + "", + f"**来源测试点**:`testpoint/基础管理-厂商管理-{p['name']}-测试点分析-01.md` ", + f"**执行 CSV**:`testcase/基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv` ", + f"**页面 URL**:`{p['path']}` ", + f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ", + f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ", + f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ", + f"**版本**:2026-09-30 ", + f"**自动化框架**:Playwright + TypeScript(`tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)", + "", + f"**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → {p['name']}」权限。 ", + f"**页面约定**:{'主体在 scjs **iframe**。' if p['iframe'] else '整页渲染,**无报表 iframe**。'} ", + f"**写操作**:{p['write_note']} ", + f"**动态取数**:{p['samples']}", + "", + "---", + "", + "## 基础设置 · 厂商管理", + "", + ] + current_h3 = "" + current_h4 = "" + for c in p["cases"]: + h3 = f"{p['name']} · {c['h3']}" + if h3 != current_h3: + current_h3 = h3 + current_h4 = "" + lines += [f"### {h3}", ""] + if c["h4"] != current_h4: + current_h4 = c["h4"] + lines += [f"#### {current_h4}", ""] + case_id = f"{p['code']}-{c['seq']:03d}" + tc = f"TC-UI-{case_id}" + lines += [ + f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}", + "", + f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}", + "- 操作步骤:", + ] + for i, step in enumerate(c["steps"], 1): + lines.append(f" {i}. {step}") + if c.get("auto"): + lines.append(f"- 自动化断言:与 CSV `{c['auto']}` 及 `tests/spd/{p['spec_file']}` 一致") + else: + lines.append("- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖") + lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""] + # trim trailing --- + while lines and lines[-1] in ("", "---"): + lines.pop() + lines.append("") + return "\n".join(lines) + + +def is_unlogin(c: dict) -> bool: + return "未登录" in c["title"] + + +def render_csv_rows(p: dict) -> list[dict]: + rows = [] + # 账号密码仅存 .env / auth.setup;CSV 不写明文凭据 + account = "测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限" + unlogin = "测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态)" + for c in p["cases"]: + case_id = c.get("auto") or f"{p['code']}-{c['seq']:03d}" + if is_unlogin(c): + pre_parts = [unlogin] + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join(f"{i}. {s}" for i, s in enumerate(c["steps"], start=1)) + else: + pre_parts = [account] + if c["seq"] > 1: + pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)") + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join( + [f"1. 确认 chromium 已加载 storageState(auth.setup),无需在用例内重复登录"] + + [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)] + ) + rows.append( + { + "用例编号": case_id, + "功能模块": "基础设置 · 厂商管理", + "功能测试点": f"{p['name']} · {c['h3']}", + "用例标题": c["title"], + "优先级": prio_csv(c["prio"]), + "前置条件": ";".join(pre_parts), + "测试步骤": steps, + "预期结果": c["expect"], + "实际结果": "", + } + ) + return rows + + +def main() -> None: + MD_DIR.mkdir(parents=True, exist_ok=True) + CSV_DIR.mkdir(parents=True, exist_ok=True) + total = 0 + for p in PAGES: + md_path = MD_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.md" + csv_path = CSV_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv" + md_path.write_text(render_md(p), encoding="utf-8") + rows = render_csv_rows(p) + with csv_path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter( + f, + fieldnames=["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"], + ) + w.writeheader() + w.writerows(rows) + total += len(rows) + print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}") + print(f"done pages={len(PAGES)} cases={total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-vendor-testpoints.py b/scripts/generate-vendor-testpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..26b3c4ffd8adbe8c7f030eb93fed88a540efbbdb --- /dev/null +++ b/scripts/generate-vendor-testpoints.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""Generate vendor (厂商管理) testpoint MD + patch UI-SPEC URLs from scan summary.""" +from __future__ import annotations + +import json +import re +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCAN_DIR = ROOT / "data" / "scan" +OUT_DIR = ROOT / "testpoint" +KB = ROOT / "knowledge-base" / "08-UI专项规范库" +TODAY = date.today().isoformat() + +PAGES = [ + { + "pageKey": "vendor-query", + "specId": "VENDOR-001", + "name": "厂商查询", + "prefix": "TP_VENDOR_QRY", + "file": "基础管理-厂商管理-厂商查询-测试点分析-01.md", + "kind": "query", + }, + { + "pageKey": "vendor-qual", + "specId": "VENDOR-002", + "name": "供应商资质管理", + "prefix": "TP_VENDOR_QUL", + "file": "基础管理-厂商管理-供应商资质管理-测试点分析-01.md", + "kind": "qual", + }, + { + "pageKey": "vendor-auth-first", + "specId": "VENDOR-003", + "name": "授权变更初审", + "prefix": "TP_VENDOR_AF1", + "file": "基础管理-厂商管理-授权变更初审-测试点分析-01.md", + "kind": "audit", + "peer": "授权变更终审", + "doc_type": "授权变更", + }, + { + "pageKey": "vendor-auth-final", + "specId": "VENDOR-004", + "name": "授权变更终审", + "prefix": "TP_VENDOR_AF2", + "file": "基础管理-厂商管理-授权变更终审-测试点分析-01.md", + "kind": "audit", + "peer": "授权变更初审", + "doc_type": "授权变更", + }, + { + "pageKey": "vendor-cert-first", + "specId": "VENDOR-005", + "name": "注册证变更初审", + "prefix": "TP_VENDOR_CF1", + "file": "基础管理-厂商管理-注册证变更初审-测试点分析-01.md", + "kind": "audit", + "peer": "注册证变更终审", + "doc_type": "注册证变更", + }, + { + "pageKey": "vendor-cert-final", + "specId": "VENDOR-006", + "name": "注册证变更终审", + "prefix": "TP_VENDOR_CF2", + "file": "基础管理-厂商管理-注册证变更终审-测试点分析-01.md", + "kind": "audit", + "peer": "注册证变更初审", + "doc_type": "注册证变更", + }, + { + "pageKey": "vendor-cert-diff", + "specId": "VENDOR-007", + "name": "注册证差异对比", + "prefix": "TP_VENDOR_CDF", + "file": "基础管理-厂商管理-注册证差异对比-测试点分析-01.md", + "kind": "diff", + }, + { + "pageKey": "vendor-eval-scheme", + "specId": "VENDOR-008", + "name": "评价方案维护", + "prefix": "TP_VENDOR_EVS", + "file": "基础管理-厂商管理-评价方案维护-测试点分析-01.md", + "kind": "scheme", + }, + { + "pageKey": "vendor-eval-indicator", + "specId": "VENDOR-009", + "name": "评价指标维护", + "prefix": "TP_VENDOR_EVI", + "file": "基础管理-厂商管理-评价指标维护-测试点分析-01.md", + "kind": "indicator", + }, + { + "pageKey": "vendor-holiday", + "specId": "VENDOR-010", + "name": "供应商节假日管理", + "prefix": "TP_VENDOR_HLD", + "file": "基础管理-厂商管理-供应商节假日管理-测试点分析-01.md", + "kind": "holiday", + }, +] + + +def load_summary() -> dict[str, dict]: + path = SCAN_DIR / "vendor-scan-summary.json" + rows = json.loads(path.read_text(encoding="utf-8")) + return {r["page"]: r for r in rows if r.get("ok")} + + +def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str: + return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}" + + +def section(title: str, blocks: list[tuple[str, list[str]]]) -> str: + parts = [f"### {title}", ""] + for h4, lines in blocks: + parts.append(f"#### {h4}") + parts.append("") + parts.extend(lines or ["- 无"]) + parts.append("") + return "\n".join(parts) + + +def nav_section(p: dict, url: str, spec: str) -> str: + pref = p["prefix"] + name = p["name"] + n = 1 + lines_f = [ + tp(pref, n, "高", f"菜单路径可达{name}", f"登录后通过「基础设置 → 厂商管理 → {name}」进入,菜单与 Tab 高亮正确", spec), + ] + n += 1 + lines_f.append( + tp(pref, n, "高", "页面主区加载完成", "整页渲染,筛选/主表/分页或左右分栏关键区可见", spec) + ) + n += 1 + lines_f.append( + tp(pref, n, "中", "同组厂商管理菜单可切换", f"切到其它厂商管理页再返回{name},URL 与内容对应", spec) + ) + lines_b = [ + tp(pref, 4, "低", "已登录直接打开 URL", f"携带有效登录态打开 `{url}`,页面正常渲染", spec), + ] + lines_e = [ + tp(pref, 5, "高", "未登录访问拦截", "无登录态访问跳转登录或不展示业务数据", spec), + tp(pref, 6, "中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录", spec), + tp(pref, 7, "中", "无菜单权限", "无权限账号不可见菜单或 URL 被拒绝", spec), + ] + lines_i = [ + tp(pref, 8, "中", "顶栏院区与厂商范围一致", "列表/筛选中的院区或供应商范围与顶栏院区不矛盾", spec), + ] + return section( + "页面访问与导航", + [ + ("功能验证点", lines_f), + ("边界验证点", lines_b), + ("异常验证点", lines_e), + ("集成验证点", lines_i), + ], + ), 8 + + +def body_for(p: dict, spec: str, start: int) -> tuple[str, int]: + kind = p["kind"] + pref = p["prefix"] + n = start + + def nxt(prio: str, title: str, check: str) -> str: + nonlocal n + n += 1 + return tp(pref, n, prio, title, check, spec) + + chunks: list[str] = [] + + if kind == "query": + func_points = [ + nxt("高", "器械注册人 Tab", "切换「器械注册人」后列表为注册人数据,分页「共 N 条」与结果一致"), + nxt("高", "经销商 Tab", "切换「经销商」后列表为经销商数据,总数与器械注册人可不同"), + nxt("高", "按厂商名称搜索", "输入厂商名称关键字后结果名称匹配,无匹配时空态明确"), + nxt("高", "列表核心列", "展示序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"), + nxt("中", "分页翻页", "总条数大于一页时可翻至第 2 页,页码高亮且首行更换;不足 2 页则该点不适用(自动化 `VENDOR-QRY-008`)"), + ] + bound_points = [ + nxt("中", "超长厂商名称关键字", "超长输入可查询或被截断并提示,不白屏"), + nxt("低", "仅空格关键字", "按空查询处理或提示,不报错"), + ] + except_points = [ + nxt("中", "查询接口失败", "失败有错误提示,列表不白屏"), + nxt("低", "无匹配空态", "总数为 0,空态文案明确"), + ] + integ_points = [ + nxt("中", "Tab 与分页总数一致", "当前 Tab 下可见行逻辑与「共 N 条」一致"), + ] + func_points += [ + nxt("中", "清空搜索恢复更宽结果", "按厂商名称过滤后清空搜索并回车,分页总数不少于过滤后(自动化 `VENDOR-QRY-005`)"), + nxt("中", "切换每页条数", "当前页行数 = min(总条数, 所选档位);总条数不变;切换 Tab 后分页重置合理;无数据则不适用"), + ] + chunks.append( + section( + "Tab、搜索与列表", + [ + ("功能验证点", func_points), + ("边界验证点", bound_points), + ("异常验证点", except_points), + ("集成验证点", integ_points), + ], + ) + ) + elif kind == "qual": + chunks.append( + section( + "证件 Tab、效期与列表", + [ + ( + "功能验证点", + [ + nxt("高", "证件分类 Tab 切换", "证件效期提醒/授权链路效期预警/供应商三证/器械注册人/注册证号/授权书/承诺书/法人委托书/二级供应商三证可切换且列表字段随 Tab 变化"), + nxt("高", "效期快捷筛选", "近三个月到期、近一个月到期、已过期切换后列表与 Tab 计数一致"), + nxt("高", "证件效期提醒筛选", "按进院供应商名称、证件类型查询,结果匹配"), + nxt("高", "授权链路预警筛选", "按授权书名称、授权单位、被授权单位查询,结果匹配"), + nxt("中", "重置筛选", "重置后条件恢复默认,列表按默认规则刷新"), + nxt("高", "行内查看", "点击「查看」打开证件/授权详情,关键字段与列表行一致"), + nxt("中", "导出", "导出/导出明细与当前 Tab+筛选条件一致"), + ], + ), + ( + "边界验证点", + [ + nxt("中", "某效期 Tab 计数为 0", "列表空态明确,总数为 0"), + nxt("低", "效期临界日", "即将到期与已过期边界日分类正确(以业务规则为准)"), + ], + ), + ( + "异常验证点", + [ + nxt("中", "查询失败", "接口失败有提示,不白屏"), + nxt("中", "导出失败", "导出失败有提示,不误导为成功"), + ], + ), + ( + "集成验证点", + [ + nxt("中", "效期提醒文案与有效期至一致", "「N 天后过期/即将过期」与有效期至抽样一致"), + ], + ), + ], + ) + ) + elif kind == "audit": + doc = p.get("doc_type", "变更") + peer = p.get("peer", "") + is_auth = "授权" in doc + filter_points = [ + nxt("高", "制单日期查询", "按制单日期区间查询,结果制单时间落在区间内"), + ] + if is_auth: + filter_points.extend( + [ + nxt("高", "单号与供应商筛选", "按单号、进院供应商、预转/原供应商查询,结果匹配"), + nxt("高", "状态与变更类型", "状态下拉与变更类型筛选后列表状态/类型匹配"), + ] + ) + if "初审" in p["name"]: + filter_points.append(nxt("中", "医院ID/医院名称筛选", "有该字段时按医院条件查询结果匹配")) + filter_points.append(nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认")) + filter_points.extend( + [ + nxt( + "高", + "列表核心列", + f"展示进院供应商名称、单号、状态、制单时间、制单人、变更类型及{doc}相关关键列、详情", + ), + nxt("高", "打开详情", "行内「详情」展示单据头与明细,单号与列表一致"), + ] + ) + else: + filter_points.extend( + [ + nxt("高", "单号与注册证号筛选", "按单号、注册证号(新)、变更类型、状态查询,结果匹配"), + nxt("中", "变更情况说明筛选", "有该字段时按说明关键字查询或精确匹配"), + nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认;收起后再展开值保留"), + nxt( + "高", + "列表核心列", + "展示进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、详情", + ), + nxt("高", "打开详情", "行内「详情」展示变更前后注册证信息,单号与列表一致"), + ] + ) + chunks.append( + section( + f"{doc}列表与详情", + [ + ("功能验证点", filter_points), + ( + "边界验证点", + [ + nxt("中", "制单日期起止同一天", "仅返回该日单据或空态明确"), + nxt("低", "无匹配单据", "总数为 0,空态明确"), + ], + ), + ( + "异常验证点", + [ + nxt("中", "日期开始晚于结束", "拒绝查询或自动纠正并提示"), + nxt("中", "查询失败", "失败有提示,不白屏"), + ], + ), + ( + "集成验证点", + [ + nxt( + "高", + f"与{peer}单据衔接", + f"同一业务单在初审/终审列表状态流转一致,不串到非{doc}菜单", + ), + ], + ), + ], + ) + ) + elif kind == "diff": + chunks.append( + section( + "差异筛选与对比结果", + [ + ( + "功能验证点", + [ + nxt("高", "默认查询", "进入后查询有数据或空态明确,分页总数正确"), + nxt("高", "物料/规格/厂家筛选", "按物料名称、规格型号、生产厂家查询,结果匹配"), + nxt("高", "望海与 FLI+ 注册证号筛选", "按两侧注册证号查询,结果匹配"), + nxt("中", "是否医疗器械与状态", "状态下拉与是否医疗器械条件生效"), + nxt("高", "一致/不一致 Tab", "全部、一致、不一致切换后对比结论匹配,计数合理"), + nxt("中", "立即更新", "点击立即更新后时间戳刷新或提示成功,列表可刷新"), + nxt("中", "导出", "导出与当前筛选+Tab 条件一致"), + nxt("高", "对比列完整", "展示物料编号/名称、规格、厂家、医保编码及望海/FLI+注册证号与效期字段"), + ], + ), + ( + "边界验证点", + [ + nxt("中", "不一致 Tab 无数据", "空态明确,总数为 0"), + nxt("低", "包含长期有效勾选项", "勾选「包含FLI+长期有效数据」后结果集变化符合规则"), + ], + ), + ( + "异常验证点", + [ + nxt("中", "查询或更新失败", "失败有提示,不白屏"), + nxt("中", "iframe/内嵌页加载失败", "内嵌对比页失败有提示,外壳不假成功"), + ], + ), + ( + "集成验证点", + [ + nxt("高", "一致行两侧关键字段抽样一致", "标记为一致的行望海与 FLI+ 注册证号/效期抽样相同"), + nxt("高", "不一致行至少一侧差异", "标记为不一致的行两侧字段至少一处不同"), + ], + ), + ], + ) + ) + elif kind == "scheme": + chunks.append( + section( + "方案筛选与维护", + [ + ( + "功能验证点", + [ + nxt("高", "按创建/启用日期筛选", "按日期区间查询结果落在区间内"), + nxt("高", "方案编码/名称筛选", "按编码、名称查询结果匹配"), + nxt("中", "重置", "重置后条件恢复默认"), + nxt("高", "打开新增", "点击「新增」进入方案编辑,含方案编码/名称等必填区"), + nxt("高", "列表核心列", "展示方案编码、名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作"), + nxt("中", "空列表态", "无数据时「暂无数据」空态明确"), + ], + ), + ( + "边界验证点", + [ + nxt("中", "起止同一天", "仅返回该日创建/启用的方案"), + ], + ), + ( + "异常验证点", + [ + nxt("高", "必填为空提交拦截", "方案编码或名称为空时提交失败并提示"), + nxt("中", "查询失败", "失败有提示"), + ], + ), + ( + "集成验证点", + [ + nxt("中", "方案与评价指标可关联", "方案引用的指标在评价指标维护中存在或可配置"), + ], + ), + ], + ) + ) + elif kind == "indicator": + chunks.append( + section( + "指标分类树与指标列表", + [ + ( + "功能验证点", + [ + nxt("高", "左树加载", "指标分类树可见,可搜索分类名称"), + nxt("高", "选中分类刷新右表", "点击分类后右侧列表为该分类指标"), + nxt("高", "指标编码/名称筛选", "右侧按编码、名称查询结果匹配"), + nxt("高", "列表核心列", "展示评价类型、指标编码、指标名称、状态、操作(编辑/详情/日志)"), + nxt("高", "新增分类", "左侧「新增」可创建分类,树中出现新节点"), + nxt("高", "新增指标", "右侧「新增」可创建指标并回填到列表"), + nxt("中", "编辑/详情/日志", "行内编辑打开回填;详情只读;日志可打开"), + ], + ), + ( + "边界验证点", + [ + nxt("中", "分类下无指标", "右表空态明确,总数为 0"), + nxt("低", "分类名超长", "超长被拦截或截断并提示"), + ], + ), + ( + "异常验证点", + [ + nxt("高", "必填为空拦截", "指标编码/名称为空提交失败并提示"), + nxt("中", "删除或停用受引用指标", "若被方案引用则拒绝或提示(以产品规则为准)"), + ], + ), + ( + "集成验证点", + [ + nxt("中", "状态启用后可供方案使用", "状态为启用的指标可在评价方案中被选择"), + ], + ), + ], + ) + ) + elif kind == "holiday": + chunks.append( + section( + "默认假日与供应商自定义", + [ + ( + "功能验证点", + [ + nxt("高", "左列表加载", "供应商节假日设置列表展示年度、名称,可按名称/年度搜索"), + nxt("高", "选中行展示默认详情", "点击左侧行后右侧默认假日详情名称/年度/起止/创建人时间与选中行一致"), + nxt("高", "左侧新增/编辑", "新增或编辑默认假日设置后列表与详情刷新"), + nxt("高", "供应商自定义新增", "右侧「新增」可添加进院供应商自定义节假日"), + nxt("中", "批量删除", "勾选自定义行后批量删除,列表条数减少"), + nxt("中", "自定义列表列", "展示进院供应商编码/名称、起止、备注、创建人时间、操作"), + ], + ), + ( + "边界验证点", + [ + nxt("中", "起止同一天", "默认或自定义假日起止同一天可保存或按规则提示"), + nxt("低", "自定义列表空态", "无自定义数据时「暂无数据」明确"), + ], + ), + ( + "异常验证点", + [ + nxt("高", "结束早于开始", "日期非法时拒绝保存并提示"), + nxt("中", "未勾选批量删除", "无勾选时批量删除不可用或提示"), + ], + ), + ( + "集成验证点", + [ + nxt("中", "进院供应商主数据一致", "自定义列表中的进院供应商可在厂商/供应商主数据中找到"), + ], + ), + ], + ) + ) + else: + chunks.append("### 业务功能\n\n#### 功能验证点\n\n- 无\n") + + return "\n".join(chunks), n + + +VENDOR_REVIEW = { + "query": "① 厂商名称搜索是回车还是即时过滤 ② 器械注册人与经销商分页是否独立 ③ 与供应商资质管理主数据对照口径", + "qual": "① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 证件 Tab 切换后稳定列名 ③ 导出是否含当前 Tab 明细", + "audit": "① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 可操作角色范围", + "diff": "① 内嵌/iframe 对比页加载失败时外壳是否假成功 ② 立即更新后列表刷新时效 ③ 导出是否含当前 Tab", + "scheme": "① 方案引用已停用指标时的拦截规则 ② 删除方案是否二次确认", + "indicator": "① 分类树搜索是即时过滤还是回车 ② 停用指标是否允许被方案继续引用", + "holiday": "① 默认假日与供应商自定义的覆盖优先级 ② 重置是否同时清掉自定义假日", +} + +VENDOR_DOWNSTREAM = { + "vendor-query": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-query.spec.ts` + `pages/spd/vendor-query.page.ts`", + "vendor-qual": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-qual.spec.ts` + `pages/spd/vendor-qual.page.ts`", + "vendor-auth-first": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-first.page.ts`", + "vendor-auth-final": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-final.page.ts`", + "vendor-cert-first": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-first.page.ts`", + "vendor-cert-final": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-final.page.ts`", + "vendor-cert-diff": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-cert-diff.spec.ts` + `pages/spd/vendor-cert-diff.page.ts`", + "vendor-eval-scheme": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-scheme.spec.ts` + `pages/spd/vendor-eval-scheme.page.ts`", + "vendor-eval-indicator": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-indicator.spec.ts` + `pages/spd/vendor-eval-indicator.page.ts`", + "vendor-holiday": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-holiday.spec.ts` + `pages/spd/vendor-holiday.page.ts`", +} + + +def h3_for(kind: str, p: dict) -> str: + if kind == "query": + return "页面访问与导航、Tab、搜索与列表" + if kind == "qual": + return "页面访问与导航、证件 Tab、效期与列表" + if kind == "audit": + return f"页面访问与导航、{p.get('doc_type', '变更')}列表与详情" + if kind == "diff": + return "页面访问与导航、差异筛选与对比结果" + if kind == "scheme": + return "页面访问与导航、方案筛选与维护" + if kind == "indicator": + return "页面访问与导航、指标分类树与指标列表" + return "页面访问与导航、默认假日与供应商自定义" + + +def render_testpoint(p: dict, summary: dict) -> str: + row = summary[p["pageKey"]] + url = row["url"] + count = row["elementCount"] + spec = f"UI-SPEC-SPD-{p['specId']}" + nav, last = nav_section(p, url, spec) + body, last = body_for(p, spec, last) + kind = p["kind"] + report = ( + "非统计报表页,不适用第四节报表能力清单;本页含内嵌/iframe 对比区,导航与内嵌加载失败须分开断言" + if kind == "diff" + else "非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe" + ) + return f"""# 基础管理-厂商管理-{p['name']} — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md` +**页面 URL**:`{url}` +**版本 / 定稿日期**:{TODAY} +**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({count} 项) +**定位器**:`locators/spd/{p['pageKey']}.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · {p['name']} + +{nav} +--- + +{body} +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:{h3_for(kind, p)}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:{h3_for(kind, p)};共 **{last}** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` | +| 待评审项 | {VENDOR_REVIEW.get(kind, "见正文;无新增冲突")} | +| 报表能力 | {report} | +| 下游 | {VENDOR_DOWNSTREAM.get(p["pageKey"], f"可转结构化 MD/CSV;Playwright `pages/spd/{p['pageKey']}.page.ts`")} | +""" + + +def patch_ui_spec(p: dict, summary: dict) -> None: + row = summary[p["pageKey"]] + path = KB / f"UI-SPEC-SPD-{p['specId']}.md" + text = path.read_text(encoding="utf-8") + text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1) + text = re.sub( + r'related_tc: "[^"]*"', + f'related_tc: "testpoint/{p["file"]}"', + text, + count=1, + ) + url = row["url"] + count = row["elementCount"] + page_key = p["pageKey"] + table = f"""| 项 | 值 | +|----|-----| +| 主框架 URL | `{url}` | +| iframe 选择器 | {"内嵌 scjs iframe(对比页)" if p["kind"] == "diff" else "无(整页渲染,非 iframe 报表)"} | +| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) | +| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |""" + text = re.sub( + r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+", + table + "\n", + text, + count=1, + ) + # 自动化建议首条改为已扫描 + text = text.replace( + "1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。", + f"1. 元素扫描已完成({count} 项):`data/scan/{page_key}-elements.json`;下一步补充 Page Object 与用例。", + ) + path.write_text(text, encoding="utf-8") + + +def main() -> None: + summary = load_summary() + OUT_DIR.mkdir(parents=True, exist_ok=True) + for p in PAGES: + if p["pageKey"] not in summary: + raise SystemExit(f"missing scan summary for {p['pageKey']}") + md = render_testpoint(p, summary) + out = OUT_DIR / p["file"] + out.write_text(md, encoding="utf-8") + patch_ui_spec(p, summary) + print(f"OK {p['specId']}\t{p['name']}\t{summary[p['pageKey']]['elementCount']} el\t{out.name}") + print("done", len(PAGES)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-wh-flow-md.py b/scripts/generate-wh-flow-md.py new file mode 100644 index 0000000000000000000000000000000000000000..94f7d9b5391793e8656700724ee79e4fb92ca971 --- /dev/null +++ b/scripts/generate-wh-flow-md.py @@ -0,0 +1,930 @@ +#!/usr/bin/env python3 +"""生成库房管理 / 流程设置结构化 MD(testcases/)与同源 CSV(testcase/)。 + +正例登录账号可写入 CSV;密码只放 .env TEST_PASS / auth.setup,禁止入库。 +反例筛选用 mock(__NOT_EXIST_ZONE__/PROBE/FLOW)。 +""" +from __future__ import annotations + +import csv +from collections import Counter +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MD_DIR = ROOT / "testcases" +CSV_DIR = ROOT / "testcase" +TODAY = date.today().isoformat() +POSITIVE_USER = "test_1" +CSV_FIELDS = [ + "用例编号", + "功能模块", + "功能测试点", + "用例标题", + "优先级", + "前置条件", + "测试步骤", + "预期结果", + "实际结果", +] + + +def page(**kwargs) -> dict: + return kwargs + + +def case( + seq: int, + h3: str, + h4: str, + prio: str, + title: str, + tp: int, + steps: list[str], + expect: str, + pre: str = "", +) -> dict: + return { + "seq": seq, + "h3": h3, + "h4": h4, + "prio": prio, + "title": title, + "tp": tp, + "steps": steps, + "expect": expect, + "pre": pre, + } + + +PAGES = [ + page( + code="WH-ZON", + tp_prefix="TP_WH_ZON", + group="库房管理", + name="库区设置", + path="/spd/basic/storehouse/platform/storehouse/area", + scan="warehouse-zone", + count=106, + spec="UI-SPEC-SPD-WH-001", + spec_file="warehouse-zone.spec.ts", + tp_total=74, + nav_note=( + "菜单「基础设置 → 库房管理 → 库区设置」;建议 " + "`openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL,勿误入探针绑定。" + ), + write_note=( + "新增/编辑只打开后取消或点确定做必填拦截,不提交成功保存;" + "不停用/启用落库;人员管理/货位管理只打开后关闭;同步仅点一次并断言树仍在。" + ), + samples=( + "从左侧树读取 `${sampleWhName}`,从右侧首行读取 `${sampleZoneNo}`、`${sampleZoneName}`;" + "mock:`__NOT_EXIST_ZONE__99999`。" + ), + skip="真实保存新增/编辑、停用启用持久化、接口失败注入、会话过期、无菜单权限、人员/货位子页字段细节", + cases=[ + case( + 1, "页面进入", "功能验证点", "P0", "菜单进入库区设置页", 1, + [ + "按菜单「基础设置 → 库房管理 → 库区设置」进入,或 `openPath` 打开 `/spd/basic/storehouse/platform/storehouse/area`", + "断言 URL 含 `/spd/basic/storehouse/platform/storehouse/area`", + "断言左侧「库房信息」树、右侧库区列表、「新 增」与分页可见", + ], + "进入库区设置页;不误入探针绑定;无报错遮罩。", + ), + case( + 2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5, + [ + "清除登录态后直接打开 `/spd/basic/storehouse/platform/storehouse/area`", + "断言跳转登录或不展示库房/库区业务数据", + ], + "未登录不可见库区数据。", + ), + case( + 3, "左侧库房树", "功能验证点", "P0", "库房树默认有选中项", 9, + [ + "断言左侧「库房信息」至少 1 个节点且有选中项", + "读取选中节点 `${sampleWhName}`", + "断言右侧列表为该库房库区,或空态且「共 0 条」", + ], + "树有选中;右侧对应当前库房。", + ), + case( + 4, "左侧库房树", "功能验证点", "P0", "按库房名称过滤树", 10, + [ + "读取当前选中 `${sampleWhName}` 取关键字(可截取前若干字)", + "在左侧「库房名称」输入关键字", + "断言可见节点名称均包含该关键字", + ], + "树过滤结果与关键字匹配。", + pre="库房树至少 1 个节点", + ), + case( + 5, "左侧库房树", "功能验证点", "P1", "清空库房名称恢复全量树", 11, + [ + "记录过滤前节点数", + "输入关键字过滤后再清空", + "断言节点数恢复为过滤前", + ], + "清空后树恢复全量。", + ), + case( + 6, "左侧库房树", "功能验证点", "P0", "切换库房刷新右侧列表", 12, + [ + "记录当前库房 `${sampleWhName}` 与右侧首行库区编号(若有)", + "点选另一库房节点(仅 1 个则 skip)", + "断言「共 N 条」可变化;若有行则库区编号/名称属于新库房", + ], + "切换库房后右侧列表刷新,不残留上一库房库区。", + ), + case( + 7, "左侧库房树", "异常验证点", "P1", "无匹配库房空树(mock反例)", 15, + [ + "库房名称填入 `__NOT_EXIST_ZONE__99999`", + "断言树为空或明确无数据,右侧不展示上一库房库区为成功态", + ], + "无匹配不报错;不残留上一库房列表当成功。", + ), + case( + 8, "右侧库区列表与分页", "功能验证点", "P0", "列表核心列完整", 18, + [ + "断言表头含库区编号、库区名称、库区地址、是否专项库区、操作", + "若有数据行,断言操作列可见「编辑」「人员管理」「货位管理」及「停用」或「启用」", + ], + "核心列完整;行内操入口可见。", + ), + case( + 9, "右侧库区列表与分页", "功能验证点", "P0", "默认库区以标签展示", 19, + [ + "在当前库房列表中查找「默认库区」标签", + "断言该标签出现在库区名称旁,不是独立列", + "断言当前库房至多 1 个默认库区标签(无默认则本条只断言不是独立列)", + ], + "默认库区为名称旁标签;同一库房至多一个。", + ), + case( + 10, "库区搜索", "功能验证点", "P0", "按库区编号搜索", 29, + [ + "从首行读取 `${sampleZoneNo}`", + "在「库区编号/名称」填入该编号并触发搜索", + "断言结果包含该编号行,且均属于当前选中库房", + ], + "搜索命中该编号;不串其它库房。", + pre="当前库房至少 1 条库区且编号非空", + ), + case( + 11, "库区搜索", "功能验证点", "P0", "按库区名称搜索", 30, + [ + "从首行读取 `${sampleZoneName}` 取关键字", + "在「库区编号/名称」填入关键字并搜索", + "断言结果行名称匹配", + ], + "名称关键字命中;无未捕获异常。", + pre="当前库房至少 1 条且名称非空", + ), + case( + 12, "库区搜索", "异常验证点", "P1", "无匹配库区空态(mock反例)", 34, + [ + "「库区编号/名称」填入 `__NOT_EXIST_ZONE__99999` 并搜索", + "断言列表空且分页为 0", + ], + "无匹配不报错;总数为 0。", + ), + case( + 13, "新增库区", "功能验证点", "P0", "打开新增库区弹窗", 36, + [ + "点击「新 增」", + "断言弹出「新增库区」,可见库区编号、库区名称、库区地址、联系人、联系方式及「取 消」「确 定」", + ], + "新增弹窗可见;关键字段与按钮齐全。", + ), + case( + 14, "新增库区", "功能验证点", "P1", "新增取消不落库", 38, + [ + "打开新增,编号填 `AUTO${timestamp}`,名称填任意合法值", + "点击「取 消」", + "按该编号搜索,断言无该记录", + ], + "弹窗关闭;列表不出现该编号。", + ), + case( + 15, "新增库区", "异常验证点", "P0", "必填为空拦截", 41, + [ + "打开新增,库区编号或名称留空后点击「确 定」", + "断言出现必填提示,弹窗仍打开", + "点「取 消」", + ], + "确定被拦截;不落库;弹窗不关闭。", + ), + case( + 16, "编辑库区", "功能验证点", "P0", "打开编辑并回填", 46, + [ + "点击首行「编辑」", + "断言弹窗标题含「编辑库区信息」", + "断言库区编号、名称与该行 `${sampleZoneNo}`、`${sampleZoneName}` 一致(允许规范化空白)", + "点「取 消」", + ], + "编辑弹窗可见;回填与列表行一致。", + pre="当前库房至少 1 条库区", + ), + case( + 17, "编辑库区", "功能验证点", "P1", "编辑取消不保存", 48, + [ + "打开首行编辑,记录原名称", + "将名称改为临时值后点「取 消」", + "再次打开编辑,断言名称仍为原值", + ], + "取消后列表与回看均为修改前名称。", + pre="当前库房至少 1 条库区", + ), + case( + 18, "编辑库区", "异常验证点", "P0", "编辑清空必填拦截", 52, + [ + "打开首行编辑,清空库区名称后点「确 定」", + "断言被拦截并提示,弹窗仍开", + "点「取 消」,断言列表名称未被写成空", + ], + "清空必填不能保存;原记录名称保持。", + pre="当前库房至少 1 条库区", + ), + case( + 19, "人员管理", "功能验证点", "P0", "打开人员管理后关闭", 61, + [ + "点击首行「人员管理」", + "断言打开弹层或子页,标题含人员管理或对应当前库区", + "关闭后断言仍在库区设置,当前库房选中保持", + ], + "人员管理可打开;关闭后回到当前库房列表。本条不改人员。", + pre="当前库房至少 1 条库区", + ), + case( + 20, "货位管理", "功能验证点", "P0", "打开货位管理后关闭", 66, + [ + "点击首行「货位管理」", + "断言打开弹层或子页,标题含货位且对应当前库区", + "关闭后断言库房选中与库区列表保持", + ], + "货位管理可打开;关闭后列表状态保持。本条不改货位。", + pre="当前库房至少 1 条库区", + ), + ], + ), + page( + code="WH-PRB", + tp_prefix="TP_WH_PRB", + group="库房管理", + name="探针绑定", + path="/spd/basic/storehouse/storehouse/probe", + scan="warehouse-probe", + count=78, + spec="UI-SPEC-SPD-WH-002", + spec_file="warehouse-probe.spec.ts", + tp_total=49, + nav_note=( + "菜单「基础设置 → 库房管理 → 探针绑定」;建议 " + "`openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。" + ), + write_note="绑定货位只打开弹窗后取消,不点确定保存;不改绑定关系。", + samples=( + "查询后从首行读取 `${sampleProbeId}`、`${sampleHostCode}`、`${sampleBindFlag}`、`${sampleBindCount}`;" + "mock:`__NOT_EXIST_PROBE__99999`。" + ), + skip="真实勾选确定绑定、换绑/占用规则落库、接口失败、会话过期、无菜单权限", + cases=[ + case( + 1, "页面进入", "功能验证点", "P0", "菜单进入探针绑定页", 1, + [ + "按菜单「基础设置 → 库房管理 → 探针绑定」进入,或 `openPath` 打开 `/spd/basic/storehouse/storehouse/probe`", + "断言 URL 含 `/spd/basic/storehouse/storehouse/probe`", + "断言探针ID、主机编码、是否绑定货位、查询、重置、列表与分页可见", + ], + "进入探针绑定页;不误入库区设置。", + ), + case( + 2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5, + [ + "清除登录态后直接打开 `/spd/basic/storehouse/storehouse/probe`", + "断言跳转登录或不展示探针业务列表", + ], + "未登录不可见探针数据。", + ), + case( + 3, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9, + [ + "点击「查 询」并等待列表稳定", + "断言出现数据行,或「暂无数据」且分页总数为 0", + ], + "查询无报错;有数据时「共 N 条」N≥1。", + ), + case( + 4, "筛选与查询", "功能验证点", "P0", "按探针ID查询", 10, + [ + "从首行读取 `${sampleProbeId}`", + "探针ID填入该值,点击「查 询」", + "断言结果探针ID匹配,精确命中时行数合理", + ], + "探针ID条件生效。", + pre="默认查询至少 1 条且探针ID非空", + ), + case( + 5, "筛选与查询", "功能验证点", "P0", "按主机编码查询", 11, + [ + "从首行读取 `${sampleHostCode}`", + "主机编码填入该值,点击「查 询」", + "断言结果行「主机编码」与条件一致", + ], + "主机编码条件生效;允许多探针共用同一主机。", + pre="默认查询至少 1 条且主机编码非空", + ), + case( + 6, "筛选与查询", "功能验证点", "P0", "按是否绑定货位查询", 12, + [ + "是否绑定货位选择「已绑定」,点击「查 询」", + "断言结果「是否绑定货位」均为已绑定,且「绑定的货位」>0", + "再选「未绑定」查询,断言均为未绑定且数量为 0(无数据则空态)", + ], + "已绑定数量大于 0;未绑定数量为 0。", + ), + case( + 7, "筛选与查询", "功能验证点", "P0", "探针ID+主机编码组合查询", 13, + [ + "填写 `${sampleProbeId}` 与 `${sampleHostCode}` 后点击「查 询」", + "断言结果同时满足已填条件", + ], + "组合条件同时生效。", + pre="默认查询至少 1 条", + ), + case( + 8, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14, + [ + "填写探针ID后点击「重 置」", + "断言条件恢复默认,列表按默认规则刷新", + ], + "条件恢复默认;列表刷新。", + ), + case( + 9, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 18, + [ + "探针ID填入 `__NOT_EXIST_PROBE__99999`,点击「查 询」", + "断言空态且分页总数为 0", + ], + "无匹配不报错;总数为 0。", + ), + case( + 10, "列表与分页", "功能验证点", "P0", "列表核心列与数量规则", 20, + [ + "断言表头含序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作", + "抽样已绑定行:绑定数量为数字且 >0;抽样未绑定行:数量为 0", + "断言「绑定的货位」列不是货位号格式", + ], + "核心列完整;数量列是数字不是货位号。", + ), + case( + 11, "打开绑定货位弹窗", "功能验证点", "P0", "行内打开货位绑定探针", 30, + [ + "点击首行「绑定货位」", + "断言弹出「货位绑定探针」,可见当前库房/库区/货位查询及货位列表", + ], + "弹窗标题正确;筛选与列表可见。", + pre="列表至少 1 条", + ), + case( + 12, "打开绑定货位弹窗", "功能验证点", "P1", "取消关闭不改主列表", 31, + [ + "记录主列表首行绑定数量与当前筛选", + "打开绑定弹窗后点「取 消」", + "断言主列表绑定数量与筛选条件保持", + ], + "取消不改绑定关系。", + pre="列表至少 1 条", + ), + case( + 13, "打开绑定货位弹窗", "边界验证点", "P1", "未选货位时确定计数为 0", 32, + [ + "打开绑定弹窗,不勾选货位", + "断言底部按钮文案为「确定(0)」或等价", + "点「取 消」,不点确定保存", + ], + "未选时计数为 0;本条不保存绑定。", + pre="列表至少 1 条", + ), + case( + 14, "打开绑定货位弹窗", "集成验证点", "P1", "弹窗分页独立于主列表", 34, + [ + "记录主列表「共 N 条」", + "打开绑定弹窗,记录弹窗分页「共 M 条」", + "断言不得用 M 断言主列表 N(货位量级可远大于探针条数)", + "点「取 消」", + ], + "弹窗总条数与主列表探针条数相互独立。", + pre="列表至少 1 条", + ), + case( + 15, "弹窗筛选货位", "功能验证点", "P0", "按当前库房筛选货位", 35, + [ + "打开绑定弹窗,记录默认 `${sampleModalWh}`", + "点「查 询」", + "断言可见行「当前库房」与所选库房一致(有数据时)", + "点「取 消」", + ], + "弹窗库房条件生效。", + pre="列表至少 1 条", + ), + case( + 16, "弹窗筛选货位", "功能验证点", "P1", "弹窗内重置", 38, + [ + "打开绑定弹窗,填写货位查询关键字后点「重 置」", + "断言货位查询条件恢复,列表按弹窗默认规则刷新", + "点「取 消」", + ], + "弹窗重置不影响主列表筛选。", + pre="列表至少 1 条", + ), + case( + 17, "勾选货位并保存绑定", "功能验证点", "P1", "勾选后确定计数增加再取消勾选", 43, + [ + "打开绑定弹窗,勾选 1 个货位", + "断言确定按钮计数为 1", + "取消该勾选,断言计数回到 0", + "点「取 消」,不保存", + ], + "计数随勾选变化;取消勾选不落库。", + pre="弹窗货位列表至少 1 条", + ), + ], + ), + page( + code="FLOW-DEF", + tp_prefix="TP_FLOW_DEF", + group="流程设置", + name="审批流程设置", + path="/spd/basic/flowSet/workflow/flowSet", + scan="flow-def", + count=43, + spec="UI-SPEC-SPD-FLOW-001", + spec_file="flow-def.spec.ts", + tp_total=41, + nav_note=( + "菜单「基础设置 → 流程设置 → 审批流程设置」;建议 " + "`openPath('/spd/basic/flowSet/workflow/flowSet')`,与「审批流实例」用 exact 区分。" + ), + write_note=( + "禁止点「保 存」提交线上审批路由。新增流程/改节点后必须「重 置」或取消条件配置;" + "必填拦截若需点保存,仅空行拦截成功后立刻重置。" + ), + samples=( + "从左侧读取 `${sampleBizType}`(如采购计划);自定义行读取 `${sampleFlowName}`、`${sampleCondText}`;" + "mock:`__NOT_EXIST_FLOW__99999`。" + ), + skip="真实保存审批链、自定义流持久化、条件保存落库、历史实例快照、接口失败、会话过期", + cases=[ + case( + 1, "页面进入", "功能验证点", "P0", "菜单进入审批流程设置页", 1, + [ + "按菜单「基础设置 → 流程设置 → 审批流程设置」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowSet`", + "断言 URL 含 `/spd/basic/flowSet/workflow/flowSet`", + "断言左侧业务类型、右侧默认审批流与自定义审批流、「保 存」「重 置」可见", + ], + "进入审批流程设置;不误入审批流实例。", + ), + case( + 2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5, + [ + "清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowSet`", + "断言跳转登录或不展示审批流配置", + ], + "未登录不可见审批流配置。", + ), + case( + 3, "左侧业务类型", "功能验证点", "P0", "业务类型列表可见", 9, + [ + "断言左侧可见采购计划等类型且有当前高亮项", + "读取 `${sampleBizType}`", + ], + "类型列表可见;当前项高亮。", + ), + case( + 4, "左侧业务类型", "功能验证点", "P0", "按流程名称过滤类型", 10, + [ + "在「流程名称」输入 `${sampleBizType}` 关键字", + "断言可见类型名称均匹配关键字", + ], + "类型过滤与关键字匹配。", + ), + case( + 5, "左侧业务类型", "功能验证点", "P1", "清空关键字恢复全量", 11, + [ + "记录过滤前类型数量", + "输入关键字后再清空", + "断言数量恢复", + ], + "清空后类型列表恢复全量。", + ), + case( + 6, "左侧业务类型", "功能验证点", "P0", "切换类型刷新右侧配置", 12, + [ + "记录当前类型默认流节点文案", + "点选另一业务类型(仅 1 个则 skip)", + "断言右侧默认流/自定义流已刷新,不残留上一类型节点人员", + ], + "切换类型后右侧与该类型已保存配置一致。", + ), + case( + 7, "左侧业务类型", "异常验证点", "P1", "无匹配类型(mock反例)", 15, + [ + "流程名称填入 `__NOT_EXIST_FLOW__99999`", + "断言左侧无匹配或空,右侧不把上一类型配置展示为当前成功配置", + ], + "无匹配不报错。", + ), + case( + 8, "默认审批流", "功能验证点", "P0", "默认审批流节点可见", 17, + [ + "选中「采购计划」(或当前默认类型)", + "断言「默认审批流」区域可见节点卡片,含节点/账号/人员三行信息(按卡片容器取文本)", + ], + "默认流节点结构可见;不点保存。", + ), + case( + 9, "自定义审批流", "功能验证点", "P0", "新增流程增加一行后重置", 22, + [ + "记录当前自定义流行数", + "点击「新增流程」", + "断言行数 +1,新行含流程名称、条件、审批流程、删除", + "点击「重 置」,断言行数回到操作前,不点「保 存」", + ], + "可新增行;重置放弃未保存行。", + ), + case( + 10, "自定义审批流", "异常验证点", "P0", "空行保存被拦截后重置", 28, + [ + "点击「新增流程」得到空行", + "点击「保 存」", + "断言出现必填拦截提示,配置未按成功态关闭", + "点击「重 置」,放弃该空行", + ], + "空名称/条件/节点不能落库;拦截后重置恢复。", + ), + case( + 11, "条件配置", "功能验证点", "P0", "打开条件配置后取消", 31, + [ + "若无自定义行则 skip", + "点击首条自定义流条件列「编辑」", + "断言打开「条件配置」", + "点「取 消」,断言行内条件文案与打开前一致", + ], + "条件配置可打开;取消不改行内条件。", + pre="当前类型至少 1 条自定义流", + ), + case( + 12, "保存与重置", "功能验证点", "P0", "重置放弃未保存改动", 38, + [ + "点击「新增流程」增加一行", + "点击「重 置」", + "断言自定义流行回到上次保存结果", + ], + "重置不提交;未保存行消失。", + ), + case( + 13, "左侧业务类型", "集成验证点", "P1", "类型与审批流实例业务类型同源", 16, + [ + "记录本页左侧类型名称集合(可见范围)", + "打开审批流实例,展开「业务类型」下拉", + "断言选项与本页类型同源(允许按权限裁剪,本页有的关键类型如采购计划应能对应)", + ], + "两页业务类型同源。", + ), + ], + ), + page( + code="FLOW-INS", + tp_prefix="TP_FLOW_INS", + group="流程设置", + name="审批流实例", + path="/spd/basic/flowSet/workflow/flowCase", + scan="flow-inst", + count=62, + spec="UI-SPEC-SPD-FLOW-002", + spec_file="flow-inst.spec.ts", + tp_total=41, + nav_note=( + "菜单「基础设置 → 流程设置 → 审批流实例」;建议 " + "`openPath('/spd/basic/flowSet/workflow/flowCase')`,不要点到审批流程设置。" + ), + write_note="本页只读。详情只打开后返回,不改审批人。断言「共 N 条」前先收窄日期。", + samples=( + "查询后从首行读取 `${sampleOrderNo}`、`${sampleBizType}`、`${sampleStatus}`、`${sampleSummary}`;" + "mock:`__NOT_EXIST_FLOW__99999`。" + ), + skip="改审批人、接口失败、会话过期、无菜单权限、详情与流程设置节点逐条对照(可另开集成专项)", + cases=[ + case( + 1, "页面进入", "功能验证点", "P0", "菜单进入审批流实例页", 1, + [ + "按菜单「基础设置 → 流程设置 → 审批流实例」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowCase`", + "断言 URL 含 `/spd/basic/flowSet/workflow/flowCase`", + "断言单据编号、审批状态、发起时间、业务类型、摘要、查询、重置、列表与分页可见", + ], + "进入审批流实例;不误入审批流程设置。", + ), + case( + 2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5, + [ + "清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowCase`", + "断言跳转登录或不展示审批单据列表", + ], + "未登录不可见审批实例。", + ), + case( + 3, "筛选与查询", "功能验证点", "P0", "默认日期区间查询", 9, + [ + "点击「查 询」并等待列表稳定", + "断言出现数据行或空态明确,「共 N 条」与结果逻辑一致", + ], + "默认查询无报错;有数据时 N≥1。", + ), + case( + 4, "筛选与查询", "功能验证点", "P0", "单据编号精确查询", 10, + [ + "从首行读取 `${sampleOrderNo}`", + "单据编号填入完整单号,点击「查 询」", + "断言至多 1 条,且编号、业务类型与该行一致", + ], + "精确单号命中当前行。", + pre="默认查询至少 1 条且单据编号非空", + ), + case( + 5, "筛选与查询", "功能验证点", "P0", "按审批状态筛选", 11, + [ + "审批状态下拉选择「审批通过」(无该选项则选列表中已出现的状态)", + "点击「查 询」", + "断言结果「审批状态」列与所选一致", + ], + "状态筛选生效。", + ), + case( + 6, "筛选与查询", "功能验证点", "P0", "按业务类型筛选", 12, + [ + "从首行读取 `${sampleBizType}`", + "业务类型选择该值,点击「查 询」", + "断言结果「业务类型」均为该类型", + ], + "业务类型筛选生效。", + pre="默认查询至少 1 条", + ), + case( + 7, "筛选与查询", "功能验证点", "P0", "按发起时间区间筛选", 13, + [ + "记录当前默认起止日期", + "点击「查 询」", + "抽样结果行「发起时间」落在起止日内(含边界)", + ], + "发起时间落在区间内。", + ), + case( + 8, "筛选与查询", "功能验证点", "P1", "按摘要关键字筛选", 14, + [ + "找到摘要非 `--` 的行读取 `${sampleSummary}` 关键字;若全为 `--` 则填 mock 关键字", + "点击「查 询」", + "有关键字命中时摘要包含该关键字;mock 无命中时空态且 `--` 行不被命中", + ], + "非空摘要可检索;`--` 不被非空关键字命中。", + ), + case( + 9, "筛选与查询", "功能验证点", "P0", "状态+类型组合查询", 15, + [ + "填写 `${sampleStatus}` 与 `${sampleBizType}` 后点击「查 询」", + "断言抽样行同时满足全部已填条件", + ], + "组合条件同时生效。", + pre="默认查询至少 1 条", + ), + case( + 10, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 16, + [ + "填写单据编号等条件后点击「重 置」", + "断言日期回到默认区间,其余条件清空,列表刷新", + ], + "重置后日期与其它条件恢复默认。", + ), + case( + 11, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21, + [ + "单据编号填入 `__NOT_EXIST_FLOW__99999`,点击「查 询」", + "断言空态且分页总数为 0", + ], + "无匹配不报错;总数为 0。", + ), + case( + 12, "列表与分页", "功能验证点", "P0", "列表核心列完整", 24, + [ + "断言表头含序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作", + "断言行内可见「详情」", + ], + "核心列完整;详情入口可见。", + ), + case( + 13, "列表与分页", "功能验证点", "P1", "单据编号前缀与业务类型对应", 25, + [ + "抽样 `PLAN`/`DB`/`GC`/`BUY` 前缀行(有则断言)", + "断言 PLAN→采购计划、DB→二级库调拨、GC→产品信息变更、BUY→请领单(以现网枚举为准,缺前缀 skip 该项)", + ], + "前缀与类型对应。", + pre="默认查询有数据", + ), + case( + 14, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 28, + [ + "记录第 1 页首行单据编号", + "翻到第 2 页", + "断言数据更换;筛选条件保持", + ], + "翻页成功;筛选不丢失。", + pre="总条数大于一页,否则 skip", + ), + case( + 15, "审批详情", "功能验证点", "P0", "打开详情对应当前行", 36, + [ + "记录首行 `${sampleOrderNo}`", + "点击「详情」", + "断言打开该单审批过程,标题或单号含 `${sampleOrderNo}`", + ], + "详情单号与当前行一致。", + pre="列表至少 1 条", + ), + case( + 16, "审批详情", "功能验证点", "P1", "关闭详情保留列表状态", 38, + [ + "填写单据编号查询后打开详情", + "点返回/关闭", + "断言筛选条件与列表仍是打开前的查询结果", + ], + "返回后条件与页码保持。", + pre="列表至少 1 条", + ), + case( + 17, "审批详情", "功能验证点", "P1", "详情只读", 37, + [ + "打开首行详情", + "断言无保存审批人/改条件的提交按钮,或此类控件不可用", + "返回列表", + ], + "详情只读;改配置须到审批流程设置。", + pre="列表至少 1 条", + ), + ], + ), +] + + +def tp_id(p: dict, c: dict) -> str: + return f"{p['tp_prefix']}_{c['tp']:03d}" + + +def prio_csv(prio: str) -> str: + return {"P0": "高", "P1": "中", "P2": "低"}[prio] + + +def is_unlogin(c: dict) -> bool: + return "未登录" in c["title"] + + +def render_csv_rows(p: dict) -> list[dict]: + logged = ( + f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState 登录" + f"(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → {p['group']} → {p['name']}」权限" + ) + unlogin = ( + f"测试环境可访问;使用独立 context,storageState 为空(不加载 {POSITIVE_USER} 登录态)" + ) + rows: list[dict] = [] + for c in p["cases"]: + case_id = f"{p['code']}-{c['seq']:03d}" + if is_unlogin(c): + pre_parts = [unlogin] + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join( + ["1. 新建独立 BrowserContext,不加载 auth/user.json"] + + [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)] + ) + else: + pre_parts = [logged] + if c["seq"] > 1: + pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)") + if c["pre"]: + pre_parts.append(c["pre"]) + steps = "\n".join( + [ + f"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 {POSITIVE_USER}),无需在用例内重复登录" + ] + + [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)] + ) + rows.append( + { + "用例编号": case_id, + "功能模块": f"基础设置 · {p['group']}", + "功能测试点": f"{p['name']} · {c['h3']}", + "用例标题": c["title"], + "优先级": prio_csv(c["prio"]), + "前置条件": ";".join(pre_parts), + "测试步骤": steps, + "预期结果": c["expect"], + "实际结果": "", + } + ) + return rows + + +def render_md(p: dict) -> str: + n = len(p["cases"]) + cnt = Counter(c["prio"] for c in p["cases"]) + prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k]) + lines = [ + f"# 基础管理-{p['group']}-{p['name']} UI 自动化测试用例", + "", + f"**来源测试点**:`testpoint/基础管理-{p['group']}-{p['name']}-测试点分析-01.md` ", + f"**执行 CSV**:`testcase/基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv` ", + f"**页面 URL**:`{p['path']}` ", + f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ", + f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ", + f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ", + f"**版本**:{TODAY} ", + f"**自动化框架**:Playwright + TypeScript(规划 `tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)", + "", + f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → {p['group']} → {p['name']}」权限。反例筛选用 mock。 ", + "**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ", + f"**菜单导航**:{p['nav_note']} ", + f"**写操作**:{p['write_note']} ", + f"**动态取数**:{p['samples']}", + "", + "---", + "", + f"## 基础设置 · {p['group']}", + "", + ] + current_h3 = "" + current_h4 = "" + for c in p["cases"]: + h3 = f"{p['name']} · {c['h3']}" + if h3 != current_h3: + current_h3 = h3 + current_h4 = "" + lines += [f"### {h3}", ""] + if c["h4"] != current_h4: + current_h4 = c["h4"] + lines += [f"#### {current_h4}", ""] + case_id = f"{p['code']}-{c['seq']:03d}" + tc = f"TC-UI-{case_id}" + lines += [ + f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}", + "", + f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}", + "- 操作步骤:", + ] + for i, step in enumerate(c["steps"], 1): + lines.append(f" {i}. {step}") + lines.append(f"- 自动化断言:规划与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致") + lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""] + while lines and lines[-1] in ("", "---"): + lines.pop() + lines += [ + "", + "", + "## 质量与覆盖说明", + "", + "| 项 | 说明 |", + "|----|------|", + f"| 用例数 | **{n}** 条({prio_txt}) |", + f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化的主路径与关键反向 |", + f"| 未纳入自动化 | {p['skip']} |", + "| 单一职责 | 每条只断言一个核心业务结果 |", + "| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 |", + "", + ] + return "\n".join(lines) + + +def main() -> None: + MD_DIR.mkdir(parents=True, exist_ok=True) + CSV_DIR.mkdir(parents=True, exist_ok=True) + total = 0 + for p in PAGES: + seqs = [c["seq"] for c in p["cases"]] + if seqs != list(range(1, len(seqs) + 1)): + raise SystemExit(f"{p['code']} seq not consecutive: {seqs}") + md_path = MD_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.md" + csv_path = CSV_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv" + md_path.write_text(render_md(p), encoding="utf-8") + rows = render_csv_rows(p) + with csv_path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=CSV_FIELDS) + w.writeheader() + w.writerows(rows) + total += len(rows) + print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}\t{csv_path.name}") + print(f"done pages={len(PAGES)} cases={total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-wh-flow-testpoints.py b/scripts/generate-wh-flow-testpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..06e7878a515301ce7ab1cb3a18566752c0f3ac33 --- /dev/null +++ b/scripts/generate-wh-flow-testpoints.py @@ -0,0 +1,1134 @@ +#!/usr/bin/env python3 +"""Generate 库房管理 / 流程设置 testpoint MD and patch UI-SPEC / INDEX.""" +from __future__ import annotations + +import json +import re +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCAN_DIR = ROOT / "data" / "scan" +OUT_DIR = ROOT / "testpoint" +KB = ROOT / "knowledge-base" / "08-UI专项规范库" +INDEX = KB / "INDEX.md" +TODAY = date.today().isoformat() + +PAGES = [ + { + "pageKey": "warehouse-zone", + "specId": "WH-001", + "group": "库房管理", + "name": "库区设置", + "prefix": "TP_WH_ZON", + "file": "基础管理-库房管理-库区设置-测试点分析-01.md", + "kind": "zone", + "path": "/spd/basic/storehouse/platform/storehouse/area", + "po": "pages/spd/warehouse-zone.page.ts", + "scan_cmd": "discover:scan-warehouse", + }, + { + "pageKey": "warehouse-probe", + "specId": "WH-002", + "group": "库房管理", + "name": "探针绑定", + "prefix": "TP_WH_PRB", + "file": "基础管理-库房管理-探针绑定-测试点分析-01.md", + "kind": "probe", + "path": "/spd/basic/storehouse/storehouse/probe", + "po": "pages/spd/warehouse-probe.page.ts", + "scan_cmd": "discover:scan-warehouse", + }, + { + "pageKey": "flow-def", + "specId": "FLOW-001", + "group": "流程设置", + "name": "审批流程设置", + "prefix": "TP_FLOW_DEF", + "file": "基础管理-流程设置-审批流程设置-测试点分析-01.md", + "kind": "def", + "path": "/spd/basic/flowSet/workflow/flowSet", + "po": "pages/spd/flow-def.page.ts", + "scan_cmd": "discover:scan-flow", + }, + { + "pageKey": "flow-inst", + "specId": "FLOW-002", + "group": "流程设置", + "name": "审批流实例", + "prefix": "TP_FLOW_INS", + "file": "基础管理-流程设置-审批流实例-测试点分析-01.md", + "kind": "inst", + "path": "/spd/basic/flowSet/workflow/flowCase", + "po": "pages/spd/flow-inst.page.ts", + "scan_cmd": "discover:scan-flow", + }, +] + + +def load_scan(page_key: str) -> dict: + path = SCAN_DIR / f"{page_key}-elements.json" + data = json.loads(path.read_text(encoding="utf-8")) + return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]} + + +def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str: + return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}" + + +def section(title: str, blocks: list[tuple[str, list[str]]]) -> str: + parts = [f"### {title}", ""] + for h4, lines in blocks: + parts.append(f"#### {h4}") + parts.append("") + parts.extend(lines or ["- 无(本功能点无独立该类场景,规则已归入其它功能点)"]) + parts.append("") + return "\n".join(parts) + + +class Seq: + def __init__(self, prefix: str, spec: str): + self.prefix = prefix + self.spec = spec + self.n = 0 + + def add(self, prio: str, title: str, check: str) -> str: + self.n += 1 + return tp(self.prefix, self.n, prio, title, check, self.spec) + + +def nav_section(p: dict, seq: Seq) -> str: + name = p["name"] + group = p["group"] + path = p["path"] + if p["kind"] in ("zone", "probe"): + peers = "库区设置 / 探针绑定" + ready = ( + "左右分栏:左侧「库房信息」树、右侧库区列表、新增与分页可见,无报错遮罩" + if p["kind"] == "zone" + else "整页单表:探针ID/主机编码/是否绑定货位筛选、查询重置、列表与分页可见,无报错遮罩" + ) + ctx = "顶栏当前库房/院区与本页可维护库房范围不矛盾" + else: + peers = "审批流程设置 / 审批流实例" + ready = ( + "左右分栏:左侧业务类型、右侧默认审批流与自定义审批流、保存/重置可见,无报错遮罩" + if p["kind"] == "def" + else "整页单表:单据编号/审批状态/发起时间/业务类型/摘要筛选、查询重置、列表与分页可见,无报错遮罩" + ) + ctx = "顶栏登录身份与本页可查看的审批单据范围不矛盾" + return section( + "页面访问与导航", + [ + ( + "功能验证点", + [ + seq.add( + "高", + f"菜单路径可达{name}", + f"登录后通过「基础设置 → {group} → {name}」进入 `{path}`,左侧菜单与顶栏 Tab 高亮为{name},不串到同组其它页", + ), + seq.add("高", "页面主区加载完成", ready), + seq.add( + "中", + f"同组{group}菜单可切换", + f"在 {peers} 间切换后 URL 与页面内容对应,再返回{name}状态正确", + ), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "已登录直接打开 URL", f"携带有效登录态打开 `{path}`,页面正常渲染"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "未登录访问拦截", "无登录态访问 URL 跳转登录或不展示业务数据"), + seq.add("中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录,不误写"), + seq.add("中", "无菜单权限", f"无{name}权限的账号不可见菜单或 URL 被拒绝"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "顶栏身份与可管理范围一致", ctx), + ], + ), + ], + ) + + +def zone_body(seq: Seq) -> str: + chunks = [ + section( + "左侧库房树", + [ + ( + "功能验证点", + [ + seq.add("高", "库房树默认有选中项", "进入后左侧「库房信息」有节点且一项处于选中,右侧为该库房的库区"), + seq.add("高", "按库房名称过滤树", "在左侧「库房名称」输入关键字后,仅保留名称匹配的库房节点"), + seq.add("中", "清空库房名称恢复全量树", "清空关键字后树恢复为过滤前的全量节点"), + seq.add("高", "切换库房刷新右侧列表", "点选另一库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长库房名称关键字", "输入达到或超过允许长度,过滤被接受或截断,页面不崩溃"), + seq.add("低", "仅空格库房名称", "按空过滤处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "无匹配库房空树", "关键字无匹配时树为空或明确无数据,右侧列表不残留上一库房库区"), + seq.add("中", "库房树加载失败", "库房接口失败有错误提示,不把过期树展示为成功态"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "库房节点与院区主数据同源", "树中库房名称与院区/库房主数据一致(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "右侧库区列表与分页", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "列表核心列完整", + "主表展示库区编号、库区名称、库区地址、是否专项库区、操作(编辑/人员管理/货位管理/停用或启用)", + ), + seq.add("高", "默认库区以标签展示", "默认库区在名称旁显示「默认库区」标签,不是独立列;同一库房至多一个默认库区"), + seq.add("中", "专项库区取值", "「是否专项库区」仅为「是」或「否」"), + seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"), + seq.add("中", "每页条数切换", "切换「50 条/页」等后当前页行数不超过所选值,总数不变"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"), + seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"), + seq.add("低", "库房无库区时分页为 0", "选中尚无库区的库房时列表空态明确,「共 0 条」"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "库区列表加载失败", "失败有错误提示,不白屏、不把上一库房数据展示为当前库房成功态"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "翻页不串库房", "带当前库房选中翻页后,新页库区仍属于该库房"), + seq.add("中", "列表条数与共 N 条一致", "可见行逻辑与底部分页「共 N 条」一致"), + ], + ), + ], + ), + section( + "库区搜索", + [ + ( + "功能验证点", + [ + seq.add("高", "按库区编号搜索", "在「库区编号/名称」输入已存在编号,结果包含该编号行"), + seq.add("高", "按库区名称搜索", "输入已存在名称关键字,结果名称匹配"), + seq.add("中", "搜索范围限于当前库房", "搜索结果全部属于左侧当前选中库房"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长库区关键字", "超长输入可检索或被截断,页面不崩溃"), + seq.add("低", "仅空格库区关键字", "按空搜索处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "无匹配库区空态", "无命中时列表为空且分页为 0"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "切换库房后搜索条件不串结果", "换库房后结果属于新库房;若保留关键字则在新库房范围内过滤"), + ], + ), + ], + ), + section( + "新增库区", + [ + ( + "功能验证点", + [ + seq.add("高", "打开新增库区弹窗", "点「新增」弹出「新增库区」,含库区编号、库区名称、库区地址、联系人、联系方式及取消/确定"), + seq.add("高", "合法必填保存成功", "填写必填后确定,弹窗关闭,当前库房列表出现新记录且字段与提交一致"), + seq.add("中", "取消不落库", "填写后点「取消」或关闭,列表不新增对应库区"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "编号名称达最大允许长度", "库区编号/名称取最大允许长度可保存成功"), + seq.add("低", "地址可与编号或名称相同", "库区地址填写与编号或名称相同的文本可保存"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "必填为空拦截", "库区编号或名称为空点确定被拦截并提示,不关闭弹窗、不落库"), + seq.add("高", "同一库房库区编号重复拦截", "使用当前库房已存在编号保存失败并提示,不覆盖原库区"), + seq.add("中", "保存接口失败", "失败有错误提示,弹窗仍在,列表不出现半成品成功态"), + seq.add("中", "重复点击确定", "连续快速点击只产生一条记录或第二次被防重"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "新增归属当前选中库房", "新增成功后该库区仅出现在当前库房列表,切换其它库房不可见"), + ], + ), + ], + ), + section( + "编辑库区", + [ + ( + "功能验证点", + [ + seq.add("高", "打开编辑并回填", "行内「编辑」打开「编辑库区信息」,回填库区编号、名称、地址、联系人、联系方式"), + seq.add("高", "修改后确定落库", "修改名称或地址后确定,列表与再次编辑回看均为新值"), + seq.add("中", "编辑取消不保存", "修改后点「取消」,列表与再次打开仍为修改前值"), + seq.add("中", "专项库区回填一致", "列表「是否专项库区」与编辑页回填一致(若编辑页含该字段)"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "不改字段直接确定", "打开编辑不改内容点确定,记录保持原值且不报错"), + seq.add("中", "默认库区编辑后仍唯一", "编辑保存后该库房仍至多一个「默认库区」标签"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "编辑清空必填拦截", "清空库区名称后确定被拦截,原记录不被写成空"), + seq.add("中", "编辑已停用库区", "停用行仍可打开编辑;若禁止编辑则入口禁用并提示,不以成功态改数"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "编辑不影响其它库房同名库区", "仅更新当前库房该行,其它库房同编号/名称库区保持原值"), + ], + ), + ], + ), + section( + "停用与启用", + [ + ( + "功能验证点", + [ + seq.add("高", "停用后操作变为启用", "启用行点「停用」后该行操作变为「启用」,状态持久化"), + seq.add("高", "启用后操作变为停用", "停用行点「启用」后该行操作变为「停用」,再次进入仍保持"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "默认库区停用限制", "默认库区若不允许停用则被拦截并提示;若允许停用则该库房须仍满足「至多一个默认库区」"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "停用/启用失败有提示", "接口失败有错误提示,行操作文案不翻转"), + seq.add("中", "停用须确认才生效", "若有确认框,取消后行仍为启用、操作仍为「停用」"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "停用库区不可再被探针绑定为新货位", "停用后探针绑定弹窗按货位状态/启用过滤时不应再选出该库区下可绑定货位(以现网过滤规则为准)"), + ], + ), + ], + ), + section( + "人员管理", + [ + ( + "功能验证点", + [ + seq.add("高", "打开人员管理", "行内「人员管理」打开弹层或子页,标题表明为人员管理且对应当前库区"), + seq.add("中", "关闭后回到库区列表", "关闭人员管理后仍停留在当前库房的库区列表,选中库房不变"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "无人员时列表空态", "未配置人员时空态明确,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "人员管理加载失败", "失败有错误提示,不把其它库区人员展示为当前库区"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "人员与用户管理账号同源", "可选人员来自用户/角色主数据(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "货位管理", + [ + ( + "功能验证点", + [ + seq.add("高", "打开货位管理", "行内「货位管理」打开弹层或子页,标题表明为货位且对应当前库区"), + seq.add("中", "关闭后回到库区列表", "关闭货位管理后当前库房选中与库区列表保持"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "无货位时空态", "库区无货位时列表空态明确"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "货位管理加载失败", "失败有提示,不串其它库区货位"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "货位可被探针绑定弹窗检索到", "本库区启用货位应出现在探针绑定「货位绑定探针」弹窗对应库房/库区筛选结果中"), + ], + ), + ], + ), + section( + "同步库房信息", + [ + ( + "功能验证点", + [ + seq.add("中", "同步刷新库房树", "点「同步」后树与列表按最新主数据刷新,不丢当前可维护库房"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "同步期间不可重复提交", "同步进行中按钮禁用或再次点击被忽略,不并发写坏树"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "同步失败有提示", "失败有错误提示,树不空白、不把失败展示为成功"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "同步结果与院区库房主数据一致", "同步后新增/更名的库房节点与主数据一致"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def probe_body(seq: Seq) -> str: + chunks = [ + section( + "筛选与查询", + [ + ( + "功能验证点", + [ + seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致"), + seq.add("高", "按探针ID查询", "输入已存在探针ID后结果探针ID匹配,精确命中时至多对应行"), + seq.add("高", "按主机编码查询", "输入主机编码后结果「主机编码」与条件一致(多探针可共用同一主机)"), + seq.add("高", "按是否绑定货位查询", "选择「已绑定」仅保留已绑定行;选择「未绑定」仅保留未绑定行"), + seq.add("高", "探针ID+主机编码+是否绑定组合查询", "多个已填条件同时生效,结果同时满足全部条件"), + seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长探针ID或主机编码", "超长输入可查询或被截断,页面不崩溃"), + seq.add("低", "仅空格关键字", "按空查询处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期数据展示为成功态"), + seq.add("低", "无匹配结果空态", "条件无匹配时「暂无数据」明确,分页总数为 0"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"), + ], + ), + ], + ), + section( + "列表与分页", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "列表核心列完整", + "主表展示序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作(绑定货位)", + ), + seq.add("高", "已绑定行数量大于 0", "「是否绑定货位」为已绑定时,「绑定的货位」数量大于 0"), + seq.add("高", "未绑定行数量为 0", "未绑定行「绑定的货位」为 `0`"), + seq.add("中", "绑定的货位列为数量不是货位号", "主表该列为数字;货位号只在弹窗「货位号」列"), + seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换且页码正确"), + seq.add("中", "每页条数切换", "切换每页条数后当前页行数不超过所选值,总数不变"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"), + seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "跳至非数字页码", "非法页码不跳转异常页"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页仍满足原筛选"), + ], + ), + ], + ), + section( + "打开绑定货位弹窗", + [ + ( + "功能验证点", + [ + seq.add("高", "行内打开货位绑定探针", "点「绑定货位」弹出「货位绑定探针」,含库房/库区/货位筛选与货位列表"), + seq.add("中", "取消关闭不改主列表", "未确定即点「取消」或关闭,主列表绑定数量与筛选条件保持"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "未选货位时确定计数为 0", "底部按钮文案为「确定(0)」或等价,未选时确定不新增绑定"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "弹窗加载失败", "货位列表失败有提示,不把主列表「共 N 条」与弹窗总条数混用"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "弹窗分页独立于主列表", "弹窗「共 N 条」(货位量级)不得用于断言主列表探针条数"), + ], + ), + ], + ), + section( + "弹窗筛选货位", + [ + ( + "功能验证点", + [ + seq.add("高", "按当前库房筛选", "选择库房后列表「当前库房」与所选一致"), + seq.add("高", "按当前库区筛选", "选择库区后列表「当前库区」与所选一致"), + seq.add("高", "按货位号查询", "「货位查询」输入货位号关键字后结果货位号匹配"), + seq.add("中", "弹窗内重置", "填写弹窗筛选后点「重置」,条件恢复,列表按弹窗默认规则刷新"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长货位号关键字", "超长输入可查询或被截断,弹窗不崩溃"), + seq.add("低", "库区下拉随库房收敛", "切换库房后库区选项属于该库房,不残留上一库房库区"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "弹窗无匹配空态", "条件无命中时货位列表空、弹窗分页为 0"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "库房库区与库区设置同源", "弹窗库房/库区选项来自库区设置中已维护且可用的库房与库区"), + ], + ), + ], + ), + section( + "勾选货位并保存绑定", + [ + ( + "功能验证点", + [ + seq.add("高", "勾选后确定计数增加", "勾选 N 个货位后确定按钮计数为 N"), + seq.add("高", "确定后主列表绑定数量增加", "为未绑定探针勾选货位并确定后,该行变为已绑定,数量等于本次保存的货位数(或按产品规则累加)"), + seq.add("中", "已绑货位行展示探针ID", "弹窗中已被该探针绑定的货位「探针ID」列显示该探针号,未绑定为 `--`"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "取消勾选后计数回退", "勾选后再取消,确定计数回到实际已选数"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "绑定保存失败", "失败有提示,主列表绑定数量不增加,再次打开不是半成功勾选"), + seq.add("中", "货位已被其它探针占用", "占用行再绑定时按换绑或拒绝执行;以保存后主列表数量与弹窗探针ID列为准,不得静默双绑"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "绑定结果与货位管理一致", "保存后在对应库区货位侧能看到该探针占用;解绑/换绑后两侧同步"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def def_body(seq: Seq) -> str: + chunks = [ + section( + "左侧业务类型", + [ + ( + "功能验证点", + [ + seq.add("高", "业务类型列表可见", "左侧列出采购计划、请领单、产品准入等类型,当前项高亮"), + seq.add("高", "按流程名称过滤类型", "输入关键字后仅保留名称匹配的业务类型"), + seq.add("中", "清空关键字恢复全量", "清空后类型列表恢复过滤前全量"), + seq.add("高", "切换类型刷新右侧配置", "点选另一类型后,默认审批流与自定义审批流与该类型已保存配置一致,不残留上一类型节点"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长流程名称关键字", "超长输入可过滤或被截断,页面不崩溃"), + seq.add("低", "仅空格关键字", "按空过滤处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "无匹配类型", "无命中时左侧为空或明确无数据,右侧不展示上一类型配置为当前配置"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "类型与审批流实例业务类型同源", "左侧类型集合与审批流实例「业务类型」下拉选项一致(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "默认审批流", + [ + ( + "功能验证点", + [ + seq.add("高", "默认审批流节点可见", "右侧「默认审批流」展示节点卡片,含节点类型、账号、人员"), + seq.add("中", "可追加默认流节点", "点节点旁「+」可增加审批节点(保存前仅在当前页可见)"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "未配置自定义流时走默认链", "该类型无自定义流时,新单据按默认审批人链流转(以审批流实例抽样为准)"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "默认流节点人员为空拦截", "保存时默认链缺少人员被拦截并提示,不写入空节点"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "只改自定义流时默认链不变", "仅增删自定义行并保存后,默认审批人节点保持原值"), + ], + ), + ], + ), + section( + "自定义审批流", + [ + ( + "功能验证点", + [ + seq.add("高", "新增流程增加一行", "点「新增流程」自定义流增加一行,含流程名称、条件、审批流程、删除"), + seq.add("中", "删除行未保存不落库", "删除自定义行后不点保存,重新进入该类型行仍在"), + seq.add("高", "保存后自定义流持久化", "填写名称/条件/节点后保存,重新进入该类型配置与保存时一致"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "流程名称必填", "名称空时保存被拦截"), + seq.add("中", "条件必填", "条件空时保存被拦截"), + seq.add("中", "审批流程必填", "无审批节点时保存被拦截"), + ], + ), + ( + "异常验证点", + [ + seq.add("高", "必填为空拦截", "流程名称/条件/审批流程任一为空点保存被拦截并提示,不关闭、不落库"), + seq.add("中", "保存接口失败", "失败有提示,再次进入不是半成功配置"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "自定义条件命中后新单走该链", "保存后新产生的符合条件单据在审批流实例中按该自定义链流转,而非默认链"), + ], + ), + ], + ), + section( + "条件配置", + [ + ( + "功能验证点", + [ + seq.add("高", "打开条件配置", "条件列「编辑」打开「条件配置」,可添加条件行并保存/取消"), + seq.add("高", "保存条件回写行内文案", "配置如「是否超限:是」或「科室:中心库(洪山)」后保存,行内条件展示与配置一致"), + seq.add("中", "取消不改行内条件", "修改后点取消,行内条件仍为打开前的值"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "可删除条件行", "条件配置内「删除」去掉一行,保存后不再出现该条件"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "条件不完整拦截", "条件类型或取值空时保存被拦截"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "科室条件与库房/科室主数据同源", "条件选科室时选项来自主数据,与库区设置/用户所属科室不矛盾"), + ], + ), + ], + ), + section( + "保存与重置", + [ + ( + "功能验证点", + [ + seq.add("高", "保存写入当前业务类型", "点「保存」只持久化当前选中类型的默认流与自定义流"), + seq.add("高", "重置放弃未保存改动", "改节点或自定义行后点「重置」,页面回到上次保存结果"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "无改动点保存不报错", "未修改直接保存成功或提示无变更,配置不变"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "未保存切换类型", "有未保存改动时切换业务类型须提示后丢弃或不允许切换,不得把上一类型的改动写入下一类型"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "保存不影响历史审批实例", "改审批人后,已存在的审批流实例详情仍为发起时的快照或按产品规则说明;不得无说明地改写历史已通过单"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def inst_body(seq: Seq) -> str: + chunks = [ + section( + "筛选与查询", + [ + ( + "功能验证点", + [ + seq.add("高", "默认日期区间查询", "进入后带默认发起时间区间,点「查询」列表有数据或空态明确,「共 N 条」与结果逻辑一致"), + seq.add("高", "单据编号精确查询", "输入完整单号后至多一条,且编号、业务类型与该行一致"), + seq.add("高", "按审批状态筛选", "选择「审批通过」或「审批中」后列表状态列与所选一致"), + seq.add("高", "按业务类型筛选", "选择某类型后列表「业务类型」均为该类型"), + seq.add("高", "按发起时间区间筛选", "结果行「发起时间」落在起止日期内(含边界日)"), + seq.add("中", "按摘要关键字筛选", "输入摘要关键字后结果摘要包含该关键字;摘要为 `--` 的行不被非空关键字命中"), + seq.add("高", "多条件组合查询", "状态、业务类型、发起时间、摘要同时生效,抽样行满足全部已填条件"), + seq.add("中", "重置恢复默认", "填写后点「重置」,日期回到默认区间,其余条件清空,「共 N 条」恢复"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "超长单据编号或摘要", "超长输入可查询或被截断,页面不崩溃"), + seq.add("中", "开始日期晚于结束日期", "非法区间被拒绝或自动纠正,不返回错乱时间结果"), + seq.add("低", "仅空格摘要", "按空查询处理或提示,不报错"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期列表展示为成功态"), + seq.add("低", "无匹配空态", "总数为 0,空态明确"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"), + seq.add("高", "业务类型与审批流程设置同源", "「业务类型」下拉与审批流程设置左侧类型一致(允许按权限裁剪)"), + ], + ), + ], + ), + section( + "列表与分页", + [ + ( + "功能验证点", + [ + seq.add( + "高", + "列表核心列完整", + "主表展示序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作(详情)", + ), + seq.add("中", "单据编号前缀与业务类型对应", "`PLAN` 对应采购计划、`DB` 对应二级库调拨、`GC` 对应产品信息变更、`BUY` 对应请领单(以现网枚举为准)"), + seq.add("中", "空摘要展示为 --", "无摘要行显示 `--`,长摘要可截断"), + seq.add("中", "窄列截断不影响查询", "发起人/部门/时间截断展示时,按完整单号搜索仍能命中"), + seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换"), + seq.add("中", "每页条数切换", "切换后当前页行数不超过所选值,总数不变"), + seq.add("中", "跳至指定页", "输入合法页码后跳到对应页"), + ], + ), + ( + "边界验证点", + [ + seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"), + seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"), + seq.add("低", "跳至超出总页数", "大于总页数时被拒绝、纠正到末页或提示"), + ], + ), + ( + "异常验证点", + [ + seq.add("低", "跳至非数字页码", "非法输入不跳转异常页"), + ], + ), + ( + "集成验证点", + [ + seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页数据仍满足原筛选"), + ], + ), + ], + ), + section( + "审批详情", + [ + ( + "功能验证点", + [ + seq.add("高", "打开详情对应当前行", "点「详情」打开该单据审批过程,标题/单号与当前行单据编号一致"), + seq.add("中", "详情只读", "详情内不能改审批人或条件;改配置须回到审批流程设置"), + seq.add("中", "关闭详情保留列表状态", "返回后筛选条件与页码保持,列表仍是打开前的查询结果"), + ], + ), + ( + "边界验证点", + [ + seq.add("低", "审批中与已通过均可打开详情", "两种状态行都能进入详情,节点展示与状态相符"), + ], + ), + ( + "异常验证点", + [ + seq.add("中", "详情加载失败", "失败有提示,不把其它单据内容展示为当前单"), + ], + ), + ( + "集成验证点", + [ + seq.add("高", "详情审批链与流程设置一致", "新单据详情中的节点/人员与该业务类型在审批流程设置中命中的默认或自定义链一致"), + ], + ), + ], + ), + ] + return "\n---\n\n".join(chunks) + + +def quality(p: dict, el_count: int, tp_count: int) -> str: + kind = p["kind"] + if kind == "zone": + cover = "导航、库房树过滤/切换、库区列表分页、库区搜索、新增/编辑弹窗、停用启用、人员/货位入口、同步" + pending = ( + "① 人员管理/货位管理扫描未打开面板,入口以行内按钮为准,子页字段待补扫 " + "② 默认库区如何指定(编辑字段或单独接口)以现网为准 " + "③ 停用默认库区是否允许待产品确认 " + "④ 新增弹窗扫描未见「是否专项库区」控件,列表有该列,编辑是否可改为待评审" + ) + elif kind == "probe": + cover = "导航、探针筛选查询、列表数量规则、绑定弹窗筛选分页、勾选确定/取消、与库区货位集成" + pending = ( + "① 同一货位已有其它探针ID时是换绑还是拒绝,以保存后主列表数量与弹窗探针ID列为准 " + "② 确定按钮文案为「确定(N)」,未选为 0 " + "③ 主表无新增探针入口,探针主数据来源不在本页维护" + ) + elif kind == "def": + cover = "导航、业务类型过滤/切换、默认审批流、自定义流增删、条件配置、保存重置、与审批流实例类型同源" + pending = ( + "① 保存会改线上审批路由,自动化冒烟只读结构、不提交 " + "② 未保存切类型按「提示后丢弃、不串写」写点,若自动保存需改判定 " + "③ 历史已通过实例是否快照冻结待产品确认" + ) + else: + cover = "导航、单据编号/状态/类型/时间/摘要筛选、列表列与前缀规则、分页、只读详情、与流程设置类型同源" + pending = ( + "① 默认发起时间跨度大,断言「共 N 条」须先收窄日期 " + "② 本页无新增/导出/改审批人;写配置只在审批流程设置 " + "③ 详情扫描打开「采购计划详情」,其它业务类型详情标题以现网为准" + ) + domains = { + "zone": "页面访问与导航、左侧库房树、右侧库区列表与分页、库区搜索、新增库区、编辑库区、停用与启用、人员管理、货位管理、同步库房信息", + "probe": "页面访问与导航、筛选与查询、列表与分页、打开绑定货位弹窗、弹窗筛选货位、勾选货位并保存绑定", + "def": "页面访问与导航、左侧业务类型、默认审批流、自定义审批流、条件配置、保存与重置", + "inst": "页面访问与导航、筛选与查询、列表与分页、审批详情", + }[kind] + return f"""## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:{domains}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` | +| 待评审项 | {pending} | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV;扫描 `npm run {p["scan_cmd"]}`;自动化建议 `{p["po"]}` | +""" + + +def render_testpoint(p: dict, scan: dict) -> tuple[str, int]: + spec = f"UI-SPEC-SPD-{p['specId']}" + seq = Seq(p["prefix"], spec) + nav = nav_section(p, seq) + if p["kind"] == "zone": + body = zone_body(seq) + elif p["kind"] == "probe": + body = probe_body(seq) + elif p["kind"] == "def": + body = def_body(seq) + else: + body = inst_body(seq) + md = f"""# 基础管理-{p['group']}-{p['name']} — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md` +**页面 URL**:`{scan["url"]}` +**版本 / 定稿日期**:{TODAY} +**元素扫描**:`data/scan/{p["pageKey"]}-elements.json`({scan["elementCount"]} 项) +**定位器**:`locators/spd/{p["pageKey"]}.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · {p['group']} · {p['name']} + +{nav} +--- + +{body} + +--- + +{quality(p, scan["elementCount"], seq.n)} +""" + return md, seq.n + + +def patch_ui_spec(p: dict, scan: dict, tp_file: str) -> None: + path = KB / f"UI-SPEC-SPD-{p['specId']}.md" + text = path.read_text(encoding="utf-8") + text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1) + text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1) + url = scan["url"] + count = scan["elementCount"] + page_key = p["pageKey"] + extra = f"| 浏览器页签标题 | `{p['name']}` |\n" + table = f"""| 项 | 值 | +|----|-----| +| 主框架 URL | `{url}` | +{extra}| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) | +| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |""" + text = re.sub( + r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+", + table + "\n", + text, + count=1, + ) + path.write_text(text, encoding="utf-8") + + +def patch_index(counts: dict[str, int], scans: dict[str, dict]) -> None: + text = INDEX.read_text(encoding="utf-8") + text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1) + rows = { + "WH-001": ( + "| 库区设置 | WH-001 | `/spd/basic/storehouse/platform/storehouse/area` | " + f"已扫描 · `warehouse-zone` · **{scans['WH-001']['elementCount']}** 项 · " + f"测试点 **{counts['WH-001']}** 条 · `TP_WH_ZON` |" + ), + "WH-002": ( + "| 探针绑定 | WH-002 | `/spd/basic/storehouse/storehouse/probe` | " + f"已扫描 · `warehouse-probe` · **{scans['WH-002']['elementCount']}** 项 · " + f"测试点 **{counts['WH-002']}** 条 · `TP_WH_PRB` |" + ), + "FLOW-001": ( + "| 审批流程设置 | FLOW-001 | `/spd/basic/flowSet/workflow/flowSet` | " + f"已扫描 · `flow-def` · **{scans['FLOW-001']['elementCount']}** 项 · " + f"测试点 **{counts['FLOW-001']}** 条 · `TP_FLOW_DEF` |" + ), + "FLOW-002": ( + "| 审批流实例 | FLOW-002 | `/spd/basic/flowSet/workflow/flowCase` | " + f"已扫描 · `flow-inst` · **{scans['FLOW-002']['elementCount']}** 项 · " + f"测试点 **{counts['FLOW-002']}** 条 · `TP_FLOW_INS` |" + ), + } + text = re.sub( + r"\| 库区设置 \| WH-001 \| `/spd/basic/storehouse/platform/storehouse/area` \| .+ \|", + rows["WH-001"], + text, + count=1, + ) + text = re.sub( + r"\| 探针绑定 \| WH-002 \| `/spd/basic/storehouse/storehouse/probe` \| .+ \|", + rows["WH-002"], + text, + count=1, + ) + text = re.sub( + r"\| 审批流程设置 \| FLOW-001 \| `/spd/basic/flowSet/workflow/flowSet` \| .+ \|", + rows["FLOW-001"], + text, + count=1, + ) + text = re.sub( + r"\| 审批流实例 \| FLOW-002 \| `/spd/basic/flowSet/workflow/flowCase` \| .+ \|", + rows["FLOW-002"], + text, + count=1, + ) + if "generate:testpoints-warehouse" not in text: + text = text.replace( + "npm run discover:scan-warehouse\n", + "npm run discover:scan-warehouse\nnpm run generate:testpoints-warehouse\n", + ) + if "generate:testpoints-flow" not in text: + text = text.replace( + "npm run discover:scan-flow\n", + "npm run discover:scan-flow\nnpm run generate:testpoints-flow\n", + ) + INDEX.write_text(text, encoding="utf-8") + + +def main() -> None: + import sys + + filter_keys = set(sys.argv[1:]) + pages = [p for p in PAGES if not filter_keys or p["pageKey"] in filter_keys] + if not pages: + raise SystemExit("No pages matched. Available: " + ", ".join(p["pageKey"] for p in PAGES)) + + OUT_DIR.mkdir(parents=True, exist_ok=True) + counts: dict[str, int] = {} + scans: dict[str, dict] = {} + for p in pages: + scan = load_scan(p["pageKey"]) + md, n = render_testpoint(p, scan) + out = OUT_DIR / p["file"] + out.write_text(md, encoding="utf-8") + patch_ui_spec(p, scan, p["file"]) + counts[p["specId"]] = n + scans[p["specId"]] = scan + print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}") + # INDEX 四行一起补全:缺页时用已落盘测试点文件行数占位,避免半更新 + if len(counts) < len(PAGES): + for p in PAGES: + if p["specId"] in counts: + continue + scans[p["specId"]] = load_scan(p["pageKey"]) + tp_file = OUT_DIR / p["file"] + n = 0 + if tp_file.exists(): + n = len(re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8"))) + counts[p["specId"]] = n + patch_index(counts, scans) + print("done", counts) + + +if __name__ == "__main__": + main() diff --git a/scripts/json-to-locator-yaml.mjs b/scripts/json-to-locator-yaml.mjs index 9ef76c2f3356b966ea3c3c5dea755aa9f0e957af..b0703834d29e290bdc565a237b3628c94f2eb06d 100644 --- a/scripts/json-to-locator-yaml.mjs +++ b/scripts/json-to-locator-yaml.mjs @@ -5,6 +5,8 @@ * Usage: * node scripts/json-to-locator-yaml.mjs data/scan/warn-strand-elements.json * node scripts/json-to-locator-yaml.mjs --all-extended + * node scripts/json-to-locator-yaml.mjs --all-basic + * node scripts/json-to-locator-yaml.mjs --all-new */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,6 +15,8 @@ import { writeYamlFromScanJson } from './scan-interactive-elements.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); +const SCAN_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); const EXTENDED_KEYS = [ 'warn-strand', @@ -35,23 +39,106 @@ const EXTENDED_KEYS = [ 'reag-entry', ]; +const NEW_PAGES = { + 'basic-notice': { pageTitle: '基础设置 > 医院资料 > 公告管理', module: 'spd-basic' }, + 'basic-hospital-info': { pageTitle: '基础设置 > 医院资料 > 医院信息登记', module: 'spd-basic' }, + 'basic-finance-month': { pageTitle: '基础设置 > 医院资料 > 财务月份设置', module: 'spd-basic' }, + 'basic-branch': { pageTitle: '基础设置 > 医院资料 > 院区管理', module: 'spd-basic' }, + 'basic-user': { pageTitle: '基础设置 > 用户管理 > 用户管理', module: 'spd-basic' }, + 'basic-user-union': { pageTitle: '基础设置 > 用户管理 > 医共体用户管理', module: 'spd-basic' }, + 'basic-role': { pageTitle: '基础设置 > 用户管理 > 角色管理', module: 'spd-basic' }, + 'admit-material': { pageTitle: '基础设置 > 准入管理 > 物料准入管理', module: 'spd-basic' }, + 'admit-product-apply': { pageTitle: '基础设置 > 准入管理 > 产品准入申请', module: 'spd-basic' }, + 'admit-material-list': { pageTitle: '基础设置 > 准入管理 > 物料准入单列表', module: 'spd-basic' }, + 'admit-product-list': { pageTitle: '基础设置 > 准入管理 > 产品准入单列表', module: 'spd-basic' }, + 'admit-supply': { pageTitle: '基础设置 > 准入管理 > 供货目录申请', module: 'spd-basic' }, + 'admit-task': { pageTitle: '基础设置 > 准入管理 > 准入任务分配', module: 'spd-basic' }, + 'vendor-query': { pageTitle: '基础设置 > 厂商管理 > 厂商查询', module: 'spd-basic' }, + 'vendor-qual': { pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', module: 'spd-basic' }, + 'vendor-auth-first': { pageTitle: '基础设置 > 厂商管理 > 授权变更初审', module: 'spd-basic' }, + 'vendor-auth-final': { pageTitle: '基础设置 > 厂商管理 > 授权变更终审', module: 'spd-basic' }, + 'vendor-cert-first': { pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', module: 'spd-basic' }, + 'vendor-cert-final': { pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', module: 'spd-basic' }, + 'vendor-cert-diff': { pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', module: 'spd-basic' }, + 'vendor-eval-scheme': { pageTitle: '基础设置 > 厂商管理 > 评价方案维护', module: 'spd-basic' }, + 'vendor-eval-indicator': { pageTitle: '基础设置 > 厂商管理 > 评价指标维护', module: 'spd-basic' }, + 'vendor-holiday': { pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', module: 'spd-basic' }, + 'surgery-base-pkg': { pageTitle: '基础设置 > 手术套包设置 > 基础包维护', module: 'spd-basic' }, + 'surgery-oper-type': { pageTitle: '基础设置 > 手术套包设置 > 术种维护', module: 'spd-basic' }, + 'surgery-std-pkg': { pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', module: 'spd-basic' }, + 'warehouse-zone': { pageTitle: '基础设置 > 库房管理 > 库区设置', module: 'spd-basic' }, + 'warehouse-probe': { pageTitle: '基础设置 > 库房管理 > 探针绑定', module: 'spd-basic' }, + 'flow-def': { pageTitle: '基础设置 > 流程设置 > 审批流程设置', module: 'spd-basic' }, + 'flow-inst': { pageTitle: '基础设置 > 流程设置 > 审批流实例', module: 'spd-basic' }, +}; + +const GROUPS = { + '--all-extended': EXTENDED_KEYS, + '--all-basic': ['basic-notice', 'basic-hospital-info', 'basic-finance-month', 'basic-branch'], + '--all-user': ['basic-user', 'basic-user-union', 'basic-role'], + '--all-admit': [ + 'admit-material', + 'admit-product-apply', + 'admit-material-list', + 'admit-product-list', + 'admit-supply', + 'admit-task', + ], + '--all-vendor': [ + 'vendor-query', + 'vendor-qual', + 'vendor-auth-first', + 'vendor-auth-final', + 'vendor-cert-first', + 'vendor-cert-final', + 'vendor-cert-diff', + 'vendor-eval-scheme', + 'vendor-eval-indicator', + 'vendor-holiday', + ], + '--all-surgery': ['surgery-base-pkg', 'surgery-oper-type', 'surgery-std-pkg'], + '--all-warehouse': ['warehouse-zone', 'warehouse-probe'], + '--all-flow': ['flow-def', 'flow-inst'], + '--all-new': Object.keys(NEW_PAGES), +}; + +function jsonPathFor(key) { + return path.join(SCAN_DIR, `${key}-elements.json`); +} + +function writeOne(jsonPath, pageKey) { + const meta = NEW_PAGES[pageKey] || {}; + const result = writeYamlFromScanJson(jsonPath, { + pageKey: pageKey || path.basename(jsonPath, '-elements.json'), + pageTitle: meta.pageTitle, + module: meta.module || (pageKey && NEW_PAGES[pageKey] ? 'spd-basic' : undefined), + jsonSourceName: path.basename(jsonPath), + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${pageKey || path.basename(jsonPath, '-elements.json')}.locator.yaml`), + }); + console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`); + return result; +} + async function main() { const args = process.argv.slice(2); - const targets = - args[0] === '--all-extended' - ? EXTENDED_KEYS.map((key) => path.join(ROOT, 'data/scan', `${key}-elements.json`)) - : args.length - ? [path.resolve(args[0])] - : []; - - if (!targets.length) { - console.error('Usage: node scripts/json-to-locator-yaml.mjs '); + if (!args.length) { + console.error( + 'Usage: node scripts/json-to-locator-yaml.mjs ', + ); process.exit(1); } - for (const jsonPath of targets) { - const result = writeYamlFromScanJson(jsonPath); - console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`); + for (const arg of args) { + if (GROUPS[arg]) { + for (const key of GROUPS[arg]) { + writeOne(jsonPathFor(key), key); + } + continue; + } + const jsonPath = path.resolve(arg); + const pageKey = path.basename(jsonPath).replace(/-elements\.json$/, ''); + writeOne(jsonPath, pageKey); } } diff --git a/scripts/lib/gate-stamp.mjs b/scripts/lib/gate-stamp.mjs index 2a33bde2bdf67e94c3bf92c3683795bc1b1d11b6..eb9d7b5947f51e324fa4b8f4a36b4f0f14df3efa 100644 --- a/scripts/lib/gate-stamp.mjs +++ b/scripts/lib/gate-stamp.mjs @@ -3,7 +3,7 @@ * 凭证不入库;源码变更后 hash 失效,必须重跑。 */ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -57,7 +57,9 @@ export function hashSources(files) { const abs = path.join(ROOT, file); hash.update(file); hash.update('\0'); - hash.update(existsSync(abs) ? readFileSync(abs) : Buffer.from('')); + if (existsSync(abs) && statSync(abs).isFile()) { + hash.update(readFileSync(abs)); + } hash.update('\0'); } return hash.digest('hex'); diff --git a/scripts/run-ci.mjs b/scripts/run-ci.mjs index 131d6f8f8d1f5a79a60995469f55af9316ec72c5..589ae256ebd536b1d87134c16cd3d47d32d0fd7a 100644 --- a/scripts/run-ci.mjs +++ b/scripts/run-ci.mjs @@ -20,6 +20,8 @@ import { readdirSync, readFileSync, rmSync, + statSync, + unlinkSync, writeFileSync, } from 'node:fs'; import path from 'node:path'; @@ -30,6 +32,9 @@ import { formatSuiteList, getSuite } from '../config/suites.config.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PLAYWRIGHT_CLI = path.join(ROOT, 'node_modules/playwright/cli.js'); +/** --keep-archives=N 的命令行值;undefined 时退回环境变量 / 默认全留。 */ +let KEEP_ARCHIVES_ARG; + function parseArgs(argv) { const out = { suite: process.env.SUITE || 'full', @@ -40,6 +45,7 @@ function parseArgs(argv) { skipTypecheck: process.env.SKIP_TYPECHECK === '1', shards: Number(process.env.SHARDS || 1), shard: process.env.PW_SHARD || '', + keepArchives: undefined, }; for (const arg of argv) { if (arg === '--list') out.list = true; @@ -49,13 +55,14 @@ function parseArgs(argv) { else if (arg.startsWith('--suite=')) out.suite = arg.slice(8); else if (arg.startsWith('--shards=')) out.shards = Math.max(1, Number(arg.slice(9)) || 1); else if (arg.startsWith('--shard=')) out.shard = arg.slice(8); + else if (arg.startsWith('--keep-archives=')) out.keepArchives = arg.slice(16); else out.extra.push(arg); } return out; } function printHelp() { - console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--skip-preflight] [--list] + console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--keep-archives=N] [--skip-preflight] [--list] Suites: ${formatSuiteList()} @@ -64,15 +71,19 @@ ${formatSuiteList()} 默认按「文件」并行(PW_WORKERS),同一 spec 内 serial 组不拆开。 --shards=N 本机拉起 N 个 Playwright 进程(先登录一次再分片) --shard=i/N 只跑第 i 片(Jenkins 多 Agent 矩阵用) + --keep-archives=N 只保留最近 N 份 reports/ci-*(0=跑完即删本次归档,等价 PW_KEEP_ARCHIVES=0) Env: - SUITE / PW_WORKERS / PW_RETRIES / RUN_FULL / BASE_URL / TEST_USER / TEST_PASS + SUITE / PW_WORKERS / PW_RETRIES / RUN_FULL / BASE_URL / TEST_USER / TEST_PASS / TEST_USER_2 / TEST_PASS_2 SHARDS / PW_SHARD / PW_FULLY_PARALLEL=1 / PW_MAX_FAILURES RUN_QUARANTINE=1 只跑 config/quarantine.json 隔离项 SKIP_PREFLIGHT=1 跳过站点探活 SKIP_TYPECHECK=1 跳过 tsc --noEmit - ALLURE=0 关闭 Allure - INCLUDE_DEBUG_SPECS=1 纳入 *.stat-*.spec.ts 调试用例 + ALLURE=1 额外生成 Allure(默认关,避免与 HTML 重复保存 trace/截图) + PW_JUNIT_COMPACT=0 保留 JUnit 完整堆栈(默认 1:只留 message) + PW_KEEP_TRACES=N 归档内只留最大 N 个 trace/截图附件(默认全留) + PW_KEEP_ARCHIVES=N 本机只保留最近 N 份 ci-(默认全留;0=跑完即删) + PW_KEEP_INTERMEDIATE=1 保留分片目录与 blob-report(默认合并后删除) `); } @@ -105,6 +116,161 @@ function appendFileIfExists(src, dest) { return true; } +/** 旧归档保留份数:-1=全留(默认,保持原行为),0=跑完即删,N=保留最新 N 份。 */ +function resolveKeepArchives() { + const raw = process.env.PW_KEEP_ARCHIVES ?? KEEP_ARCHIVES_ARG; + if (raw === undefined || raw === '') return -1; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : -1; +} + +function dirSizeBytes(dir) { + let total = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) total += dirSizeBytes(full); + else if (entry.isFile()) { + try { + total += statSync(full).size; + } catch { + // 文件被占用/已删除则跳过 + } + } + } + return total; +} + +/** 删除本次归档内的中间目录(分片产物、blob),它们只在合并阶段有用。 */ +function dropIntermediateDirs(archiveDir, blobDir) { + if (process.env.PW_KEEP_INTERMEDIATE === '1') return; + rmSync(blobDir, { recursive: true, force: true }); + for (const item of readdirSync(archiveDir, { withFileTypes: true })) { + if (item.isDirectory() && /^shard-\d+$/.test(item.name)) { + rmSync(path.join(archiveDir, item.name), { recursive: true, force: true }); + } + } +} + +function pruneArchives(currentDir, keep) { + if (keep === 0) { + rmSync(currentDir, { recursive: true, force: true }); + console.log(`[ci] PW_KEEP_ARCHIVES=0,已删除本次归档(latest/ 仍保留最近一次)`); + return; + } + const base = path.join(ROOT, 'reports'); + const archives = readdirSync(base, { withFileTypes: true }) + .filter((item) => item.isDirectory() && /^ci-/.test(item.name)) + .map((item) => { + const full = path.join(base, item.name); + return { full, name: item.name, mtime: statSync(full).mtimeMs }; + }) + .sort((a, b) => b.mtime - a.mtime); + for (const item of archives.slice(Math.max(keep, 1))) { + try { + rmSync(item.full, { recursive: true, force: true }); + console.log(`[ci] 清理旧归档 ${item.name}`); + } catch (err) { + console.warn(`[ci] 清理 ${item.name} 失败(可能被占用):${err.message}`); + } + } +} + +/** 附件保留份数:-1=全留(默认),0=全部删除,N=仅留体积最大的 N 个。 */ +function resolveKeepTraces() { + const raw = process.env.PW_KEEP_TRACES; + if (raw === undefined || raw === '') return -1; + const n = Number(raw); + return Number.isFinite(n) && n >= -1 ? Math.trunc(n) : -1; +} + +/** mergeAllureResults 会给文件名加 `<分片>-` 前缀,但 result.json 里仍是原始 uuid,比对时要剥掉前缀。 */ +function attachmentUuid(fileName) { + return fileName.replace(/^shard-\d+-/, ''); +} + +/** + * Allure 与 HTML 各自保存一份 trace.zip / 截图(同一失败用例 2 份)。 + * 保留一份 HTML 报告即可:默认不再把二进制附件复制进 allure-results, + * 并同步删掉 result.json 里的附件条目,避免 Allure 页面出现坏链接。 + */ +function pruneAllureBinaryAttachments(resultsDir, keep) { + if (!existsSync(resultsDir)) return; + const binaries = readdirSync(resultsDir, { withFileTypes: true }) + .filter((item) => item.isFile() && /-attachment\.(zip|png|jpg|jpeg|webm|mp4)$/i.test(item.name)) + .map((item) => { + const full = path.join(resultsDir, item.name); + return { name: item.name, uuid: attachmentUuid(item.name), full, size: statSync(full).size }; + }); + if (!binaries.length) return; + binaries.sort((a, b) => b.size - a.size); + const dropped = new Set(); + let freed = 0; + for (const item of binaries.slice(Math.max(keep, 0))) { + try { + unlinkSync(item.full); + dropped.add(item.uuid); + freed += item.size; + } catch { + // 删除失败则保留引用 + } + } + if (!dropped.size) return; + for (const item of readdirSync(resultsDir, { withFileTypes: true })) { + if (!item.isFile() || !item.name.endsWith('-result.json')) continue; + const full = path.join(resultsDir, item.name); + try { + writeFileSync(full, stripAttachmentRefs(readFileSync(full, 'utf8'), dropped)); + } catch { + // 单个 result 解析失败不影响其余文件 + } + } + console.log( + `[ci] allure 二进制附件瘦身:删除 ${dropped.size} 个,释放 ${(freed / 1024 / 1024).toFixed(0)} MB`, + ); +} + +function stripAttachmentRefs(raw, dropped) { + const isMarked = (ref) => typeof ref === 'string' && dropped.has(ref); + const clean = (node) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (let i = node.length - 1; i >= 0; i -= 1) { + if (isMarked(node[i]?.source)) node.splice(i, 1); + else clean(node[i]); + } + return; + } + if (Array.isArray(node.attachments)) { + node.attachments = node.attachments.filter((entry) => !isMarked(entry?.source)); + } + for (const value of Object.values(node)) clean(value); + }; + const json = JSON.parse(raw); + clean(json); + return JSON.stringify(json); +} + +/** 紧凑 JUnit:去掉每条 failure/error 的整段 /堆栈,只留 message 属性。 */ +function compactJunit(file) { + const xml = readFileSync(file, 'utf8'); + const compact = xml.replace(/<(failure|error)\b([^>]*)>[\s\S]*?<\/\1>/g, (match, tag, attrs) => + attrs.includes('message=') ? `<${tag}${attrs} />` : match, + ); + writeFileSync(file, compact); +} + +/** 归档瘦身:产物只供 Jenkins 浏览,可重建的中间文件与重复附件不再全量落盘。 */ +function slimArchive({ archiveDir, htmlDir }) { + if (process.env.PW_JUNIT_COMPACT === '1' && existsSync(path.join(archiveDir, 'junit.xml'))) { + compactJunit(path.join(archiveDir, 'junit.xml')); + } + const resultsDir = path.join(archiveDir, 'allure-results'); + pruneAllureBinaryAttachments(resultsDir, resolveKeepTraces()); + const htmlBytes = existsSync(htmlDir) ? dirSizeBytes(htmlDir) : 0; + if (htmlBytes > 0) console.log(`[ci] HTML 体积 ${(htmlBytes / 1024 / 1024).toFixed(0)} MB`); +} + + function mergeFlakeDirs(parts, dest) { mkdirSync(dest, { recursive: true }); for (const dir of parts) { @@ -263,7 +429,11 @@ function buildBaseEnv(workers, retries, runFull, dirs) { PW_RETRIES: retries, RUN_FULL: runFull, RUN_QUARANTINE: process.env.RUN_QUARANTINE || '0', - ALLURE: process.env.ALLURE === '0' ? '0' : '1', + // Allure 默认关:Allure 与 HTML 保存同一份 trace/截图,且 Allure 插件只读 latest/allure-results, + // 多出的归档副本没有消费方。需要时 ALLURE=1 打开。 + ALLURE: process.env.ALLURE === '1' ? '1' : '0', + // JUnit 默认用紧凑版:Jenkins recordIssues 只读 message,不需要整段堆栈。 + PW_JUNIT_COMPACT: process.env.PW_JUNIT_COMPACT ?? '1', JUNIT: '1', PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(ROOT, '.playwright-browsers'), @@ -281,6 +451,7 @@ async function main() { console.log(formatSuiteList()); process.exit(0); } + KEEP_ARCHIVES_ARG = opts.keepArchives; const suite = getSuite(opts.suite); const buildId = resolveBuildId(); @@ -452,7 +623,11 @@ async function main() { finishFlakeReport(flakeDir); - const allureOk = process.env.ALLURE !== '0' && tryGenerateAllure(allureResultsDir, allureReportDir); + // 只保留一份 HTML + JUnit + summary 等文本产物;中间目录先删,再生成 Allure,避免附件重复落盘。 + dropIntermediateDirs(archiveDir, blobDir); + slimArchive({ archiveDir, htmlDir }); + + const allureOk = process.env.ALLURE === '1' && tryGenerateAllure(allureResultsDir, allureReportDir); const summary = { suite: suite.name, @@ -482,7 +657,8 @@ async function main() { mkdirSync(latestDir, { recursive: true }); copyIfExists(junitFile, path.join(latestDir, 'junit.xml')); copyIfExists(htmlDir, path.join(latestDir, 'playwright-report')); - copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results')); + // latest 只需要供 Jenkins 插件消费的报告文件;默认不开 Allure,也就不复制 allure-results。 + if (allureOk) copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results')); if (allureOk) copyIfExists(allureReportDir, path.join(latestDir, 'allure-report')); copyIfExists(path.join(archiveDir, 'summary.json'), path.join(latestDir, 'summary.json')); copyIfExists(path.join(archiveDir, 'meta.txt'), path.join(latestDir, 'meta.txt')); @@ -501,6 +677,10 @@ async function main() { console.log(`[ci] JUnit: ${path.relative(ROOT, junitFile)}`); if (allureOk) console.log(`[ci] Allure: ${path.relative(ROOT, allureReportDir)}`); console.log(`[ci] latest: reports/latest/`); + if (existsSync(archiveDir)) { + console.log(`[ci] 归档体积 ${(dirSizeBytes(archiveDir) / 1024 / 1024).toFixed(0)} MB`); + } + pruneArchives(archiveDir, resolveKeepArchives()); process.exit(exitCode); } diff --git a/scripts/run-full-at-1900.sh b/scripts/run-full-at-1900.sh new file mode 100755 index 0000000000000000000000000000000000000000..70ec9db82d8a5d4136c02762bb9321b524e67238 --- /dev/null +++ b/scripts/run-full-at-1900.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 2026-10-23 19:00 全量回归(PW_WORKERS=2);由 LaunchAgent 触发 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LOG="$ROOT/reports/scheduled-full-20261023.log" +LABEL="com.mytestui.full-run-20261023" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" +UID_NUM="$(id -u)" + +mkdir -p "$ROOT/reports" +export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${PATH:-}" + +{ + echo "[$(date '+%Y-%m-%d %H:%M:%S')] LaunchAgent 触发:PW_WORKERS=2 npm run test:ci:full" + cd "$ROOT" + export CI=1 + export PW_WORKERS=2 + set +e + /usr/bin/caffeinate -dims npm run test:ci:full + EC=$? + set -e + echo "[$(date '+%Y-%m-%d %H:%M:%S')] 结束 exit=$EC" + launchctl bootout "gui/${UID_NUM}/${LABEL}" 2>/dev/null || true + rm -f "$PLIST" +} >> "$LOG" 2>&1 + +exit "${EC:-0}" diff --git a/scripts/scan-admit-batch.mjs b/scripts/scan-admit-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ba698ec3348dec725daf4606fdbde2fc5ded6b55 --- /dev/null +++ b/scripts/scan-admit-batch.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 准入管理 (6 pages). + * Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml from that JSON. + * + * Usage: + * node scripts/scan-admit-batch.mjs + * node scripts/scan-admit-batch.mjs admit-material admit-supply + */ +import { existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { chromium } from 'playwright'; + +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const MENUS = [ + { pageKey: 'admit-material', menu: '物料准入管理', pageTitle: '基础设置 > 准入管理 > 物料准入管理' }, + { pageKey: 'admit-product-apply', menu: '产品准入申请', pageTitle: '基础设置 > 准入管理 > 产品准入申请' }, + { pageKey: 'admit-material-list', menu: '物料准入单列表', pageTitle: '基础设置 > 准入管理 > 物料准入单列表' }, + { pageKey: 'admit-product-list', menu: '产品准入单列表', pageTitle: '基础设置 > 准入管理 > 产品准入单列表' }, + { pageKey: 'admit-supply', menu: '供货目录申请', pageTitle: '基础设置 > 准入管理 > 供货目录申请' }, + { pageKey: 'admit-task', menu: '准入任务分配', pageTitle: '基础设置 > 准入管理 > 准入任务分配' }, +]; + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await page.waitForURL(/\/spd\/home/, { timeout: 120_000 }); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +async function discoverMenuUrls(menus) { + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + storageState: AUTH_STATE, + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + }); + const page = await context.newPage(); + const urls = {}; + + for (const item of menus) { + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(1500); + if (page.url().includes('/login')) { + throw new Error('登录态失效,无法打开准入管理菜单'); + } + await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 }); + await page.waitForTimeout(500); + const admitTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^准入管理$/ }).first(); + if (await admitTitle.isVisible().catch(() => false)) { + const expanded = await admitTitle.locator('xpath=..').evaluate((el) => el.classList.contains('ant-menu-submenu-open')).catch(() => false); + if (!expanded) await admitTitle.click({ timeout: 10_000 }); + } + await page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }).first().click({ timeout: 20_000 }); + await page.waitForTimeout(2000); + const url = page.url().split('?')[0]; + if (url.includes('/login') || url.endsWith('/spd/home/index')) { + throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`); + } + urls[item.pageKey] = url; + console.log(`URL ${item.menu}: ${url}`); + } + + await browser.close(); + return urls; +} + +async function openFirstDropdown(page) { + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({ + state: 'visible', + timeout: 8_000, + }).catch(() => undefined); + await page.waitForTimeout(400); + return { dropdownOpened: true }; +} + +async function layerOpened(page) { + const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + if (modal) return true; + return page.locator('.put-page').first().isVisible().catch(() => false); +} + +async function openNamedButton(page, pattern, flag) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + await btn.click({ timeout: 12_000, force: true }); + await page.locator('.ant-modal, .ant-drawer-open, .put-page').first().waitFor({ state: 'visible', timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(500); + const opened = await layerOpened(page); + return { [flag]: opened, skipRescan: !opened }; +} + +async function openRowAction(page, pattern, flag) { + const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); + if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + let action = row.getByRole('button', { name: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) { + action = row.locator('a,button,span').filter({ hasText: pattern }).first(); + } + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + const before = page.url(); + const hadDetail = await page.locator('.put-page').first().isVisible().catch(() => false); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(800); + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { [flag]: true, navigated: true, returnUrl: before }; + const opened = (await layerOpened(page)) && (!hadDetail || await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false) || await page.locator('.put-page').first().isVisible().catch(() => false)); + return { [flag]: Boolean(opened), skipRescan: !opened }; +} + +function pageCfg(targetUrl, item) { + return { + targetUrl, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + pageKey: item.pageKey, + pageTitle: item.pageTitle, + jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + jsonSourceName: `${item.pageKey}-elements.json`, + waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn'], + waitSelector: '.ant-form, .ant-table, .ant-btn', + waitUntil: 'domcontentloaded', + expandFirstRow: false, + openAdvancedFilter: false, + interactivePhases: [ + openFirstDropdown, + (page) => openNamedButton(page, /新\s*增/, 'dialogOpened'), + (page) => openNamedButton(page, /关联产品/, 'dialogOpened'), + (page) => openRowAction(page, /详\s*情/, 'detailOpened'), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /日\s*志/, 'logOpened'), + ], + }; +} + +async function main() { + const filter = process.argv.slice(2); + const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS; + if (!menus.length) { + console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + const urls = await discoverMenuUrls(menus); + const summary = []; + + for (const item of menus) { + const targetUrl = urls[item.pageKey]; + console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`); + try { + const result = await runScan(pageCfg(targetUrl, item)); + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: item.pageKey, + pageTitle: item.pageTitle, + module: 'spd-basic', + jsonSourceName: `${item.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + }); + summary.push({ page: item.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut }); + } catch (err) { + console.error(`FAILED ${item.pageKey}:`, err); + summary.push({ page: item.pageKey, ok: false, error: String(err) }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scan-basic-batch.mjs b/scripts/scan-basic-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..33aaa9176cbd64ad9f7be41a699bd7f3b8e2051b --- /dev/null +++ b/scripts/scan-basic-batch.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 医院资料 (4 pages) from UI-SPEC BASIC-001~004. + * Multi-phase: list + dropdown/dialog/edit/confirm/upload/richtext. + * + * Usage: + * node scripts/scan-basic-batch.mjs + * node scripts/scan-basic-batch.mjs basic-notice basic-branch + */ +import { existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { chromium } from 'playwright'; + +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const BASIC_BASE = `${BASE}/spd/basic/hospital`; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const COMMON_WAIT = [ + '.ant-form-item input', + '.ant-form', + '.ant-table', + '.ant-btn', + '#form_item_title', + '#form_item_settleMonthDate', +]; + +async function clickButton(content, pattern) { + const btn = content.getByRole('button', { name: pattern }).first(); + if (await btn.isVisible().catch(() => false)) { + await btn.click({ timeout: 15_000 }); + return true; + } + return false; +} + +async function closeOverlays(page) { + const footerCancel = page.locator('.ant-modal .ant-modal-footer button').filter({ hasText: /取\s*消/ }).last(); + if (await footerCancel.isVisible().catch(() => false)) { + await footerCancel.click({ timeout: 8_000 }).catch(() => undefined); + await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(400); + } + for (let i = 0; i < 3; i += 1) { + const popCancel = page + .locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button') + .filter({ hasText: /取\s*消/ }) + .last(); + if (await popCancel.isVisible().catch(() => false)) { + await popCancel.click({ timeout: 5_000 }).catch(() => undefined); + await page.waitForTimeout(300); + continue; + } + const modalClose = page.locator('.ant-modal-wrap .ant-modal-close').last(); + if (await modalClose.isVisible().catch(() => false)) { + await modalClose.click({ timeout: 5_000 }).catch(() => undefined); + await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(400); + continue; + } + break; + } +} + +function firstDataRow(page) { + return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); +} + +async function waitModal(content, titleRe, timeout = 15_000) { + const loc = titleRe + ? content.locator('.ant-modal').filter({ has: content.locator('.ant-modal-title', { hasText: titleRe }) }) + : content.locator('.ant-modal-wrap .ant-modal, .ant-modal').first(); + await loc.first().waitFor({ state: 'visible', timeout }).catch(() => undefined); + return loc.first(); +} + +async function openAddModal(content, titleRe) { + const clicked = await clickButton(content, /新\s*增/); + if (!clicked) return { dialogOpened: false }; + await waitModal(content, titleRe); + return { dialogOpened: true }; +} + +async function openPublishScopeDropdown(page) { + const select = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false }; + await select.click({ timeout: 10_000 }); + await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({ + state: 'visible', + timeout: 10_000, + }).catch(() => undefined); + await page.waitForTimeout(400); + return { dropdownOpened: true }; +} + +async function openNoticeAddWithEditor(page) { + await closeOverlays(page); + const clicked = await clickButton(page, /新\s*增/); + if (!clicked) return { dialogOpened: false, richTextOpened: false }; + await waitModal(page, /新增公告/); + await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({ + state: 'visible', + timeout: 10_000, + }).catch(() => undefined); + await page.waitForTimeout(500); + const rich = await page.locator('.ant-modal .ql-editor').first().isVisible().catch(() => false); + return { dialogOpened: true, richTextOpened: Boolean(rich) }; +} + +async function openNoticeDeleteConfirm(page) { + await closeOverlays(page); + const del = firstDataRow(page).getByRole('button', { name: /删\s*除/ }); + if (!(await del.isVisible().catch(() => false))) { + const fallback = page.getByRole('button', { name: /删\s*除/ }).first(); + if (!(await fallback.isVisible().catch(() => false))) return { deleteConfirmOpened: false }; + await fallback.click({ timeout: 10_000 }); + } else { + await del.click({ timeout: 10_000 }); + } + await page + .locator('.ant-popconfirm, .ant-popover-open, .ant-modal-confirm, .ant-modal') + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .catch(() => undefined); + await page.waitForTimeout(400); + return { deleteConfirmOpened: true }; +} + +async function openNoticeEditModal(page) { + await closeOverlays(page); + const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ }); + if (await edit.isVisible().catch(() => false)) { + await edit.click({ timeout: 10_000 }); + } else { + const fallback = page.getByRole('button', { name: /编\s*辑/ }).first(); + if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false }; + await fallback.click({ timeout: 10_000 }); + } + await waitModal(page, /编辑公告|新增公告|公告/); + await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({ + state: 'visible', + timeout: 8_000, + }).catch(() => undefined); + await page.waitForTimeout(500); + return { editDialogOpened: true }; +} + +async function openHospitalEdit(page) { + const clicked = await clickButton(page, /编\s*辑/); + if (!clicked) return { editModeOpened: false }; + await page.getByRole('button', { name: /保\s*存/ }).first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined); + await page.waitForTimeout(400); + return { editModeOpened: true }; +} + +async function openFinanceMonthEdit(page) { + const clicked = await clickButton(page, /修\s*改/); + if (!clicked) return { editModeOpened: false }; + await page + .locator('.ant-table-tbody .ant-picker, .ant-table-tbody input, .ant-table-tbody button') + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .catch(() => undefined); + await page.waitForTimeout(600); + return { editModeOpened: true }; +} + +async function openBranchEditModal(page) { + await closeOverlays(page); + const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ }); + if (await edit.isVisible().catch(() => false)) { + await edit.click({ timeout: 10_000 }); + } else { + const fallback = page.getByRole('button', { name: /编\s*辑/ }).first(); + if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false }; + await fallback.click({ timeout: 10_000 }); + } + await waitModal(page, /编辑院区/); + await page.waitForTimeout(500); + return { editDialogOpened: true }; +} + +function pageCfg(reportPath, rest) { + return { + targetUrl: `${BASIC_BASE}${reportPath}`, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`), + jsonSourceName: `${rest.pageKey}-elements.json`, + waitSelectors: COMMON_WAIT, + waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form', + expandFirstRow: false, + openAdvancedFilter: false, + skipQueryClick: rest.skipQueryClick ?? false, + ...rest, + }; +} + +const PAGES = [ + pageCfg('/paltform/notice', { + pageKey: 'basic-notice', + pageTitle: '基础设置 > 医院资料 > 公告管理', + interactivePhases: [ + openPublishScopeDropdown, + openNoticeAddWithEditor, + openNoticeDeleteConfirm, + openNoticeEditModal, + ], + }), + pageCfg('/paltform/info', { + pageKey: 'basic-hospital-info', + pageTitle: '基础设置 > 医院资料 > 医院信息登记', + skipQueryClick: true, + interactivePhases: [openHospitalEdit], + }), + pageCfg('/balance/month', { + pageKey: 'basic-finance-month', + pageTitle: '基础设置 > 医院资料 > 财务月份设置', + interactivePhases: [openFinanceMonthEdit], + }), + pageCfg('/paltform/area', { + pageKey: 'basic-branch', + pageTitle: '基础设置 > 医院资料 > 院区管理', + skipQueryClick: true, + interactivePhases: [ + async (page) => { + await closeOverlays(page); + return openAddModal(page, /新增院区/); + }, + openBranchEditModal, + ], + }), +]; + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await page.waitForURL(/\/spd\/home/, { timeout: 120_000 }); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +async function main() { + const filter = process.argv.slice(2); + const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES; + + if (!pages.length) { + console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + + const summary = []; + for (const page of pages) { + console.log(`\n=== Scanning ${page.pageKey} ===`); + try { + const result = await runScan(page); + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: page.pageKey, + pageTitle: page.pageTitle, + module: 'spd-basic', + jsonSourceName: `${page.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${page.pageKey}.locator.yaml`), + }); + summary.push({ page: page.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut }); + } catch (err) { + console.error(`FAILED ${page.pageKey}:`, err); + summary.push({ page: page.pageKey, ok: false, error: String(err) }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scan-interactive-elements.mjs b/scripts/scan-interactive-elements.mjs index 5d45a1c3ab43cac602bc35ab3c4cf361c2b73e8e..df3ef85ea5fcb8412f5788f6edcd9410a02825c1 100644 --- a/scripts/scan-interactive-elements.mjs +++ b/scripts/scan-interactive-elements.mjs @@ -52,7 +52,9 @@ const SCAN_SCRIPT = `(() => { '.ant-pagination-options-size-changer,.ant-pagination-options-quick-jumper input,' + '.ant-table-column-sorter,.ant-dropdown-trigger,.ant-tabs-tab,.ant-drawer-close,' + '.ant-modal-close,.advance-filter,.ant-cascader,.ant-drawer-footer .ant-btn,' + - '.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell'; + '.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell,' + + // antd Tree:节点无 tabindex/role,checkbox 不是原生 input,需显式纳入 + '.ant-tree-checkbox,.ant-tree-switcher,.ant-tree-title'; function isVisible(el) { const style = window.getComputedStyle(el); @@ -68,6 +70,9 @@ const SCAN_SCRIPT = `(() => { } function labelFor(el) { + // 树节点(权限树 / 分类树)的语义来自自身文本,不能回溯到外层表单标签, + // 否则所有节点都会被打上同一个 label(如「角色名称」)而无法区分。 + if (el.closest('.ant-tree')) return ''; if (el.id) { const lbl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); if (lbl) return norm(lbl.textContent); @@ -91,6 +96,9 @@ const SCAN_SCRIPT = `(() => { if (el.classList.contains('ant-picker')) return 'datepicker'; if (el.classList.contains('ant-btn')) return 'button'; if (el.classList.contains('ant-checkbox-wrapper')) return 'checkbox'; + if (el.classList.contains('ant-tree-checkbox')) return 'checkbox'; + if (el.classList.contains('ant-tree-switcher')) return 'tree_switcher'; + if (el.classList.contains('ant-tree-title')) return 'tree_title'; if (el.classList.contains('ant-radio-wrapper')) return 'radio'; if (el.classList.contains('ant-tabs-tab')) return 'tab'; if (el.classList.contains('advance-filter')) return 'filter_trigger'; @@ -122,6 +130,35 @@ const SCAN_SCRIPT = `(() => { return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\\]^ \`{|}~])/g, '\\\\$1'); } + // 树节点祖先路径:用于生成可读且唯一的树定位器键(如 基础设置 > 医院资料 > 院区管理)。 + // 注意:antd Tree 的 DOM 是扁平的(靠 level 缩进表示层级),不能靠 parentElement 回溯。 + function treePathOf(el) { + const tree = el.closest('.ant-tree'); + if (!tree) return null; + const titleNode = el.closest('.ant-tree-treenode') || (el.classList.contains('ant-tree-title') ? el.parentElement?.closest('.ant-tree-treenode') : null); + if (!titleNode) return null; + const all = [...tree.querySelectorAll('.ant-tree-treenode')]; + const idx = all.indexOf(titleNode); + if (idx < 0) return null; + const levelOf = (n) => Number((n.className.match(/ant-tree-treenode-level-(\\d+)/) || [])[1] ?? 0); + const targetLevel = levelOf(titleNode); + const path = []; + const self = titleNode.querySelector('.ant-tree-title'); + const selfText = self ? norm(self.textContent) : ''; + if (!selfText) return null; + path.unshift(selfText); + let need = targetLevel - 1; + for (let i = idx - 1; i >= 0 && need >= 0; i -= 1) { + if (levelOf(all[i]) === need) { + const t = all[i].querySelector('.ant-tree-title'); + const txt = t ? norm(t.textContent) : ''; + if (txt) path.unshift(txt); + need -= 1; + } + } + return path; + } + function buildLocators(el, label, text, type) { const locators = []; const testId = el.getAttribute('data-testid') || el.getAttribute('data-test'); @@ -164,18 +201,64 @@ const SCAN_SCRIPT = `(() => { if (name && name.length <= 40) { locators.push({ strategy: 'text', value: name, priority: 8 }); } + // 树节点:优先用树容器 + 节点文本精确定位,避免与页面同名菜单/行混淆 + if (el.closest('.ant-tree') && text) { + const treeCls = el.closest('.ant-tree')?.className || ''; + const treeSel = String(treeCls).includes('menu-role-tree') ? '.menu-role-tree' : '.ant-tree'; + if (el.classList.contains('ant-tree-title')) { + locators.unshift({ + strategy: 'tree_node', + value: text, + tree: treeSel, + priority: 1, + }); + } else if (el.classList.contains('ant-tree-checkbox')) { + const nodeText = norm(el.closest('.ant-tree-treenode')?.querySelector('.ant-tree-title')?.textContent || ''); + if (nodeText) { + locators.unshift({ + strategy: 'tree_checkbox', + value: nodeText, + tree: treeSel, + priority: 1, + }); + } + } + } locators.sort((a, b) => a.priority - b.priority); return locators; } function sectionOf(el) { - if (el.closest('.ant-pagination')) return 'pagination'; - const drawer = el.closest('.ant-drawer-open, .ant-drawer'); + const detail = el.closest('.put-page'); + if (detail) { + const title = detail.querySelector('.ant-descriptions-title, .ant-page-header-heading-title'); + const t = title ? norm(title.textContent) : ''; + return t ? 'detail:' + t : 'detail'; + } + const modal = el.closest('.ant-modal'); + if (modal) { + const wrap = modal.closest('.ant-modal-wrap'); + const hidden = wrap && (wrap.style.display === 'none' || wrap.classList.contains('ant-modal-wrap-hidden')); + if (!hidden) { + const title = modal.querySelector('.ant-modal-title'); + const t = title ? norm(title.textContent) : ''; + return t ? 'modal:' + t : 'modal'; + } + } + const pop = el.closest('.ant-popconfirm, .ant-modal-confirm'); + if (pop) return 'confirm'; + const dd = el.closest('.ant-select-dropdown, .ant-picker-dropdown, .ant-dropdown'); + if (dd && !dd.classList.contains('ant-select-dropdown-hidden') && !dd.classList.contains('ant-picker-dropdown-hidden') && !dd.classList.contains('ant-dropdown-hidden')) { + return 'dropdown'; + } + const drawer = el.closest('.ant-drawer'); if (drawer && drawer.classList.contains('ant-drawer-open')) { - if (el.closest('.ant-drawer-footer')) return 'panel:高级筛选:footer'; - const title = drawer.querySelector('.ant-drawer-title, .ant-modal-title'); - if (title) return 'panel:' + norm(title.textContent); + if (el.closest('.ant-drawer-footer')) return 'panel:drawer:footer'; + const title = drawer.querySelector('.ant-drawer-title'); + if (title && norm(title.textContent)) return 'panel:' + norm(title.textContent); + return 'panel:drawer'; } + if (el.closest('.ant-pagination')) return 'pagination'; const tab = el.closest('.ant-tabs-tabpane-active'); if (tab) { const tabBtn = document.querySelector('.ant-tabs-tab-active'); @@ -231,6 +314,7 @@ const SCAN_SCRIPT = `(() => { }, locators, recommended: best, + treePath: treePathOf(el), }); }); @@ -291,6 +375,16 @@ function toPlaywrightExpr(loc, frameLabel) { return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}', { exact: true })`; case 'text': return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}')`; + case 'tree_node': { + const tree = loc.tree || '.ant-tree'; + const t = loc.value.replace(/'/g, "\\'"); + return `${prefix}.locator('${tree} .ant-tree-title').filter({ hasText: /^\\s*${t}\\s*$/ })`; + } + case 'tree_checkbox': { + const tree = loc.tree || '.ant-tree'; + const t = loc.value.replace(/'/g, "\\'"); + return `${prefix}.locator('${tree} .ant-tree-treenode').filter({ hasText: /^\\s*${t}\\s*$/ }).locator('.ant-tree-checkbox')`; + } case 'css': return `${prefix}.locator('${loc.value.replace(/'/g, "\\'")}')`; default: @@ -328,6 +422,49 @@ const FORM_ID_KEY_MAP = { form_item_billDate: 'documentDateStart', form_item_statPeriod: 'statPeriodStart', form_item_goodsKind: 'consumableType', + // 手术套包设置(基础包维护 / 术种维护 / 标准套包维护) + table_search_form_keyword: 'productName', + table_search_form_goodsSpec: 'specModel', + table_search_form_goodsMfrsName: 'manufacturer', + table_search_form_operationName: 'operationName', + table_search_form_disease: 'diseaseName', + form_item_pkgName: 'basePkgName', + form_item_pkgCode: 'basePkgCode', + form_item_kindId: 'basePkgCategory', + form_item_kindName: 'basePkgCategoryName', + form_item_remark: 'remark', + basic_kindName: 'operTypeCategoryName', + // 医院资料 / 厂商 / 准入 / 用户 + form_item_title: 'noticeTitle', + form_item_expireDate: 'expireDate', + form_item_provId: 'supplierId', + form_item_subProvId: 'subSupplierId', + table_search_form_provName: 'supplierName', + table_search_form_provId: 'supplierId', + table_search_form_dateValue: 'dateRange', + table_search_form_orderNo: 'documentNo', + table_search_form_purModes: 'purchaseMode', + form_item_goodsMfrsId: 'manufacturer', + form_item_goodsSpec: 'specModel', + form_item_twoHourFlag: 'twoHourArrival', + form_item_daysOfStockout: 'stockoutDays', +}; + +// 仅有 placeholder、无 label/id 的输入框(树搜索、弹窗内搜索等) +const PLACEHOLDER_KEY_MAP = { + 请输入分类名称: 'categorySearchInput', + 请输入术种分类名称: 'operTypeCategorySearchInput', + '请输入术种分类名称、术种名称、标准套包名称': 'stdPkgTreeSearchInput', + '请输入分类/基础包名称': 'addBasePkgSearchInput', + 厂商名称: 'mfrsNameInput', + 公告标题: 'noticeTitleInput', + 院区名称: 'campusNameInput', + 请输入院区名称: 'campusNameInput', + 请输入方案编码: 'schemeCodeInput', + 请输入指标编码: 'indicatorCodeInput', + 请输入指标名称: 'indicatorNameInput', + '请输入名称/年度': 'nameOrYearInput', + '请输入产品编码、名称': 'productCodeNameInput', }; function slugify(s) { @@ -402,12 +539,152 @@ function slugify(s) { 科室名称: 'dimensionDeptName', 产品编号名称: 'productName', '产品编号/名称': 'productName', + // 手术套包设置 + 基础包维护: 'basePkgMaintenance', + 术种维护: 'operTypeMaintenance', + 标准套包维护: 'stdPkgMaintenance', + 基础包名称: 'basePkgName', + 包编码: 'basePkgCode', + 所属分类: 'basePkgCategory', + 分类名称: 'categoryName', + 术种分类名称: 'operTypeCategoryName', + 术种分类: 'operTypeCategory', + 术种名称: 'operationName', + 病种名称: 'diseaseName', + 诊断编码: 'diagnosisCode', + 诊断名称: 'diagnosisName', + 操作编码: 'operationCode', + 操作名称: 'operationName', + 新增分类: 'addCategory', + 新增基础包: 'addBasePkg', + 新增术种: 'addOperType', + 添加基础包: 'addBasePkg', + 添加散件: 'addLoosePart', + 添加产品: 'addProduct', + 新增: 'add', + '新 增': 'add', + 编辑: 'edit', + 移除: 'remove', + 停用: 'disable', + 启用: 'enable', + 保存: 'save', + '保 存': 'save', + 取消: 'cancel', + '取 消': 'cancel', + 确认: 'confirm', + '确 认': 'confirm', + 确定: 'confirm', + '确定(0)': 'confirm', + 关闭: 'close', + '关 闭': 'close', + 返回: 'back', + '返 回': 'back', + 展开: 'expandFilter', + 收起: 'collapseFilter', + 产品概览: 'productOverview', + 停用Tab: 'tabDisabled', + 未建分类: 'tabUncategorized', + 详情: 'detail', + 日志: 'log', + 提交: 'submit', + '提 交': 'submit', + 操作: 'action', + 状态: 'status', + 备注: 'remark', + 内容: 'content', + 年度: 'year', + 变更类型: 'changeType', + 截止日期: 'expireDate', + 新增日期: 'createDate', + 公告标题: 'noticeTitle', + 发布日期: 'publishDate', + 发布范围: 'publishScope', + 厂商名称: 'mfrsName', + 器械注册人: 'registrant', + 经销商: 'distributor', + 方案编码: 'schemeCode', + 指标编号: 'indicatorCode', + 二级供应商: 'subSupplier', + 是否2小时到货: 'twoHourArrival', + 授权链路效期预警: 'authExpiryWarn', + 一致: 'matched', + 不一致: 'mismatched', + // 基础设置子菜单 + 医院资料: 'hospitalInfo', + 院区管理: 'branchManage', + 公告管理: 'noticeManage', + 财务月份设置: 'financeMonthSetting', + 医院信息登记: 'hospitalInfoRegister', + 厂商管理: 'vendorManage', + 厂商查询: 'vendorQuery', + 供应商资质管理: 'vendorQual', + 授权变更初审: 'authFirst', + 授权变更终审: 'authFinal', + 注册证变更初审: 'certFirst', + 注册证变更终审: 'certFinal', + 注册证差异对比: 'certDiff', + 评价方案维护: 'evalScheme', + 评价指标维护: 'evalIndicator', + 供应商节假日管理: 'vendorHoliday', + 准入管理: 'admitManage', + 物料准入管理: 'admitMaterial', + 产品准入申请: 'admitProductApply', + 物料准入单列表: 'admitMaterialList', + 产品准入单列表: 'admitProductList', + 供货目录申请: 'admitSupply', + 准入任务分配: 'admitTask', + 手术套包设置: 'surgerySetting', + 用户管理: 'userManage', + 医共体用户管理: 'unionUserManage', + 角色管理: 'roleManage', + // 顶部主导航 + 首页: 'home', + 基础设置: 'basicSetting', + 采购管理: 'purchase', + 期初管理: 'opening', + 中心库管理: 'centerStock', + 科室业务管理: 'deptBusiness', + 财务管理: 'finance', + 骨科带量: 'orthoVolume', + 综合管理: 'general', + 阳采管理: 'yangcai', + 流程中心: 'processCenter', + 后勤物资管理: 'logistics', + 统计报表: 'statReport', + 数据可视化: 'dataViz', }; if (map[s]) return map[s]; if (map[normalized]) return map[normalized]; - const ascii = s.match(/[a-zA-Z0-9_]+/g); - if (ascii?.length) return ascii.join('_').toLowerCase(); - return `el_${Buffer.from(s).toString('hex').slice(0, 8)}`; + // 含汉字时不要抽中间的数字当键(否则「是否2小时到货」会塌成 Select/Input) + if (!/[\u4e00-\u9fff]/.test(normalized)) { + const ascii = s.match(/[a-zA-Z0-9_]+/g); + if (ascii?.length) return ascii.join('_').toLowerCase(); + } + // 中文兜底:用完整 hex 而非前 8 位,避免「基础设置/医院资料…」等前缀相同导致键塌缩 + return `el_${Buffer.from(normalized).toString('hex')}`; +} + +function isNoiseElement(el) { + const rec = el.recommended || {}; + const recVal = String(rec.value || el.label || '').trim(); + const recText = String(el.text || '').trim(); + // 图标类 aria-label(more / remove / Close 等):不稳定且无业务语义 + if (rec.strategy === 'label' && /^(more|remove|close|delete|edit|info-circle|question-circle|down|up|left|right|increase value|decrease value)$/i.test(String(rec.value || ''))) { + return true; + } + // 纯图标按钮:无文案、无 placeholder、无 id + const meaninglessText = !el.text || /^(more|remove|close|delete)$/i.test(recText); + if (rec.strategy === 'label' && meaninglessText) return true; + if (/^(Increase Value|Decrease Value)$/i.test(recText)) return true; + // 表头排序器只保留一个(已在循环内处理);此处过滤无文案的排序 span + if (el.className?.includes('ant-table-column-sorter') && !el.text) return true; + // 顶部用户栏信息块(院区切换 / 消息数 / 用户名)- 非页面业务元素 + if (/^中心库[((]/.test(recVal) && (rec.strategy === 'label_text' || el.className?.includes('ant-dropdown-trigger'))) { + return true; + } + if (rec.strategy === 'label_text' && /^(中心库|99\+|\d+)$/.test(recVal)) return true; + if (/^(99\+)$/.test(recText) && el.className?.includes('ant-dropdown-trigger')) return true; + return false; } function buildYaml(scan) { @@ -418,9 +695,14 @@ function buildYaml(scan) { lines.push(`# Page: ${CONFIG.pageTitle}`); if (scan.advancedFilterOpened) lines.push('# Note: advanced filter panel was opened during scan'); if (scan.rowExpanded) lines.push('# Note: first table row was expanded during scan'); + if (scan.dropdownOpened) lines.push('# Note: filter dropdown was opened during scan'); + if (scan.dialogOpened) lines.push('# Note: dialog/modal was opened during scan'); + if (scan.editDialogOpened) lines.push('# Note: edit dialog was opened during scan'); + if (scan.detailOpened) lines.push('# Note: detail view was opened during scan'); + if (scan.logOpened) lines.push('# Note: log view was opened during scan'); lines.push(''); lines.push(`page: ${CONFIG.pageKey}`); - lines.push('module: spd-stat'); + lines.push(`module: ${CONFIG.module || 'spd-stat'}`); lines.push(`url: ${scan.url}`); lines.push(''); lines.push('locators:'); @@ -444,9 +726,17 @@ function buildYaml(scan) { el.recommended = { strategy: 'css', value: '.ant-table-column-sorter', priority: 9 }; } if (!el.recommended) continue; + // 过滤无业务价值的噪声元素(图标 aria-label、行内重复操作按钮等) + if (isNoiseElement(el)) continue; const frameLabel = frameLabelFromUrl(el.frame); - let key = el.id && FORM_ID_KEY_MAP[el.id] ? FORM_ID_KEY_MAP[el.id] : null; + // 树节点:用祖先路径生成可读且唯一的键,避免所有节点塌缩成同一个 hex 前缀 + const isTree = el.type === 'tree_title' || el.type === 'tree_switcher' || (el.type === 'checkbox' && el.treePath); + let key = isTree && el.treePath ? 'tree_' + el.treePath.map(slugify).join('_') : null; + if (!key && el.id && FORM_ID_KEY_MAP[el.id]) key = FORM_ID_KEY_MAP[el.id]; + if (!key && el.placeholder && !el.label && !el.id) { + key = PLACEHOLDER_KEY_MAP[el.placeholder] || null; + } const baseName = el.label || el.text || @@ -491,7 +781,24 @@ function buildYaml(scan) { if (section.includes('panel:高级筛选')) key = `drawer_${key}`; if (section.includes('pagination')) key = `pagination_${key}`; - let finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`; + let finalKey; + if (isTree && el.treePath?.length) { + // 树节点:键必须包含完整祖先路径,否则不同模块下的同名菜单(如「库存查询」)会撞键。 + // 叶子若是英文菜单则用可读键,中文叶子用短 hex 后缀保证唯一且稳定。 + const leaf = slugify(el.treePath[el.treePath.length - 1]); + const leafReadable = /^[a-z][a-z0-9_]*$/i.test(leaf) && !leaf.startsWith('el_'); + const kind = el.type === 'checkbox' ? 'cb' : el.type === 'tree_switcher' ? 'sw' : 'node'; + const pathHash = Buffer.from(el.treePath.join('>')).toString('hex').slice(0, 12); + finalKey = leafReadable ? `tree_${kind}_${leaf}_${pathHash}` : `tree_${kind}_${pathHash}`; + } else { + finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`; + } + // 表格行内重复的行操作按钮(编辑/移除/详情等)只保留一个,避免 editBtn_2/3... + const isRowAction = + (el.type === 'button' || el.tag === 'button' || el.className?.includes('ant-btn')) && + /Btn$/.test(finalKey) && + (el.section || '').includes('main'); + if (isRowAction && usedKeys.has(finalKey)) continue; let n = 2; while (usedKeys.has(finalKey)) { finalKey = `${key}_${n++}`; @@ -513,6 +820,10 @@ function buildYaml(scan) { if (rec.role) lines.push(` role: ${rec.role}`); if (rec.name) lines.push(` name: ${JSON.stringify(rec.name)}`); if (rec.value) lines.push(` value: ${JSON.stringify(rec.value)}`); + if (rec.tree) lines.push(` tree: ${JSON.stringify(rec.tree)}`); + } + if (el.treePath?.length > 1) { + lines.push(` tree_path: ${JSON.stringify(el.treePath.join(' > '))}`); } if (pw) lines.push(` playwright: ${JSON.stringify(pw)}`); lines.push(''); @@ -659,10 +970,65 @@ async function clickQueryIfPresent(reportFrame) { return false; } +function fingerprint(el) { + return [el.section, el.type, el.id || '', el.role || '', el.label || '', el.text || '', el.placeholder || ''].join('|'); +} + +async function closeScanOverlays(page) { + for (let i = 0; i < 6; i += 1) { + const back = page + .locator('.put-page, .ant-drawer-open, .ant-modal-wrap, .ant-modal') + .getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }) + .first(); + if (await back.isVisible().catch(() => false)) { + await back.click({ timeout: 4_000, force: true }).catch(() => undefined); + await page.waitForTimeout(400); + continue; + } + const backText = page.locator('a,button,span').filter({ hasText: /^返\s*回$/ }).last(); + if (await backText.isVisible().catch(() => false)) { + await backText.click({ timeout: 4_000, force: true }).catch(() => undefined); + await page.waitForTimeout(400); + continue; + } + const cancel = page + .locator('.ant-modal-footer button, .ant-modal-confirm button, .ant-popconfirm button, .ant-drawer-footer button') + .filter({ hasText: /取\s*消|关\s*闭/ }) + .last(); + if (await cancel.isVisible().catch(() => false)) { + await cancel.click({ timeout: 4_000, force: true }).catch(() => undefined); + await page.waitForTimeout(300); + continue; + } + const closer = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last(); + if (await closer.isVisible().catch(() => false)) { + await closer.click({ timeout: 4_000, force: true }).catch(() => undefined); + await page.waitForTimeout(300); + continue; + } + const closedByScript = await page.evaluate(() => { + const layer = document.querySelector('.put-page'); + if (!layer) return false; + const btn = [...layer.querySelectorAll('button,a,span')].find((el) => /返\s*回|关\s*闭/.test((el.textContent || '').trim())); + if (!btn) return false; + btn.click(); + return true; + }).catch(() => false); + if (closedByScript) { + await page.waitForTimeout(400); + continue; + } + await page.keyboard.press('Escape').catch(() => undefined); + break; + } +} + async function prepareReportPage(page) { - const reportFrame = page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`); + const reportFrame = CONFIG.useIframe === false + ? page + : page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`); const readySelector = await waitReportReady(reportFrame); - console.log(` iframe ready (${readySelector})`); + console.log(` content ready (${readySelector})`); const meta = { queryExecuted: false, advancedFilterOpened: false, rowExpanded: false }; if (CONFIG.skipQueryClick !== true) { @@ -723,7 +1089,10 @@ export async function runScan(overrides = {}) { const page = await context.newPage(); console.log('Navigating to', CONFIG.targetUrl); - await page.goto(CONFIG.targetUrl, { waitUntil: 'networkidle', timeout: 120_000 }); + await page.goto(CONFIG.targetUrl, { + waitUntil: CONFIG.waitUntil || 'networkidle', + timeout: 120_000, + }); await page.waitForTimeout(5000); const prepMeta = await prepareReportPage(page); @@ -731,6 +1100,43 @@ export async function runScan(overrides = {}) { let scan = await scanPageOrFrames(page); Object.assign(scan, prepMeta); + const seen = new Set(scan.elements.map(fingerprint)); + const phases = Array.isArray(CONFIG.interactivePhases) ? CONFIG.interactivePhases : []; + for (const phase of phases) { + let meta = {}; + try { + meta = (await phase(page)) || {}; + } catch (err) { + console.warn(' phase failed:', err); + meta = {}; + } + Object.assign(scan, meta); + if (!meta.skipRescan) { + const extra = await scanPageOrFrames(page); + for (const el of extra.elements) { + const key = fingerprint(el); + if (seen.has(key)) continue; + seen.add(key); + scan.elements.push(el); + } + scan.frames.push(...extra.frames); + } + if (meta.navigated && meta.returnUrl) { + await page.goto(meta.returnUrl, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(1500); + } else { + await closeScanOverlays(page); + } + } + scan.elements.forEach((el, i) => { + el.index = i + 1; + }); + scan.elementCount = scan.elements.length; + + delete scan.skipRescan; + delete scan.navigated; + delete scan.returnUrl; + scan.url = sanitizeUrl(scan.url); for (const el of scan.elements) { if (el.frame) el.frame = frameLabelFromUrl(el.frame); diff --git a/scripts/scan-surgery-batch.mjs b/scripts/scan-surgery-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..78a3a1449715a0f71ce25dbf8564064499f88e40 --- /dev/null +++ b/scripts/scan-surgery-batch.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 手术套包设置 (3 pages) from UI-SPEC SURG-001~003. + * Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml. + * + * Usage: + * node scripts/scan-surgery-batch.mjs + * node scripts/scan-surgery-batch.mjs surgery-base-pkg surgery-oper-type + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { chromium } from 'playwright'; + +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); +const URL_MAP_OUT = path.join(OUT_DIR, 'surgery-menu-urls.json'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const MENUS = [ + { pageKey: 'surgery-base-pkg', menu: '基础包维护', pageTitle: '基础设置 > 手术套包设置 > 基础包维护', specId: 'SURG-001' }, + { pageKey: 'surgery-oper-type', menu: '术种维护', pageTitle: '基础设置 > 手术套包设置 > 术种维护', specId: 'SURG-002' }, + { pageKey: 'surgery-std-pkg', menu: '标准套包维护', pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', specId: 'SURG-003' }, +]; + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await Promise.all([ + page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }), + page.getByRole('button', { name: /登\s*录/ }).click(), + ]).catch(async () => { + for (let i = 0; i < 60; i += 1) { + if (page.url().includes('/spd/home')) return; + await page.waitForTimeout(1000); + } + throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`); + }); + await page.waitForTimeout(1500); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +/** 展开 基础设置 > 手术套包设置 子菜单 */ +async function expandSurgeryMenu(page) { + await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 }); + await page.waitForTimeout(500); + const surgTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^手术套包设置$/ }).first(); + if (await surgTitle.isVisible().catch(() => false)) { + const expanded = await surgTitle + .locator('xpath=..') + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!expanded) await surgTitle.click({ timeout: 10_000 }); + await page.waitForTimeout(400); + } +} + +async function discoverMenuUrls(menus) { + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + storageState: AUTH_STATE, + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + }); + const page = await context.newPage(); + const urls = {}; + + for (const item of menus) { + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(1500); + if (page.url().includes('/login')) { + throw new Error('登录态失效,无法打开手术套包设置菜单'); + } + await expandSurgeryMenu(page); + await page + .locator('.ant-menu-item') + .filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }) + .first() + .click({ timeout: 20_000 }); + await page.waitForTimeout(2000); + const url = page.url().split('?')[0]; + if (url.includes('/login') || url.endsWith('/spd/home/index')) { + throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`); + } + urls[item.pageKey] = url; + console.log(`URL ${item.menu}: ${url}`); + } + + await browser.close(); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8'); + console.log('Wrote URL map:', URL_MAP_OUT); + return urls; +} + +async function openFirstDropdown(page) { + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(400); + return { dropdownOpened: true }; +} + +async function layerOpened(page) { + const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + if (modal) return true; + return page.locator('.put-page').first().isVisible().catch(() => false); +} + +async function openNamedButton(page, pattern, flag) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + await btn.click({ timeout: 12_000, force: true }); + await page + .locator('.ant-modal, .ant-drawer-open, .put-page') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + const opened = await layerOpened(page); + return { [flag]: opened, skipRescan: !opened }; +} + +async function openRowAction(page, pattern, flag) { + const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); + if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + let action = row.getByRole('button', { name: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) { + action = row.locator('a,button,span').filter({ hasText: pattern }).first(); + } + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + const before = page.url(); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(800); + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { [flag]: true, navigated: true, returnUrl: before }; + const opened = await layerOpened(page); + return { [flag]: Boolean(opened), skipRescan: !opened }; +} + +async function clickTab(page, name) { + const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first(); + if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true }; + await tab.click({ timeout: 8_000 }); + await page.waitForTimeout(600); + return { tabOpened: true }; +} + +/** 展开左侧分类树首个可展开节点,采集树中元素 */ +async function expandFirstTreeNode(page) { + const switcher = page + .locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-treenode .ant-tree-switcher_open') + .first(); + if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true }; + await switcher.click({ timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(600); + return { treeExpanded: true }; +} + +function pageCfg(targetUrl, item) { + return { + targetUrl, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + pageKey: item.pageKey, + pageTitle: item.pageTitle, + jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + jsonSourceName: `${item.pageKey}-elements.json`, + waitSelectors: ['.ant-tree', '.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'], + waitSelector: '.ant-tree, .ant-form, .ant-table, .ant-btn, .ant-tabs', + waitUntil: 'domcontentloaded', + expandFirstRow: false, + openAdvancedFilter: false, + interactivePhases: [ + openFirstDropdown, + expandFirstTreeNode, + (page) => clickTab(page, '停用'), + (page) => clickTab(page, '未建分类'), + (page) => openNamedButton(page, /新增基础包/, 'addBasePkgOpened'), + (page) => openNamedButton(page, /添加基础包/, 'addBasePkgOpened'), + (page) => openNamedButton(page, /添加散件/, 'addLoosePartOpened'), + (page) => openNamedButton(page, /新\s*增/, 'dialogOpened'), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /详\s*情/, 'detailOpened'), + (page) => openRowAction(page, /停\s*用/, 'disableDialogOpened'), + ], + }; +} + +async function main() { + const filter = process.argv.slice(2); + const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS; + if (!menus.length) { + console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + const urls = await discoverMenuUrls(menus); + const summary = []; + + for (const item of menus) { + const targetUrl = urls[item.pageKey]; + console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`); + try { + const result = await runScan(pageCfg(targetUrl, item)); + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: item.pageKey, + pageTitle: item.pageTitle, + module: 'spd-basic', + jsonSourceName: `${item.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + }); + summary.push({ + page: item.pageKey, + menu: item.menu, + specId: item.specId, + ok: true, + url: targetUrl, + ...result, + yamlOut: yaml.yamlOut, + }); + } catch (err) { + console.error(`FAILED ${item.pageKey}:`, err); + summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + writeFileSync( + path.join(OUT_DIR, 'surgery-scan-summary.json'), + JSON.stringify(summary, null, 2), + 'utf8', + ); + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scan-user-batch.mjs b/scripts/scan-user-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e9adb417861c6f6cdfc3d6b683ba5e093dc152bd --- /dev/null +++ b/scripts/scan-user-batch.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 用户管理 (3 pages) from UI-SPEC USER-001~003. + * 直接按 UI-SPEC 中已确认的页面路径打开(无需从菜单点进去,避免同名菜单歧义)。 + * + * 输出: + * data/scan/basic-user-elements.json 用户管理 + * data/scan/basic-user-union-elements.json 医共体用户管理 + * data/scan/basic-role-elements.json 角色管理 + * locators/spd/{pageKey}.locator.yaml 由 JSON 提炼的 Playwright 定位器 + * + * Usage: + * node scripts/scan-user-batch.mjs + * node scripts/scan-user-batch.mjs basic-user basic-role + * + * 只重算 YAML(不重新扫描): + * node scripts/json-to-locator-yaml.mjs data/scan/basic-user-elements.json + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); +const SUMMARY_OUT = path.join(OUT_DIR, 'user-scan-summary.json'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const COMMON_WAIT = [ + '.ant-form-item input', + '.ant-form-item .ant-select', + '.ant-form', + '.ant-table', + '.ant-btn', + '.ant-pagination', +]; + +async function closeOverlays(page) { + const footerCancel = page + .locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button') + .filter({ hasText: /取\s*消/ }) + .last(); + if (await footerCancel.isVisible().catch(() => false)) { + await footerCancel.click({ timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(400); + } + for (let i = 0; i < 3; i += 1) { + const popCancel = page + .locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button') + .filter({ hasText: /取\s*消/ }) + .last(); + if (await popCancel.isVisible().catch(() => false)) { + await popCancel.click({ timeout: 5_000 }).catch(() => undefined); + await page.waitForTimeout(300); + continue; + } + const modalClose = page + .locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close') + .last(); + if (await modalClose.isVisible().catch(() => false)) { + await modalClose.click({ timeout: 5_000 }).catch(() => undefined); + await page.waitForTimeout(400); + continue; + } + break; + } +} + +/** 打开页面上第一个筛选下拉,采集下拉面板内的选项元素 */ +async function openFirstFilterDropdown(page) { + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + return { dropdownOpened: true }; +} + +/** 展开「高级筛选」面板(用户管理 / 医共体用户管理) */ +async function openAdvancedFilter(page) { + await closeOverlays(page); + const btn = page.getByText(/高级筛选/).first(); + if (!(await btn.isVisible().catch(() => false))) return { advancedFilterOpened: false, skipRescan: true }; + await btn.click({ timeout: 10_000 }).catch(() => undefined); + await page.waitForTimeout(600); + return { advancedFilterOpened: true }; +} + +/** 打开「新增」弹窗(按标题断言,避免采到别的弹窗) */ +async function openAddModal(content, titleRe) { + await closeOverlays(content); + const btn = content.getByRole('button', { name: /新\s*增/ }).first(); + if (!(await btn.isVisible().catch(() => false))) return { dialogOpened: false, skipRescan: true }; + await btn.click({ timeout: 15_000, force: true }); + await content + .locator('.ant-modal, .ant-drawer-open') + .filter({ hasText: titleRe }) + .first() + .waitFor({ state: 'visible', timeout: 15_000 }) + .catch(() => undefined); + await content.waitForTimeout(800); + const opened = await content.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + return { dialogOpened: opened, skipRescan: !opened }; +} + +function firstDataRow(page) { + return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); +} + +/** 点击首行的某个行操作,记录是弹窗还是跳转 */ +async function openRowAction(page, pattern, flag) { + await closeOverlays(page); + const row = firstDataRow(page); + if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + let action = row.getByRole('button', { name: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) { + action = row.locator('a,button,span').filter({ hasText: pattern }).first(); + } + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + + const before = page.url(); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(900); + + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { [flag]: true, navigated: true, returnUrl: before }; + + const opened = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + return { [flag]: Boolean(opened), skipRescan: !opened }; +} + +/** 角色管理:点击左侧角色列表的首个角色,采集右侧权限树 */ +async function selectFirstRole(page) { + await closeOverlays(page); + const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); + const target = (await row.isVisible().catch(() => false)) + ? row.locator('td').first() + : page.locator('.ant-list-item, .role-list li').first(); + if (!(await target.isVisible().catch(() => false))) return { roleSelected: false, skipRescan: true }; + await target.click({ timeout: 10_000 }).catch(() => undefined); + await page.waitForTimeout(900); + return { roleSelected: true }; +} + +/** 角色管理:展开权限树的一个二级模块 */ +async function expandFirstPermissionNode(page) { + const switcher = page + .locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-switcher_open') + .first(); + if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true }; + await switcher.click({ timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(700); + return { treeExpanded: true }; +} + +function pageCfg(uiPath, rest) { + return { + targetUrl: `${BASE}${uiPath}`, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`), + jsonSourceName: `${rest.pageKey}-elements.json`, + waitSelectors: COMMON_WAIT, + waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form', + waitUntil: 'domcontentloaded', + expandFirstRow: false, + openAdvancedFilter: false, + skipQueryClick: true, + ...rest, + }; +} + +const PAGES = [ + pageCfg('/spd/basic/user/platform/person', { + pageKey: 'basic-user', + pageTitle: '基础设置 > 用户管理 > 用户管理', + specId: 'USER-001', + interactivePhases: [ + openFirstFilterDropdown, + openAdvancedFilter, + (page) => openAddModal(page, /新增用户|新增/), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /授\s*权/, 'authDialogOpened'), + ], + }), + pageCfg('/spd/basic/user/platform/personCom', { + pageKey: 'basic-user-union', + pageTitle: '基础设置 > 用户管理 > 医共体用户管理', + specId: 'USER-002', + interactivePhases: [ + openFirstFilterDropdown, + openAdvancedFilter, + (page) => openAddModal(page, /新增/), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /授\s*权/, 'authDialogOpened'), + ], + }), + pageCfg('/spd/basic/user/platform/role', { + pageKey: 'basic-role', + pageTitle: '基础设置 > 用户管理 > 角色管理', + specId: 'USER-003', + interactivePhases: [ + selectFirstRole, + expandFirstPermissionNode, + (page) => openAddModal(page, /新增/), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + ], + }), +]; + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await page.waitForURL(/\/spd\/home/, { timeout: 120_000 }); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +async function main() { + const filter = process.argv.slice(2); + const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES; + if (!pages.length) { + console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + const summary = []; + + for (const item of pages) { + console.log(`\n=== Scanning ${item.pageKey} ${item.targetUrl} ===`); + try { + const result = await runScan(item); + // 扫描阶段会顺带写一份 YAML;这里再按落盘 JSON 提炼一次,确保 YAML 只由 JSON 决定 + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: item.pageKey, + pageTitle: item.pageTitle, + module: 'spd-basic', + jsonSourceName: `${item.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + }); + summary.push({ + page: item.pageKey, + specId: item.specId, + ok: true, + url: item.targetUrl, + elementCount: result.elementCount, + jsonOut: result.jsonOut, + yamlOut: yaml.yamlOut, + }); + } catch (err) { + console.error(`FAILED ${item.pageKey}:`, err); + summary.push({ page: item.pageKey, specId: item.specId, ok: false, error: String(err) }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8'); + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scan-vendor-batch.mjs b/scripts/scan-vendor-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..84b4985c3de7247a374b2d30c1a01ac92e10d515 --- /dev/null +++ b/scripts/scan-vendor-batch.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 厂商管理 (10 pages) from UI-SPEC VENDOR-001~010. + * Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml. + * + * Usage: + * node scripts/scan-vendor-batch.mjs + * node scripts/scan-vendor-batch.mjs vendor-query vendor-qual + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { chromium } from 'playwright'; + +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); +const URL_MAP_OUT = path.join(OUT_DIR, 'vendor-menu-urls.json'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const MENUS = [ + { pageKey: 'vendor-query', menu: '厂商查询', pageTitle: '基础设置 > 厂商管理 > 厂商查询', specId: 'VENDOR-001' }, + { pageKey: 'vendor-qual', menu: '供应商资质管理', pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', specId: 'VENDOR-002' }, + { pageKey: 'vendor-auth-first', menu: '授权变更初审', pageTitle: '基础设置 > 厂商管理 > 授权变更初审', specId: 'VENDOR-003' }, + { pageKey: 'vendor-auth-final', menu: '授权变更终审', pageTitle: '基础设置 > 厂商管理 > 授权变更终审', specId: 'VENDOR-004' }, + { pageKey: 'vendor-cert-first', menu: '注册证变更初审', pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', specId: 'VENDOR-005' }, + { pageKey: 'vendor-cert-final', menu: '注册证变更终审', pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', specId: 'VENDOR-006' }, + { pageKey: 'vendor-cert-diff', menu: '注册证差异对比', pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', specId: 'VENDOR-007' }, + { pageKey: 'vendor-eval-scheme', menu: '评价方案维护', pageTitle: '基础设置 > 厂商管理 > 评价方案维护', specId: 'VENDOR-008' }, + { pageKey: 'vendor-eval-indicator', menu: '评价指标维护', pageTitle: '基础设置 > 厂商管理 > 评价指标维护', specId: 'VENDOR-009' }, + { pageKey: 'vendor-holiday', menu: '供应商节假日管理', pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', specId: 'VENDOR-010' }, +]; + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await Promise.all([ + page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }), + page.getByRole('button', { name: /登\s*录/ }).click(), + ]).catch(async () => { + // 部分环境导航事件不触发 waitForURL,轮询兜底 + for (let i = 0; i < 60; i += 1) { + if (page.url().includes('/spd/home')) return; + await page.waitForTimeout(1000); + } + throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`); + }); + await page.waitForTimeout(1500); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +async function expandVendorMenu(page) { + await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 }); + await page.waitForTimeout(500); + const vendorTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^厂商管理$/ }).first(); + if (await vendorTitle.isVisible().catch(() => false)) { + const expanded = await vendorTitle + .locator('xpath=..') + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!expanded) await vendorTitle.click({ timeout: 10_000 }); + } +} + +async function discoverMenuUrls(menus) { + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + storageState: AUTH_STATE, + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + }); + const page = await context.newPage(); + const urls = {}; + + for (const item of menus) { + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(1500); + if (page.url().includes('/login')) { + throw new Error('登录态失效,无法打开厂商管理菜单'); + } + await expandVendorMenu(page); + await page + .locator('.ant-menu-item') + .filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }) + .first() + .click({ timeout: 20_000 }); + await page.waitForTimeout(2000); + const url = page.url().split('?')[0]; + if (url.includes('/login') || url.endsWith('/spd/home/index')) { + throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`); + } + urls[item.pageKey] = url; + console.log(`URL ${item.menu}: ${url}`); + } + + await browser.close(); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8'); + console.log('Wrote URL map:', URL_MAP_OUT); + return urls; +} + +async function openFirstDropdown(page) { + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(400); + return { dropdownOpened: true }; +} + +async function layerOpened(page) { + const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + if (modal) return true; + return page.locator('.put-page').first().isVisible().catch(() => false); +} + +async function openNamedButton(page, pattern, flag) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + await btn.click({ timeout: 12_000, force: true }); + await page + .locator('.ant-modal, .ant-drawer-open, .put-page') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + const opened = await layerOpened(page); + return { [flag]: opened, skipRescan: !opened }; +} + +async function openRowAction(page, pattern, flag) { + const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); + if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + let action = row.getByRole('button', { name: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) { + action = row.locator('a,button,span').filter({ hasText: pattern }).first(); + } + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + const before = page.url(); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(800); + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { [flag]: true, navigated: true, returnUrl: before }; + const opened = await layerOpened(page); + return { [flag]: Boolean(opened), skipRescan: !opened }; +} + +async function clickTab(page, name) { + const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first(); + if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true }; + await tab.click({ timeout: 8_000 }); + await page.waitForTimeout(600); + return { tabOpened: true }; +} + +function pageCfg(targetUrl, item) { + return { + targetUrl, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + pageKey: item.pageKey, + pageTitle: item.pageTitle, + jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + jsonSourceName: `${item.pageKey}-elements.json`, + waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'], + waitSelector: '.ant-form, .ant-table, .ant-btn, .ant-tabs', + waitUntil: 'domcontentloaded', + expandFirstRow: false, + openAdvancedFilter: false, + interactivePhases: [ + openFirstDropdown, + (page) => clickTab(page, '经销商'), + (page) => clickTab(page, '授权链路效期预警'), + (page) => clickTab(page, '不一致'), + (page) => openNamedButton(page, /新\s*增/, 'dialogOpened'), + (page) => openNamedButton(page, /导\s*出/, 'exportOpened'), + (page) => openRowAction(page, /详\s*情/, 'detailOpened'), + (page) => openRowAction(page, /查\s*看/, 'viewOpened'), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /日\s*志/, 'logOpened'), + ], + }; +} + +async function main() { + const filter = process.argv.slice(2); + const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS; + if (!menus.length) { + console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + const urls = await discoverMenuUrls(menus); + const summary = []; + + for (const item of menus) { + const targetUrl = urls[item.pageKey]; + console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`); + try { + const result = await runScan(pageCfg(targetUrl, item)); + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: item.pageKey, + pageTitle: item.pageTitle, + module: 'spd-basic', + jsonSourceName: `${item.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + }); + summary.push({ + page: item.pageKey, + menu: item.menu, + specId: item.specId, + ok: true, + url: targetUrl, + ...result, + yamlOut: yaml.yamlOut, + }); + } catch (err) { + console.error(`FAILED ${item.pageKey}:`, err); + summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + writeFileSync( + path.join(OUT_DIR, 'vendor-scan-summary.json'), + JSON.stringify(summary, null, 2), + 'utf8', + ); + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/scan-wh-flow-batch.mjs b/scripts/scan-wh-flow-batch.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8269f57bc05849e79b37823d9f79940ffbb66dbe --- /dev/null +++ b/scripts/scan-wh-flow-batch.mjs @@ -0,0 +1,475 @@ +#!/usr/bin/env node +/** + * Batch scan: 基础设置 > 库房管理 / 流程设置 (UI-SPEC WH-001~002, FLOW-001~002). + * 先按菜单点击解析真实 URL,再扫描交互元素。 + * + * 输出: + * data/scan/warehouse-zone-elements.json 库区设置 + * data/scan/warehouse-probe-elements.json 探针绑定 + * data/scan/flow-def-elements.json 审批流程设置 + * data/scan/flow-inst-elements.json 审批流实例 + * locators/spd/{pageKey}.locator.yaml + * + * Usage: + * node scripts/scan-wh-flow-batch.mjs + * node scripts/scan-wh-flow-batch.mjs warehouse-zone warehouse-probe + * node scripts/scan-wh-flow-batch.mjs flow-def flow-inst + * + * 只重算 YAML(不重新扫描): + * node scripts/json-to-locator-yaml.mjs --all-warehouse --all-flow + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import './playwright-browsers-path.mjs'; +import { chromium } from 'playwright'; + +import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const AUTH_STATE = path.join(ROOT, 'auth/user.json'); +const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080'; +const OUT_DIR = path.join(ROOT, 'data/scan'); +const LOC_DIR = path.join(ROOT, 'locators/spd'); +const URL_MAP_OUT = path.join(OUT_DIR, 'wh-flow-menu-urls.json'); +const SUMMARY_OUT = path.join(OUT_DIR, 'wh-flow-scan-summary.json'); + +if (existsSync(path.join(ROOT, '.env'))) { + process.loadEnvFile(path.join(ROOT, '.env')); +} + +const COMMON_WAIT = [ + '.ant-form-item input', + '.ant-form-item .ant-select', + '.ant-form', + '.ant-table', + '.ant-btn', + '.ant-pagination', + '.ant-list-item', + '.ant-menu-item', +]; + +const MENUS = [ + { + pageKey: 'warehouse-zone', + group: '库房管理', + menu: '库区设置', + pageTitle: '基础设置 > 库房管理 > 库区设置', + specId: 'WH-001', + skipQueryClick: true, + interactivePhases: [ + selectLeftFirstItem, + openFirstFilterDropdown, + (page) => openNamedButton(page, /新\s*增/, 'dialogOpened'), + (page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'), + (page) => openRowAction(page, /人员管理/, 'staffDialogOpened'), + (page) => openRowAction(page, /货位管理/, 'locationDialogOpened'), + ], + }, + { + pageKey: 'warehouse-probe', + group: '库房管理', + menu: '探针绑定', + pageTitle: '基础设置 > 库房管理 > 探针绑定', + specId: 'WH-002', + skipQueryClick: false, + interactivePhases: [ + openFirstFilterDropdown, + (page) => openRowAction(page, /绑定货位/, 'bindDialogOpened'), + openModalFirstDropdown, + ], + }, + { + pageKey: 'flow-def', + group: '流程设置', + menu: '审批流程设置', + pageTitle: '基础设置 > 流程设置 > 审批流程设置', + specId: 'FLOW-001', + skipQueryClick: true, + interactivePhases: [ + selectLeftFirstItem, + openFirstFilterDropdown, + (page) => openNamedButton(page, /新增流程/, 'addFlowClicked'), + (page) => openInlineEdit(page, /编\s*辑/, 'conditionEditorOpened'), + ], + }, + { + pageKey: 'flow-inst', + group: '流程设置', + menu: '审批流实例', + pageTitle: '基础设置 > 流程设置 > 审批流实例', + specId: 'FLOW-002', + skipQueryClick: false, + interactivePhases: [ + openFirstFilterDropdown, + openSecondFilterDropdown, + openFlowDetail, + ], + }, +]; + +async function closeOverlays(page) { + await page.keyboard.press('Escape').catch(() => undefined); + await page.waitForTimeout(200); + const footerCancel = page + .locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button') + .filter({ hasText: /取\s*消|关\s*闭/ }) + .last(); + if (await footerCancel.isVisible().catch(() => false)) { + await footerCancel.click({ timeout: 8_000 }).catch(() => undefined); + await page.waitForTimeout(400); + } + for (let i = 0; i < 3; i += 1) { + const popCancel = page + .locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button') + .filter({ hasText: /取\s*消/ }) + .last(); + if (await popCancel.isVisible().catch(() => false)) { + await popCancel.click({ timeout: 5_000 }).catch(() => undefined); + await page.waitForTimeout(300); + continue; + } + const modalClose = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last(); + if (await modalClose.isVisible().catch(() => false)) { + await modalClose.click({ timeout: 5_000 }).catch(() => undefined); + await page.waitForTimeout(400); + continue; + } + break; + } +} + +function firstDataRow(page) { + return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first(); +} + +async function layerOpened(page) { + const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false); + if (modal) return true; + return page.locator('.put-page').first().isVisible().catch(() => false); +} + +async function openFirstFilterDropdown(page) { + await closeOverlays(page); + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + return { dropdownOpened: true }; +} + +async function openSecondFilterDropdown(page) { + await closeOverlays(page); + const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').nth(1); + if (!(await select.isVisible().catch(() => false))) return { secondDropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 10_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(400); + return { secondDropdownOpened: true }; +} + +async function openModalFirstDropdown(page) { + const modal = page.locator('.ant-modal:visible, .ant-drawer-open').first(); + if (!(await modal.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true }; + const select = modal.locator('.ant-select').first(); + if (!(await select.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true }; + await select.click({ timeout: 8_000 }); + await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(400); + return { modalDropdownOpened: true }; +} + +async function selectLeftFirstItem(page) { + await closeOverlays(page); + const candidates = [ + page.locator('.ant-tree .ant-tree-title').first(), + page.locator('.ant-menu-inline .ant-menu-item:not(.ant-menu-item-selected)').first(), + page.locator('.ant-list-item').first(), + page.locator('.ant-table-tbody tr.ant-table-row td').first(), + ]; + for (const loc of candidates) { + if (await loc.isVisible().catch(() => false)) { + await loc.click({ timeout: 10_000 }).catch(() => undefined); + await page.waitForTimeout(800); + return { leftItemSelected: true }; + } + } + return { leftItemSelected: false, skipRescan: true }; +} + +async function openNamedButton(page, pattern, flag) { + await closeOverlays(page); + const btn = page.getByRole('button', { name: pattern }).first(); + if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + await btn.click({ timeout: 12_000, force: true }); + await page + .locator('.ant-modal, .ant-drawer-open, .put-page') + .first() + .waitFor({ state: 'visible', timeout: 8_000 }) + .catch(() => undefined); + await page.waitForTimeout(600); + const opened = await layerOpened(page); + return { [flag]: opened, skipRescan: !opened }; +} + +async function openRowAction(page, pattern, flag) { + await closeOverlays(page); + const row = firstDataRow(page); + if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + let action = row.getByRole('button', { name: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) { + action = row.locator('a,button,span').filter({ hasText: pattern }).first(); + } + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + + const before = page.url(); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(1600); + + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { [flag]: true, navigated: true, returnUrl: before }; + + const opened = await layerOpened(page); + return { [flag]: Boolean(opened), skipRescan: !opened }; +} + +/** 审批流实例「详情」多为链接/文字按钮,且可能被下拉遮挡 */ +async function openFlowDetail(page) { + await closeOverlays(page); + const row = firstDataRow(page); + const candidates = [ + row.getByRole('button', { name: /详\s*情/ }).first(), + row.getByRole('link', { name: /详\s*情/ }).first(), + row.locator('a,button,span').filter({ hasText: /详\s*情/ }).first(), + page.locator('.ant-table-tbody a, .ant-table-tbody button, .ant-table-tbody span').filter({ hasText: /详\s*情/ }).first(), + ]; + let action = null; + for (const loc of candidates) { + if (await loc.isVisible().catch(() => false)) { + action = loc; + break; + } + } + if (!action) return { detailOpened: false, skipRescan: true }; + const before = page.url(); + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(1600); + const moved = page.url() !== before && !page.url().includes('/login'); + if (moved) return { detailOpened: true, navigated: true, returnUrl: before }; + const opened = await layerOpened(page); + return { detailOpened: Boolean(opened), skipRescan: !opened }; +} + +async function openInlineEdit(page, pattern, flag) { + await closeOverlays(page); + const action = page.locator('a,button,span').filter({ hasText: pattern }).first(); + if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true }; + await action.click({ timeout: 8_000, force: true }); + await page.waitForTimeout(700); + const opened = await layerOpened(page); + const picker = await page + .locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden), .ant-picker-dropdown, .ant-popover-open') + .first() + .isVisible() + .catch(() => false); + return { [flag]: opened || picker, skipRescan: !(opened || picker) }; +} + +async function ensureAuthStorage() { + mkdirSync(path.dirname(AUTH_STATE), { recursive: true }); + const user = process.env.TEST_USER; + const pass = process.env.TEST_PASS; + if (!user || !pass) { + throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); + } + + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined, + }); + const page = await context.newPage(); + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(2000); + if (!page.url().includes('/login')) { + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Auth state OK:', AUTH_STATE); + return; + } + + await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user); + await page.locator('#password, input[type="password"]').first().fill(pass); + await Promise.all([ + page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }), + page.getByRole('button', { name: /登\s*录/ }).click(), + ]).catch(async () => { + for (let i = 0; i < 60; i += 1) { + if (page.url().includes('/spd/home')) return; + await page.waitForTimeout(1000); + } + throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`); + }); + await page.waitForTimeout(1500); + await context.storageState({ path: AUTH_STATE }); + await browser.close(); + console.log('Logged in and saved auth:', AUTH_STATE); +} + +async function expandGroupMenu(page, groupName) { + await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 }); + await page.waitForTimeout(500); + const title = page.locator('.ant-menu-submenu-title').filter({ hasText: new RegExp(`^${groupName}$`) }).first(); + if (await title.isVisible().catch(() => false)) { + const expanded = await title + .locator('xpath=..') + .evaluate((el) => el.classList.contains('ant-menu-submenu-open')) + .catch(() => false); + if (!expanded) await title.click({ timeout: 10_000 }); + await page.waitForTimeout(400); + } +} + +async function discoverMenuUrls(menus) { + const browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const context = await browser.newContext({ + storageState: AUTH_STATE, + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + }); + const page = await context.newPage(); + const urls = {}; + + for (const item of menus) { + await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.waitForTimeout(1500); + if (page.url().includes('/login')) { + throw new Error(`登录态失效,无法打开${item.group}菜单`); + } + await expandGroupMenu(page, item.group); + await page + .locator('.ant-menu-item') + .filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }) + .first() + .click({ timeout: 20_000 }); + await page.waitForTimeout(2000); + const url = page.url().split('?')[0]; + if (url.includes('/login') || url.endsWith('/spd/home/index')) { + throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`); + } + urls[item.pageKey] = url; + console.log(`URL ${item.menu}: ${url}`); + } + + await browser.close(); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8'); + console.log('Wrote URL map:', URL_MAP_OUT); + return urls; +} + +function pageCfg(targetUrl, item) { + return { + targetUrl, + useIframe: false, + iframeMatch: '', + iframeLabel: 'main', + module: 'spd-basic', + pageKey: item.pageKey, + pageTitle: item.pageTitle, + jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`), + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + jsonSourceName: `${item.pageKey}-elements.json`, + waitSelectors: COMMON_WAIT, + waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form, .ant-list-item', + waitUntil: 'domcontentloaded', + expandFirstRow: false, + openAdvancedFilter: false, + skipQueryClick: item.skipQueryClick === true, + interactivePhases: item.interactivePhases, + }; +} + +async function main() { + const filter = process.argv.slice(2); + const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS; + if (!menus.length) { + console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', ')); + process.exit(1); + } + + await ensureAuthStorage(); + const urls = await discoverMenuUrls(menus); + const summary = []; + + for (const item of menus) { + const targetUrl = urls[item.pageKey]; + console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`); + try { + const result = await runScan(pageCfg(targetUrl, item)); + const yaml = writeYamlFromScanJson(result.jsonOut, { + pageKey: item.pageKey, + pageTitle: item.pageTitle, + module: 'spd-basic', + jsonSourceName: `${item.pageKey}-elements.json`, + iframeMatch: '', + yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`), + }); + summary.push({ + page: item.pageKey, + menu: item.menu, + specId: item.specId, + ok: true, + url: targetUrl, + elementCount: result.elementCount, + jsonOut: result.jsonOut, + yamlOut: yaml.yamlOut, + }); + } catch (err) { + console.error(`FAILED ${item.pageKey}:`, err); + summary.push({ + page: item.pageKey, + menu: item.menu, + specId: item.specId, + ok: false, + error: String(err), + }); + } + } + + console.log('\n========== SUMMARY =========='); + for (const row of summary) { + if (row.ok) { + console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`); + console.log(` JSON: ${row.jsonOut}`); + console.log(` YAML: ${row.yamlOut}`); + } else { + console.log(`✗ ${row.page}: ${row.error}`); + } + } + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8'); + if (summary.some((r) => !r.ok)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/start-full-run-detached.sh b/scripts/start-full-run-detached.sh new file mode 100755 index 0000000000000000000000000000000000000000..3e215eebb40a27cd94ed4c7beb36a648ebd7ec44 --- /dev/null +++ b/scripts/start-full-run-detached.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# 脱离终端启动全量 CI;caffeinate 防止锁屏/空闲触发系统休眠中断用例 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAMP="$(date +%Y%m%d-%H%M%S)" +LOG="${ROOT}/reports/full-run-${STAMP}.log" +PID_FILE="${ROOT}/reports/full-run.pid" +CAFF_PID_FILE="${ROOT}/reports/caffeinate-full.pid" + +mkdir -p "${ROOT}/reports" +export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${PATH:-}" + +nohup /usr/bin/caffeinate -dims /bin/bash -c " + set -euo pipefail + cd \"${ROOT}\" + export CI=1 + export PW_WORKERS=\"${PW_WORKERS:-2}\" + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] detached 全量 (caffeinate): PW_WORKERS=\$PW_WORKERS npm run test:ci:full\" + npm run test:ci:full + EC=\$? + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] 结束 exit=\$EC\" + exit \$EC +" >> "$LOG" 2>&1 < /dev/null & + +echo $! > "$PID_FILE" +# 外层 bash 会立刻退出;记录 caffeinate 父进程需从 log/ pgrep 查看。另写 wait 包装: +if pgrep -f "caffeinate -dims /bin/bash -c" >/dev/null 2>&1; then + pgrep -f "caffeinate -dims /bin/bash -c" | head -1 > "$CAFF_PID_FILE" || true +fi + +echo "LOG=$LOG" +echo "PID=$(cat "$PID_FILE")" +echo "说明: 已用 caffeinate -dims,锁屏不影响;请勿合盖休眠/关机。" diff --git a/scripts/sync-docs-ui-specs.py b/scripts/sync-docs-ui-specs.py index 6b3f11885b22adeb3d65d840cdc7805391dcac7c..abe015dc7101a2db839755460083d48e27fc3544 100644 --- a/scripts/sync-docs-ui-specs.py +++ b/scripts/sync-docs-ui-specs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""将 docs/统计报表-* 截图同步至 knowledge-base/08-UI专项规范库。""" +"""将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、用户、库房、流程等)。""" from __future__ import annotations @@ -13,7 +13,7 @@ DOCS = ROOT / "docs" KB = ROOT / "knowledge-base" / "08-UI专项规范库" TODAY = date.today().isoformat() -PAGES: list[dict] = [ +STAT_PAGES: list[dict] = [ # 对账报表 {"id": "RECON-001", "group": "对账报表", "name": "科室对账查询", "folder": "统计报表-对账报表-科室对账查询"}, {"id": "RECON-002", "group": "对账报表", "name": "科室净入汇总表", "folder": "统计报表-对账报表-科室净入汇总表"}, @@ -79,12 +79,716 @@ PAGES: list[dict] = [ {"id": "REAG-001", "group": "试剂批号统计", "name": "试剂批号统计明细", "folder": "统计报表-试剂批号统计-试剂批号统计明细"}, ] +# 基础设置 → 准入管理(docs/基础管理-准入管理-*) +ADMIT_PAGES: list[dict] = [ + { + "id": "ADMIT-001", + "name": "物料准入管理", + "folder": "基础管理-准入管理-物料准入管理", + "filters": "新增日期(区间)、进院供应商、耗材类型、产品名称、规格型号、注册证号、器械注册人", + "status_tabs": "全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效", + "toolbar": "**关联产品**、**生成申请单**、**一键生成**、**作废**;列设置", + "list_note": "含准入类型(新品入院/产品增规/C码变更等)、产品编号/名称、规格型号、器械注册人、耗材类型、默认进院供应商;行操作 **详情**、**日志**、**编辑**(视状态)", + }, + { + "id": "ADMIT-002", + "name": "产品准入申请", + "folder": "基础管理-准入管理-产品准入申请", + "filters": "以截图为准(常见:日期、供应商、产品/规格、单号等)", + "status_tabs": "以截图状态 Tab 为准", + "toolbar": "以截图工具栏为准", + "list_note": "产品准入申请列表与行内操作以截图为准", + }, + { + "id": "ADMIT-003", + "name": "物料准入单列表", + "folder": "基础管理-准入管理-物料准入单列表", + "filters": "以截图为准", + "status_tabs": "以截图状态 Tab 为准", + "toolbar": "以截图工具栏为准", + "list_note": "物料准入单列表、详情/审批相关操作以截图为准", + }, + { + "id": "ADMIT-004", + "name": "产品准入单列表", + "folder": "基础管理-准入管理-产品准入单列表", + "filters": "以截图为准", + "status_tabs": "以截图状态 Tab 为准", + "toolbar": "以截图工具栏为准", + "list_note": "产品准入单列表与行内操作以截图为准", + }, + { + "id": "ADMIT-005", + "name": "供货目录申请", + "folder": "基础管理-准入管理-供货目录申请", + "filters": "制单日期(区间)、进院供应商、二级供应商、变更单号、产品名称、规格型号", + "status_tabs": "全部、待提交、审批中、已通过、已驳回", + "toolbar": "**批量提交**、**新增**;列设置", + "list_note": "申请单号、申请人、申请时间、进院/二级供应商、业务模式、是否默认、备注;行操作 **编辑**、**详情**", + }, + { + "id": "ADMIT-006", + "name": "准入任务分配", + "folder": "基础管理-准入管理-准入任务分配", + "filters": "以截图为准", + "status_tabs": "以截图为准(若有)", + "toolbar": "以截图工具栏为准", + "list_note": "准入任务分配列表与分配操作以截图为准", + }, +] + +# 基础设置 → 厂商管理(docs/基础管理-厂商管理-*) +VENDOR_PAGES: list[dict] = [ + { + "id": "VENDOR-001", + "name": "厂商查询", + "folder": "基础管理-厂商管理-厂商查询", + "filters": "厂商名称(搜索框)", + "status_tabs": "器械注册人、经销商", + "toolbar": "搜索、列设置、刷新/导出(以截图图标为准)", + "list_note": "列:序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号;底部分页", + "extra_imgs": 1, + }, + { + "id": "VENDOR-002", + "name": "供应商资质管理", + "folder": "基础管理-厂商管理-供应商资质管理", + "filters": "随证件 Tab 变化(如进院供应商名称、证件类型;授权链路:授权书名称/授权单位/被授权单位)", + "status_tabs": ( + "证件 Tab:证件效期提醒、授权链路效期预警、供应商三证、器械注册人、器械注册证号、" + "授权书、承诺书、法人委托书、二级供应商三证;效期快捷:近三个月到期、近一个月到期、已过期(带计数)" + ), + "toolbar": "**导出** / **导出明细**(视 Tab);列设置", + "list_note": "证件效期提醒列含数据来源、进院供应商、有效期至、证件名称/类型、效期提醒、查看;授权链路预警列含授权单位、授权书名称、被授权单位、效期起止、效期提醒、查看", + "extra_imgs": 8, + }, + { + "id": "VENDOR-003", + "name": "授权变更初审", + "folder": "基础管理-厂商管理-授权变更初审", + "filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商、医院ID、医院名称", + "status_tabs": "无独立状态 Tab(状态下拉筛选)", + "toolbar": "查询、重置、收起/展开", + "list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商;行操作 **详情**", + }, + { + "id": "VENDOR-004", + "name": "授权变更终审", + "folder": "基础管理-厂商管理-授权变更终审", + "filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商", + "status_tabs": "无独立状态 Tab(状态下拉筛选)", + "toolbar": "查询、重置、收起/展开", + "list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原/预转供应商;行操作 **详情**", + }, + { + "id": "VENDOR-005", + "name": "注册证变更初审", + "folder": "基础管理-厂商管理-注册证变更初审", + "filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态、变更情况具体说明", + "status_tabs": "无独立状态 Tab(状态下拉筛选)", + "toolbar": "查询、重置、收起/展开", + "list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**", + }, + { + "id": "VENDOR-006", + "name": "注册证变更终审", + "folder": "基础管理-厂商管理-注册证变更终审", + "filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态", + "status_tabs": "无独立状态 Tab(状态下拉筛选)", + "toolbar": "查询、重置、收起/展开", + "list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**", + }, + { + "id": "VENDOR-007", + "name": "注册证差异对比", + "folder": "基础管理-厂商管理-注册证差异对比", + "filters": ( + "物料名称、规格型号、状态、望海注册证号、是否医疗器械、生产厂家、FLI+注册证号、" + "27位医保编码;可选「包含FLI+长期有效数据」" + ), + "status_tabs": "全部、一致、不一致", + "toolbar": "**导出**;数据更新时间 + **立即更新**;列设置", + "list_note": ( + "对比望海 vs FLI+:物料编号/名称、规格型号、生产厂家、27位医保编码、" + "两侧注册证号/生效日期/有效期至;宽表横向滚动" + ), + }, + { + "id": "VENDOR-008", + "name": "评价方案维护", + "folder": "基础管理-厂商管理-评价方案维护", + "filters": "创建日期(区间)、启用日期(区间)、方案编码、方案名称", + "status_tabs": "无", + "toolbar": "**新增**;列设置", + "list_note": "列:方案编码、方案名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作", + }, + { + "id": "VENDOR-009", + "name": "评价指标维护", + "folder": "基础管理-厂商管理-评价指标维护", + "filters": "左侧指标分类树搜索;右侧:指标编码、指标名称", + "status_tabs": "无(左树 + 右列表)", + "toolbar": "左树 **新增**;右表 **新增**;列设置", + "list_note": "左:指标分类树(可编辑);右列:评价类型、指标编码、指标名称、状态;行操作 **编辑**、**详情**、**日志**", + }, + { + "id": "VENDOR-010", + "name": "供应商节假日管理", + "folder": "基础管理-厂商管理-供应商节假日管理", + "filters": "左侧:名称/年度搜索", + "status_tabs": "无(左列表 + 右详情)", + "toolbar": "左:**新增**;右:供应商自定义列表 **新增**、**批量删除**", + "list_note": ( + "左:供应商节假日设置(年度、名称、编辑);右上:默认假日详情(名称/年度/起止/创建人时间);" + "右下:供应商自定义节假日(进院供应商编码/名称、起止、备注、创建人时间、操作)" + ), + }, +] + +# 基础设置 → 用户管理(docs/基础管理-用户管理-*) +USER_PAGES: list[dict] = [ + { + "id": "USER-001", + "name": "用户管理", + "folder": "基础管理-用户管理-用户管理", + "path": "/spd/basic/user/platform/person", + "tab_title": "用户管理", + "layout": "整页单表:顶部筛选区 + 工具栏 + 用户列表 + 底部分页(无左侧组织树)", + "filters": [ + ("姓名", "文本", "占位「姓名、登录账号」"), + ("科室", "下拉(可搜索)", "请选择"), + ("角色", "下拉(可搜索)", "请选择"), + ], + "actions": "**重置**、**高级筛选**(`下高级筛选` 收起/展开);本页截图未见独立「查询」按钮", + "toolbar": "页签右侧 **新增用户** 图标按钮(按实际文案定位)", + "columns": [ + ("序号", "行号(可勾选)"), + ("姓名", "用户真实姓名"), + ("登录账号", "登录用户名"), + ("联系方式", "手机号/电话,可能为非法占位值(如 `1`、`0922`)"), + ("所属科室", "科室名称,未分配显示 `--`"), + ("医院员工编号", "工号,未填显示 `--`"), + ("角色", "已分配角色,多角色截断显示(如 `内网医院初始化模板角色 (…`)"), + ("科室任职数", "该用户在各科室的任职条数,无则 `--`"), + ("操作", "**编辑**、**授权**、**重置密码**"), + ], + "row_ops": "**编辑**、**授权**、**重置密码**", + "paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**", + "scan_key": "basic-user", + "po_hint": "pages/spd/basic-user.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '用户管理', exact: true }).click()` 存在歧义(一级菜单名同为「用户管理」),建议直接 `openPath('/spd/basic/user/platform/person')` 并断言 URL。", + "本页**无「所属医院」筛选/列**;与医共体用户管理页列顺序不同,勿复用同一套列断言。", + "行操作 **授权**、**重置密码** 均为写操作,须先确认当前行。", + ], + "points": [ + "姓名/科室/角色组合筛选后列表条数与「共 N 条」一致;重置后恢复默认。", + "「角色」多角色截断显示(如 `内网医院初始化模板角色 (…`),编辑页应能查看全部角色。", + "「科室任职数」与用户在各科室任职记录条数一致,无任职显示 `--`。", + "新增/编辑:必填为空拦截;取消不保存;编辑回填与原值一致。", + "授权弹窗保存后重新打开应保留勾选;取消不生效。", + ], + }, + { + "id": "USER-002", + "name": "医共体用户管理", + "folder": "基础管理-用户管理-医共体用户管理", + "path": "/spd/basic/user/platform/personCom", + "tab_title": "医共体用户管理", + "layout": "整页单表,结构与「用户管理」一致,差异在引入**所属医院**维度(医共体多院区统一查看)", + "filters": [ + ("姓名", "文本", "占位「姓名、登录账号」"), + ("所属医院", "下拉(可搜索)", "默认已带医院,如 `邹城市人民医院(test)`"), + ("联系方式", "文本", "占位「联系方式」"), + ], + "actions": "**重置**、**高级筛选**;本页截图未见独立「查询」按钮", + "toolbar": "页签右侧 **新增** 图标按钮(按实际文案定位)", + "columns": [ + ("序号", "行号(可勾选)"), + ("登录账号", "列宽较窄,长账号截断显示(如 `zhon.…`、`jiang.…`)"), + ("姓名", "用户真实姓名"), + ("联系方式", "手机号/电话,可能为非法占位值"), + ("所属医院", "医共体下所属医院,如 `邹城市人民医院(test)`"), + ("所属科室", "科室名称,未分配显示 `--`"), + ("医院员工编号", "工号,未填 `--`"), + ("角色", "已分配角色,多角色截断显示"), + ("科室任职数", "截断表头(截图显示 `科'`),与用户管理页同义"), + ("操作", "**编辑**、**授权**、**重置密码**"), + ], + "row_ops": "**编辑**、**授权**、**重置密码**", + "paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**(与用户管理页数据量一致,两页为同一用户池)", + "scan_key": "basic-user-union", + "po_hint": "pages/spd/basic-user-union.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '医共体用户管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/personCom')` 断言 URL。", + "**勿与用户管理页混用列断言**:本页为 `登录账号 → 姓名 → 联系方式 → 所属医院 → 所属科室 → 医院员工编号 → 角色 → 科室任职数 → 操作`,用户管理页为 `姓名 → 登录账号 → …`(无「所属医院」)。", + "「登录账号」列窄,`cellText` 取到的是截断文本;按账号搜索/断言应使用筛选框而非列文本。", + "写操作须先定位当前行,医共体下避免误改其他院区用户。", + ], + "points": [ + "切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。", + "两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。", + "姓名 + 联系方式组合筛选精确命中;重置恢复默认。", + "写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。", + ], + }, + { + "id": "USER-003", + "name": "角色管理", + "folder": "基础管理-用户管理-角色管理", + "path": "/spd/basic/user/platform/role", + "tab_title": "", + "layout": ( + "左右分栏:左侧角色列表,右侧「菜单权限」勾选树(应用 `SPD内网系统PC端` → 模块 → 菜单/功能,三级结构)" + ), + "filters": [ + ("角色名称", "文本(左侧列表顶部)", "占位含「角色」,输入即过滤"), + ], + "actions": "左侧搜索框实时过滤;右侧权限树按模块展开勾选;底部 **收起导航** 为全局导航按钮,非本页功能", + "toolbar": "左栏 **「角 色」** 按钮(新增);选中后底部/行内 **编辑**、删除图标", + "columns": [ + ("角色名称", "左列主键;示例:医院管理员、中心库库管、护士、护士长、库管、测试01、test、验收-herp、验收-ict、角色测试123"), + ("操作", "行内 **编辑** / 删除类图标按钮"), + ], + "row_ops": "**编辑** / 删除类图标按钮;右侧为菜单权限树(非行操作)", + "paging": "左列底部 **收起导航**(全局按钮);未见独立分页控件", + "scan_key": "basic-role", + "po_hint": "pages/spd/basic-role.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '角色管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/role')` 断言 URL。", + "左右分栏:先断言角色列表与权限树均可见;选中角色后再断言右侧勾选状态已刷新(避免读到上一个角色的树)。", + "权限树按模块展开,断言时优先定位「模块名 + 菜单名」层级,避免全局文本匹配到多份同名菜单。", + "新增弹窗:「角 色」按钮;含角色名称、查询配置化复选框;取消/X 关闭不落库。", + "自动化:`tests/spd/basic-role.spec.ts`(12 条 USER-ROL);`npm run test:basic-role:all`。", + ], + "points": [ + "切换角色后右侧菜单权限勾选与该角色已配置权限一致(无串选/残留)。", + "权限树模块可展开/收起;长列表可纵向滚动(左栏角色列表、右侧子菜单树)。", + "新增/编辑角色弹窗:必填校验、查询配置化勾选、取消与 X 关闭。", + "编辑保存成功提示;权限树批量授权保存与用户页下拉同步见测试点层,当前自动化未全覆盖。", + ], + "modules": [ + ("基础设置", "医院资料、医院信息登记、院区管理、公告管理、财务月份设置"), + ( + "运维管理", + "菜单控制规则设置、医院配置、菜单受控列表、院区配置、科室配置、术语配置、资源模板设置、" + "字典管理、打印机配置、编码规则管理、PDA授权、PDA版本管理、缓存监控、操作日志、" + "消息通知模板、消息通知设置、功能注册、任务执行记录查询", + ), + ( + "厂商管理", + "厂商查询、供应商资质管理、授权变更初审、注册证变更初审、注册证变更终审、授权变更终审、" + "注册证差异对比、评价方案维护、评价指标维护、供应商节假日管理", + ), + ( + "产品管理", + "产品核对重新发起、产品维护、物料信息管理、产品初审核对、产品供应关系维护、产品物价核对、" + "院区产品设置、院区单元包设置、中心库上下限建议(AI)、请领出库渠道设置、结算点设置、" + "调价管理、产品信息变更、变更单明细查询、外围系统产品新增、外围系统产品变更管理、产品查询 等", + ), + ], + }, +] + +# 基础设置 → 库房管理(docs/基础管理-库房管理-*) +# 菜单另有「华清通道」,本次无截图,不建规范。 +WH_PAGES: list[dict] = [ + { + "id": "WH-001", + "group": "库房管理", + "name": "库区设置", + "folder": "基础管理-库房管理-库区设置", + "path": "/spd/basic/storehouse/platform/storehouse/area", + "siblings": "**库区设置** / **探针绑定**", + "tab_title": "库区设置", + "layout": "左右分栏:左侧「库房信息」树,右侧为当前库房下的库区列表", + "filters": [ + ("库房名称", "文本(左侧树顶部)", "占位「库房名称」,过滤库房树"), + ("库区编号/名称", "文本(右侧列表右上)", "占位「库区编号/名称」,搜索图标触发"), + ], + "actions": "左侧 **同步**;右侧搜索图标检索,另有刷新与列设置。截图未见独立「查询」按钮", + "toolbar": "右侧 **新增**", + "columns": [ + ("库区编号", "如 `A001`、`B001`、`C001`、`E001`、`0807`"), + ("库区名称", "如「中心库A区」;默认库区在名称旁带绿色「默认库区」标签(截图为中心库B区)"), + ("库区地址", "自由文本,可与编号或名称相同"), + ("是否专项库区", "是 / 否"), + ("操作", "**编辑**、**人员管理**、**货位管理**、**停用**或**启用**"), + ], + "row_ops": "**编辑**、**人员管理**、**货位管理**、**停用** / **启用**", + "paging": "右侧「共 **5** 条」、页码 `1`、**50 条/页**(条数随所选库房变化)", + "scan_key": "warehouse-zone", + "po_hint": "pages/spd/warehouse-zone.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '库区设置', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL。", + "先点选左侧库房,再断言右侧列表;切换库房后列表须刷新,避免读到上一库房的库区。", + "**停用** / **启用**、**同步**、**新增** 均为写操作。默认库区是名称旁标签,不是独立列。", + "「人员管理」「货位管理」会离开当前列表(弹层或子页),本次无截图,断言前先确认面板标题。", + ], + "points": [ + "左侧库房关键字过滤只命中名称匹配的节点;清空后恢复全量树。", + "切换库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化。", + "「库区编号/名称」搜索同时覆盖编号与名称;无命中时列表为空且分页为 0。", + "同一库房仅一个「默认库区」标签;专项库区「是/否」与编辑回填一致。", + "停用后该行操作变为「启用」,再次进入仍保持;启用行显示「停用」。", + ], + "extra_md": """ +### 左侧库房树 + +标题 **库房信息**,操作 **同步**。截图节点示例:光谷院区中心库房(当前选中)、洪山院区中心库房、1205院区中心库房、结算院区中心库房、回归测试院区中心库房、千泉山院区中心库房、马王堆院区中心库房、主院区中心库房、岳麓山院区中心库房、数据检查院区中心库房、东土城院区中心库房、智能院区中心库房、自动采购/请领建议专用院区中心库房。 + +选中库房后,右侧列表只展示该库房的库区。 +""", + }, + { + "id": "WH-002", + "group": "库房管理", + "name": "探针绑定", + "folder": "基础管理-库房管理-探针绑定", + "path": "/spd/basic/storehouse/storehouse/probe", + "siblings": "**库区设置** / **探针绑定**", + "tab_title": "探针绑定", + "shot_note": "`UI-SPEC-SPD-WH-002/page-main.png`、`page-main-2.png`(货位绑定弹窗)", + "layout": "整页单表:顶部筛选 + 探针列表;行内「绑定货位」打开「货位绑定探针」弹窗", + "filters": [ + ("探针ID", "文本", "占位「请输入」"), + ("主机编码", "文本", "占位「请输入」"), + ("是否绑定货位", "下拉", "占位「请选择」(已绑定 / 未绑定)"), + ], + "actions": "**查询**、**重置**", + "toolbar": "列设置(截图未见新增按钮)", + "columns": [ + ("序号", "行号"), + ("探针ID", "如 `360001`–`360011`"), + ("主机编码", "如 `X233300001`,多条探针可共用同一主机"), + ("是否绑定货位", "已绑定 / 未绑定"), + ("绑定的货位", "已绑定货位数量(如 `28`、`3`、`2`);未绑定为 `0`"), + ("操作", "**绑定货位**"), + ], + "row_ops": "**绑定货位**(打开货位选择弹窗)", + "paging": "主列表「共 **11** 条」、**50 条/页**(以当前环境为准)", + "scan_key": "warehouse-probe", + "po_hint": "pages/spd/warehouse-probe.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '探针绑定', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。", + "「绑定的货位」是数量,不是货位号;货位号在弹窗「货位号」列。", + "弹窗有独立筛选与分页(截图「共 2626 条」),不要用弹窗总条数断言主列表。", + "弹窗 **确定** 会改绑定关系,按钮文案带已选数量(如 `确定(0)`);冒烟只打开后 **取消**。", + ], + "points": [ + "探针ID、主机编码、是否绑定货位组合查询后,列表与「共 N 条」一致;重置恢复默认。", + "「已绑定」行的绑定数量大于 0,「未绑定」行数量为 0。", + "打开「绑定货位」后弹窗标题为「货位绑定探针」,关闭后主列表勾选与筛选条件不丢失。", + "弹窗内按当前库房/库区/货位号筛选,结果列与条件一致;未选货位时确定按钮计数为 0。", + "同一货位行若已有探针ID,再次勾选前需确认是换绑还是追加(以保存后的主列表数量为准)。", + ], + "extra_md": """ +### 货位绑定探针弹窗 + +![探针绑定-货位绑定探针](UI-SPEC-SPD-WH-002/page-main-2.png) + +行内 **绑定货位** 打开。筛选与主列表相互独立。 + +| 字段 | 控件 | 占位/默认值 | +|------|------|-------------| +| 当前库房 | 下拉 | 可带默认库房,如「千泉山院区中心…」 | +| 当前库区 | 下拉 | 空 | +| 货位查询 | 文本 | 占位「请输入」 | +| 货位状态 | 下拉 | 空 | +| 默认货位 | 下拉 | 空 | + +- 操作:**查询**、**重置** + +| 列 | 说明 | +|----|------| +| 勾选 | 多选;底部 **确定(N)** 的 N 为已选货位数 | +| 货位号 | 如 `001-01-01-01`、`001-ZNG001-01-01` | +| 库区默认货位 | 是 / 否 | +| 当前库房 | 如「千泉山院区中心库房」 | +| 当前库区 | 如「千泉山院区中心库库区1」 | +| 状态 | 启用等 | +| 探针ID | 已绑定显示探针号(如 `360003`),未绑定 `--` | + +弹窗分页截图为「共 **2626** 条」、**50 条/页**、**跳至 N 页**。底部 **取消**、**确定(0)**。 +""", + }, +] + +# 基础设置 → 流程设置(docs/基础管理-流程设置-*) +FLOW_PAGES: list[dict] = [ + { + "id": "FLOW-001", + "group": "流程设置", + "name": "审批流程设置", + "folder": "基础管理-流程设置-审批流程设置", + "path": "/spd/basic/flowSet/workflow/flowSet", + "siblings": "**审批流程设置** / **审批流实例**", + "tab_title": "审批流程设置", + "layout": "左右分栏:左侧业务类型列表,右侧为该类型的默认审批流与自定义审批流", + "filters": [ + ("流程名称", "文本(左侧列表顶部)", "占位「流程名称」,过滤业务类型"), + ], + "actions": "左侧搜索过滤类型;右侧 **新增流程**、底部 **保存** / **重置**", + "toolbar": "自定义审批流区域右上 **新增流程**", + "columns": [ + ("流程名称", "必填;自定义流名称,截图示例 `1`、`2`"), + ("条件", "必填;行内 **编辑**。示例:「是否超限:是」「科室:中心库(洪山)」"), + ("审批流程", "必填;节点卡片(节点类型、账号、人员)+ **+** 追加节点"), + ("操作", "**删除**"), + ], + "row_ops": "条件列 **编辑**;审批节点 **+**;行 **删除**", + "paging": "左侧为业务类型全量列表,未见分页;右侧自定义流按行展示,未见分页", + "scan_key": "flow-def", + "po_hint": "pages/spd/flow-def.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '审批流程设置', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowSet')` 断言 URL。与「审批流实例」区分,使用 exact。", + "先选中左侧业务类型,再读右侧默认流与自定义流;切换类型后两侧都要刷新,避免串用上一类型的节点。", + "**保存** 会改变线上审批路由,冒烟只断言设计器结构,不提交;改完须 **重置** 或恢复原配置。", + "节点卡片含「节点 / 账号 / 人员」三行,断言时按卡片容器取文本,避免全局匹配到同名人员。", + ], + "points": [ + "流程名称关键字只保留匹配的业务类型;清空后恢复全量。", + "切换业务类型后,默认审批流节点与自定义审批流行与该类型已保存配置一致。", + "自定义流「流程名称 / 条件 / 审批流程」为空时保存被拦截;删除行后未点保存不落库。", + "重置放弃未保存改动,重新进入与上次保存结果一致。", + "默认流与自定义流互不覆盖:只改自定义行时,默认审批人节点保持不变。", + ], + "extra_md": """ +### 左侧业务类型 + +截图可见类型(采购计划为当前选中,列表可继续向下滚动):采购计划、结算、调价、产品信息变更、请领单、领用申请单、跟台手术申请单、试剂性能验证、退库出库单、临采任务、二级库调拨、备货计划、产品准入、供货目录申请、移库申请单、物料准入、退货出库拣货、产品DI变更、科室消耗退回、限量任务、科室领用退库出库单。 + +### 默认审批流 + +标题 **默认审批流**。审批人节点卡片示例:节点「运营主管」、账号 `test_1`、人员「蒋华」,卡片右侧 **+** 追加节点。未配置自定义流时,单据走该默认链。 + +### 自定义审批流 + +标题 **自定义审批流**。列头「流程名称」「条件」「审批流程」带必填标记。节点示例: + +| 行 | 条件 | 节点 | +|----|------|------| +| 1 | 是否超限:是 | 指定用户 / `cgzxtest01` / 采购中心管理员01 | +| 2 | 科室:中心库(洪山) | 指定用户 / `qizhenjun` / 齐-超管 | + +底部 **保存**、**重置**。 +""", + }, + { + "id": "FLOW-002", + "group": "流程设置", + "name": "审批流实例", + "folder": "基础管理-流程设置-审批流实例", + "path": "/spd/basic/flowSet/workflow/flowCase", + "siblings": "**审批流程设置** / **审批流实例**", + "tab_title": "审批流实例", + "layout": "整页单表:顶部筛选 + 审批单据列表 + 底部分页", + "filters": [ + ("单据编号", "文本", "占位「单据编号」"), + ("审批状态", "下拉", "空;列表可见「审批通过」「审批中」"), + ("发起时间", "日期区间", "截图示例 `2026-08-21` → `2026-10-20`(约两个月,结束日为当天)"), + ("业务类型", "下拉", "空;与审批流程设置左侧类型同源"), + ("摘要", "文本", "占位「摘要」"), + ], + "actions": "**查询**、**重置**", + "toolbar": "列设置(截图未见新增、导出)", + "columns": [ + ("序号", "行号"), + ("单据编号", "前缀随业务类型变化:`PLAN` 采购计划、`DB` 二级库调拨、`GC` 产品信息变更、`BUY` 请领单"), + ("业务类型", "如采购计划、二级库调拨、产品信息变更、请领单"), + ("发起人", "姓名,窄列截断"), + ("发起部门", "部门名称,窄列截断"), + ("发起时间", "日期时间,窄列截断为 `YYYY-MM-DD …`"), + ("摘要", "业务摘要;无则 `--`,长文本截断"), + ("审批状态", "审批通过 / 审批中(以页面枚举为准)"), + ("操作", "**详情**"), + ], + "row_ops": "**详情**(只读,打开该单据审批过程)", + "paging": "「共 **2348** 条」、页码、**50 条/页**、**跳至 N 页**(默认日期区间较大,条数随区间变化)", + "scan_key": "flow-inst", + "po_hint": "pages/spd/flow-inst.page.ts", + "notes": [ + "菜单导航:`page.getByRole('menuitem', { name: '审批流实例', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowCase')` 断言 URL。不要点到「审批流程设置」。", + "默认发起时间跨度大,全量查询条数多;收窄日期后再断言「共 N 条」,且列表「发起时间」须落在区间内。", + "单据编号前缀与业务类型对应,按编号搜索时用完整单号,不用截断后的发起人/部门列文本。", + "本页行操作只有 **详情**;改审批人、条件应回到审批流程设置,且该页保存会影响后续新实例。", + ], + "points": [ + "单据编号精确查询至多一条,且编号、业务类型、状态与条件一致。", + "审批状态、业务类型、发起时间、摘要组合筛选后,抽样行同时满足全部条件。", + "重置后日期回到默认区间,其余条件清空,「共 N 条」恢复。", + "摘要为 `--` 的行,按非空摘要关键字查询不应命中。", + "详情打开的单据编号与当前行一致;关闭详情后筛选条件与页码保持。", + ], + "extra_md": "", + }, +] + +PAGES: list[dict] = STAT_PAGES + ADMIT_PAGES + VENDOR_PAGES + USER_PAGES + WH_PAGES + FLOW_PAGES + def png_files(folder: Path) -> list[Path]: return sorted(p for p in folder.iterdir() if p.suffix.lower() == ".png") -def render_spec(page: dict, spec_id: str) -> str: +def render_admit_spec(page: dict, spec_id: str) -> str: + title = f"准入管理-{page['name']}页面说明" + menu = f"**基础设置** → **准入管理** → **{page['name']}**" + docs_path = f"docs/{page['folder']}/" + kb_dir = f"UI-SPEC-SPD-{spec_id}" + extra_img = "" + if spec_id == "ADMIT-005": + extra_img = "\n\n![供货目录申请-补充截图](UI-SPEC-SPD-ADMIT-005/page-main-2.png)\n" + + return f"""--- +title: {title} +module: 基础设置 +status: 生效 +related_br: "" +related_api: "" +related_jrn: "" +related_tc: "" +related_def: "" +create_time: "{TODAY}" +update_time: "{TODAY}" +author: "" +--- + +# 准入管理 - {page['name']} + +> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与准入流程验收参考。 +> 同模块索引见 [`INDEX.md`](./INDEX.md)。 + +## 文档与截图 + +| 项 | 路径 | +|----|------| +| 截图原件 | `{docs_path}` | +| 知识库副本 | `{kb_dir}/page-main.png` | + +## 页面路径 + +{menu} + +| 项 | 值 | +|----|-----| +| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) | +| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` | + +## 主界面 + +![{page['name']}主界面]({kb_dir}/page-main.png){extra_img} + +### 筛选区 + +- 字段:{page.get('filters', '以截图为准')} +- 操作:**查询**、**重置**;部分页面含 **收起/展开** + +### 状态 Tab(若有) + +- {page.get('status_tabs', '以截图为准')} + +### 列表与工具栏 + +- {page.get('toolbar', '以截图为准')} +- {page.get('list_note', '主表列与行内操作以截图为准')} +- 底部分页:「共 N 条」、页码与每页条数切换 + +## 自动化建议 + +1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。 +2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意与同组「产品/物料准入单列表」等近似菜单区分)。 +3. 状态 Tab 切换后列表与 Tab 计数一致;批量操作需先勾选行。 + +## 测试关注点 + +1. 筛选 + 状态 Tab 组合后,列表条数与底部分页「共 N 条」一致。 +2. 生成申请单/一键生成/批量提交等写操作与当前勾选行、筛选条件一致。 +3. 审批态行操作(编辑/详情/作废)与状态机规则一致。 +""" + + +def render_vendor_spec(page: dict, spec_id: str) -> str: + title = f"厂商管理-{page['name']}页面说明" + menu = f"**基础设置** → **厂商管理** → **{page['name']}**" + docs_path = f"docs/{page['folder']}/" + kb_dir = f"UI-SPEC-SPD-{spec_id}" + extra_n = int(page.get("extra_imgs") or 0) + extra_img = "" + for i in range(2, extra_n + 2): + extra_img += f"\n\n![{page['name']}-补充截图{i - 1}]({kb_dir}/page-main-{i}.png)\n" + + return f"""--- +title: {title} +module: 基础设置 +status: 生效 +related_br: "" +related_api: "" +related_jrn: "" +related_tc: "" +related_def: "" +create_time: "{TODAY}" +update_time: "{TODAY}" +author: "" +--- + +# 厂商管理 - {page['name']} + +> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与厂商/资质流程验收参考。 +> 同模块索引见 [`INDEX.md`](./INDEX.md)。 + +## 文档与截图 + +| 项 | 路径 | +|----|------| +| 截图原件 | `{docs_path}` | +| 知识库副本 | `{kb_dir}/page-main.png` | + +## 页面路径 + +{menu} + +| 项 | 值 | +|----|-----| +| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) | +| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` | + +## 主界面 + +![{page['name']}主界面]({kb_dir}/page-main.png){extra_img} + +### 筛选区 + +- 字段:{page.get('filters', '以截图为准')} +- 操作:**查询**、**重置**;部分页面含 **收起/展开** + +### 状态 / 分类 Tab(若有) + +- {page.get('status_tabs', '以截图为准')} + +### 列表与工具栏 + +- {page.get('toolbar', '以截图为准')} +- {page.get('list_note', '主表列与行内操作以截图为准')} +- 底部分页:「共 N 条」、页码与每页条数切换(左右分栏页面对应侧分页) + +## 自动化建议 + +1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。 +2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意初审/终审、方案/指标等近似菜单区分)。 +3. 资质多 Tab、变更审批详情、左右分栏(指标树/节假日)需先断言目标面板可见再操作。 + +## 测试关注点 + +1. 筛选 + Tab/状态下拉组合后,列表条数与底部分页「共 N 条」一致。 +2. 授权/注册证初审与终审详情、状态流转与业务规则一致;写操作前确认勾选/当前行。 +3. 注册证差异对比「一致/不一致」与两侧字段抽样一致;导出与当前查询条件一致。 +""" + + +def render_stat_spec(page: dict, spec_id: str) -> str: title = f"{page['group']}-{page['name']}页面说明" menu = f"**统计报表** → **{page['group']}** → **{page['name']}**" docs_path = f"docs/{page['folder']}/" @@ -161,6 +865,251 @@ author: "" """ +def render_user_spec(page: dict, spec_id: str) -> str: + title = f"用户管理-{page['name']}页面说明" + menu = f"**基础设置** → **用户管理** → **{page['name']}**" + docs_path = f"docs/{page['folder']}/" + kb_dir = f"UI-SPEC-SPD-{spec_id}" + scan_key = page["scan_key"] + + tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else "" + # 第 1 条固定为「先扫描再补 PO」,其余建议顺延编号 + notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2)) + points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1)) + + filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"]) + col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"]) + + modules = "" + if page.get("modules"): + rows = "\n".join(f"| {m} | {items} |" for m, items in page["modules"]) + modules = f""" +### 右侧菜单权限树 + +二级模块(示例): + +| 模块 | 菜单/功能(示例) | +|------|-------------------| +{rows} + +- 勾选父节点联动子节点,保存后即为该角色授权范围。 +""" + + return f"""--- +title: {title} +module: 基础设置 +status: 生效 +related_br: "" +related_api: "" +related_jrn: "" +related_tc: "" +related_def: "" +create_time: "{TODAY}" +update_time: "{TODAY}" +author: "" +--- + +# 用户管理 - {page['name']} + +> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与用户/权限验收参考。 +> 同模块索引见 [`INDEX.md`](./INDEX.md)。 + +## 文档与截图 + +| 项 | 路径 | +|----|------| +| 截图原件 | `{docs_path}` | +| 知识库副本 | `{kb_dir}/page-main.png` | + +## 页面路径 + +{menu}(同组页面:**用户管理** / **医共体用户管理** / **角色管理**) + +| 项 | 值 | +|----|-----| +| 主框架 URL | `http://spdtest.cmic.com.cn:8080{page['path']}` | +{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 元素扫描 JSON | 待扫描补充(建议 `data/scan/{scan_key}-elements.json`) | +| 定位器 YAML | 待扫描补充(建议 `locators/spd/{scan_key}.locator.yaml`) | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` | + +## 主界面 + +![{page['name']}主界面]({kb_dir}/page-main.png) + +{page['layout']} + +### 筛选/搜索区 + +| 字段 | 控件 | 占位/默认值 | +|------|------|-------------| +{filter_rows} + +- 操作:{page['actions']} + +### 列表与工具栏 + +工具栏:{page.get('toolbar', '**新增**(按实际文案定位)')}。 + +| 列 | 说明 | +|----|------| +{col_rows} + +行操作:{page['row_ops']}。 + +### 分页 + +{page['paging']} +{modules} +## 自动化建议 + +1. 完成元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object(建议 `{page['po_hint']}`)。 +{notes} + +## 测试关注点 + +{points} + +## 关联 + +- 同模块:用户管理([`UI-SPEC-SPD-USER-001`](./UI-SPEC-SPD-USER-001.md))、医共体用户管理([`UI-SPEC-SPD-USER-002`](./UI-SPEC-SPD-USER-002.md))、角色管理([`UI-SPEC-SPD-USER-003`](./UI-SPEC-SPD-USER-003.md))。 +- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、厂商管理(`UI-SPEC-SPD-VENDOR-*`)、手术套包设置(`UI-SPEC-SPD-SURG-*`)。 +{page.get('related_extra', '')}""" + + +def render_group_spec(page: dict, spec_id: str) -> str: + """库房管理 / 流程设置等左右分栏或单表页面(结构对齐用户管理规范)。""" + group = page["group"] + title = f"{group}-{page['name']}页面说明" + menu = f"**基础设置** → **{group}** → **{page['name']}**" + docs_path = f"docs/{page['folder']}/" + kb_dir = f"UI-SPEC-SPD-{spec_id}" + scan_key = page["scan_key"] + siblings = page.get("siblings", f"**{page['name']}**") + shot = page.get("shot_note") or f"`{kb_dir}/page-main.png`" + + tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else "" + notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2)) + points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1)) + filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"]) + col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"]) + extra = (page.get("extra_md") or "").strip() + extra = f"\n{extra}\n" if extra else "\n" + page_url = ( + f"`http://spdtest.cmic.com.cn:8080{page['path']}`" + if page.get("path") + else f"待元素扫描补充(菜单名:`{page['name']}`)" + ) + + if group == "库房管理": + peer = ( + "- 同模块:库区设置([`UI-SPEC-SPD-WH-001`](./UI-SPEC-SPD-WH-001.md))、" + "探针绑定([`UI-SPEC-SPD-WH-002`](./UI-SPEC-SPD-WH-002.md))。" + "菜单另有「华清通道」,本次无截图,未建规范。\n" + "- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、用户管理(`UI-SPEC-SPD-USER-*`)、" + "流程设置(`UI-SPEC-SPD-FLOW-*`)。" + ) + else: + peer = ( + "- 同模块:审批流程设置([`UI-SPEC-SPD-FLOW-001`](./UI-SPEC-SPD-FLOW-001.md))、" + "审批流实例([`UI-SPEC-SPD-FLOW-002`](./UI-SPEC-SPD-FLOW-002.md))。\n" + "- 同层菜单:库房管理(`UI-SPEC-SPD-WH-*`)、准入管理(`UI-SPEC-SPD-ADMIT-*`)、" + "用户管理(`UI-SPEC-SPD-USER-*`)。自定义审批流保存后,新单据出现在审批流实例。" + ) + + return f"""--- +title: {title} +module: 基础设置 +status: 生效 +related_br: "" +related_api: "" +related_jrn: "" +related_tc: "" +related_def: "" +create_time: "{TODAY}" +update_time: "{TODAY}" +author: "" +--- + +# {group} - {page['name']} + +> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与{group}验收参考。 +> 同模块索引见 [`INDEX.md`](./INDEX.md)。 + +## 文档与截图 + +| 项 | 路径 | +|----|------| +| 截图原件 | `{docs_path}` | +| 知识库副本 | {shot} | + +## 页面路径 + +{menu}(同组页面:{siblings}) + +| 项 | 值 | +|----|-----| +| 主框架 URL | {page_url} | +{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) | +| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` | +| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` | +| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` | + +## 主界面 + +![{page['name']}主界面]({kb_dir}/page-main.png) + +{page['layout']} + +### 筛选/搜索区 + +| 字段 | 控件 | 占位/默认值 | +|------|------|-------------| +{filter_rows} + +- 操作:{page['actions']} + +### 列表与工具栏 + +工具栏:{page.get('toolbar', '列设置')}。 + +| 列 | 说明 | +|----|------| +{col_rows} + +行操作:{page['row_ops']}。 + +### 分页 + +{page['paging']} +{extra} +## 自动化建议 + +1. 元素扫描 JSON 与定位器 YAML 已生成;下一步补 Page Object(建议 `{page['po_hint']}`)。 +{notes} + +## 测试关注点 + +{points} + +## 关联 + +{peer} +""" + + +def render_spec(page: dict, spec_id: str) -> str: + if spec_id.startswith("ADMIT-"): + return render_admit_spec(page, spec_id) + if spec_id.startswith("VENDOR-"): + return render_vendor_spec(page, spec_id) + if spec_id.startswith("USER-"): + return render_user_spec(page, spec_id) + if spec_id.startswith("WH-") or spec_id.startswith("FLOW-"): + return render_group_spec(page, spec_id) + return render_stat_spec(page, spec_id) + + def main() -> None: parser = argparse.ArgumentParser(description="同步 docs 截图至 UI 专项规范库") parser.add_argument( @@ -184,8 +1133,12 @@ def main() -> None: if not images: raise SystemExit(f"no png in {src_dir}") shutil.copy2(images[0], kb_dir / "page-main.png") - if len(images) > 1: - shutil.copy2(images[1], kb_dir / "page-main-2.png") + # 复制全部补充截图:page-main-2.png … page-main-N.png + for idx, img in enumerate(images[1:], start=2): + shutil.copy2(img, kb_dir / f"page-main-{idx}.png") + # 若配置了 extra_imgs,确保规范中引用的张数不超过实际文件 + if "extra_imgs" in page: + page = {**page, "extra_imgs": max(0, min(int(page["extra_imgs"]), len(images) - 1))} spec_file.write_text(render_spec(page, spec_id), encoding="utf-8") print(f"OK UI-SPEC-SPD-{spec_id}\t{page['name']}\t{len(images)} img") print("done", len(pages)) diff --git a/scripts/sync-quarantine.mjs b/scripts/sync-quarantine.mjs new file mode 100644 index 0000000000000000000000000000000000000000..87fb1978064df309d789388538589eb15baf8f72 --- /dev/null +++ b/scripts/sync-quarantine.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * Flake 隔离闭环:把 reports/flake 的隔离候选合并写入 config/quarantine.json。 + * + * 背景:flake-report.mjs 只产出「隔离候选」清单,若不落库,主门禁会一直跑这些高 flake 用例, + * 导致夜间全量被环境/瞬时问题反复刷红。本脚本负责把它变成真正的隔离项(闭环)。 + * + * 用法: + * node scripts/sync-quarantine.mjs # 预览:只打印将要写入的项 + * node scripts/sync-quarantine.mjs --write # 实际写入 config/quarantine.json + * node scripts/sync-quarantine.mjs --write --owner=liguangyu --mode=observe + * + * 安全约束: + * - 只隔离 environment / transient / script 类候选;**product(断言/业务缺陷)绝不自动隔离**。 + * - 已存在的人工条目(含 ticket/owner 等)一律保留,只补充/更新自动条目。 + * - 自动条目带 `auto: true` 标记,便于下次同步识别与回收。 + */ +import { + existsSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** 允许自动隔离的失败类别;product 属业务缺陷,禁止自动隔离。 */ +const AUTO_ISOLATABLE = new Set(['environment', 'transient', 'script', 'data']); + +const CLASS_LABEL = { + environment: '环境/基础设施抖动', + transient: '瞬时 Flake', + script: '脚本/定位不稳定', + data: '测试数据不足', + product: '产品缺陷', +}; + +function parseArgs(argv) { + const out = { + trend: path.join(ROOT, 'reports/flake/latest.json'), + quarantine: path.join(ROOT, 'config/quarantine.json'), + write: false, + mode: 'quarantine', + owner: process.env.QUARANTINE_OWNER || 'ui-auto', + ticket: process.env.QUARANTINE_TICKET || '', + }; + for (const arg of argv) { + if (arg === '--write') out.write = true; + else if (arg.startsWith('--trend=')) out.trend = path.resolve(arg.slice(8)); + else if (arg.startsWith('--quarantine=')) out.quarantine = path.resolve(arg.slice(13)); + else if (arg.startsWith('--mode=')) out.mode = arg.slice(7); + else if (arg.startsWith('--owner=')) out.owner = arg.slice(8); + else if (arg.startsWith('--ticket=')) out.ticket = arg.slice(9); + } + if (!['quarantine', 'observe'].includes(out.mode)) out.mode = 'quarantine'; + return out; +} + +function readJson(file, fallback) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return fallback; + } +} + +function extractCaseId(title) { + return String(title || '').match(/\b(STAT-[A-Z]+-\d{3,})\b/)?.[1]; +} + +/** 取该用例出现最多的失败类别(决定是否可自动隔离)。 */ +function dominantClass(classes) { + const entries = Object.entries(classes || {}); + if (!entries.length) return { cls: '', n: 0 }; + entries.sort((a, b) => b[1] - a[1]); + return { cls: entries[0][0], n: entries[0][1] }; +} + +function buildAutoItems(trend, opts) { + const items = []; + const skipped = []; + for (const cand of trend.quarantineCandidates || []) { + const { cls } = dominantClass(cand.classes); + const id = extractCaseId(cand.title); + if (!id) { + skipped.push({ title: cand.title, reason: '标题无 STAT- 编号,无法稳定匹配' }); + continue; + } + if (!AUTO_ISOLATABLE.has(cls)) { + skipped.push({ title: cand.title, reason: `失败类别 ${cls || '未知'}(疑似产品缺陷)不自动隔离` }); + continue; + } + items.push({ + id, + mode: opts.mode, + reason: `flake failRate=${Number(cand.failRate).toFixed(2)} (${cand.failCount}/${cand.decided}) 主导类别=${CLASS_LABEL[cls] || cls}`, + owner: opts.owner, + since: new Date().toISOString().slice(0, 10), + category: cls, + auto: true, + ...(opts.ticket ? { ticket: opts.ticket } : {}), + }); + } + return { items, skipped }; +} + +function mergeItems(existingItems, autoItems) { + const manual = (existingItems || []).filter((item) => !item.auto); + const manualIds = new Set(manual.map((item) => item.id).filter(Boolean)); + const autoById = new Map(); + for (const item of autoItems) { + // 人工已声明的用例不再自动覆盖,尊重人工结论。 + if (manualIds.has(item.id)) continue; + autoById.set(item.id, item); + } + return [...manual, ...[...autoById.values()]]; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!existsSync(opts.trend)) { + console.error(`[quarantine] 未找到 flake 趋势文件:${path.relative(ROOT, opts.trend)}`); + console.error('[quarantine] 先执行 `npm run flake:report` 生成隔离候选。'); + process.exit(2); + } + + const trend = readJson(opts.trend, { quarantineCandidates: [] }); + const { items: autoItems, skipped } = buildAutoItems(trend, opts); + const current = readJson(opts.quarantine, { version: 1, items: [] }); + const merged = mergeItems(current.items, autoItems); + + const added = merged.filter((item) => item.auto).length; + console.log( + `[quarantine] 候选=${(trend.quarantineCandidates || []).length} 自动可隔离=${autoItems.length} 跳过=${skipped.length} 合并后总计=${merged.length}`, + ); + for (const item of autoItems) { + console.log(` + ${item.id} [${item.mode}] ${item.reason}`); + } + for (const s of skipped) { + console.log(` ~ 跳过 ${s.title}:${s.reason}`); + } + + if (!opts.write) { + console.log('[quarantine] 预览模式,未写入。加 --write 落库。'); + return; + } + + const next = { version: current.version ?? 1, items: merged }; + writeFileSync(opts.quarantine, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + console.log( + `[quarantine] 已写入 ${path.relative(ROOT, opts.quarantine)}(自动项 ${added},人工项保留 ${ + merged.length - added + })。`, + ); +} + +main(); diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..605593043ece4f0d35acb830215a8a80ba40e29a --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,46 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-PLS-001","基础设置 · 准入管理","产品准入单列表 · 页面进入","菜单进入产品准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入单列表」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/list`,且路径不是 `zrlist` +3. 断言制单日期、查询、状态 Tab、列表表头可见","进入产品准入单列表;不误入物料准入单列表。","" +"ADMIT-PLS-002","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-PLS-003","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","按产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条且产品名称非空","1. 从首行读取 `${sampleProductName}` +2. 若筛选区无产品名称,则用注册证号或器械注册人中首行非空字段 +3. 制单日期保持默认,点击「查 询」 +4. 断言结果包含该关键字","结果与已填关键字匹配。","" +"ADMIT-PLS-004","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已填入筛选关键字","1. 点击「重 置」 +2. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。","" +"ADMIT-PLS-005","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999` +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"ADMIT-PLS-006","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","列表核心列与附件计数","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作 +2. 断言首行附件列展示个数(可为 0 个)","核心列可见;附件列有个数;操作为「详情」。本页列表不要求「准入类型」列。","" +"ADMIT-PLS-007","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「已通过」 +2. 断言有数据时状态为已通过 +3. 断言 Tab 括号数量等于分页「共 N 条」","已通过 Tab 的状态与计数一致。","" +"ADMIT-PLS-008","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","与物料准入单列表不串 URL","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);当前在产品准入单列表","1. 记录当前 URL +2. 断言 URL 含 `productOrder/list` 且不含 `zrlist` +3. 不点击物料准入单列表菜单","停留在产品准入单列表路径,不串到物料单路径。","" +"ADMIT-PLS-009","基础设置 · 准入管理","产品准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}` +2. 点击「详情」 +3. 断言可见「审批进程」「单据日志」「返 回」 +4. 返回后 URL 仍含 `productOrder/list`","详情可打开;返回后仍是产品准入单列表。","" +"ADMIT-PLS-010","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开首行详情","1. 读取详情明细分页总数或当前行数 +2. 断言等于列表「产品条数」","明细条数与产品条数一致。","" +"ADMIT-PLS-011","基础设置 · 准入管理","产品准入单列表 · 单据详情","单据日志可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 点击「单据日志」 +2. 断言日志层可见 +3. 关闭后仍在该单据","单据日志可查看;详情不被销毁。","" +"ADMIT-PLS-012","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 搜索框填入 `__NOT_EXIST_GOODS__99999` +2. 断言明细空态,返回按钮仍在","无匹配只影响明细;单据头仍在。","" +"ADMIT-PLS-013","基础设置 · 准入管理","产品准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已通过 Tab 至少 1 条并打开详情","1. 「通过」禁用或不可见时,断言状态保持已通过 +2. 若可点且出现确认框,点「取 消」 +3. 返回列表,断言状态仍为已通过","不把已通过单据再次审批成功。无已通过数据则 skip。","" +"ADMIT-PLS-014","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「销售商」填写关键字 +4. 「耗材类型」下拉选择任一可选项 +5. 「器械注册人」填写关键字 +6. 「注册证号」填写关键字 +7. 「供应商」填写关键字 +8. 点击「查询」 +9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..15e2ad5aee8c553a21eb2658d55594c6206275eb --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,56 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-PAP-001","基础设置 · 准入管理","产品准入申请 · 页面进入","菜单进入产品准入申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入申请」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/list`,且不是 `ZRlist` +3. 断言可见「查 询」、「Excel导入」「生成申请单」「一键生成」「新增产品」「作 废」及列表表头","进入产品准入申请页;不误入物料准入管理(ZRlist)或产品准入单列表。","" +"ADMIT-PAP-002","基础设置 · 准入管理","产品准入申请 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-PAP-003","基础设置 · 准入管理","产品准入申请 · 筛选查询","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleProductName}` +2. 填入产品名称,新增日期保持默认,点击「查 询」 +3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。","" +"ADMIT-PAP-004","基础设置 · 准入管理","产品准入申请 · 筛选查询","名称与耗材类型组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}` +2. 同时填入后点击「查 询」(日期保持默认) +3. 断言结果同时满足两个条件","组合查询结果同时满足产品名称与耗材类型。类型为空则只断言名称。","" +"ADMIT-PAP-005","基础设置 · 准入管理","产品准入申请 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 填入 `${sampleProductName}` +2. 点击「重 置」 +3. 断言筛选恢复默认","筛选字段恢复默认;列表按默认规则刷新。","" +"ADMIT-PAP-006","基础设置 · 准入管理","产品准入申请 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 产品名称填入 `__NOT_EXIST_GOODS__99999` +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"ADMIT-PAP-007","基础设置 · 准入管理","产品准入申请 · 状态与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、产品编号、产品名称、规格型号、注册规格、注册型号、器械注册人、单位、操作","核心列可见;操作含「详情」「日志」。","" +"ADMIT-PAP-008","基础设置 · 准入管理","产品准入申请 · 状态与列表","切换待申请 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「待申请」 +2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态","待申请 Tab 与列表状态一致。","" +"ADMIT-PAP-009","基础设置 · 准入管理","产品准入申请 · 状态与列表","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);分页总数大于每页条数","1. 记录第 1 页首行准入单号 +2. 点击页码「2」或「下一页」 +3. 断言页码高亮为 2,首行单号与第 1 页不同","翻页成功且数据更换。不足两页则 skip。","" +"ADMIT-PAP-010","基础设置 · 准入管理","产品准入申请 · 状态与列表","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);总页数不少于 2,分页存在「跳至」","1. 在跳至输入框填入 `2` 并回车 +2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。","" +"ADMIT-PAP-011","基础设置 · 准入管理","产品准入申请 · 新增与导入","打开新增产品后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「新增产品」 +2. 断言可见产品名称、器械注册人、规格型号、注册证号及返回 +3. 点击返回,不保存","新增页字段可见;返回后列表不新增记录。","" +"ADMIT-PAP-012","基础设置 · 准入管理","产品准入申请 · 新增与导入","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录分页总数 `${sampleTotal}` +2. 打开新增,产品名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存 +3. 按该名称查询,断言无此行,总数不变","取消后不产生新产品。","" +"ADMIT-PAP-013","基础设置 · 准入管理","产品准入申请 · 新增与导入","Excel 导入打开后取消","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录总数 `${sampleTotal}` +2. 点击「Excel导入」 +3. 断言导入层打开 +4. 关闭或取消,不选择文件 +5. 断言总数不变","取消导入不增加产品。","" +"ADMIT-PAP-014","基础设置 · 准入管理","产品准入申请 · 新增与导入","产品名称为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已打开新增产品页","1. 产品名称保持 mock 空值,其它能填的非名称字段可留空 +2. 点击保存 +3. 断言必填提示,页面不关闭 +4. 返回列表,断言总数不变","保存被拦截;不落库。","" +"ADMIT-PAP-015","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleProductName}` +2. 点击「详情」,断言名称一致 +3. 返回列表","详情与该行一致;返回后仍在产品准入申请列表。","" +"ADMIT-PAP-016","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","待申请编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 行含「编辑」","1. 读取该行 `${sampleProductName}`、`${sampleSpec}` +2. 点击「编辑」 +3. 断言名称与规格型号回填一致 +4. 返回不保存","编辑回填与列表行一致。无编辑按钮则 skip。","" +"ADMIT-PAP-017","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);存在可编辑行","1. 打开编辑,把产品名称改为 `${uniqProductName}` 后返回不保存 +2. 再次打开,断言名称仍为原值","取消后数据不变。","" +"ADMIT-PAP-018","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","作废确认后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 条可勾选","1. 勾选 1 行并点击「作 废」 +2. 断言确认框出现 +3. 点击「取 消」 +4. 断言该行仍在,状态未变为已作废","取消作废不改变状态。本条不点击确定。","" +"ADMIT-PAP-019","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","已通过行不提供编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已通过 Tab 计数大于 0","1. 切换「已通过」 +2. 断言首行无「编辑」,或打开后保存被拒绝","已通过行不能改写产品名称。计数为 0 则 skip。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..f3a1032295aa69c4352d915e36828a9554c1a457 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,57 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-SUP-001","基础设置 · 准入管理","供货目录申请 · 页面进入","菜单进入供货目录申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 供货目录申请」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/supplyApply/list` +3. 断言可见「查 询」、「批量提交」、「新 增」及列表表头","进入供货目录申请页;不误入准入任务分配。","" +"ADMIT-SUP-002","基础设置 · 准入管理","供货目录申请 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-SUP-003","基础设置 · 准入管理","供货目录申请 · 筛选与状态","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条且进院供应商非空","1. 从首行读取 `${sampleHospitalSupplier}` +2. 在进院供应商选择该值,制单日期保持默认,点击「查 询」 +3. 断言结果行「进院供应商」与所选值一致","进院供应商列与条件一致。","" +"ADMIT-SUP-004","基础设置 · 准入管理","供货目录申请 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已选择进院供应商","1. 点击「重 置」 +2. 断言进院供应商等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。","" +"ADMIT-SUP-005","基础设置 · 准入管理","供货目录申请 · 筛选与状态","变更单号无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在产品名称填同一 mock +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"ADMIT-SUP-006","基础设置 · 准入管理","供货目录申请 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作","核心列可见;操作含「编辑」或「详情」(随状态变化)。","" +"ADMIT-SUP-007","基础设置 · 准入管理","供货目录申请 · 筛选与状态","切换待提交 Tab 且计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「待提交」 +2. 读取括号数量 +3. 断言分页「共 N 条」等于该数量;有数据时状态为待提交","待提交 Tab 与分页总数一致。计数为 0 时为空态。","" +"ADMIT-SUP-008","基础设置 · 准入管理","供货目录申请 · 新增与编辑","打开新增供货目录","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「新 增」 +2. 断言可见进院供应商、二级供应商、业务模式、是否默认、计划生效时间、添加产品、提交 +3. 返回不提交","新增页字段齐全;返回后列表不新增申请单。","" +"ADMIT-SUP-009","基础设置 · 准入管理","供货目录申请 · 新增与编辑","新增放弃不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 记录总数 `${sampleTotal}` +2. 打开新增,备注填 `${uniqRemark}`(建议 `AUTO-SUP-${timestamp}`)后返回不提交 +3. 断言总数不变,列表无该备注","未提交不产生申请单。","" +"ADMIT-SUP-010","基础设置 · 准入管理","供货目录申请 · 新增与编辑","业务模式与是否默认只切换不提交","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页","1. 展开业务模式、是否默认,断言存在可选项 +2. 各选一项后返回,不提交 +3. 断言列表总数不变","选项可见;不提交则无新单。","" +"ADMIT-SUP-011","基础设置 · 准入管理","供货目录申请 · 新增与编辑","待提交编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 行含「编辑」","1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}` +2. 点击「编辑」 +3. 断言进院供应商、业务模式、是否默认与该行一致 +4. 返回不提交","编辑回填与列表行一致。无待提交或无编辑则 skip。","" +"ADMIT-SUP-012","基础设置 · 准入管理","供货目录申请 · 新增与编辑","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);存在可编辑的待提交行","1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交 +2. 再次打开,断言备注仍为原值","取消后备注不变。","" +"ADMIT-SUP-013","基础设置 · 准入管理","供货目录申请 · 新增与编辑","进院供应商为空不可提交(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,进院供应商未选","1. 不选进院供应商,点击「提 交」 +2. 断言必填提示,页面不关闭 +3. 返回列表,断言总数不变","提交被拦截;不产生申请单。","" +"ADMIT-SUP-014","基础设置 · 准入管理","供货目录申请 · 新增与编辑","未添加产品不可提交","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,产品明细为 0","1. 若进院供应商、二级供应商、业务模式可从样本选择则先选上,但不添加产品 +2. 点击「提 交」 +3. 断言拦截提示,明细仍为 0,列表总数不变","没有产品明细时不能提交。","" +"ADMIT-SUP-015","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","未勾选时批量提交不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),无勾选行","1. 断言「批量提交」计数为 0,或按钮不可用 +2. 若仍可点击,断言出现请选择提示,且无单据离开待提交","未勾选不提交任何申请单。","" +"ADMIT-SUP-016","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","勾选后批量提交取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 条","1. 勾选 1 行,记录申请单号与状态 +2. 点击「批量提交」 +3. 若出现确认框,点「取 消」 +4. 断言该单仍为待提交","取消批量提交不改变状态。本条不点击确定。无待提交则 skip。","" +"ADMIT-SUP-017","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 点击首行「详情」 +2. 断言可见「审批进程」「单据日志」「返 回」 +3. 返回后 URL 仍含 `supplyApply/list`","详情可打开;返回后仍在供货目录申请列表。","" +"ADMIT-SUP-018","基础设置 · 准入管理","供货目录申请 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「销售商」下拉选择任一可选项 +4. 「供应商」下拉选择任一可选项 +5. 「变更单号」填写关键字 +6. 「物料名称」填写关键字 +7. 「规格型号」填写关键字 +8. 点击「查询」 +9. 检查列表返回结果","多条件同时生效:制单日期、销售商、供应商、变更单号、物料名称、规格型号 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..7bd5815f2eeaf7685b2d66e609d623f45a4a45e6 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,54 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-TSK-001","基础设置 · 准入管理","准入任务分配 · 页面进入","菜单进入准入任务分配页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 准入任务分配」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/accessKind/list` +3. 断言可见「查 询」、「待分类」「已分类」、「批量分配」及列表表头","进入准入任务分配页;不误入供货目录申请。","" +"ADMIT-TSK-002","基础设置 · 准入管理","准入任务分配 · 筛选与分类","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe),当前为待分类","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-TSK-003","基础设置 · 准入管理","准入任务分配 · 筛选与分类","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 从首行读取 `${sampleProductName}` +2. 填入产品名称,新增日期与操作日期保持默认,点击「查 询」 +3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。","" +"ADMIT-TSK-004","基础设置 · 准入管理","准入任务分配 · 筛选与分类","待分类与已分类切换","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 记录「待分类」括号数量 `${pendingCount}` +2. 点击「已分类」,断言分页总数等于已分类括号数量;为 0 时为空态 +3. 再点「待分类」,断言分页总数等于 `${pendingCount}`","两个 Tab 的列表范围与各自计数一致,不串数据。","" +"ADMIT-TSK-005","基础设置 · 准入管理","准入任务分配 · 筛选与分类","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已填入 `${sampleProductName}`","1. 点击「重 置」 +2. 断言产品名称等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。","" +"ADMIT-TSK-006","基础设置 · 准入管理","准入任务分配 · 筛选与分类","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在待分类","1. 产品名称填入 `__NOT_EXIST_GOODS__99999` +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"ADMIT-TSK-007","基础设置 · 准入管理","准入任务分配 · 列表分页","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 断言表头含准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、操作 +2. 断言待分类行操作含「分配类型」","核心列可见;待分类行可发起分配类型。","" +"ADMIT-TSK-008","基础设置 · 准入管理","准入任务分配 · 列表分页","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类总数大于每页条数","1. 记录第 1 页首行产品名称或产品编号 +2. 点击页码「2」或「下一页」 +3. 断言页码为 2,首行与第 1 页不同","翻页成功且数据更换。不足两页则 skip。","" +"ADMIT-TSK-009","基础设置 · 准入管理","准入任务分配 · 列表分页","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);总页数不少于 2,且存在「跳至」","1. 跳至输入 `2` 并回车 +2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。","" +"ADMIT-TSK-010","基础设置 · 准入管理","准入任务分配 · 列表分页","第 1 页上一页不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在第 1 页","1. 断言「上一页」禁用,或点击后仍停留在第 1 页","第 1 页不能再向前翻。","" +"ADMIT-TSK-011","基础设置 · 准入管理","准入任务分配 · 分配操作","单行分配类型取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 行含「分配类型」","1. 记录该行产品名称,以及待分类计数 `${pendingCount}` +2. 点击「分配类型」 +3. 断言分配层打开 +4. 取消或关闭,不确认 +5. 断言该行仍在待分类,计数仍为 `${pendingCount}`","取消分配不改变分类。本条不点击确定。","" +"ADMIT-TSK-012","基础设置 · 准入管理","准入任务分配 · 分配操作","未勾选时批量分配不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类页无勾选行","1. 点击「批量分配」 +2. 断言按钮不可用,或提示先选择数据 +3. 断言待分类计数不变","未勾选不分配任何行。","" +"ADMIT-TSK-013","基础设置 · 准入管理","准入任务分配 · 分配操作","勾选后批量分配取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条","1. 勾选 1 行,记录待分类计数 +2. 点击「批量分配」 +3. 若打开分配层或确认框,点取消或关闭 +4. 断言该行仍在待分类,计数不变","取消批量分配不改变分类。本条不确认分配。","" +"ADMIT-TSK-014","基础设置 · 准入管理","准入任务分配 · 分配操作","已分类行不再按待分类分配","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已分类计数大于 0","1. 切换到「已分类」 +2. 断言首行无「分配类型」,或点击后被拒绝且该行仍留在已分类 +3. 断言待分类计数不因此增加","已分类数据不会被这次操作退回待分类。已分类为 0 则 skip。","" +"ADMIT-TSK-015","基础设置 · 准入管理","准入任务分配 · 筛选与分类","多条件组合查询(新增日期等11项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「新增日期」日期区间选择当天 +3. 「操作日期」日期区间选择当天 +4. 「销售商」填写关键字 +5. 「物料名称」填写关键字 +6. 「注册证号」填写关键字 +7. 「操作人」填写关键字 +8. 「器械注册人」填写关键字 +9. 「耗材类型」下拉选择任一可选项 +10. 「规格型号」填写关键字 +11. 「准入类型」下拉选择任一可选项 +12. 「准入状态」下拉选择任一可选项 +13. 点击「查询」 +14. 检查列表返回结果","多条件同时生效:新增日期、操作日期、销售商、物料名称、注册证号、操作人、器械注册人、耗材类型、规格型号、准入类型、准入状态 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..3019b571f02215fdbe3d54a5792ed4807d864620 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,48 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-MLS-001","基础设置 · 准入管理","物料准入单列表 · 页面进入","菜单进入物料准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 物料准入单列表」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/zrlist`,且不是 `productOrder/list` +3. 断言制单日期、查询、状态 Tab、列表表头可见","进入物料准入单列表;不误入产品准入单列表。","" +"ADMIT-MLS-002","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-MLS-003","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","按准入单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleOrderNo}` +2. 若页面有准入单号条件则填入;否则用注册证号或产品名称中首行非空字段 +3. 制单日期保持默认,点击「查 询」 +4. 断言结果行包含该单号或该关键字","结果与已填单号或关键字匹配。列表筛选区没有单号框时,改用首行注册证号,并在用例备注中记录实际字段。","" +"ADMIT-MLS-004","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 填入上一条使用的关键字 +2. 点击「重 置」 +3. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。","" +"ADMIT-MLS-005","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999` +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"ADMIT-MLS-006","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入类型、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、操作","核心列可见;操作列为「详情」。","" +"ADMIT-MLS-007","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「已通过」 +2. 断言有数据时「状态」列为已通过;计数为 0 时为空态 +3. 读取 Tab 括号数量,断言与分页「共 N 条」一致","已通过 Tab 的状态列、计数与分页总数一致。","" +"ADMIT-MLS-008","基础设置 · 准入管理","物料准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}`、`${sampleProductCount}` +2. 点击「详情」 +3. 断言可见「审批进程」「单据日志」「返 回」 +4. 点击「返 回」 +5. 断言 URL 仍含 `productOrder/zrlist`","进入单据详情并可返回列表。","" +"ADMIT-MLS-009","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开首行详情,且 `${sampleProductCount}` 为正整数","1. 统计详情明细数据行数(跨页则读详情分页总数) +2. 断言等于列表「产品条数」`${sampleProductCount}`","详情明细条数与列表产品条数一致。","" +"ADMIT-MLS-010","基础设置 · 准入管理","物料准入单列表 · 单据详情","审批进程可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「审批进程」 +2. 断言进程层或区域可见 +3. 关闭后仍停留在该单据","审批进程可查看;单据不被关掉。","" +"ADMIT-MLS-011","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情导出不白屏","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「导 出」 +2. 断言出现下载,或出现导出任务/成功提示 +3. 断言页面仍为该单据详情,不白屏","导出有反馈;详情页仍在。无导出按钮则 skip。","" +"ADMIT-MLS-012","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 在「请输入产品编码、名称」填入 `__NOT_EXIST_GOODS__99999` +2. 触发搜索 +3. 断言明细空态,单据头(审批进程、返回)仍在","无匹配时只清空明细,不销毁单据头。","" +"ADMIT-MLS-013","基础设置 · 准入管理","物料准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已通过 Tab 至少 1 条,并打开其详情","1. 若「通过」不可见或禁用,断言该状态保持已通过后返回 +2. 若「通过」可点,点击后出现确认框则点「取 消」 +3. 返回列表,断言该单状态仍为已通过","本条不把已通过单据再次审批成功。已通过 Tab 无数据则 skip。","" +"ADMIT-MLS-014","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「销售商」填写关键字 +4. 「耗材类型」下拉选择任一可选项 +5. 「器械注册人」填写关键字 +6. 「注册证号」填写关键字 +7. 「供应商」填写关键字 +8. 点击「查询」 +9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..f20e915117a4b0b93c0e4e92cad6c79d4c4be105 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,70 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"ADMIT-MAT-001","基础设置 · 准入管理","物料准入管理 · 页面进入","菜单进入物料准入管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 物料准入管理」进入 +2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/ZRlist` +3. 断言筛选区、「查 询」、状态 Tab、「关联产品」「生成申请单」「一键生成」「作 废」及列表表头可见 +4. 断言当前高亮不是「产品准入申请」或「物料准入单列表」","进入物料准入管理页;不误入同组其它准入页;筛选、状态 Tab 与工具栏可见。","" +"ADMIT-MAT-002","基础设置 · 准入管理","物料准入管理 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"ADMIT-MAT-003","基础设置 · 准入管理","物料准入管理 · 筛选查询","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleProductName}` +2. 填入「产品名称」,新增日期保持页面默认,点击「查 询」 +3. 断言结果行「产品名称」均包含该关键字","查询结果产品名称与已填关键字匹配;无未捕获异常。","" +"ADMIT-MAT-004","基础设置 · 准入管理","物料准入管理 · 筛选查询","耗材类型筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条且首行耗材类型非空","1. 从首行读取 `${sampleConsumableType}` +2. 在耗材类型下拉选择该值,点击「查 询」 +3. 断言结果行「耗材类型」与所选值一致","耗材类型列与所选条件一致。首行类型为空则 skip。","" +"ADMIT-MAT-005","基础设置 · 准入管理","物料准入管理 · 筛选查询","名称与耗材类型组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}` +2. 同时填入名称与耗材类型(新增日期保持默认,勿改成单日) +3. 点击「查 询」 +4. 断言结果同时满足两个条件","组合查询无报错;结果同时满足产品名称与耗材类型。","" +"ADMIT-MAT-006","基础设置 · 准入管理","物料准入管理 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 填入 `${sampleProductName}` +2. 点击「重 置」 +3. 断言产品名称等筛选恢复默认","筛选字段恢复默认;列表按默认规则刷新。","" +"ADMIT-MAT-007","基础设置 · 准入管理","物料准入管理 · 筛选查询","收起后再展开保留条件","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 填入 `${sampleProductName}` +2. 点击「收起」,再展开筛选区 +3. 断言产品名称仍为已填值","收起不丢失已填条件;展开后值仍在。","" +"ADMIT-MAT-008","基础设置 · 准入管理","物料准入管理 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 产品名称填入 mock 值 `__NOT_EXIST_GOODS__99999` +2. 点击「查 询」 +3. 断言空态或「暂无数据」,分页总数为 0","无匹配时不报错;空态明确;总数为 0。","" +"ADMIT-MAT-009","基础设置 · 准入管理","物料准入管理 · 状态与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 点击「查 询」 +2. 断言表头含准入类型、产品编号、产品名称、规格型号、器械注册人、单位、耗材类型、默认进院供应商、操作","核心列可见;操作列含「详情」「日志」,待申请行另可见「编辑」。","" +"ADMIT-MAT-010","基础设置 · 准入管理","物料准入管理 · 状态与列表","切换待申请 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 点击状态 Tab「待申请」 +2. 断言列表刷新;若有数据,行状态属于待申请(不出现已生效、已作废) +3. 再点「全部」,断言列表不再只限定待申请","待申请 Tab 只展示待申请数据;「全部」不额外收窄。计数为 0 时为空态且不报错。","" +"ADMIT-MAT-011","基础设置 · 准入管理","物料准入管理 · 状态与列表","待申请 Tab 计数与分页总数一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 读取「待申请」括号内数量 `${tabCount}` +2. 点击该 Tab 并查询稳定 +3. 断言分页「共 N 条」的 N 等于 `${tabCount}`","Tab 计数与该状态下分页总数一致。","" +"ADMIT-MAT-012","基础设置 · 准入管理","物料准入管理 · 状态与列表","未勾选时生成申请单不可用或提示","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe),且当前无勾选行","1. 确认表头复选框与行复选框均未选中 +2. 点击「生成申请单」 +3. 断言按钮不可用,或出现「请选择」类提示,且列表不新增申请单","未勾选不产生申请单。","" +"ADMIT-MAT-013","基础设置 · 准入管理","物料准入管理 · 详情与编辑","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleProductName}` +2. 点击该行「详情」 +3. 断言详情中产品名称、规格型号、注册证号可见,且名称包含 `${sampleProductName}` +4. 点击「返回」 +5. 断言回到列表,URL 仍含 `productApply/ZRlist`","详情展示该行产品主数据;返回后仍在物料准入管理列表。","" +"ADMIT-MAT-014","基础设置 · 准入管理","物料准入管理 · 详情与编辑","详情关键字段不可直接改写","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);已打开首行详情","1. 断言产品名称等主数据无可用的保存按钮,或输入框为只读 +2. 返回列表,断言首行名称仍为 `${sampleProductName}`","详情态不能把产品名称改写入库。","" +"ADMIT-MAT-015","基础设置 · 准入管理","物料准入管理 · 详情与编辑","待申请行编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);待申请 Tab 至少 1 行含「编辑」","1. 在待申请列表读取首行 `${sampleProductName}`、`${sampleSpec}` +2. 点击「编辑」 +3. 断言产品名称、规格型号与该行一致 +4. 返回且不保存","编辑页回填与列表行一致。无「编辑」则 skip。","" +"ADMIT-MAT-016","基础设置 · 准入管理","物料准入管理 · 详情与编辑","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);存在可编辑行","1. 打开编辑,记录原产品名称 +2. 将产品名称改为 `${uniqProductName}`(建议 `AUTO-MAT-${timestamp}`)后返回或取消,不保存 +3. 再次打开编辑,断言名称仍为原值","取消后列表与回看均为修改前名称。","" +"ADMIT-MAT-017","基础设置 · 准入管理","物料准入管理 · 详情与编辑","编辑清空产品名称拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);存在可编辑行","1. 打开编辑,将产品名称清空为 mock 空值 +2. 点击保存 +3. 断言必填提示,页面不关闭 +4. 取消后再次打开,断言名称仍为原值","保存被拦截;原记录不被写成空。","" +"ADMIT-MAT-018","基础设置 · 准入管理","物料准入管理 · 详情与编辑","已生效行不提供编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);已生效 Tab 计数大于 0","1. 切换到「已生效」 +2. 断言首行操作无「编辑」,或点击后保存被拒绝","已生效行不能改写成新的产品名称。计数为 0 则 skip。","" +"ADMIT-MAT-019","基础设置 · 准入管理","物料准入管理 · 关联与作废","关联产品取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` 与首行默认进院供应商 +2. 点击「关联产品」 +3. 断言关联层打开 +4. 取消或关闭,不确认 +5. 断言总数不变,首行供应商不变","取消关联不产生申请单,也不改默认进院供应商。","" +"ADMIT-MAT-020","基础设置 · 准入管理","物料准入管理 · 关联与作废","作废确认后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);待申请至少 1 条可勾选","1. 勾选 1 行,记录其产品名称与当前状态 +2. 点击「作 废」 +3. 断言出现确认框,含取消与确定 +4. 点击「取 消」 +5. 断言该行仍在,状态未变为已作废","作废需确认;取消后记录仍在。本条不点击确定,避免作废生产数据。","" +"ADMIT-MAT-021","基础设置 · 准入管理","物料准入管理 · 关联与作废","打开操作日志后关闭","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe);默认查询至少 1 条数据","1. 点击首行「日志」 +2. 断言出现操作日志层 +3. 关闭后断言仍在列表页","日志层可打开可关闭;列表数据不变。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..9beb5b5313d2dd144237668408ef34406fee747a --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,55 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"BASIC-NTC-001","基础设置 · 医院资料","公告管理 · 页面进入","菜单进入公告管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 公告管理」进入 +2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/notice +3. 检查筛选区、新增按钮、列表表头是否可见","进入公告管理页;不误入医院信息登记/院区管理/财务月份设置;筛选区与列表可见。","" +"BASIC-NTC-002","基础设置 · 医院资料","公告管理 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"BASIC-NTC-003","基础设置 · 医院资料","公告管理 · 筛选查询","公告标题模糊查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询至少 1 条数据","1. 从首行读取 ${sampleNoticeTitle}(禁止写死业务标题) +2. 填入筛选「公告标题」,点击「查 询」 +3. 检查结果行「公告标题」均包含该关键字(或与产品模糊规则一致)","查询结果标题与已填关键字匹配;无未捕获异常。","" +"BASIC-NTC-004","基础设置 · 医院资料","公告管理 · 筛选查询","发布范围下拉查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开发布范围下拉,选择与首行 ${samplePublishScope} 相同的选项(勿写死) +2. 点击「查 询」 +3. 检查结果行「发布范围」列与所选值一致(选「全部」时不额外收窄)","下拉选项含全部/全院/中心库/进院供应商;查询结果范围列与条件一致。","" +"BASIC-NTC-005","基础设置 · 医院资料","公告管理 · 筛选查询","标题与范围组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询至少 1 条数据","1. 读取首行 ${sampleNoticeTitle}、${samplePublishScope} +2. 填入标题与范围(发布日期保持页面默认,勿 fallback 单日) +3. 点击「查 询」 +4. 检查结果同时满足两个已填条件","组合查询无报错;结果同时满足标题与发布范围。","" +"BASIC-NTC-006","基础设置 · 医院资料","公告管理 · 筛选查询","快捷区重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 填入 ${sampleNoticeTitle} 等筛选条件 +2. 点击「重 置」 +3. 检查标题/范围/日期是否恢复默认","筛选字段恢复默认;列表按默认规则刷新。","" +"BASIC-NTC-007","基础设置 · 医院资料","公告管理 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 公告标题填入 mock 值 __NOT_EXIST_NOTICE__99999(唯一写死的反例 mock) +2. 点击「查 询」 +3. 检查空态或暂无数据,分页总数为 0","无匹配时不报错;空态明确;总数为 0。","" +"BASIC-NTC-008","基础设置 · 医院资料","公告管理 · 列表分页","查询后核心列展示","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「查 询」 +2. 检查表头是否含公告编号、公告标题、发布范围、发布科室、发布人、操作","核心列完整;操作列可见「编辑」「删除」。","" +"BASIC-NTC-009","基础设置 · 医院资料","公告管理 · 列表分页","分页翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);默认查询总条数大于一页","1. 记录第 1 页首行公告编号 +2. 点击页码「2」或「下一页」 +3. 检查页码高亮为 2,首行编号与第 1 页不同","翻页成功;数据更换;筛选条件保持。总条数不足一页则 skip。","" +"BASIC-NTC-010","基础设置 · 医院资料","公告管理 · 列表分页","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);总页数≥2","1. 在分页「跳至」输入 2 并回车 +2. 检查当前页为 2","跳转至指定页;列表刷新为该页数据。无 jumper 或仅 1 页则 skip。","" +"BASIC-NTC-011","基础设置 · 医院资料","公告管理 · 新增公告","打开新增公告弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 点击「新 增」 +2. 检查弹窗标题是否为「新增公告」 +3. 检查是否可见标题、发布范围、置顶、截止日期、富文本、取消、发布","新增弹窗可见;必填/选填控件齐全;富文本容器 .ql-editor 可见。","" +"BASIC-NTC-012","基础设置 · 医院资料","公告管理 · 新增公告","新增取消不落库","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 ${uniqNoticeTitle}(建议 AUTO-NTC-${timestamp}),内容填简短正文 +2. 点击「取 消」 +3. 按标题查询,检查无该记录","弹窗关闭;列表不出现 ${uniqNoticeTitle}。","" +"BASIC-NTC-013","基础设置 · 医院资料","公告管理 · 新增公告","合法必填发布成功","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);允许自动化写公告","1. 打开新增,标题 ${uniqNoticeTitle},发布范围选「中心库」,内容写入 .ql-editor +2. 点击「发 布」 +3. 按标题查询,检查出现该行且发布范围为中心库 +4. 后置:删除该测试公告","弹窗关闭;列表出现新记录;字段与提交一致。环境禁止写则 skip。","" +"BASIC-NTC-014","基础设置 · 医院资料","公告管理 · 新增公告","标题为空发布拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 mock 空值 """",内容填 mock 正文 __MOCK_NOTICE_BODY__ +2. 点击「发 布」 +3. 检查必填提示出现,弹窗仍打开","发布被拦截;不落库;弹窗不关闭。","" +"BASIC-NTC-015","基础设置 · 医院资料","公告管理 · 新增公告","内容为空发布拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)","1. 打开新增,标题填 mock 值 __MOCK_EMPTY_CONTENT_TITLE__,富文本留空 """" +2. 点击「发 布」 +3. 检查内容必填提示,弹窗仍打开","发布被拦截;不落库。","" +"BASIC-NTC-016","基础设置 · 医院资料","公告管理 · 编辑与删除","打开编辑公告并回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 点击首行「编辑」 +2. 检查弹窗标题是否为「编辑公告」 +3. 检查标题输入框值与该行 ${sampleNoticeTitle} 一致(允许规范化空白)","编辑弹窗可见;标题/范围/内容回填正确。","" +"BASIC-NTC-017","基础设置 · 医院资料","公告管理 · 编辑与删除","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 打开首行编辑,记录原标题 +2. 将标题改为 ${uniqNoticeTitle} 后点「取 消」 +3. 再次打开编辑,检查标题仍为原值","取消后列表与回看均为修改前标题。","" +"BASIC-NTC-018","基础设置 · 医院资料","公告管理 · 编辑与删除","删除弹出确认框","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe);列表至少 1 条数据","1. 点击首行「删除」 +2. 检查出现确认框(标题含「提示」),可见「取 消」「确 定」 +3. 点击「取 消」 +4. 检查该行仍在","删除需二次确认;取消后记录仍在。本条不断言真实删除,避免污染数据。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..902516cd47a8cac5b45b12903e501f33ceec5e47 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,32 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"BASIC-HOS-001","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","菜单进入医院信息登记页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe)","1. 按菜单进入「医院信息登记」 +2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/info +3. 检查可见「编 辑」及医院编码/名称/地址等详情标签","进入详情页;不误入公告/院区/财务月份页;查看态「编辑」可见。","" +"BASIC-HOS-002","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","查看态只读字段不可改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);未点编辑","1. 检查医院编码、医院名称、地址区域无可用输入框 +2. 检查「保 存」「取 消」不可见","查看模式下编码/名称/地址只读;不出现保存按钮。","" +"BASIC-HOS-003","基础设置 · 医院资料","医院信息登记 · 页面进入与查看","已上传图片可预览","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前医院已有登录页背景或 logo","1. 点击「预览文件」(背景或 logo 任一可见项) +2. 检查预览打开或新预览层可见","预览可打开;不报错。均无预览入口则 skip。","" +"BASIC-HOS-004","基础设置 · 医院资料","医院信息登记 · 编辑保存","进入编辑模式","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」 +2. 检查「保 存」「取 消」可见 +3. 检查 #form_item_linkman、#form_item_contactWay、#form_item_anotherName 可编辑","进入编辑态;三个可改文本框可输入;编码/名称/地址仍不可改。","" +"BASIC-HOS-005","基础设置 · 医院资料","医院信息登记 · 编辑保存","编辑态只读字段仍不可改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);已进入编辑模式","1. 点击「编 辑」 +2. 检查医院编码、医院名称、地址仍无可用输入框","编辑模式下系统分配字段保持只读。","" +"BASIC-HOS-006","基础设置 · 医院资料","医院信息登记 · 编辑保存","取消不保存","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」,读取原联系人 ${sampleLinkman} +2. 将联系人改为 ${uniqLinkman} 后点「取 消」 +3. 检查回到查看态,联系人展示仍为 ${sampleLinkman}","取消后不落库;查看态为修改前值。","" +"BASIC-HOS-007","基础设置 · 医院资料","医院信息登记 · 编辑保存","保存后字段持久化","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);允许写医院资料","1. 记录原联系人 ${sampleLinkman} +2. 编辑为 ${uniqLinkman} 并「保 存」 +3. 刷新或重新打开本页,检查联系人为 ${uniqLinkman} +4. 后置:再编辑回 ${sampleLinkman} 并保存","保存后刷新仍为新值;用例结束恢复原值。环境禁止写则 skip。","" +"BASIC-HOS-008","基础设置 · 医院资料","医院信息登记 · 编辑保存","联系人为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」,将联系人清空为 mock 空值 """" +2. 点击「保 存」 +3. 检查必填提示,仍停留编辑态","保存被拦截;原联系人不被写成空。","" +"BASIC-HOS-009","基础设置 · 医院资料","医院信息登记 · 编辑保存","联系方式为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 进入编辑态,将联系方式清空为 mock 空值 """" +2. 点击「保 存」 +3. 检查必填提示,编辑态保持","保存被拦截;联系方式不被清空落库。","" +"BASIC-HOS-010","基础设置 · 医院资料","医院信息登记 · 编辑保存","医院简称为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 进入编辑态,将医院简称清空为 mock 空值 """" +2. 点击「保 存」 +3. 检查必填提示","保存被拦截;简称不被写成空。","" +"BASIC-HOS-011","基础设置 · 医院资料","医院信息登记 · 图片上传","编辑态存在背景与 logo 上传控件","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe);当前为查看态","1. 点击「编 辑」 +2. 检查存在登录页背景、医院 logo 对应 input[type=file](loginBgUploadInput / logoUploadInput) +3. 点「取 消」,不实际上传","编辑态两个上传控件可定位;本条不强制替换线上图片。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..22aed47bc67de8e4ce645bb665aab6fd24b8e7e4 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,41 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"BASIC-FMN-001","基础设置 · 医院资料","财务月份设置 · 页面进入","菜单进入财务月份设置页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 财务月份设置」进入 +2. 检查当前 URL 是否含 /spd/basic/hospital/balance/month +3. 检查筛选区(#form_item_settleMonthDate)、查询/重置、列表列头是否可见","进入财务月份设置页;不误入公告/医院信息/院区页;筛选与列表可见。","" +"BASIC-FMN-002","基础设置 · 医院资料","财务月份设置 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"BASIC-FMN-003","基础设置 · 医院资料","财务月份设置 · 筛选查询","按财务月份筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询至少 1 条数据","1. 从首行或当前月份行读取 ${sampleFinanceMonth}(禁止写死月份) +2. 在 #form_item_settleMonthDate 选择该月份,点击「查 询」 +3. 检查结果行「财务月份」均等于该月份(或与产品筛选规则一致)","结果仅含所选财务月份;无未捕获异常。","" +"BASIC-FMN-004","基础设置 · 医院资料","财务月份设置 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)","1. 选择 ${sampleFinanceMonth} 并查询 +2. 点击「重 置」 +3. 检查月份筛选恢复默认,列表按默认规则刷新","重置后筛选清空或回到默认;列表可再查询。","" +"BASIC-FMN-005","基础设置 · 医院资料","财务月份设置 · 列表与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询至少 1 条数据","1. 检查表头是否包含财务月份、开始日期、结束日期、自动结算日期、状态、操作","核心列均可见,不缺「状态」「操作」。","" +"BASIC-FMN-006","基础设置 · 医院资料","财务月份设置 · 列表与状态","仅当前月份行可修改","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);列表同时存在当前月份与历史月份(若仅一行当前月份则只断言该行)","1. 定位状态为「当前月份」的行,检查操作列可见「修 改」(modifyBtn) +2. 对「历史月份」行检查无「修 改」或操作为「--」","可修改入口只出现在当前月份行。无历史月份则只校验当前月份入口。","" +"BASIC-FMN-007","基础设置 · 医院资料","财务月份设置 · 列表与状态","当前月份至多一行","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);默认查询已出列表","1. 统计状态为「当前月份」的行数 +2. 检查行数 ≤ 1","同一时点至多一行「当前月份」。","" +"BASIC-FMN-008","基础设置 · 医院资料","财务月份设置 · 列表与状态","历史月份无法进入编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);列表至少 1 条历史月份","1. 对历史月份行尝试点击操作区 +2. 检查不出现行内「保 存」「取 消」,自动结算日期仍为文本","历史月份不能改开始/结束/自动结算日期。无历史月份则 skip。","" +"BASIC-FMN-009","基础设置 · 医院资料","财务月份设置 · 当前月份修改","修改进入行内编辑","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在状态为「当前月份」的行","1. 点击该行「修 改」 +2. 检查出现「保 存」「取 消」 +3. 检查自动结算日期变为日期框(autoSettleDateInput)","进入行内编辑;仅自动结算日可改。","" +"BASIC-FMN-010","基础设置 · 医院资料","财务月份设置 · 当前月份修改","编辑态开始结束日期只读","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);已进入当前月份行内编辑","1. 点击「修 改」 +2. 检查开始日期、结束日期无可用输入框 +3. 检查仅 autoSettleDateInput 可编辑 +4. 点击「取 消」退出","开始/结束日期保持只读;账期区间不被改写。","" +"BASIC-FMN-011","基础设置 · 医院资料","财务月份设置 · 当前月份修改","取消不保存自动结算日","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行","1. 读取 ${sampleAutoSettleDate} +2. 点「修 改」,将自动结算日改为当月区间内另一天 ${uniqAutoSettleDate} +3. 点击「取 消」 +4. 检查该行自动结算日仍为 ${sampleAutoSettleDate}","取消后不落库;日期恢复修改前值。","" +"BASIC-FMN-012","基础设置 · 医院资料","财务月份设置 · 当前月份修改","保存自动结算日期持久化","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行;允许写财务月份","1. 记录 ${sampleAutoSettleDate}、${sampleStartDate}、${sampleEndDate} +2. 将自动结算日改为区间内另一天 ${uniqAutoSettleDate} 并「保 存」 +3. 刷新或重新查询,检查该行自动结算日为 ${uniqAutoSettleDate} +4. 后置:再改为 ${sampleAutoSettleDate} 并保存","保存后刷新仍为新日期;用例结束恢复原值。环境禁止写则 skip。","" +"BASIC-FMN-013","基础设置 · 医院资料","财务月份设置 · 当前月份修改","自动结算日为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行","1. 点「修 改」,将 autoSettleDateInput 清空为 mock 空值 """" +2. 点击「保 存」 +3. 检查必填或非法提示,行上日期仍为 ${sampleAutoSettleDate}","保存被拦截;原日期不被写成空。","" +"BASIC-FMN-014","基础设置 · 医院资料","财务月份设置 · 当前月份修改","自动结算日超出当月区间拦截(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe);存在当前月份行且能读到开始/结束日","1. 点「修 改」,将日期改为 mock 越界值:早于 ${sampleStartDate} 或晚于 ${sampleEndDate}(如 __MOCK_OUT_OF_RANGE_DATE__) +2. 点击「保 存」 +3. 检查拒绝保存并提示,原日期不变 +4. 点「取 消」退出","超出账期区间时拒绝保存;不脏写。日期控件无法选出区间外日期则 skip 并记录产品限制。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..d570793498f1e52add07b459363de93aa1466798 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,55 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"BASIC-BRN-001","基础设置 · 医院资料","院区管理 · 页面进入","菜单进入院区管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备医院资料权限;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 按菜单「基础设置 → 医院资料 → 院区管理」进入 +2. 检查当前 URL 是否含 /spd/basic/hospital/paltform/area +3. 检查「新 增」、院区名称搜索框(campusNameInput)、列表列头可见 +4. 检查页面无独立「查 询」「重 置」按钮","进入院区管理页;不误入公告/医院信息/财务月份页;搜索框与新增可见,无查询/重置。","" +"BASIC-BRN-002","基础设置 · 医院资料","院区管理 · 列表搜索","默认列出院区或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 等待列表稳定 +2. 检查是否出现数据行,或「暂无数据」且分页总数为 0","加载无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。","" +"BASIC-BRN-003","基础设置 · 医院资料","院区管理 · 列表搜索","院区名称搜索过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);默认列表至少 1 条数据","1. 从首行读取 ${sampleCampusName}(禁止写死院区名称) +2. 在 campusNameInput 填入该关键字(即时过滤或回车,以现网为准) +3. 检查结果行「院区名称」均包含该关键字","过滤结果名称与已填关键字匹配;无未捕获异常。","" +"BASIC-BRN-004","基础设置 · 医院资料","院区管理 · 列表搜索","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);默认列表至少 1 条数据","1. 检查表头是否包含序号、院区编号、院区名称、院区地址、状态、启用云仓、操作","核心列均可见,操作列含「编 辑」。","" +"BASIC-BRN-005","基础设置 · 医院资料","院区管理 · 列表搜索","清空搜索恢复全量","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已用 ${sampleCampusName} 过滤","1. 记录过滤后条数 +2. 清空 campusNameInput 并等待列表刷新 +3. 检查条数回到未过滤的默认集合(或不少于过滤后条数)","清空后列表恢复默认集合。","" +"BASIC-BRN-006","基础设置 · 医院资料","院区管理 · 列表搜索","搜索无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 在院区名称框输入 mock 值 __NOT_EXIST_CAMPUS__99999(唯一写死的反例 mock) +2. 检查「暂无数据」且分页总数为 0","无匹配时空态明确,不白屏。","" +"BASIC-BRN-007","基础设置 · 医院资料","院区管理 · 新增院区","打开新增院区弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 点击「新 增」 +2. 检查弹窗标题是否为「新增院区」 +3. 检查可见院区名称、所在地区、详细地址、主院区标志、状态、取消/确定 +4. 检查无 #form_item_id、无启用云仓 +5. 点击「取 消」关闭","新增弹窗字段完整;不含院区编号与启用云仓。","" +"BASIC-BRN-008","基础设置 · 医院资料","院区管理 · 新增院区","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)","1. 记录当前分页总数 ${sampleTotal} +2. 打开新增,填写名称 ${uniqCampusName}(建议 AUTO-BRN-${timestamp})后点「取 消」 +3. 检查列表总数不变,且无该名称行","取消后不新增院区。","" +"BASIC-BRN-009","基础设置 · 医院资料","院区管理 · 新增院区","合法必填新增成功","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);允许写院区;名称使用 ${uniqCampusName}","1. 打开新增,填写名称 ${uniqCampusName}、所在地区、详细地址,主院区选「否」,状态选「启用」 +2. 点击「确 定」 +3. 检查弹窗关闭,列表出现该名称行且字段一致 +4. 后置:按环境约定停用或清理该院区,避免污染主数据","新增成功且列表可见;用例结束清理或停用,不留生产脏数据。环境禁止写则 skip。","" +"BASIC-BRN-010","基础设置 · 医院资料","院区管理 · 新增院区","院区名称为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 空值 """",补齐地区与地址后点「确 定」 +2. 检查必填提示,弹窗仍在,列表无新行","确定被拦截;不落库。","" +"BASIC-BRN-011","基础设置 · 医院资料","院区管理 · 新增院区","所在地区为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 值 __MOCK_CAMPUS_NO_REGION__,地址填 __MOCK_ADDR__,不选所在地区 +2. 点击「确 定」 +3. 检查必填提示,弹窗仍在","确定被拦截;不落库。","" +"BASIC-BRN-012","基础设置 · 医院资料","院区管理 · 新增院区","详细地址为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开新增弹窗","1. 名称填 mock 值 __MOCK_CAMPUS_NO_ADDR__ 并选择地区,地址留空 """" +2. 点击「确 定」 +3. 检查必填提示,弹窗仍在","确定被拦截;不落库。","" +"BASIC-BRN-013","基础设置 · 医院资料","院区管理 · 编辑院区","打开编辑弹窗并回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 从首行读取 ${sampleCampusId}、${sampleCampusName}、${sampleCampusAddress} +2. 点击「编 辑」 +3. 检查弹窗标题是否为「编辑院区」 +4. 检查 #form_item_id 等于 ${sampleCampusId},名称/地址回填正确 +5. 检查额外可见「启用云仓」 +6. 点击「取 消」","编辑弹窗回填正确;相对新增多出院区编号与启用云仓。","" +"BASIC-BRN-014","基础设置 · 医院资料","院区管理 · 编辑院区","编辑态院区编号不可换号","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开首行编辑","1. 检查 #form_item_id 可见且值等于列表「院区编号」 +2. 若编号只读则直接检查 disabled/readonly;若可输入则改写为 mock 值 __MOCK_CAMPUS_ID__ 后点确定,刷新后编号仍为原值 +3. 未改其它字段时点「取 消」","院区编号与列表一致;保存后编号不变。","" +"BASIC-BRN-015","基础设置 · 医院资料","院区管理 · 编辑院区","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 打开首行编辑,记录原名称 +2. 将名称改为 ${uniqCampusName} 后点「取 消」 +3. 再次打开编辑,检查名称仍为原值","取消后列表与回看均为修改前名称。","" +"BASIC-BRN-016","基础设置 · 医院资料","院区管理 · 编辑院区","编辑状态为禁用启用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开编辑弹窗","1. 检查状态选项文案为「禁用 / 启用」(不是新增弹窗的「停用 / 启用」) +2. 点「取 消」,不改状态","编辑态状态文案与新增不同;本条不真实切换生产院区状态。","" +"BASIC-BRN-017","基础设置 · 医院资料","院区管理 · 编辑院区","编辑可见启用云仓","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);已打开编辑弹窗","1. 检查「启用云仓」单选「是 / 否」可见 +2. 点「取 消」,不改云仓标志","编辑可看到启用云仓;本条不真实改生产院区云仓开关。","" +"BASIC-BRN-018","基础设置 · 医院资料","院区管理 · 编辑院区","编辑清空必填拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe);列表至少 1 条数据","1. 打开首行编辑,将院区名称清空为 mock 空值 """" 后点「确 定」 +2. 检查必填提示,弹窗仍在 +3. 点「取 消」后再打开,检查名称仍为原值","确定被拦截;原记录不被写成空。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..12e0011a7429296f280c083d31a5cf076801a0e3 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,19 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-HLD-001","基础设置 · 厂商管理","供应商节假日管理 · 页面进入","菜单进入供应商节假日管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 按菜单进入供应商节假日管理 +2. 断言 URL 含 `prov/holiday` +3. 断言可见节假日主区与新增","进入供应商节假日管理页。","" +"VENDOR-HLD-002","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","选中左侧节假日展示详情","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe);左侧有数据","1. 点击左侧首行 +2. 断言默认假日详情或自定义列表区可见","详情联动成功。","" +"VENDOR-HLD-003","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","左侧搜索无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 左侧搜索框填 mock 并回车 +2. 断言左侧列表空态","无匹配空态明确。","" +"VENDOR-HLD-004","基础设置 · 厂商管理","供应商节假日管理 · 维护","左侧新增取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 点击新增 +2. 取消/关闭不保存 +3. 断言仍停留本页且无异常","取消不落库。","" +"VENDOR-HLD-005","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","自定义节假日列表区可见","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 选中左侧行 +2. 断言「供应商自定义节假日列表」区域可见(可为空态)","自定义区可见。","" +"VENDOR-HLD-006","基础设置 · 厂商管理","供应商节假日管理 · 维护","结束早于开始日期拦截","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 打开新增/编辑,结束日期早于开始日期尝试保存 +2. 断言拒绝保存并提示","非法日期被拦截。","" +"VENDOR-HLD-007","基础设置 · 厂商管理","供应商节假日管理 · 维护","开始结束为同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件) +2. 断言允许保存或明确提示规则(按产品规则),不报错白屏","同日区间规则明确。","" +"VENDOR-HLD-008","基础设置 · 厂商管理","供应商节假日管理 · 左右分栏","进院供应商主数据一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商节假日管理页(/spd/basic/mfrs/basic/prov/holiday,无 iframe)","1. 在自定义列表抽样进院供应商(若有数据) +2. 断言可在厂商/供应商主数据中找到对应记录","自定义节假日供应商与主数据一致。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..b4ca495b3cecf01fb4355b9e8d7804af30b08b9f --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,33 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-QUL-001","基础设置 · 厂商管理","供应商资质管理 · 页面进入","菜单进入供应商资质管理页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入 +2. 断言 URL 含 `/spd/basic/mfrs/basic/qcManage` +3. 断言可见「查 询」「重 置」及证件效期提醒相关入口","进入供应商资质管理;不误入厂商查询。","" +"VENDOR-QUL-002","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。","" +"VENDOR-QUL-003","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","切换授权链路效期预警 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 点击「授权链路效期预警」 +2. 断言列表有数据或空态明确","Tab 切换成功,列表字段可随 Tab 变化。","" +"VENDOR-QUL-004","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","近三个月到期计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 切换「近三个月到期」(或等价文案) +2. 若可读到括号/冒号计数,断言等于分页「共 N 条」","计数与分页一致(可读时)。","" +"VENDOR-QUL-005","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe);证件效期提醒至少 1 条且进院供应商非空","1. 从首行读取 `${sampleHospitalSupplier}` 取关键字 +2. 在进院供应商名称填入关键字并「查 询」 +3. 断言结果行「进院供应商」包含关键字","进院供应商列与条件一致。","" +"VENDOR-QUL-006","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 填写筛选条件后点击「重 置」 +2. 断言列表按默认规则刷新","筛选恢复默认。","" +"VENDOR-QUL-007","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999` +2. 点击「查 询」 +3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-QUL-008","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","证件效期提醒核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 处于证件效期提醒 Tab +2. 断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作","核心列可见。","" +"VENDOR-QUL-009","基础设置 · 厂商管理","供应商资质管理 · 查看与导出","打开查看后关闭","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe);列表至少 1 行含「查看」","1. 点击首行「查 看」 +2. 断言详情层可见 +3. 关闭/返回,断言回到列表","详情可打开;关闭后回到列表。","" +"VENDOR-QUL-010","基础设置 · 厂商管理","供应商资质管理 · 查看与导出","导出按钮可见","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 断言「导出」或「导出明细」按钮可见(本批不确认下载)","导出入口可见。","" +"VENDOR-QUL-011","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","制单/效期日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 若存在日期区间筛选,起止选同一天并查询 +2. 断言仅返回该日数据或空态明确,不报错","同日区间行为明确。","" +"VENDOR-QUL-012","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","效期提醒与有效期至抽样一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入供应商资质管理页(/spd/basic/mfrs/basic/qcManage,无 iframe)","1. 在近三个月到期列表抽样一行 +2. 核对「效期提醒」文案与「有效期至」日期逻辑一致","提醒文案与有效期至不矛盾。","" +"VENDOR-QUL-013","基础设置 · 厂商管理","供应商资质管理 · 筛选与 Tab","多条件组合查询(销售商名称等2项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「销售商名称」填写关键字 +3. 「证件类型」下拉选择任一可选项 +4. 点击「查询」 +5. 检查列表返回结果","多条件同时生效:销售商名称、证件类型 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..6b7109f3b9d0d92319affa9d3fbbb7163588a370 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,26 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-QRY-001","基础设置 · 厂商管理","厂商查询 · 页面进入","菜单进入厂商查询页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 厂商查询」进入 +2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory` +3. 断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头","进入厂商查询页;不误入供应商资质管理。","" +"VENDOR-QRY-002","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","默认器械注册人列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 切换或停留「器械注册人」 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","器械注册人列表稳定;有数据时分页「共 N 条」N≥1。","" +"VENDOR-QRY-003","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","切换经销商 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 点击「经销商」 +2. 断言 Tab 激活,列表有数据或空态明确","经销商列表加载成功;总数可与器械注册人不同。","" +"VENDOR-QRY-004","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","按厂商名称搜索","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);器械注册人至少 1 条且厂商名称非空","1. 从首行读取 `${sampleMfrsName}` 取关键字 +2. 在厂商名称搜索框填入关键字并回车 +3. 断言结果行「厂商名称」包含关键字","厂商名称列与关键字匹配。","" +"VENDOR-QRY-005","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","清空搜索恢复","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);已按厂商名称过滤","1. 在已过滤状态下清空搜索框并回车 +2. 断言分页总数不少于过滤后","恢复更宽结果集。","" +"VENDOR-QRY-006","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车 +2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-QRY-007","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号","核心列可见。","" +"VENDOR-QRY-008","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","分页翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe);器械注册人总数足够翻页","1. 总数足够时翻到第 2 页 +2. 断言列表可加载;不足两页则 skip","第 2 页可加载。","" +"VENDOR-QRY-009","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","仅空格关键字搜索","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 厂商名称仅填空格并回车 +2. 断言按空查询处理或提示,不白屏","不报错;行为明确。","" +"VENDOR-QRY-010","基础设置 · 厂商管理","厂商查询 · 页面进入","未登录访问拦截","高","测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态)","1. 新建独立 BrowserContext,不加载 auth/user.json(test.use 空 storageState) +2. 直接打开厂商查询 URL(/spd/basic/mfrs/paltform/mfrsName/factory) +3. 断言跳转登录或不展示业务列表","未登录不可见厂商数据。","" +"VENDOR-QRY-011","基础设置 · 厂商管理","厂商查询 · Tab 与搜索","厂商名称与资质管理进院供应商可对照","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入厂商查询页(/spd/basic/mfrs/paltform/mfrsName/factory,无 iframe)","1. 记录厂商查询中某厂商名称(若有) +2. 打开供应商资质管理,用同源关键字检索或目视对照 +3. 断言不出现明显主数据串名(允许一方无记录)","厂商主数据跨菜单可对照,不串名。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ceaa533ff116aafb9b34884c0ad3a36df2a --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,36 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-AF1-001","基础设置 · 厂商管理","授权变更初审 · 页面进入","菜单进入授权变更初审页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 授权变更初审」进入 +2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/authAudit` +3. 断言可见「查 询」「重 置」及制单日期筛选","进入授权变更初审页;不误入其它变更审批菜单。","" +"VENDOR-AF1-002","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。","" +"VENDOR-AF1-003","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","按单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe);列表至少 1 条且单号非空","1. 从首行读取 `${sampleOrderNo}` 取关键字 +2. 单号填入关键字并「查 询」 +3. 断言结果行「单号」包含关键字","单号列与条件一致。","" +"VENDOR-AF1-004","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe);列表至少 1 条","1. 按样本填写或选择「进院供应商」并查询 +2. 断言结果列与条件匹配","筛选条件生效。","" +"VENDOR-AF1-005","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 填写筛选项后点击「重 置」 +2. 断言文本条件清空、列表按默认规则刷新","重置成功。","" +"VENDOR-AF1-006","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 +2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-AF1-007","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作 +2. 断言存在「详情」操作","核心列与详情入口可见。","" +"VENDOR-AF1-008","基础设置 · 厂商管理","授权变更初审 · 详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe);存在含「详情」的行","1. 点击行内「详 情」 +2. 断言详情层可见且含单号片段 +3. 返回/关闭,不提交审批","详情只读可返回;列表状态不变。","" +"VENDOR-AF1-009","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","制单日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 制单日期起止选同一天并查询 +2. 断言仅返回该日单据或空态明确","同日区间行为明确。","" +"VENDOR-AF1-010","基础设置 · 厂商管理","授权变更初审 · 详情","与终审单据衔接不串单","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更初审页(/spd/basic/mfrs/paltform/mfrsName/authAudit,无 iframe)","1. 记录本页某单号后切换到对应终审菜单 +2. 断言同业务单据状态流转合理、不串到非本业务菜单","初审/终审衔接正确。","" +"VENDOR-AF1-011","基础设置 · 厂商管理","授权变更初审 · 筛选与列表","多条件组合查询(制单日期等9项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「单号」填写关键字 +4. 「销售商」下拉选择任一可选项 +5. 「预转供应商」填写关键字 +6. 「状态」下拉选择任一可选项 +7. 「变更类型」下拉选择任一可选项 +8. 「原供应商」填写关键字 +9. 「医院ID」填写关键字 +10. 「医院名称」填写关键字 +11. 点击「查询」 +12. 检查列表返回结果","多条件同时生效:制单日期、单号、销售商、预转供应商、状态、变更类型、原供应商、医院ID、医院名称 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..e62754414fd20c653b3a1e3d47919a360cca3d51 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,34 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-AF2-001","基础设置 · 厂商管理","授权变更终审 · 页面进入","菜单进入授权变更终审页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 授权变更终审」进入 +2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/authAuditFinal` +3. 断言可见「查 询」「重 置」及制单日期筛选","进入授权变更终审页;不误入其它变更审批菜单。","" +"VENDOR-AF2-002","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。","" +"VENDOR-AF2-003","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","按单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe);列表至少 1 条且单号非空","1. 从首行读取 `${sampleOrderNo}` 取关键字 +2. 单号填入关键字并「查 询」 +3. 断言结果行「单号」包含关键字","单号列与条件一致。","" +"VENDOR-AF2-004","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe);列表至少 1 条","1. 按样本填写或选择「进院供应商」并查询 +2. 断言结果列与条件匹配","筛选条件生效。","" +"VENDOR-AF2-005","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 填写筛选项后点击「重 置」 +2. 断言文本条件清空、列表按默认规则刷新","重置成功。","" +"VENDOR-AF2-006","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 +2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-AF2-007","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作 +2. 断言存在「详情」操作","核心列与详情入口可见。","" +"VENDOR-AF2-008","基础设置 · 厂商管理","授权变更终审 · 详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe);存在含「详情」的行","1. 点击行内「详 情」 +2. 断言详情层可见且含单号片段 +3. 返回/关闭,不提交审批","详情只读可返回;列表状态不变。","" +"VENDOR-AF2-009","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","制单日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 制单日期起止选同一天并查询 +2. 断言仅返回该日单据或空态明确","同日区间行为明确。","" +"VENDOR-AF2-010","基础设置 · 厂商管理","授权变更终审 · 详情","与初审单据衔接不串单","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入授权变更终审页(/spd/basic/mfrs/paltform/mfrsName/authAuditFinal,无 iframe)","1. 记录本页某单号后切换到对应初审菜单 +2. 断言同业务单据状态流转合理、不串到非本业务菜单","初审/终审衔接正确。","" +"VENDOR-AF2-011","基础设置 · 厂商管理","授权变更终审 · 筛选与列表","多条件组合查询(制单日期等7项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「单号」填写关键字 +4. 「销售商」下拉选择任一可选项 +5. 「预转供应商」填写关键字 +6. 「状态」下拉选择任一可选项 +7. 「变更类型」下拉选择任一可选项 +8. 「原供应商」填写关键字 +9. 点击「查询」 +10. 检查列表返回结果","多条件同时生效:制单日期、单号、销售商、预转供应商、状态、变更类型、原供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..b3c7debcba1362b639e2a31253acdffbfae77128 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,33 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-CF1-001","基础设置 · 厂商管理","注册证变更初审 · 页面进入","菜单进入注册证变更初审页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 注册证变更初审」进入 +2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/regCertAudit` +3. 断言可见「查 询」「重 置」及制单日期筛选","进入注册证变更初审页;不误入其它变更审批菜单。","" +"VENDOR-CF1-002","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。","" +"VENDOR-CF1-003","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","按单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe);列表至少 1 条且单号非空","1. 从首行读取 `${sampleOrderNo}` 取关键字 +2. 单号填入关键字并「查 询」 +3. 断言结果行「单号」包含关键字","单号列与条件一致。","" +"VENDOR-CF1-004","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","按注册证号(新)筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe);列表至少 1 条","1. 按样本填写或选择「注册证号(新)」并查询 +2. 断言结果列与条件匹配","筛选条件生效。","" +"VENDOR-CF1-005","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 填写筛选项后点击「重 置」 +2. 断言文本条件清空、列表按默认规则刷新","重置成功。","" +"VENDOR-CF1-006","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 +2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-CF1-007","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作 +2. 断言存在「详情」操作","核心列与详情入口可见。","" +"VENDOR-CF1-008","基础设置 · 厂商管理","注册证变更初审 · 详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe);存在含「详情」的行","1. 点击行内「详 情」 +2. 断言详情层可见且含单号片段 +3. 返回/关闭,不提交审批","详情只读可返回;列表状态不变。","" +"VENDOR-CF1-009","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","制单日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 制单日期起止选同一天并查询 +2. 断言仅返回该日单据或空态明确","同日区间行为明确。","" +"VENDOR-CF1-010","基础设置 · 厂商管理","注册证变更初审 · 详情","与终审单据衔接不串单","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更初审页(/spd/basic/mfrs/paltform/mfrsName/regCertAudit,无 iframe)","1. 记录本页某单号后切换到对应终审菜单 +2. 断言同业务单据状态流转合理、不串到非本业务菜单","初审/终审衔接正确。","" +"VENDOR-CF1-011","基础设置 · 厂商管理","注册证变更初审 · 筛选与列表","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「单号」填写关键字 +4. 「变更类型」下拉选择任一可选项 +5. 「注册证号(新)」填写关键字 +6. 「状态」下拉选择任一可选项 +7. 「变更情况具体说明」填写关键字 +8. 点击「查询」 +9. 检查列表返回结果","多条件同时生效:制单日期、单号、变更类型、注册证号(新)、状态、变更情况具体说明 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..2fe019594c1796d0eec48354dec628f7f9b03aa3 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,32 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-CF2-001","基础设置 · 厂商管理","注册证变更终审 · 页面进入","菜单进入注册证变更终审页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 注册证变更终审」进入 +2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal` +3. 断言可见「查 询」「重 置」及制单日期筛选","进入注册证变更终审页;不误入其它变更审批菜单。","" +"VENDOR-CF2-002","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 点击「查 询」并等待列表稳定 +2. 断言出现数据行或空态明确","查询无报错;有数据时分页 N≥1。","" +"VENDOR-CF2-003","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","按单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe);列表至少 1 条且单号非空","1. 从首行读取 `${sampleOrderNo}` 取关键字 +2. 单号填入关键字并「查 询」 +3. 断言结果行「单号」包含关键字","单号列与条件一致。","" +"VENDOR-CF2-004","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","按注册证号(新)筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe);列表至少 1 条","1. 按样本填写或选择「注册证号(新)」并查询 +2. 断言结果列与条件匹配","筛选条件生效。","" +"VENDOR-CF2-005","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 填写筛选项后点击「重 置」 +2. 断言文本条件清空、列表按默认规则刷新","重置成功。","" +"VENDOR-CF2-006","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 +2. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。","" +"VENDOR-CF2-007","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作 +2. 断言存在「详情」操作","核心列与详情入口可见。","" +"VENDOR-CF2-008","基础设置 · 厂商管理","注册证变更终审 · 详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe);存在含「详情」的行","1. 点击行内「详 情」 +2. 断言详情层可见且含单号片段 +3. 返回/关闭,不提交审批","详情只读可返回;列表状态不变。","" +"VENDOR-CF2-009","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","制单日期起止同一天","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 制单日期起止选同一天并查询 +2. 断言仅返回该日单据或空态明确","同日区间行为明确。","" +"VENDOR-CF2-010","基础设置 · 厂商管理","注册证变更终审 · 详情","与初审单据衔接不串单","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证变更终审页(/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal,无 iframe)","1. 记录本页某单号后切换到对应初审菜单 +2. 断言同业务单据状态流转合理、不串到非本业务菜单","初审/终审衔接正确。","" +"VENDOR-CF2-011","基础设置 · 厂商管理","注册证变更终审 · 筛选与列表","多条件组合查询(制单日期等5项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「制单日期」日期区间选择当天 +3. 「单号」填写关键字 +4. 「变更类型」下拉选择任一可选项 +5. 「注册证号(新)」填写关键字 +6. 「状态」下拉选择任一可选项 +7. 点击「查询」 +8. 检查列表返回结果","多条件同时生效:制单日期、单号、变更类型、注册证号(新)、状态 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..4bbbcd7d5f2bdd0f22451825a429a096385b231b --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,34 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-CDF-001","基础设置 · 厂商管理","注册证差异对比 · 页面进入","菜单进入注册证差异对比页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 按菜单「基础设置 → 厂商管理 → 注册证差异对比」进入 +2. 断言 URL 含 `certCompare` +3. 断言外壳或 iframe 内可见物料名称/全部/一致/不一致等主区","进入差异对比页;iframe 或主区加载成功。","" +"VENDOR-CDF-002","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 在对比页执行查询(若有查询按钮) +2. 断言列表有数据或空态明确","查询无报错。","" +"VENDOR-CDF-003","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","切换不一致 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 点击「不一致」 +2. 断言列表稳定","不一致 Tab 切换成功。","" +"VENDOR-CDF-004","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","切换一致 Tab","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 点击「一致」 +2. 断言列表稳定","一致 Tab 切换成功。","" +"VENDOR-CDF-005","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","按物料名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe);对比列表至少 1 条","1. 从首行读取 `${sampleMaterialName}` 取关键字 +2. 物料名称填入并查询 +3. 断言结果行物料名称包含关键字","物料名称匹配。","" +"VENDOR-CDF-006","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 物料名称填 mock 关键字并查询 +2. 断言空态或总数为 0","无匹配时不报错。","" +"VENDOR-CDF-007","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 断言表头含物料编号/物料名称/规格型号/生产厂家等关键列","核心对比列可见。","" +"VENDOR-CDF-008","基础设置 · 厂商管理","注册证差异对比 · 导出","导出按钮可见","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 断言「导出」按钮可见(不确认下载)","导出入口可见。","" +"VENDOR-CDF-009","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","仅空格物料名称搜索","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 物料名称仅填空格并查询 +2. 断言按空查询处理或提示,不白屏","空格关键字不导致页面异常。","" +"VENDOR-CDF-010","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","一致行两侧字段抽样一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 在「一致」Tab 抽样一行 +2. 核对望海与 FLI+ 注册证号/效期关键字段一致","标记一致的行两侧关键字段抽样相同。","" +"VENDOR-CDF-011","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","不一致行至少一侧差异","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入注册证差异对比页(/spd/basic/mfrs/scjs/yc/certCompare,无 iframe)","1. 在「不一致」Tab 抽样一行 +2. 断言望海与 FLI+ 至少一处字段不同","不一致结论与字段差异相符。","" +"VENDOR-CDF-012","基础设置 · 厂商管理","注册证差异对比 · 筛选与对比","多条件组合查询(物料名称等8项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「物料名称」填写关键字 +3. 「规格型号」填写关键字 +4. 「状态」下拉选择任一可选项 +5. 「望海注册证号」填写关键字 +6. 「是否医疗器械」下拉选择任一可选项 +7. 「生产厂家」填写关键字 +8. 「FLI+注册证号」填写关键字 +9. 「27位医保编码」填写关键字 +10. 点击「查询」 +11. 检查列表返回结果","多条件同时生效:物料名称、规格型号、状态、望海注册证号、是否医疗器械、生产厂家、FLI+注册证号、27位医保编码 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..40a854184ba9aae909a1ae1422a34614e127c7d9 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,25 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-EVI-001","基础设置 · 厂商管理","评价指标维护 · 页面进入","菜单进入评价指标维护页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 按菜单进入评价指标维护 +2. 断言 URL 含 `indicators` +3. 断言可见「指标分类」与查询","进入评价指标维护页。","" +"VENDOR-EVI-002","基础设置 · 厂商管理","评价指标维护 · 树与列表","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 点击查询 +2. 断言右表有数据或空态","查询无报错。","" +"VENDOR-EVI-003","基础设置 · 厂商管理","评价指标维护 · 树与列表","点击左侧分类树","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 点击左侧首个分类节点 +2. 断言右侧列表刷新","分类切换成功。","" +"VENDOR-EVI-004","基础设置 · 厂商管理","评价指标维护 · 树与列表","按指标编码筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe);右表有数据","1. 取样本指标编码/编号筛选并查询 +2. 断言编码列匹配","指标编码匹配。","" +"VENDOR-EVI-005","基础设置 · 厂商管理","评价指标维护 · 树与列表","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 指标编码填 mock 后查询 +2. 断言空态","无匹配不报错。","" +"VENDOR-EVI-006","基础设置 · 厂商管理","评价指标维护 · 树与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 断言表头含评价类型、指标编码或指标编号、指标名称、状态、操作","核心列可见。","" +"VENDOR-EVI-007","基础设置 · 厂商管理","评价指标维护 · 详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe);存在详情操作行","1. 点击行内详情 +2. 断言详情层可见 +3. 返回不保存","详情只读可返回。","" +"VENDOR-EVI-008","基础设置 · 厂商管理","评价指标维护 · 树与列表","分类下无指标空态","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 选择无指标的分类(若有) +2. 断言右表空态明确","空分类下列表空态正确。","" +"VENDOR-EVI-009","基础设置 · 厂商管理","评价指标维护 · 树与列表","启用指标可供方案使用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价指标维护页(/spd/basic/mfrs/paltform/mfrsName/indicators,无 iframe)","1. 确认状态为启用的指标 +2. 在评价方案维护中核对其可选(只读核对或抽样)","启用指标与方案引用衔接正确。","" +"VENDOR-EVI-010","基础设置 · 厂商管理","评价指标维护 · 树与列表","多条件组合查询(指标编码等2项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「指标编码」填写关键字 +3. 「指标名称」填写关键字 +4. 点击「查询」 +5. 检查列表返回结果","多条件同时生效:指标编码、指标名称 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..c5a164a329fff913c996e046a123ef2100ae5d6e --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,30 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"VENDOR-EVS-001","基础设置 · 厂商管理","评价方案维护 · 页面进入","菜单进入评价方案维护页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 按菜单进入评价方案维护 +2. 断言 URL 含 `evaluationPlan` +3. 断言可见查询与新增","进入评价方案维护页。","" +"VENDOR-EVS-002","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 点击查询 +2. 断言列表有数据或「暂无数据」空态","查询无报错。","" +"VENDOR-EVS-003","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","按方案编码筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe);列表有数据","1. 取样本方案编码筛选并查询 +2. 断言方案编码列匹配","方案编码匹配。","" +"VENDOR-EVS-004","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 填写筛选后重置 +2. 断言条件清空、列表刷新","重置成功。","" +"VENDOR-EVS-005","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 方案编码填 mock 后查询 +2. 断言空态总数 0","无匹配不报错。","" +"VENDOR-EVS-006","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 断言表头含方案编码、方案名称、创建人、创建时间、状态、操作","核心列可见。","" +"VENDOR-EVS-007","基础设置 · 厂商管理","评价方案维护 · 新增","打开新增后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 记录总数 +2. 点击新增打开编辑层 +3. 取消/返回不提交 +4. 断言总数不变","取消不落库。","" +"VENDOR-EVS-008","基础设置 · 厂商管理","评价方案维护 · 新增","必填为空提交拦截","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 打开新增不填必填项尝试提交(若可点) +2. 断言拦截提示,页面不关闭或总数不变","空提交被拦截。","" +"VENDOR-EVS-009","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","方案名称超长输入","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 打开新增,方案名称输入超长字符串(如 200+ 字符) +2. 断言前端截断/校验提示或后端拒绝,不白屏","超长输入被约束且页面可用。","" +"VENDOR-EVS-010","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","方案与评价指标可关联","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限;已进入评价方案维护页(/spd/basic/mfrs/paltform/mfrsName/evaluationPlan,无 iframe)","1. 在方案配置中查看指标选择(只读打开) +2. 断言可选指标来源于评价指标维护启用项或规则允许集合","方案与指标主数据衔接正确。","" +"VENDOR-EVS-011","基础设置 · 厂商管理","评价方案维护 · 筛选与维护","多条件组合查询(创建日期等4项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 厂商管理」权限;已进入厂商管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「创建日期」日期区间选择当天 +3. 「启用日期」日期区间选择当天 +4. 「方案编码」填写关键字 +5. 「方案名称」填写关键字 +6. 点击「查询」 +7. 检查列表返回结果","多条件同时生效:创建日期、启用日期、方案编码、方案名称 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..fcb517af073b2d68bbf798d3ae8432c8c741afed --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,77 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +WH-ZON-001,基础设置 · 库房管理,库区设置 · 页面进入,菜单进入库区设置页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 按菜单「基础设置 → 库房管理 → 库区设置」进入,或 `openPath` 打开 `/spd/basic/storehouse/platform/storehouse/area` +3. 断言 URL 含 `/spd/basic/storehouse/platform/storehouse/area` +4. 断言左侧「库房信息」树、右侧库区列表、「新 增」与分页可见",进入库区设置页;不误入探针绑定;无报错遮罩。, +WH-ZON-002,基础设置 · 库房管理,库区设置 · 页面进入,未登录访问拦截,高,测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态),"1. 新建独立 BrowserContext,不加载 auth/user.json +2. 清除登录态后直接打开 `/spd/basic/storehouse/platform/storehouse/area` +3. 断言跳转登录或不展示库房/库区业务数据",未登录不可见库区数据。, +WH-ZON-003,基础设置 · 库房管理,库区设置 · 左侧库房树,库房树默认有选中项,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言左侧「库房信息」至少 1 个节点且有选中项 +3. 读取选中节点 `${sampleWhName}` +4. 断言右侧列表为该库房库区,或空态且「共 0 条」",树有选中;右侧对应当前库房。, +WH-ZON-004,基础设置 · 库房管理,库区设置 · 左侧库房树,按库房名称过滤树,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);库房树至少 1 个节点,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 读取当前选中 `${sampleWhName}` 取关键字(可截取前若干字) +3. 在左侧「库房名称」输入关键字 +4. 断言可见节点名称均包含该关键字",树过滤结果与关键字匹配。, +WH-ZON-005,基础设置 · 库房管理,库区设置 · 左侧库房树,清空库房名称恢复全量树,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录过滤前节点数 +3. 输入关键字过滤后再清空 +4. 断言节点数恢复为过滤前",清空后树恢复全量。, +WH-ZON-006,基础设置 · 库房管理,库区设置 · 左侧库房树,切换库房刷新右侧列表,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录当前库房 `${sampleWhName}` 与右侧首行库区编号(若有) +3. 点选另一库房节点(仅 1 个则 skip) +4. 断言「共 N 条」可变化;若有行则库区编号/名称属于新库房",切换库房后右侧列表刷新,不残留上一库房库区。, +WH-ZON-007,基础设置 · 库房管理,库区设置 · 左侧库房树,无匹配库房空树(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 库房名称填入 `__NOT_EXIST_ZONE__99999` +3. 断言树为空或明确无数据,右侧不展示上一库房库区为成功态",无匹配不报错;不残留上一库房列表当成功。, +WH-ZON-008,基础设置 · 库房管理,库区设置 · 右侧库区列表与分页,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言表头含库区编号、库区名称、库区地址、是否专项库区、操作 +3. 若有数据行,断言操作列可见「编辑」「人员管理」「货位管理」及「停用」或「启用」",核心列完整;行内操入口可见。, +WH-ZON-009,基础设置 · 库房管理,库区设置 · 右侧库区列表与分页,默认库区以标签展示,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 在当前库房列表中查找「默认库区」标签 +3. 断言该标签出现在库区名称旁,不是独立列 +4. 断言当前库房至多 1 个默认库区标签(无默认则本条只断言不是独立列)",默认库区为名称旁标签;同一库房至多一个。, +WH-ZON-010,基础设置 · 库房管理,库区设置 · 库区搜索,按库区编号搜索,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区且编号非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleZoneNo}` +3. 在「库区编号/名称」填入该编号并触发搜索 +4. 断言结果包含该编号行,且均属于当前选中库房",搜索命中该编号;不串其它库房。, +WH-ZON-011,基础设置 · 库房管理,库区设置 · 库区搜索,按库区名称搜索,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条且名称非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleZoneName}` 取关键字 +3. 在「库区编号/名称」填入关键字并搜索 +4. 断言结果行名称匹配",名称关键字命中;无未捕获异常。, +WH-ZON-012,基础设置 · 库房管理,库区设置 · 库区搜索,无匹配库区空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 「库区编号/名称」填入 `__NOT_EXIST_ZONE__99999` 并搜索 +3. 断言列表空且分页为 0",无匹配不报错;总数为 0。, +WH-ZON-013,基础设置 · 库房管理,库区设置 · 新增库区,打开新增库区弹窗,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「新 增」 +3. 断言弹出「新增库区」,可见库区编号、库区名称、库区地址、联系人、联系方式及「取 消」「确 定」",新增弹窗可见;关键字段与按钮齐全。, +WH-ZON-014,基础设置 · 库房管理,库区设置 · 新增库区,新增取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,编号填 `AUTO${timestamp}`,名称填任意合法值 +3. 点击「取 消」 +4. 按该编号搜索,断言无该记录",弹窗关闭;列表不出现该编号。, +WH-ZON-015,基础设置 · 库房管理,库区设置 · 新增库区,必填为空拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,库区编号或名称留空后点击「确 定」 +3. 断言出现必填提示,弹窗仍打开 +4. 点「取 消」",确定被拦截;不落库;弹窗不关闭。, +WH-ZON-016,基础设置 · 库房管理,库区设置 · 编辑库区,打开编辑并回填,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「编辑」 +3. 断言弹窗标题含「编辑库区信息」 +4. 断言库区编号、名称与该行 `${sampleZoneNo}`、`${sampleZoneName}` 一致(允许规范化空白) +5. 点「取 消」",编辑弹窗可见;回填与列表行一致。, +WH-ZON-017,基础设置 · 库房管理,库区设置 · 编辑库区,编辑取消不保存,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行编辑,记录原名称 +3. 将名称改为临时值后点「取 消」 +4. 再次打开编辑,断言名称仍为原值",取消后列表与回看均为修改前名称。, +WH-ZON-018,基础设置 · 库房管理,库区设置 · 编辑库区,编辑清空必填拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行编辑,清空库区名称后点「确 定」 +3. 断言被拦截并提示,弹窗仍开 +4. 点「取 消」,断言列表名称未被写成空",清空必填不能保存;原记录名称保持。, +WH-ZON-019,基础设置 · 库房管理,库区设置 · 人员管理,打开人员管理后关闭,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「人员管理」 +3. 断言打开弹层或子页,标题含人员管理或对应当前库区 +4. 关闭后断言仍在库区设置,当前库房选中保持",人员管理可打开;关闭后回到当前库房列表。本条不改人员。, +WH-ZON-020,基础设置 · 库房管理,库区设置 · 货位管理,打开货位管理后关闭,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 库区设置」权限;已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe);当前库房至少 1 条库区,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「货位管理」 +3. 断言打开弹层或子页,标题含货位且对应当前库区 +4. 关闭后断言库房选中与库区列表保持",货位管理可打开;关闭后列表状态保持。本条不改货位。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..f01bfe5c6c41e0cdccfd06abf2b528c633f450c1 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,66 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +WH-PRB-001,基础设置 · 库房管理,探针绑定 · 页面进入,菜单进入探针绑定页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 按菜单「基础设置 → 库房管理 → 探针绑定」进入,或 `openPath` 打开 `/spd/basic/storehouse/storehouse/probe` +3. 断言 URL 含 `/spd/basic/storehouse/storehouse/probe` +4. 断言探针ID、主机编码、是否绑定货位、查询、重置、列表与分页可见",进入探针绑定页;不误入库区设置。, +WH-PRB-002,基础设置 · 库房管理,探针绑定 · 页面进入,未登录访问拦截,高,测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态),"1. 新建独立 BrowserContext,不加载 auth/user.json +2. 清除登录态后直接打开 `/spd/basic/storehouse/storehouse/probe` +3. 断言跳转登录或不展示探针业务列表",未登录不可见探针数据。, +WH-PRB-003,基础设置 · 库房管理,探针绑定 · 筛选与查询,默认查询列表有数据或空态明确,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」并等待列表稳定 +3. 断言出现数据行,或「暂无数据」且分页总数为 0",查询无报错;有数据时「共 N 条」N≥1。, +WH-PRB-004,基础设置 · 库房管理,探针绑定 · 筛选与查询,按探针ID查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);默认查询至少 1 条且探针ID非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleProbeId}` +3. 探针ID填入该值,点击「查 询」 +4. 断言结果探针ID匹配,精确命中时行数合理",探针ID条件生效。, +WH-PRB-005,基础设置 · 库房管理,探针绑定 · 筛选与查询,按主机编码查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);默认查询至少 1 条且主机编码非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleHostCode}` +3. 主机编码填入该值,点击「查 询」 +4. 断言结果行「主机编码」与条件一致",主机编码条件生效;允许多探针共用同一主机。, +WH-PRB-006,基础设置 · 库房管理,探针绑定 · 筛选与查询,按是否绑定货位查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 是否绑定货位选择「已绑定」,点击「查 询」 +3. 断言结果「是否绑定货位」均为已绑定,且「绑定的货位」>0 +4. 再选「未绑定」查询,断言均为未绑定且数量为 0(无数据则空态)",已绑定数量大于 0;未绑定数量为 0。, +WH-PRB-007,基础设置 · 库房管理,探针绑定 · 筛选与查询,探针ID+主机编码组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写 `${sampleProbeId}` 与 `${sampleHostCode}` 后点击「查 询」 +3. 断言结果同时满足已填条件",组合条件同时生效。, +WH-PRB-008,基础设置 · 库房管理,探针绑定 · 筛选与查询,重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写探针ID后点击「重 置」 +3. 断言条件恢复默认,列表按默认规则刷新",条件恢复默认;列表刷新。, +WH-PRB-009,基础设置 · 库房管理,探针绑定 · 筛选与查询,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 探针ID填入 `__NOT_EXIST_PROBE__99999`,点击「查 询」 +3. 断言空态且分页总数为 0",无匹配不报错;总数为 0。, +WH-PRB-010,基础设置 · 库房管理,探针绑定 · 列表与分页,列表核心列与数量规则,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言表头含序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作 +3. 抽样已绑定行:绑定数量为数字且 >0;抽样未绑定行:数量为 0 +4. 断言「绑定的货位」列不是货位号格式",核心列完整;数量列是数字不是货位号。, +WH-PRB-011,基础设置 · 库房管理,探针绑定 · 打开绑定货位弹窗,行内打开货位绑定探针,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「绑定货位」 +3. 断言弹出「货位绑定探针」,可见当前库房/库区/货位查询及货位列表",弹窗标题正确;筛选与列表可见。, +WH-PRB-012,基础设置 · 库房管理,探针绑定 · 打开绑定货位弹窗,取消关闭不改主列表,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录主列表首行绑定数量与当前筛选 +3. 打开绑定弹窗后点「取 消」 +4. 断言主列表绑定数量与筛选条件保持",取消不改绑定关系。, +WH-PRB-013,基础设置 · 库房管理,探针绑定 · 打开绑定货位弹窗,未选货位时确定计数为 0,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开绑定弹窗,不勾选货位 +3. 断言底部按钮文案为「确定(0)」或等价 +4. 点「取 消」,不点确定保存",未选时计数为 0;本条不保存绑定。, +WH-PRB-014,基础设置 · 库房管理,探针绑定 · 打开绑定货位弹窗,弹窗分页独立于主列表,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录主列表「共 N 条」 +3. 打开绑定弹窗,记录弹窗分页「共 M 条」 +4. 断言不得用 M 断言主列表 N(货位量级可远大于探针条数) +5. 点「取 消」",弹窗总条数与主列表探针条数相互独立。, +WH-PRB-015,基础设置 · 库房管理,探针绑定 · 弹窗筛选货位,按当前库房筛选货位,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开绑定弹窗,记录默认 `${sampleModalWh}` +3. 点「查 询」 +4. 断言可见行「当前库房」与所选库房一致(有数据时) +5. 点「取 消」",弹窗库房条件生效。, +WH-PRB-016,基础设置 · 库房管理,探针绑定 · 弹窗筛选货位,弹窗内重置,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开绑定弹窗,填写货位查询关键字后点「重 置」 +3. 断言货位查询条件恢复,列表按弹窗默认规则刷新 +4. 点「取 消」",弹窗重置不影响主列表筛选。, +WH-PRB-017,基础设置 · 库房管理,探针绑定 · 勾选货位并保存绑定,勾选后确定计数增加再取消勾选,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 库房管理 → 探针绑定」权限;已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe);弹窗货位列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开绑定弹窗,勾选 1 个货位 +3. 断言确定按钮计数为 1 +4. 取消该勾选,断言计数回到 0 +5. 点「取 消」,不保存",计数随勾选变化;取消勾选不落库。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..12272f31c74cc71964b7eb4f421a8cb59c8f34e5 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,72 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"SURG-BASE-001","基础设置 · 手术套包设置","基础包维护 · 左侧分类树","分类名称搜索过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 从左树读取 `${sampleCategoryName}` +2. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准) +3. 断言树节点文本均包含该关键字","过滤后树节点名称与关键字匹配;无未捕获异常。","" +"SURG-BASE-002","基础设置 · 手术套包设置","基础包维护 · 左侧分类树","选择分类联动右侧列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 点击 `${sampleCategoryName}` 分类节点 +2. 等待右侧列表刷新稳定 +3. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致","右侧仅展示该分类下基础包;总数与筛选一致。","" +"SURG-BASE-003","基础设置 · 手术套包设置","基础包维护 · 左侧分类树","打开新增分类弹窗","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 点击「新增分类」 +2. 断言弹窗可见,含分类名称输入(#form_item_kindName)与「取 消」「确 认」 +3. 点击「取 消」关闭","新增分类弹窗字段完整;取消可关闭。","" +"SURG-BASE-004","基础设置 · 手术套包设置","基础包维护 · 左侧分类树","分类搜索清空恢复全量","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 用 `${sampleCategoryName}` 过滤并记录过滤后节点数 +2. 清空分类搜索框 +3. 断言节点数回到未过滤的默认集合(或不少于过滤后节点数)","清空后分类树恢复默认集合。","" +"SURG-BASE-005","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","默认列出基础包或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 等待列表稳定 +2. 断言出现数据行,或「暂无数据」且分页总数为 0","加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。","" +"SURG-BASE-006","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","产品名称筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleProductName}` +2. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」 +3. 断言结果行产品名称均包含该关键字","过滤结果与关键字匹配;「共 N 条」随之更新。","" +"SURG-BASE-007","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","规格型号筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleSpecModel}` +2. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」 +3. 断言结果行规格型号均包含该关键字","按规格型号过滤生效。","" +"SURG-BASE-008","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","器械注册人筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleMfrsName}` +2. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」 +3. 断言结果行器械注册人均包含该关键字","按器械注册人过滤生效。","" +"SURG-BASE-009","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 断言表头包含序号、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作","核心列均可见,操作列含「编 辑」「移 除」。","" +"SURG-BASE-010","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","查询与重置","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 用关键字过滤后点击「重 置」 +2. 断言筛选输入框被清空、列表恢复默认集合","重置清空筛选并恢复默认列表。","" +"SURG-BASE-011","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","分页与页大小切换","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据;列表总数大于单页行数上限","1. 切换每页条数(如 50 → 100) +2. 断言单页行数不超过所选值,分页总数不变","页大小切换生效;总数保持稳定。","" +"SURG-BASE-012","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","首页上一页不可用","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 处于第 1 页 +2. 断言「上一页」置灰或点击无效果","第 1 页无法回到上一页。","" +"SURG-BASE-013","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","搜索无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_BASE__99999` +2. 点击「查 询」 +3. 断言「暂无数据」且分页总数为 0","无匹配时空态明确,不白屏;总数为 0。","" +"SURG-BASE-014","基础设置 · 手术套包设置","基础包维护 · 新增基础包","打开新增基础包弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 点击「新增基础包」 +2. 断言弹窗可见,含基础包名称(#form_item_pkgName)、所属分类(#form_item_kindId)、包编码(#form_item_pkgCode)、备注(#form_item_remark) +3. 断言含「取 消」「确 认」","新增基础包弹窗字段完整(名称/所属分类/包编码/备注)。","" +"SURG-BASE-015","基础设置 · 手术套包设置","基础包维护 · 新增基础包","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` +2. 打开新增,填写名称 `${uniqPkgName}` 后点「取 消」 +3. 断言列表总数不变,且无该名称行","取消后不新增基础包。","" +"SURG-BASE-016","基础设置 · 手术套包设置","基础包维护 · 新增基础包","合法必填新增成功","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);允许写基础包;名称 `${uniqPkgName}`(建议 AUTO-BASE-${timestamp})、编码 `${uniqPkgCode}`","1. 打开新增,填写名称、所属分类、包编码 +2. 点击「确 认」 +3. 断言弹窗关闭,列表出现该名称行且字段一致 +4. 后置:按环境约定清理该基础包,避免污染主数据","新增成功且列表可见;用例结束清理,不留生产脏数据。","" +"SURG-BASE-017","基础设置 · 手术套包设置","基础包维护 · 新增基础包","基础包名称为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 打开新增基础包弹窗,名称留空,补齐所属分类与包编码 +2. 点击「确 认」 +3. 断言必填提示,弹窗仍在,列表无新行","确定被拦截;不落库。","" +"SURG-BASE-018","基础设置 · 手术套包设置","基础包维护 · 新增基础包","所属分类为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)","1. 打开新增基础包弹窗,填写名称与包编码,不选所属分类 +2. 点击「确 认」 +3. 断言必填提示,弹窗仍在","确定被拦截;不落库。","" +"SURG-BASE-019","基础设置 · 手术套包设置","基础包维护 · 新增基础包","重复点击确定防重","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);允许写基础包;使用唯一名称 `${uniqPkgName}`","1. 打开新增弹窗并填写合法数据 +2. 快速连点「确 认」两次 +3. 断言只产生一条基础包(或无重复名称/编码行)","连点只落一条;无重复脏数据。","" +"SURG-BASE-020","基础设置 · 手术套包设置","基础包维护 · 行内操作(编辑 / 移除)","行内编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 点击首行「编 辑」 +2. 断言编辑弹窗可见且回填当前行数据 +3. 点击「取 消」关闭","编辑弹窗回填正确;取消不影响数据。","" +"SURG-BASE-021","基础设置 · 手术套包设置","基础包维护 · 行内操作(编辑 / 移除)","移除二次确认防误删","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);默认列表至少 1 条数据","1. 记录当前总数 `${sampleTotal}` +2. 点击首行「移 除」 +3. 若出现确认框则点「取 消」 +4. 断言总数仍为 `${sampleTotal}`","移除有二次确认;取消不删除。","" +"SURG-BASE-022","基础设置 · 手术套包设置","基础包维护 · 行内操作(编辑 / 移除)","新增基础包可在标准套包被引用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe);已新增 `${uniqPkgName}` 且未清理;允许跨页只读访问","1. 进入「标准套包维护」,点击「添加基础包」 +2. 在弹窗搜索框输入 `${uniqPkgName}` +3. 断言可检索到该基础包并可勾选 +4. 后置:清理该基础包","新增基础包在标准套包可选;跨页数据一致。","" +"SURG-BASE-023","基础设置 · 手术套包设置","基础包维护 · 右侧筛选与列表","多条件组合查询(物料名称等5项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 手术套包设置」权限;已进入手术套包设置对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「物料名称」填写关键字 +3. 「规格型号」填写关键字 +4. 「器械注册人」填写关键字 +5. 「耗材类型」下拉选择任一可选项 +6. 「是否收费」下拉选择任一可选项 +7. 点击「查询」 +8. 检查列表返回结果","多条件同时生效:物料名称、规格型号、器械注册人、耗材类型、是否收费 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..f4dd640ebefcda0e38da4d92b1aecb1f58653eac --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,72 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"SURG-TYPE-001","基础设置 · 手术套包设置","术种维护 · 页面进入","菜单进入术种维护页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 按菜单「基础设置 → 手术套包设置 → 术种维护」进入 +2. 断言 URL 含 `/spd/basic/operationSetting/typeMaintenance` +3. 断言左侧分类树(术种分类名称搜索框)、右侧筛选区、状态 Tab、列表与分页可见","进入术种维护页;不误入基础包维护/标准套包维护;左右分栏渲染完整。","" +"SURG-TYPE-002","基础设置 · 手术套包设置","术种维护 · 页面进入","同组页签切换后返回","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 点击页内 Tab「标准套包维护」,等待 URL 切换 +2. 返回点击 Tab「术种维护」 +3. 断言 URL 回到 typeMaintenance,左树与状态 Tab 重新渲染","三页签互切正常;返回后本页数据恢复。","" +"SURG-TYPE-003","基础设置 · 手术套包设置","术种维护 · 页面进入","未登录访问拦截","高","测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态;直接访问页面进入 URL(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 不加载登录态(空 storageState) +2. 直接访问 `/spd/basic/operationSetting/typeMaintenance` +3. 断言跳转登录页或不展示术种列表数据","未登录被拦截,不泄露术种数据。","" +"SURG-TYPE-004","基础设置 · 手术套包设置","术种维护 · 左侧术种分类树","术种分类名称搜索过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 从左树读取 `${sampleOperCategoryName}` +2. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准) +3. 断言树节点文本均包含该关键字","过滤后树节点名称与关键字匹配;无未捕获异常。","" +"SURG-TYPE-005","基础设置 · 手术套包设置","术种维护 · 左侧术种分类树","选择分类联动右侧列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 点击 `${sampleOperCategoryName}` 分类节点 +2. 等待右侧列表刷新稳定 +3. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致","右侧仅展示该分类下术种;总数与筛选一致。","" +"SURG-TYPE-006","基础设置 · 手术套包设置","术种维护 · 左侧术种分类树","分类树计数与下钻","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);至少 1 个带计数的分类节点","1. 读取某分类节点括号内计数 `${nodeCount}` +2. 点击该节点 +3. 断言右侧「共 N 条」与 `${nodeCount}` 一致","分类计数与该分类下术种条数一致。","" +"SURG-TYPE-007","基础设置 · 手术套包设置","术种维护 · 左侧术种分类树","打开分类维护弹窗","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 点击左侧「新增」 +2. 断言弹窗可见,含分类名称输入(#basic_kindName)与「取 消」「保 存」 +3. 点击「取 消」关闭","分类维护弹窗字段完整;取消可关闭。","" +"SURG-TYPE-008","基础设置 · 手术套包设置","术种维护 · 左侧术种分类树","分类搜索清空恢复全量","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 用 `${sampleOperCategoryName}` 过滤并记录过滤后节点数 +2. 清空分类搜索框 +3. 断言节点数回到未过滤的默认集合","清空后分类树恢复默认集合。","" +"SURG-TYPE-009","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","术种名称筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleOperName}` +2. 在 `#table_search_form_operationName` 填入该关键字并点击「查 询」 +3. 断言结果行术种名称均包含该关键字","过滤结果与关键字匹配;「共 N 条」随之更新。","" +"SURG-TYPE-010","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","病种名称筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleDiseaseName}` +2. 在 `#table_search_form_disease` 填入该关键字并点击「查 询」 +3. 断言结果行病种名称均包含该关键字","按病种名称过滤生效。","" +"SURG-TYPE-011","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","状态 Tab 切换与计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 读取「全部」Tab 括号计数 `${totalCount}` +2. 依次点击「停用」「未建分类」「全部」 +3. 每次断言列表条数与「共 N 条」和该 Tab 括号计数一致","Tab 切换后列表仅展示对应状态;括号计数与结果规模一致。","" +"SURG-TYPE-012","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","左树 + 筛选 + Tab 组合过滤","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 选择 `${sampleOperCategoryName}` 分类节点 +2. 填入术种名称关键字并点「查 询」 +3. 切到「全部」Tab +4. 断言结果为三者交集且无未捕获异常","三条件叠加后结果正确,总数一致。","" +"SURG-TYPE-013","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","重置清空筛选","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 用关键字过滤后点击「重 置」 +2. 断言筛选输入框被清空、列表恢复默认集合","重置清空筛选并恢复默认列表。","" +"SURG-TYPE-014","基础设置 · 手术套包设置","术种维护 · 筛选与状态 Tab","无匹配组合空态(mock反例)","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 在 `#table_search_form_operationName` 输入 mock 不存在关键字 `__NOT_EXIST_OPER__99999` +2. 点击「查 询」 +3. 断言「暂无数据」且分页总数为 0","无匹配时空态明确,不白屏;总数为 0。","" +"SURG-TYPE-015","基础设置 · 手术套包设置","术种维护 · 列表与编码展示","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 断言表头包含序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作","核心列均可见,操作列含「编 辑」「停用/启用」。","" +"SURG-TYPE-016","基础设置 · 手术套包设置","术种维护 · 列表与编码展示","ICD-10 诊断编码展示完整","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据;至少 1 条含诊断编码的数据","1. 读取含 ICD-10 编码的行 `${sampleIcd10Code}` +2. 断言编码文本完整(如 A02.000x010)不截断、无乱码","ICD-10 编码完整展示。","" +"SURG-TYPE-017","基础设置 · 手术套包设置","术种维护 · 列表与编码展示","ICD-9-CM-3 操作编码展示完整","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据;至少 1 条含操作编码的数据","1. 读取含操作编码的行 `${sampleIcd9Code}` +2. 断言操作编码(如 00.0200)与操作名称(如 心脏治疗性超声)对应展示无乱码","ICD-9-CM-3 编码与操作名称对应正确。","" +"SURG-TYPE-018","基础设置 · 手术套包设置","术种维护 · 列表与编码展示","分页与页大小切换","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据;列表总数大于单页行数上限","1. 切换每页条数(如 50 → 100) +2. 断言单页行数不超过所选值,分页总数不变","页大小切换生效;总数保持稳定。","" +"SURG-TYPE-019","基础设置 · 手术套包设置","术种维护 · 列表与编码展示","首页上一页不可用","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 处于第 1 页 +2. 断言「上一页」置灰或点击无效果","第 1 页无法回到上一页。","" +"SURG-TYPE-020","基础设置 · 手术套包设置","术种维护 · 新增术种","打开新增术种入口","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 点击「新增术种」 +2. 断言新增弹窗/页面可见,含术种名称、病种名称、分类与编码相关字段 +3. 断言含「取 消」「保 存」","新增术种入口字段完整。","" +"SURG-TYPE-021","基础设置 · 手术套包设置","术种维护 · 新增术种","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` +2. 打开新增,填写名称 `${uniqOperName}` 后点「取 消」 +3. 断言列表总数不变,且无该名称行","取消后不新增术种。","" +"SURG-TYPE-022","基础设置 · 手术套包设置","术种维护 · 新增术种","术种名称为空拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)","1. 打开新增术种弹窗,名称留空 +2. 点击「保 存」 +3. 断言必填提示,弹窗仍在,列表无新行","保存被拦截;不落库。","" +"SURG-TYPE-023","基础设置 · 手术套包设置","术种维护 · 行内操作(编辑 / 停用)","行内编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);默认列表至少 1 条数据","1. 点击首行「编 辑」 +2. 断言编辑弹窗可见且回填当前术种数据 +3. 点击「取 消」关闭","编辑弹窗回填正确;取消不影响数据。","" +"SURG-TYPE-024","基础设置 · 手术套包设置","术种维护 · 行内操作(编辑 / 停用)","停用状态流转","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);存在启用状态术种(优先选 `${uniqOperName}` 自建数据);允许写术种","1. 记录「停用」Tab 当前计数 `${disabledCount}` +2. 点击目标行「停用」,若有确认框则点「确 定」 +3. 断言「停用」Tab 计数 +1 +4. 后置:对该术种执行「启用」恢复","术种状态变为停用;「停用」计数 +1;结束恢复原状态。","" +"SURG-TYPE-025","基础设置 · 手术套包设置","术种维护 · 行内操作(编辑 / 停用)","停用二次确认防误操作","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe);存在启用状态术种","1. 记录「停用」Tab 当前计数 +2. 点击首行「停用」 +3. 若出现确认框则点「取 消」 +4. 断言计数未变化","停用有二次确认;取消不改状态。","" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..78379c5268dd25b1dfa678fd153ba0657690b6b7 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,85 @@ +"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果" +"SURG-STD-001","基础设置 · 手术套包设置","标准套包维护 · 页面进入","菜单进入标准套包维护页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 按菜单「基础设置 → 手术套包设置 → 标准套包维护」进入 +2. 断言 URL 含 `/spd/basic/operationSetting/standardMaintenance` +3. 断言左侧分类树、右侧筛选区、构成列表与分页可见","进入标准套包维护页;不误入基础包维护/术种维护;左右分栏渲染完整。","" +"SURG-STD-002","基础设置 · 手术套包设置","标准套包维护 · 页面进入","同组页签切换后返回","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 点击页内 Tab「术种维护」,等待 URL 切换 +2. 返回点击 Tab「标准套包维护」 +3. 断言 URL 回到 standardMaintenance,左树与构成列表重新渲染","三页签互切正常;返回后本页数据恢复。","" +"SURG-STD-003","基础设置 · 手术套包设置","标准套包维护 · 页面进入","未登录访问拦截","高","测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态;直接访问页面进入 URL(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 不加载登录态(空 storageState) +2. 直接访问 `/spd/basic/operationSetting/standardMaintenance` +3. 断言跳转登录页或不展示套包列表数据","未登录被拦截,不泄露套包数据。","" +"SURG-STD-004","基础设置 · 手术套包设置","标准套包维护 · 左侧术种分类树","术种分类名称搜索过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 从左树读取 `${sampleOperCategoryName}` +2. 在分类搜索框填入该关键字(即时过滤或回车,以现网为准) +3. 断言树节点文本均包含该关键字","过滤后树节点名称与关键字匹配;无未捕获异常。","" +"SURG-STD-005","基础设置 · 手术套包设置","标准套包维护 · 左侧术种分类树","选择分类联动构成列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据","1. 点击 `${sampleOperCategoryName}` 分类节点 +2. 等待右侧构成列表刷新稳定 +3. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致","右侧仅展示该分类下标准套包;总数与筛选一致。","" +"SURG-STD-006","基础设置 · 手术套包设置","标准套包维护 · 左侧术种分类树","分类搜索清空恢复全量","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 用 `${sampleOperCategoryName}` 过滤并记录过滤后节点数 +2. 清空分类搜索框 +3. 断言节点数回到未过滤的默认集合","清空后分类树恢复默认集合。","" +"SURG-STD-007","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","默认展示标准套包构成","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 等待列表稳定 +2. 断言按「包名称」分组出现数据行,或「暂无数据」且分页总数为 0","加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。","" +"SURG-STD-008","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","产品名称筛选过滤","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleProductName}` +2. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」 +3. 断言结果行产品名称均包含该关键字","过滤结果与关键字匹配;「共 N 条」随之更新。","" +"SURG-STD-009","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","规格型号筛选过滤","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleSpecModel}` +2. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」 +3. 断言结果行规格型号均包含该关键字","按规格型号过滤生效。","" +"SURG-STD-010","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","器械注册人筛选过滤","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据","1. 从首行读取 `${sampleMfrsName}` +2. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」 +3. 断言结果行器械注册人均包含该关键字","按器械注册人过滤生效。","" +"SURG-STD-011","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","构成列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据;存在分组","1. 断言表头/首行包含包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作","核心列均可见,操作列含「编 辑」「移 除」。","" +"SURG-STD-012","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","包名称展开/收起子项","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据;存在可展开的包分组","1. 读取首个包分组名称 `${samplePkgGroupName}` +2. 点击展开该分组,断言出现子项行 +3. 再次点击收起,断言子项行隐藏","展开显示子项、收起隐藏子项;分组计数不受影响。","" +"SURG-STD-013","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","查询与重置","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据","1. 用关键字过滤后点击「重 置」 +2. 断言筛选输入框被清空、列表恢复默认集合","重置清空筛选并恢复默认列表。","" +"SURG-STD-014","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","分页与页大小切换","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据;列表总数大于单页行数上限","1. 切换每页条数(如 50 → 100) +2. 断言单页行数不超过所选值,分页总数不变","页大小切换生效;总数保持稳定。","" +"SURG-STD-015","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","无匹配组合空态(mock反例)","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_STD__99999` +2. 点击「查 询」 +3. 断言「暂无数据」且分页总数为 0","无匹配时空态明确,不白屏;总数为 0。","" +"SURG-STD-016","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","打开添加基础包弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 点击「添加基础包」 +2. 断言弹窗可见,含左侧基础包分类树与右侧基础包明细 +3. 断言含「取 消」「确定(N)」","添加基础包弹窗字段完整;确定按钮带选择计数。","" +"SURG-STD-017","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","弹窗内筛选与选择计数联动","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 打开添加基础包弹窗,在搜索框输入基础包关键字 +2. 勾选 1 个基础包 +3. 断言「确定(N)」计数由 0 变为 1","筛选生效;选择数与确定按钮计数联动。","" +"SURG-STD-018","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","弹窗内构成数量可编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);已勾选至少 1 个基础包","1. 定位明细行「构成数量」步进器,点击「Increase Value」 +2. 断言数量 +1 且合计随之更新","构成数量可编辑;加减生效。","" +"SURG-STD-019","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","添加基础包取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` +2. 打开添加基础包,勾选 1 个基础包后点「取 消」 +3. 断言构成列表总数不变","取消后不新增构成行。","" +"SURG-STD-020","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","确定按钮零选禁用","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);已打开添加基础包弹窗且未选择","1. 断言「确定(0)」置灰或点击无效果","未选择时确定不可用。","" +"SURG-STD-021","基础设置 · 手术套包设置","标准套包维护 · 添加基础包弹窗","重复添加同一基础包","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);标准套包已含某基础包(或先添加一个);允许写套包","1. 再次打开添加基础包,选择同一基础包并确定 +2. 断言按产品规则拒绝/合并/累加,不产生重复脏行","重复添加不产生重复行(策略以现网为准)。","" +"SURG-STD-022","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","打开添加散件弹窗","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 点击「添加散件」 +2. 断言弹窗可见并展示「产品列表」视图,含产品名称/规格型号/器械注册人/耗材类型筛选与分页","添加散件弹窗产品列表视图完整。","" +"SURG-STD-023","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","产品列表筛选与选择散件","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);已打开添加散件弹窗","1. 在产品名称筛选框输入 `${sampleProductName}` +2. 勾选首个匹配散件 +3. 断言散件进入应拣明细,计数同步变化","筛选生效;选中散件进入应拣明细。","" +"SURG-STD-024","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","散件规格/应拣明细视图","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);已勾选至少 1 个散件","1. 切换到规格明细/应拣明细视图 +2. 断言展示耗材品种数、耗材总数,以及应拣数量、产品编号/名称/规格/注册人/单位/耗材类型/是否收费列","应拣明细视图字段完整。","" +"SURG-STD-025","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","明细统计与实际一致","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);应拣明细含至少 1 个散件","1. 读取「耗材品种数」`${itemKindCount}` 与「耗材总数」`${itemTotalCount}` +2. 断言品种数 = 明细去重产品数;总数 = 应拣数量合计","统计口径与实际所选一致。","" +"SURG-STD-026","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","添加散件取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)","1. 记录当前分页总数 `${sampleTotal}` +2. 打开添加散件,勾选散件后点「取 消」 +3. 断言构成列表总数不变","取消后不新增散件行。","" +"SURG-STD-027","基础设置 · 手术套包设置","标准套包维护 · 添加散件弹窗","万级散件列表首屏可交互","低","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);添加散件产品列表总量达万级(示例「共 16299 条」)","1. 打开添加散件弹窗 +2. 断言首屏可交互(筛选/分页可用),不卡死 +3. 避免全量断言,仅校验首行与总数格式","万级列表首屏可用;无超时白屏。","" +"SURG-STD-028","基础设置 · 手术套包设置","标准套包维护 · 行内操作(编辑 / 移除)","行内编辑构成项","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据;先展开目标包","1. 点击子项行「编 辑」 +2. 断言编辑弹窗/表单可见并回填当前值 +3. 点击「取 消」关闭","编辑回填正确;取消不影响数据。","" +"SURG-STD-029","基础设置 · 手术套包设置","标准套包维护 · 行内操作(编辑 / 移除)","移除二次确认防误删整包","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 手术套包设置」权限;已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe);默认列表至少 1 条数据;先展开目标包","1. 记录当前行数 +2. 点击子项行「移 除」 +3. 若出现确认框则点「取 消」 +4. 断言行数未变化","移除有二次确认;取消不删除。","" +"SURG-STD-030","基础设置 · 手术套包设置","标准套包维护 · 筛选与构成列表","多条件组合查询(物料名称等5项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 手术套包设置」权限;已进入手术套包设置对应功能页;筛选区已展开","1. 进入页面并等待列表加载 +2. 「物料名称」填写关键字 +3. 「规格型号」填写关键字 +4. 「器械注册人」填写关键字 +5. 「耗材类型」下拉选择任一可选项 +6. 「是否收费」下拉选择任一可选项 +7. 点击「查询」 +8. 检查列表返回结果","多条件同时生效:物料名称、规格型号、器械注册人、耗材类型、是否收费 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行" diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..0035c00db8bcbeb9da11c1d94eae4c894c24af7f --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,62 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +FLOW-INS-001,基础设置 · 流程设置,审批流实例 · 页面进入,菜单进入审批流实例页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 按菜单「基础设置 → 流程设置 → 审批流实例」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowCase` +3. 断言 URL 含 `/spd/basic/flowSet/workflow/flowCase` +4. 断言单据编号、审批状态、发起时间、业务类型、摘要、查询、重置、列表与分页可见",进入审批流实例;不误入审批流程设置。, +FLOW-INS-002,基础设置 · 流程设置,审批流实例 · 页面进入,未登录访问拦截,高,测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态),"1. 新建独立 BrowserContext,不加载 auth/user.json +2. 清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowCase` +3. 断言跳转登录或不展示审批单据列表",未登录不可见审批实例。, +FLOW-INS-003,基础设置 · 流程设置,审批流实例 · 筛选与查询,默认日期区间查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」并等待列表稳定 +3. 断言出现数据行或空态明确,「共 N 条」与结果逻辑一致",默认查询无报错;有数据时 N≥1。, +FLOW-INS-004,基础设置 · 流程设置,审批流实例 · 筛选与查询,单据编号精确查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);默认查询至少 1 条且单据编号非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleOrderNo}` +3. 单据编号填入完整单号,点击「查 询」 +4. 断言至多 1 条,且编号、业务类型与该行一致",精确单号命中当前行。, +FLOW-INS-005,基础设置 · 流程设置,审批流实例 · 筛选与查询,按审批状态筛选,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 审批状态下拉选择「审批通过」(无该选项则选列表中已出现的状态) +3. 点击「查 询」 +4. 断言结果「审批状态」列与所选一致",状态筛选生效。, +FLOW-INS-006,基础设置 · 流程设置,审批流实例 · 筛选与查询,按业务类型筛选,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 从首行读取 `${sampleBizType}` +3. 业务类型选择该值,点击「查 询」 +4. 断言结果「业务类型」均为该类型",业务类型筛选生效。, +FLOW-INS-007,基础设置 · 流程设置,审批流实例 · 筛选与查询,按发起时间区间筛选,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录当前默认起止日期 +3. 点击「查 询」 +4. 抽样结果行「发起时间」落在起止日内(含边界)",发起时间落在区间内。, +FLOW-INS-008,基础设置 · 流程设置,审批流实例 · 筛选与查询,按摘要关键字筛选,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 找到摘要非 `--` 的行读取 `${sampleSummary}` 关键字;若全为 `--` 则填 mock 关键字 +3. 点击「查 询」 +4. 有关键字命中时摘要包含该关键字;mock 无命中时空态且 `--` 行不被命中",非空摘要可检索;`--` 不被非空关键字命中。, +FLOW-INS-009,基础设置 · 流程设置,审批流实例 · 筛选与查询,状态+类型组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写 `${sampleStatus}` 与 `${sampleBizType}` 后点击「查 询」 +3. 断言抽样行同时满足全部已填条件",组合条件同时生效。, +FLOW-INS-010,基础设置 · 流程设置,审批流实例 · 筛选与查询,重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写单据编号等条件后点击「重 置」 +3. 断言日期回到默认区间,其余条件清空,列表刷新",重置后日期与其它条件恢复默认。, +FLOW-INS-011,基础设置 · 流程设置,审批流实例 · 筛选与查询,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 单据编号填入 `__NOT_EXIST_FLOW__99999`,点击「查 询」 +3. 断言空态且分页总数为 0",无匹配不报错;总数为 0。, +FLOW-INS-012,基础设置 · 流程设置,审批流实例 · 列表与分页,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言表头含序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作 +3. 断言行内可见「详情」",核心列完整;详情入口可见。, +FLOW-INS-013,基础设置 · 流程设置,审批流实例 · 列表与分页,单据编号前缀与业务类型对应,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);默认查询有数据,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 抽样 `PLAN`/`DB`/`GC`/`BUY` 前缀行(有则断言) +3. 断言 PLAN→采购计划、DB→二级库调拨、GC→产品信息变更、BUY→请领单(以现网枚举为准,缺前缀 skip 该项)",前缀与类型对应。, +FLOW-INS-014,基础设置 · 流程设置,审批流实例 · 列表与分页,分页翻至第 2 页,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);总条数大于一页,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录第 1 页首行单据编号 +3. 翻到第 2 页 +4. 断言数据更换;筛选条件保持",翻页成功;筛选不丢失。, +FLOW-INS-015,基础设置 · 流程设置,审批流实例 · 审批详情,打开详情对应当前行,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录首行 `${sampleOrderNo}` +3. 点击「详情」 +4. 断言打开该单审批过程,标题或单号含 `${sampleOrderNo}`",详情单号与当前行一致。, +FLOW-INS-016,基础设置 · 流程设置,审批流实例 · 审批详情,关闭详情保留列表状态,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写单据编号查询后打开详情 +3. 点返回/关闭 +4. 断言筛选条件与列表仍是打开前的查询结果",返回后条件与页码保持。, +FLOW-INS-017,基础设置 · 流程设置,审批流实例 · 审批详情,详情只读,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流实例」权限;已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行详情 +3. 断言无保存审批人/改条件的提交按钮,或此类控件不可用 +4. 返回列表",详情只读;改配置须到审批流程设置。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1dbc1328c4cc4996218225876ddc2258cd4f2bfa --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,51 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +FLOW-DEF-001,基础设置 · 流程设置,审批流程设置 · 页面进入,菜单进入审批流程设置页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 按菜单「基础设置 → 流程设置 → 审批流程设置」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowSet` +3. 断言 URL 含 `/spd/basic/flowSet/workflow/flowSet` +4. 断言左侧业务类型、右侧默认审批流与自定义审批流、「保 存」「重 置」可见",进入审批流程设置;不误入审批流实例。, +FLOW-DEF-002,基础设置 · 流程设置,审批流程设置 · 页面进入,未登录访问拦截,高,测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态),"1. 新建独立 BrowserContext,不加载 auth/user.json +2. 清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowSet` +3. 断言跳转登录或不展示审批流配置",未登录不可见审批流配置。, +FLOW-DEF-003,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,业务类型列表可见,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言左侧可见采购计划等类型且有当前高亮项 +3. 读取 `${sampleBizType}`",类型列表可见;当前项高亮。, +FLOW-DEF-004,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,按流程名称过滤类型,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 在「流程名称」输入 `${sampleBizType}` 关键字 +3. 断言可见类型名称均匹配关键字",类型过滤与关键字匹配。, +FLOW-DEF-005,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,清空关键字恢复全量,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录过滤前类型数量 +3. 输入关键字后再清空 +4. 断言数量恢复",清空后类型列表恢复全量。, +FLOW-DEF-006,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,切换类型刷新右侧配置,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录当前类型默认流节点文案 +3. 点选另一业务类型(仅 1 个则 skip) +4. 断言右侧默认流/自定义流已刷新,不残留上一类型节点人员",切换类型后右侧与该类型已保存配置一致。, +FLOW-DEF-007,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,无匹配类型(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 流程名称填入 `__NOT_EXIST_FLOW__99999` +3. 断言左侧无匹配或空,右侧不把上一类型配置展示为当前成功配置",无匹配不报错。, +FLOW-DEF-008,基础设置 · 流程设置,审批流程设置 · 默认审批流,默认审批流节点可见,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 选中「采购计划」(或当前默认类型) +3. 断言「默认审批流」区域可见节点卡片,含节点/账号/人员三行信息(按卡片容器取文本)",默认流节点结构可见;不点保存。, +FLOW-DEF-009,基础设置 · 流程设置,审批流程设置 · 自定义审批流,新增流程增加一行后重置,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录当前自定义流行数 +3. 点击「新增流程」 +4. 断言行数 +1,新行含流程名称、条件、审批流程、删除 +5. 点击「重 置」,断言行数回到操作前,不点「保 存」",可新增行;重置放弃未保存行。, +FLOW-DEF-010,基础设置 · 流程设置,审批流程设置 · 自定义审批流,空行保存被拦截后重置,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「新增流程」得到空行 +3. 点击「保 存」 +4. 断言出现必填拦截提示,配置未按成功态关闭 +5. 点击「重 置」,放弃该空行",空名称/条件/节点不能落库;拦截后重置恢复。, +FLOW-DEF-011,基础设置 · 流程设置,审批流程设置 · 条件配置,打开条件配置后取消,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe);当前类型至少 1 条自定义流,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 若无自定义行则 skip +3. 点击首条自定义流条件列「编辑」 +4. 断言打开「条件配置」 +5. 点「取 消」,断言行内条件文案与打开前一致",条件配置可打开;取消不改行内条件。, +FLOW-DEF-012,基础设置 · 流程设置,审批流程设置 · 保存与重置,重置放弃未保存改动,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「新增流程」增加一行 +3. 点击「重 置」 +4. 断言自定义流行回到上次保存结果",重置不提交;未保存行消失。, +FLOW-DEF-013,基础设置 · 流程设置,审批流程设置 · 左侧业务类型,类型与审批流实例业务类型同源,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 流程设置 → 审批流程设置」权限;已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录本页左侧类型名称集合(可见范围) +3. 打开审批流实例,展开「业务类型」下拉 +4. 断言选项与本页类型同源(允许按权限裁剪,本页有的关键类型如采购计划应能对应)",两页业务类型同源。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..e0ac5c3efdcf99c1d90f95127b37a3691c76a552 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,73 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +USER-UNI-001,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,默认查询列表有数据或空态明确,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」并等待列表稳定 +3. 断言出现数据行,或空态且分页总数为 0",查询无报错;有数据时「共 N 条」N≥1。, +USER-UNI-002,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,姓名、所属医院、联系方式、科室、角色等条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleHospital}`、`${samplePhone}`、`${sampleDept}`、`${sampleRole}`(科室或联系方式为 `--`/空时换一行) +3. 填写姓名、所属医院,展开高级筛选填写「联系方式」、选择科室与角色后点「筛 选」(无则点「查 询」) +4. 断言结果非空;首行登录账号与采样行一致;可见行姓名/所属医院/联系方式/科室/角色与已填条件一致",组合查询无报错;结果与采样条件一致且同时满足全部已填条件。, +USER-UNI-003,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,重置恢复默认医院,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 更改所属医院或姓名后点击「重 置」 +3. 断言所属医院恢复进入页时的默认值,列表刷新",医院等条件恢复默认。, +USER-UNI-004,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,展开高级筛选,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「高级筛选」 +3. 断言可见所属医院、联系方式、科室、角色及「筛 选」",高级筛选含医院维度字段。, +USER-UNI-005,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 姓名框填入 `__NOT_EXIST_USER__99999`,点击「查 询」 +3. 断言空态且分页总数为 0",无匹配不报错;总数为 0。, +USER-UNI-006,基础设置 · 用户管理,医共体用户管理 · 列表与分页,列表列顺序含所属医院,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 断言列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作 +3. 勿用用户管理页列断言(用户管理无所属医院且姓名在登录账号前)",列顺序与用户管理页不同;含所属医院。, +USER-UNI-007,基础设置 · 用户管理,医共体用户管理 · 列表与分页,分页翻至第 2 页,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);总条数大于一页,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录第 1 页首行登录账号 +3. 翻到第 2 页 +4. 断言数据更换且所属医院筛选仍保持",翻页成功;医院条件不丢失。, +USER-UNI-008,基础设置 · 用户管理,医共体用户管理 · 列表与分页,与用户管理页同一用户字段一致,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);两页都能查到同一账号,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录本页首行 `${sampleLoginName}`、姓名、所属科室、角色 +3. 打开用户管理页,按登录账号查询同一用户 +4. 断言姓名、角色、所属科室一致;本页仅多「所属医院」",同一登录账号跨页字段一致。, +USER-UNI-009,基础设置 · 用户管理,医共体用户管理 · 新增与编辑,打开新增弹窗,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「新 增」 +3. 断言弹出用户表单,含账号、姓名、联系方式及取消/确定",新增弹窗可见。, +USER-UNI-010,基础设置 · 用户管理,医共体用户管理 · 新增与编辑,新增取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,账号填 `${uniqLogin}` 后点「取 消」 +3. 当前医院下列表查询该账号,断言不存在",取消后当前医院列表不出现该账号。, +USER-UNI-011,基础设置 · 用户管理,医共体用户管理 · 新增与编辑,必填为空拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,账号或姓名留空后点「确 定」 +3. 断言被拦截,弹窗仍打开,不落库",必填为空不可保存。, +USER-UNI-012,基础设置 · 用户管理,医共体用户管理 · 新增与编辑,打开编辑并回填,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「编辑」 +3. 断言弹窗回填与该行登录账号、姓名、所属医院一致 +4. 点「取 消」",编辑回填正确;本条不保存。, +USER-UNI-013,基础设置 · 用户管理,医共体用户管理 · 筛选与查询,高级筛选按科室过滤,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);默认查询至少 1 条且所属科室非空/--,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」后读取首行 `${sampleDept}`(空或 `--` 时换一行) +3. 展开高级筛选,选择该科室后点「筛 选」 +4. 断言结果非空或空态明确;有数据时可见行「所属科室」与所选科室一致",按科室过滤成功;无报错;结果所属科室与条件一致。, +USER-UNI-014,基础设置 · 用户管理,医共体用户管理 · 列表与分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数 +3. 分页区切换每页条数为「20 条/页」 +4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;所属医院筛选、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。, +USER-UNI-015,基础设置 · 用户管理,医共体用户管理 · 列表与分页,打开列设置面板,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」等待列表稳定 +3. 点击表格工具栏列设置(齿轮)图标 +4. 断言弹出面板含「列展示」「字段设置」「重置」,关闭面板",列设置面板可打开;关键项可见;关闭后列表正常。, +USER-UNI-016,基础设置 · 用户管理,医共体用户管理 · 列表与分页,列设置取消勾选后列表列恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭 +3. 断言该列表头隐藏;再次打开列设置勾选恢复并关闭 +4. 断言该列表头重新可见",取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。, +USER-UNI-017,基础设置 · 用户管理,医共体用户管理 · 授权,打开授权后取消不生效,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「授权」 +3. 断言打开用户角色授权层,可见可勾选菜单/功能 +4. 如可勾选则改一处勾选后点取消/关闭 +5. 再次打开同一用户授权,断言勾选与操作前一致",授权层可打开;取消后勾选不落库。, +USER-UNI-018,基础设置 · 用户管理,医共体用户管理 · 授权,授权弹框账号与所选行一致,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);列表至少 1 条且登录账号非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}` +3. 点击该行「授权」 +4. 断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致 +5. 关闭弹框",用户角色授权弹框打开;弹框账号与所选列表行一致。, +USER-UNI-019,基础设置 · 用户管理,医共体用户管理 · 授权,授权弹框添加移除角色行,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 医共体用户管理」权限;已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe);列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,任选列表一条数据行,点击该行「授权」 +3. 断言弹出用户角色授权弹框 +4. 记录弹框内可「移除」的角色行数,点击「添加」 +5. 断言可移除行数 +1;点击新增行的「移除」 +6. 断言可移除行数恢复为添加前;关闭弹框且不保存",添加后列表下方新增一行;移除后新增行被删除;未保存不落库。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..70931522ff39cb71072614c398ec6badbf76f97e --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,80 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +USER-USR-001,基础设置 · 用户管理,用户管理 · 筛选与查询,姓名+科室+角色+状态组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`、`${sampleStatus}`(角色取截断前列名;状态优先读列表「状态」列,否则按行内启用/禁用文案推断) +3. 填写姓名、科室、角色,展开高级筛选选择 `${sampleStatus}` 后点「筛 选」(无「筛 选」则点「查 询」) +4. 断言结果非空;首行登录账号与采样行一致;可见行姓名/科室/角色/状态与已填条件一致",组合查询无报错;查询结果与首行采样条件一致且同时满足全部已填条件。, +USER-USR-002,基础设置 · 用户管理,用户管理 · 筛选与查询,重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 填写姓名等筛选条件后点击「重 置」 +3. 断言筛选字段恢复默认,列表按默认规则刷新",条件恢复默认;列表刷新。, +USER-USR-003,基础设置 · 用户管理,用户管理 · 筛选与查询,展开高级筛选,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「高级筛选」 +3. 断言展开后面板可见姓名/科室/角色/状态及「重 置」「筛 选」",高级筛选面板展开;关键字段与筛选按钮可见。, +USER-USR-004,基础设置 · 用户管理,用户管理 · 筛选与查询,高级筛选按状态过滤,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 展开高级筛选,状态下拉选择「启用」或「禁用」 +3. 点击「筛 选」 +4. 断言列表仅保留对应状态用户(无匹配则空态且总数为 0)",按状态过滤成功;无报错。, +USER-USR-005,基础设置 · 用户管理,用户管理 · 筛选与查询,无匹配结果空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 姓名框填入 `__NOT_EXIST_USER__99999` +3. 点击「查 询」 +4. 断言空态或「暂无数据」,分页总数为 0",无匹配时不报错;空态明确;总数为 0。, +USER-USR-006,基础设置 · 用户管理,用户管理 · 列表与分页,分页翻至第 2 页,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);默认查询总条数大于一页,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 记录第 1 页首行登录账号 +3. 点击页码「2」或「下一页」 +4. 断言页码高亮为 2,首行账号与第 1 页不同",翻页成功;数据更换。, +USER-USR-007,基础设置 · 用户管理,用户管理 · 新增用户,打开新增用户弹窗,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「新 增」 +3. 断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」",新增弹窗可见;关键字段与按钮齐全。, +USER-USR-008,基础设置 · 用户管理,用户管理 · 新增用户,新增取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,账号填 `${uniqLogin}`,姓名填任意合法值 +3. 点击「取 消」 +4. 按账号查询,断言无该记录",弹窗关闭;列表不出现 `${uniqLogin}`。, +USER-USR-009,基础设置 · 用户管理,用户管理 · 新增用户,必填为空拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,账号或姓名留空后点击「确 定」 +3. 断言出现必填提示,弹窗仍打开",确定被拦截;不落库;弹窗不关闭。, +USER-USR-010,基础设置 · 用户管理,用户管理 · 新增用户,登录账号重复拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);默认查询至少 1 条且登录账号非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开新增,账号填入已存在的 `${sampleLoginName}`,补齐其它必填后点「确 定」 +3. 断言保存失败并提示,弹窗仍打开 +4. 查询该账号,断言原用户未被覆盖",重复账号被拒绝;不覆盖原用户。, +USER-USR-011,基础设置 · 用户管理,用户管理 · 编辑用户,打开编辑用户弹窗并回填,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「编辑」 +3. 断言弹窗标题含「编辑用户」 +4. 断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白)",编辑弹窗可见;回填与列表行一致。, +USER-USR-012,基础设置 · 用户管理,用户管理 · 编辑用户,编辑取消不保存,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行编辑,记录原姓名 +3. 将姓名改为临时值后点「取 消」 +4. 再次打开编辑,断言姓名仍为原值",取消后列表与回看均为修改前姓名。, +USER-USR-013,基础设置 · 用户管理,用户管理 · 编辑用户,编辑时账号只读,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行编辑 +3. 断言登录账号不可改(disabled/readonly 或无法输入)",编辑态登录账号只读。, +USER-USR-014,基础设置 · 用户管理,用户管理 · 编辑用户,编辑弹窗可见签名图片,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 打开首行编辑 +3. 断言可见「签名图片」区域(有图则展示,无图则上传入口可见) +4. 点「取 消」,不保存",签名图片控件可见;本条不上传、不保存。, +USER-USR-015,基础设置 · 用户管理,用户管理 · 授权,打开授权后取消不生效,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击首行「授权」 +3. 断言打开授权层(弹窗或权限树),可见可勾选菜单/功能 +4. 如可勾选则改一处勾选后点取消/关闭 +5. 再次打开同一用户授权,断言勾选与操作前一致",授权层可打开;取消后勾选不落库。, +USER-USR-016,基础设置 · 用户管理,用户管理 · 列表与分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数 +3. 分页区切换每页条数为「20 条/页」 +4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。, +USER-USR-017,基础设置 · 用户管理,用户管理 · 列表与分页,打开列设置面板,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);默认查询至少 1 条,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」等待列表稳定 +3. 点击表格工具栏列设置(齿轮)图标 +4. 断言弹出面板含「列展示」「字段设置」「重置」,关闭面板",列设置面板可打开;关键项可见;关闭后列表正常。, +USER-USR-018,基础设置 · 用户管理,用户管理 · 列表与分页,列设置取消勾选后列表列恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭 +3. 断言该列表头隐藏;再次打开列设置勾选恢复并关闭 +4. 断言该列表头重新可见",取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。, +USER-USR-019,基础设置 · 用户管理,用户管理 · 授权,授权弹框账号与所选行一致,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条且登录账号非空,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}` +3. 点击该行「授权」 +4. 断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致 +5. 关闭弹框",用户角色授权弹框打开;弹框账号与所选列表行一致。, +USER-USR-020,基础设置 · 用户管理,用户管理 · 授权,授权弹框添加移除角色行,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 用户管理」权限;已进入用户管理页(/spd/basic/user/platform/person,无 iframe);列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击「查 询」,任选列表一条数据行,点击该行「授权」 +3. 断言弹出用户角色授权弹框 +4. 记录弹框内可「移除」的角色行数,点击「添加」 +5. 断言可移除行数 +1;点击新增行的「移除」 +6. 断言可移除行数恢复为添加前;关闭弹框且不保存",添加后列表下方新增一行;移除后新增行被删除;未保存不落库。, diff --git "a/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" new file mode 100644 index 0000000000000000000000000000000000000000..c90dd37e16dc9adec9ff06d29f7087e3272c0226 --- /dev/null +++ "b/testcase/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.csv" @@ -0,0 +1,64 @@ +用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 +USER-ROL-001,基础设置 · 用户管理,角色管理 · 右侧菜单权限树,权限树按应用与模块展开,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 选中任一角色 +3. 断言右侧树根为「SPD内网系统PC端」,可见基础设置/运维管理/厂商管理/产品管理等模块 +4. 展开「基础设置」,断言可见子菜单(如医院资料)",权限树层级可见;节点可展开。, +USER-ROL-002,基础设置 · 用户管理,角色管理 · 右侧菜单权限树,权限树模块节点收起正常,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 选中任一角色 +3. 展开「基础设置」,断言可见子菜单(如医院资料) +4. 点击「基础设置」节点展开图标收起该模块 +5. 断言子菜单(如医院资料)不可见,且「基础设置」节点处于收起态",权限树节点可收起;子级随父节点隐藏。, +USER-ROL-003,基础设置 · 用户管理,角色管理 · 左侧角色列表,切换角色右侧权限树同步更新,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);左栏至少 2 个角色,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点选左栏第一个角色,记录右侧权限树勾选快照 +3. 点选左栏第二个不同角色,断言该行选中且右侧权限树仍可见 +4. 再次点选第一个角色,断言右侧权限树勾选快照与步骤 2 一致",切换后右侧与当前角色一致;切回后恢复该角色已保存勾选,无错乱残留。, +USER-ROL-004,基础设置 · 用户管理,角色管理 · 角色增删改,编辑角色弹框回填名称一致,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);左栏至少 1 个角色,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点选左栏任一角色,记录其角色名称 +3. 点击左栏右下角(或行内)编辑图标 +4. 断言弹出编辑角色弹框,标题或内容含「编辑」 +5. 断言弹框内「角色名称」输入框回填值与所选角色名称一致 +6. 点「取 消」或「关 闭」关闭弹框,不保存",编辑弹框正常打开;角色名称与当前选中角色一致;本条不保存。, +USER-ROL-005,基础设置 · 用户管理,角色管理 · 角色增删改,编辑角色保存成功,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);左栏至少 1 个可编辑角色;环境允许写操作(非 SKIP_BASIC_WRITE=1),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点选左栏任一角色 +3. 点击左栏右下角(或行内)编辑图标,断言弹出编辑角色弹框 +4. 不修改角色名称,点击弹框内「保 存」 +5. 断言页面无报错提示,且出现「编辑成功」类成功提示 +6. 断言编辑弹框关闭",保存成功;提示编辑成功;角色数据无异常报错。, +USER-ROL-006,基础设置 · 用户管理,角色管理 · 角色增删改,点击角色按钮打开新增弹框,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击左栏工具区「角 色」按钮 +3. 断言弹出新增角色弹框,标题或内容含「新增」或「角色」 +4. 断言页面无报错提示(无错误 toast/遮罩) +5. 点「取 消」或「关 闭」关闭弹框,不保存",新增角色弹框正常打开;页面无报错;本条不提交保存。, +USER-ROL-007,基础设置 · 用户管理,角色管理 · 角色增删改,新增角色名称为空保存拦截,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 +3. 不填写角色名称,直接点击弹框内「保 存」 +4. 断言「角色名称」输入框呈校验失败态(变红) +5. 断言同字段下方提示:请输入角色名称! +6. 点「取 消」关闭弹框,不保存",空名称保存被拦截;角色名称框变红并提示请输入角色名称!;弹框不提交。, +USER-ROL-008,基础设置 · 用户管理,角色管理 · 角色增删改,新增角色弹框取消关闭,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 +3. 不填写任何内容,直接点击弹框内「取 消」 +4. 断言新增角色弹框关闭 +5. 断言页面无报错提示,左栏角色数量不增加",取消后弹框关闭;页面无报错;未新增角色。, +USER-ROL-009,基础设置 · 用户管理,角色管理 · 角色增删改,新增弹框查询配置化复选框可勾选,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);新增弹框含「查询配置化」复选框,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 +3. 定位「查询配置化」复选框,点击勾选,断言为选中态 +4. 再次点击取消勾选,断言为未选中态 +5. 断言页面无报错提示 +6. 点「取 消」关闭弹框,不保存",查询配置化可正常勾选与取消勾选;页面无报错;本条不提交保存。, +USER-ROL-010,基础设置 · 用户管理,角色管理 · 角色增删改,新增角色弹框X关闭,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 +3. 点击弹框右上角「X」关闭图标 +4. 断言新增角色弹框关闭 +5. 断言页面无报错提示,左栏角色数量不增加",X 关闭后弹框正常关闭;页面无报错;未新增角色。, +USER-ROL-011,基础设置 · 用户管理,角色管理 · 左侧角色列表,左栏角色列表可上下滑动,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);左栏列表高度溢出可滚动(不足则 skip),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 定位左栏角色列表滚动区域 +3. 向下滚动列表,断言 scrollTop 增加且列表可继续交互 +4. 向上滚回(滚轮或滑至顶部),断言 scrollTop 减小 +5. 断言页面无报错遮罩",左栏可正常上下滑动;滚动后页面无异常。, +USER-ROL-012,基础设置 · 用户管理,角色管理 · 右侧菜单权限树,右侧权限树子菜单可上下滑动,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → 角色管理」权限;已进入角色管理页(/spd/basic/user/platform/role,无 iframe);已选中任一角色且右侧权限树高度溢出可滚动(不足则 skip),"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录 +2. 点选左栏任一角色,断言右侧菜单权限树可见 +3. 定位右侧权限树滚动区域(含子菜单列表) +4. 向下滚动,断言 scrollTop 增加 +5. 向上滚回,断言 scrollTop 减小 +6. 断言页面无报错遮罩",右侧子菜单列表可正常上下滑动;滚动后页面无异常。, diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..91eda21ee36bb370ab5185f2977c50084fe35bb5 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,242 @@ +# 基础管理-准入管理-产品准入单列表 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-产品准入单列表-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/productOrder/list` +**定位器 YAML**:`locators/spd/admit-product-list.locator.yaml` +**元素扫描**:`data/scan/admit-product-list-elements.json`(77 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-product-list.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 准入管理 → 产品准入单列表」权限。 +**页面约定**:整页渲染,**无报表 iframe**。与物料准入单列表 URL 不同,列表多「备注」「附件」列。 +**菜单导航**:展开「基础设置 → 准入管理」后,点击 `.ant-menu-item` 文案精确为「产品准入单列表」。 +**写操作**:不确认「通过」,避免推进真实审批。 + +**动态取数约定**: +1. 默认查询后从首行读取 `${sampleOrderNo}`、`${sampleProductName}`、`${sampleProductCount}`、`${sampleStatus}`、`${sampleAttachmentCount}`。 +2. **禁止写死**准入单号与产品名称。 +3. 状态 Tab:全部、审批中、已通过、已驳回。 + +--- + +## 基础设置 · 准入管理 + +### 产品准入单列表 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PLS-001】【TP_ADMIT_PLS_001】菜单进入产品准入单列表 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 产品准入单列表」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/list`,且路径不是 `zrlist` + 3. 断言制单日期、查询、状态 Tab、列表表头可见 +- 自动化断言:规划与 CSV `ADMIT-PLS-001` 及 `tests/spd/admit-product-list.spec.ts` 一致 + +###### 预期结果 + +进入产品准入单列表;不误入物料准入单列表。 + +--- + +### 产品准入单列表 · 筛选与状态 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PLS-002】【TP_ADMIT_PLS_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入产品准入单列表 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-PLS-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-PLS-003】【TP_ADMIT_PLS_010】按产品名称筛选 + +- 前置条件:默认查询至少 1 条且产品名称非空 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 若筛选区无产品名称,则用注册证号或器械注册人中首行非空字段 + 3. 制单日期保持默认,点击「查 询」 + 4. 断言结果包含该关键字 +- 自动化断言:规划与 CSV `ADMIT-PLS-003` 一致 + +###### 预期结果 + +结果与已填关键字匹配。 + +--- + +##### 【P1】【TC-UI-ADMIT-PLS-004】【TP_ADMIT_PLS_011】重置恢复默认 + +- 前置条件:已填入筛选关键字 +- 操作步骤: + 1. 点击「重 置」 + 2. 断言筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-PLS-004` 一致 + +###### 预期结果 + +筛选恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-PLS-005】【TP_ADMIT_PLS_019】无匹配结果空态(mock反例) + +- 前置条件:已进入产品准入单列表 +- 操作步骤: + 1. 注册证号填入 `__NOT_EXIST_CERT__99999` + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-PLS-005` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PLS-006】【TP_ADMIT_PLS_013】列表核心列与附件计数 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 断言表头含准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作 + 2. 断言首行附件列展示个数(可为 0 个) +- 自动化断言:规划与 CSV `ADMIT-PLS-006` 一致 + +###### 预期结果 + +核心列可见;附件列有个数;操作为「详情」。本页列表不要求「准入类型」列。 + +--- + +##### 【P0】【TC-UI-ADMIT-PLS-007】【TP_ADMIT_PLS_012】切换已通过 Tab + +- 前置条件:已进入产品准入单列表 +- 操作步骤: + 1. 点击「已通过」 + 2. 断言有数据时状态为已通过 + 3. 断言 Tab 括号数量等于分页「共 N 条」 +- 自动化断言:规划与 CSV `ADMIT-PLS-007` 一致 + +###### 预期结果 + +已通过 Tab 的状态与计数一致。 + +--- + +##### 【P1】【TC-UI-ADMIT-PLS-008】【TP_ADMIT_PLS_021】与物料准入单列表不串 URL + +- 前置条件:当前在产品准入单列表 +- 操作步骤: + 1. 记录当前 URL + 2. 断言 URL 含 `productOrder/list` 且不含 `zrlist` + 3. 不点击物料准入单列表菜单 +- 自动化断言:规划与 CSV `ADMIT-PLS-008` 一致 + +###### 预期结果 + +停留在产品准入单列表路径,不串到物料单路径。 + +--- + +### 产品准入单列表 · 单据详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PLS-009】【TP_ADMIT_PLS_022】打开详情后返回 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 记录首行 `${sampleOrderNo}` + 2. 点击「详情」 + 3. 断言可见「审批进程」「单据日志」「返 回」 + 4. 返回后 URL 仍含 `productOrder/list` +- 自动化断言:规划与 CSV `ADMIT-PLS-009` 一致 + +###### 预期结果 + +详情可打开;返回后仍是产品准入单列表。 + +--- + +##### 【P0】【TC-UI-ADMIT-PLS-010】【TP_ADMIT_PLS_023】详情产品条数与列表一致 + +- 前置条件:已打开首行详情 +- 操作步骤: + 1. 读取详情明细分页总数或当前行数 + 2. 断言等于列表「产品条数」 +- 自动化断言:规划与 CSV `ADMIT-PLS-010` 一致 + +###### 预期结果 + +明细条数与产品条数一致。 + +--- + +##### 【P1】【TC-UI-ADMIT-PLS-011】【TP_ADMIT_PLS_025】单据日志可打开 + +- 前置条件:已打开单据详情 +- 操作步骤: + 1. 点击「单据日志」 + 2. 断言日志层可见 + 3. 关闭后仍在该单据 +- 自动化断言:规划与 CSV `ADMIT-PLS-011` 一致 + +###### 预期结果 + +单据日志可查看;详情不被销毁。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-PLS-012】【TP_ADMIT_PLS_028】详情搜索无匹配(mock反例) + +- 前置条件:已打开单据详情 +- 操作步骤: + 1. 搜索框填入 `__NOT_EXIST_GOODS__99999` + 2. 断言明细空态,返回按钮仍在 +- 自动化断言:规划与 CSV `ADMIT-PLS-012` 一致 + +###### 预期结果 + +无匹配只影响明细;单据头仍在。 + +--- + +##### 【P0】【TC-UI-ADMIT-PLS-013】【TP_ADMIT_PLS_033】已通过单据不确认通过 + +- 前置条件:已通过 Tab 至少 1 条并打开详情 +- 操作步骤: + 1. 「通过」禁用或不可见时,断言状态保持已通过 + 2. 若可点且出现确认框,点「取 消」 + 3. 返回列表,断言状态仍为已通过 +- 自动化断言:规划与 CSV `ADMIT-PLS-013` 一致 + +###### 预期结果 + +不把已通过单据再次审批成功。无已通过数据则 skip。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **13** 条(P0=7 / P1=6) | +| 来源 | 从 34 条测试点选取查询、附件列、详情与审批门禁 | +| 未纳入自动化 | 未登录/权限、确认「通过」、与产品准入申请的跨页追单、附件文件内容打开 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..0aeb477a55532f15c46e385b5e68c1844703ea5e --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,339 @@ +# 基础管理-准入管理-产品准入申请 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-产品准入申请-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/productApply/list` +**定位器 YAML**:`locators/spd/admit-product-apply.locator.yaml` +**元素扫描**:`data/scan/admit-product-apply-elements.json`(467 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-product-apply.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 准入管理 → 产品准入申请」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**菜单导航**:展开「基础设置 → 准入管理」后,点击 `.ant-menu-item` 文案精确为「产品准入申请」。 +**写操作**:不真实保存新产品、不导入 Excel、不确认生成申请单或作废。新增页只校验打开、取消和必填拦截。 + +**动态取数约定**: +1. 默认查询后从首行读取 `${sampleProductName}`、`${sampleSpec}`、`${sampleConsumableType}`、`${sampleOrderNo}`。 +2. 筛选使用样本占位,**禁止写死**产品名称与单号。 +3. 状态 Tab:全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效。分页含「跳至」。总条数不足两页时翻页用例 skip。 + +--- + +## 基础设置 · 准入管理 + +### 产品准入申请 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-001】【TP_ADMIT_PAP_001】菜单进入产品准入申请页 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 产品准入申请」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/list`,且不是 `ZRlist` + 3. 断言可见「查 询」、「Excel导入」「生成申请单」「一键生成」「新增产品」「作 废」及列表表头 +- 自动化断言:规划与 CSV `ADMIT-PAP-001` 及 `tests/spd/admit-product-apply.spec.ts` 一致 + +###### 预期结果 + +进入产品准入申请页;不误入物料准入管理(ZRlist)或产品准入单列表。 + +--- + +### 产品准入申请 · 筛选查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-002】【TP_ADMIT_PAP_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-PAP-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-003】【TP_ADMIT_PAP_012】产品名称筛选 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 填入产品名称,新增日期保持默认,点击「查 询」 + 3. 断言结果行「产品名称」均包含该关键字 +- 自动化断言:规划与 CSV `ADMIT-PAP-003` 一致 + +###### 预期结果 + +产品名称与已填关键字匹配。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-004】【TP_ADMIT_PAP_013】名称与耗材类型组合查询 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}` + 2. 同时填入后点击「查 询」(日期保持默认) + 3. 断言结果同时满足两个条件 +- 自动化断言:规划与 CSV `ADMIT-PAP-004` 一致 + +###### 预期结果 + +组合查询结果同时满足产品名称与耗材类型。类型为空则只断言名称。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-005】【TP_ADMIT_PAP_014】重置恢复默认 + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 填入 `${sampleProductName}` + 2. 点击「重 置」 + 3. 断言筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-PAP-005` 一致 + +###### 预期结果 + +筛选字段恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-PAP-006】【TP_ADMIT_PAP_018】无匹配结果空态(mock反例) + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 产品名称填入 `__NOT_EXIST_GOODS__99999` + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-PAP-006` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +### 产品准入申请 · 状态与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-007】【TP_ADMIT_PAP_022】列表核心列完整 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 断言表头含准入单号、产品编号、产品名称、规格型号、注册规格、注册型号、器械注册人、单位、操作 +- 自动化断言:规划与 CSV `ADMIT-PAP-007` 一致 + +###### 预期结果 + +核心列可见;操作含「详情」「日志」。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-008】【TP_ADMIT_PAP_020】切换待申请 Tab + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 点击「待申请」 + 2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态 +- 自动化断言:规划与 CSV `ADMIT-PAP-008` 一致 + +###### 预期结果 + +待申请 Tab 与列表状态一致。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-009】【TP_ADMIT_PAP_023】翻至第 2 页 + +- 前置条件:分页总数大于每页条数 +- 操作步骤: + 1. 记录第 1 页首行准入单号 + 2. 点击页码「2」或「下一页」 + 3. 断言页码高亮为 2,首行单号与第 1 页不同 +- 自动化断言:规划与 CSV `ADMIT-PAP-009` 一致 + +###### 预期结果 + +翻页成功且数据更换。不足两页则 skip。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-010】【TP_ADMIT_PAP_024】跳至指定页 + +- 前置条件:总页数不少于 2,分页存在「跳至」 +- 操作步骤: + 1. 在跳至输入框填入 `2` 并回车 + 2. 断言当前页为 2 +- 自动化断言:规划与 CSV `ADMIT-PAP-010` 一致 + +###### 预期结果 + +跳转到第 2 页。无跳至或仅 1 页则 skip。 + +--- + +### 产品准入申请 · 新增与导入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-011】【TP_ADMIT_PAP_027】打开新增产品后返回 + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 点击「新增产品」 + 2. 断言可见产品名称、器械注册人、规格型号、注册证号及返回 + 3. 点击返回,不保存 +- 自动化断言:规划与 CSV `ADMIT-PAP-011` 一致 + +###### 预期结果 + +新增页字段可见;返回后列表不新增记录。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-012】【TP_ADMIT_PAP_029】新增取消不落库 + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 记录分页总数 `${sampleTotal}` + 2. 打开新增,产品名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存 + 3. 按该名称查询,断言无此行,总数不变 +- 自动化断言:规划与 CSV `ADMIT-PAP-012` 一致 + +###### 预期结果 + +取消后不产生新产品。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-013】【TP_ADMIT_PAP_030】Excel 导入打开后取消 + +- 前置条件:已进入产品准入申请页 +- 操作步骤: + 1. 记录总数 `${sampleTotal}` + 2. 点击「Excel导入」 + 3. 断言导入层打开 + 4. 关闭或取消,不选择文件 + 5. 断言总数不变 +- 自动化断言:规划与 CSV `ADMIT-PAP-013` 一致 + +###### 预期结果 + +取消导入不增加产品。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-014】【TP_ADMIT_PAP_034】产品名称为空保存拦截(mock反例) + +- 前置条件:已打开新增产品页 +- 操作步骤: + 1. 产品名称保持 mock 空值,其它能填的非名称字段可留空 + 2. 点击保存 + 3. 断言必填提示,页面不关闭 + 4. 返回列表,断言总数不变 +- 自动化断言:规划与 CSV `ADMIT-PAP-014` 一致 + +###### 预期结果 + +保存被拦截;不落库。 + +--- + +### 产品准入申请 · 详情、编辑与作废 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-PAP-015】【TP_ADMIT_PAP_046】打开详情后返回 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 记录首行 `${sampleProductName}` + 2. 点击「详情」,断言名称一致 + 3. 返回列表 +- 自动化断言:规划与 CSV `ADMIT-PAP-015` 一致 + +###### 预期结果 + +详情与该行一致;返回后仍在产品准入申请列表。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-016】【TP_ADMIT_PAP_047】待申请编辑回填 + +- 前置条件:待申请至少 1 行含「编辑」 +- 操作步骤: + 1. 读取该行 `${sampleProductName}`、`${sampleSpec}` + 2. 点击「编辑」 + 3. 断言名称与规格型号回填一致 + 4. 返回不保存 +- 自动化断言:规划与 CSV `ADMIT-PAP-016` 一致 + +###### 预期结果 + +编辑回填与列表行一致。无编辑按钮则 skip。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-017】【TP_ADMIT_PAP_049】编辑取消不保存 + +- 前置条件:存在可编辑行 +- 操作步骤: + 1. 打开编辑,把产品名称改为 `${uniqProductName}` 后返回不保存 + 2. 再次打开,断言名称仍为原值 +- 自动化断言:规划与 CSV `ADMIT-PAP-017` 一致 + +###### 预期结果 + +取消后数据不变。 + +--- + +##### 【P0】【TC-UI-ADMIT-PAP-018】【TP_ADMIT_PAP_041】作废确认后取消 + +- 前置条件:待申请至少 1 条可勾选 +- 操作步骤: + 1. 勾选 1 行并点击「作 废」 + 2. 断言确认框出现 + 3. 点击「取 消」 + 4. 断言该行仍在,状态未变为已作废 +- 自动化断言:规划与 CSV `ADMIT-PAP-018` 一致 + +###### 预期结果 + +取消作废不改变状态。本条不点击确定。 + +--- + +##### 【P1】【TC-UI-ADMIT-PAP-019】【TP_ADMIT_PAP_051】已通过行不提供编辑 + +- 前置条件:已通过 Tab 计数大于 0 +- 操作步骤: + 1. 切换「已通过」 + 2. 断言首行无「编辑」,或打开后保存被拒绝 +- 自动化断言:规划与 CSV `ADMIT-PAP-019` 一致 + +###### 预期结果 + +已通过行不能改写产品名称。计数为 0 则 skip。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **19** 条(P0=11 / P1=8) | +| 来源 | 从 56 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录/权限、合法新增落库、Excel 真实导入、确认生成申请单、一键生成、确认作废、跨单列表追溯 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6733376ad7a2adc881b58ee89b563c233c6c513a --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,305 @@ +# 基础管理-准入管理-供货目录申请 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-供货目录申请-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/supplyApply/list` +**定位器 YAML**:`locators/spd/admit-supply.locator.yaml` +**元素扫描**:`data/scan/admit-supply-elements.json`(117 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-supply.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 准入管理 → 供货目录申请」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**菜单导航**:展开「基础设置 → 准入管理」后,点击 `.ant-menu-item` 文案精确为「供货目录申请」。 +**写操作**:不提交新申请、不导入 Excel、不确认批量提交。新增页只做打开、取消和空提交拦截。 + +**动态取数约定**: +1. 默认查询后从首行读取 `${sampleOrderNo}`、`${sampleHospitalSupplier}`、`${sampleSecondSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}`。 +2. **禁止写死**供应商名称与申请单号。 +3. 状态 Tab:全部、待提交、审批中、已通过、已驳回。无待提交行时,编辑与批量勾选用例 skip。 + +--- + +## 基础设置 · 准入管理 + +### 供货目录申请 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-SUP-001】【TP_ADMIT_SUP_001】菜单进入供货目录申请页 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 供货目录申请」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/supplyApply/list` + 3. 断言可见「查 询」、「批量提交」、「新 增」及列表表头 +- 自动化断言:规划与 CSV `ADMIT-SUP-001` 及 `tests/spd/admit-supply.spec.ts` 一致 + +###### 预期结果 + +进入供货目录申请页;不误入准入任务分配。 + +--- + +### 供货目录申请 · 筛选与状态 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-SUP-002】【TP_ADMIT_SUP_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入供货目录申请页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-SUP-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-003】【TP_ADMIT_SUP_010】按进院供应商筛选 + +- 前置条件:默认查询至少 1 条且进院供应商非空 +- 操作步骤: + 1. 从首行读取 `${sampleHospitalSupplier}` + 2. 在进院供应商选择该值,制单日期保持默认,点击「查 询」 + 3. 断言结果行「进院供应商」与所选值一致 +- 自动化断言:规划与 CSV `ADMIT-SUP-003` 一致 + +###### 预期结果 + +进院供应商列与条件一致。 + +--- + +##### 【P1】【TC-UI-ADMIT-SUP-004】【TP_ADMIT_SUP_012】重置恢复默认 + +- 前置条件:已选择进院供应商 +- 操作步骤: + 1. 点击「重 置」 + 2. 断言进院供应商等筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-SUP-004` 一致 + +###### 预期结果 + +筛选恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-SUP-005】【TP_ADMIT_SUP_019】变更单号无匹配空态(mock反例) + +- 前置条件:已进入供货目录申请页 +- 操作步骤: + 1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在产品名称填同一 mock + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-SUP-005` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-SUP-006】【TP_ADMIT_SUP_014】列表核心列完整 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作 +- 自动化断言:规划与 CSV `ADMIT-SUP-006` 一致 + +###### 预期结果 + +核心列可见;操作含「编辑」或「详情」(随状态变化)。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-007】【TP_ADMIT_SUP_013】切换待提交 Tab 且计数一致 + +- 前置条件:已进入供货目录申请页 +- 操作步骤: + 1. 点击「待提交」 + 2. 读取括号数量 + 3. 断言分页「共 N 条」等于该数量;有数据时状态为待提交 +- 自动化断言:规划与 CSV `ADMIT-SUP-007` 一致 + +###### 预期结果 + +待提交 Tab 与分页总数一致。计数为 0 时为空态。 + +--- + +### 供货目录申请 · 新增与编辑 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-SUP-008】【TP_ADMIT_SUP_021】打开新增供货目录 + +- 前置条件:已进入供货目录申请页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言可见进院供应商、二级供应商、业务模式、是否默认、计划生效时间、添加产品、提交 + 3. 返回不提交 +- 自动化断言:规划与 CSV `ADMIT-SUP-008` 一致 + +###### 预期结果 + +新增页字段齐全;返回后列表不新增申请单。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-009】【TP_ADMIT_SUP_026】新增放弃不落库 + +- 前置条件:已进入供货目录申请页 +- 操作步骤: + 1. 记录总数 `${sampleTotal}` + 2. 打开新增,备注填 `${uniqRemark}`(建议 `AUTO-SUP-${timestamp}`)后返回不提交 + 3. 断言总数不变,列表无该备注 +- 自动化断言:规划与 CSV `ADMIT-SUP-009` 一致 + +###### 预期结果 + +未提交不产生申请单。 + +--- + +##### 【P1】【TC-UI-ADMIT-SUP-010】【TP_ADMIT_SUP_048】业务模式与是否默认只切换不提交 + +- 前置条件:已打开新增页 +- 操作步骤: + 1. 展开业务模式、是否默认,断言存在可选项 + 2. 各选一项后返回,不提交 + 3. 断言列表总数不变 +- 自动化断言:规划与 CSV `ADMIT-SUP-010` 一致 + +###### 预期结果 + +选项可见;不提交则无新单。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-011】【TP_ADMIT_SUP_025】待提交编辑回填 + +- 前置条件:待提交至少 1 行含「编辑」 +- 操作步骤: + 1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}` + 2. 点击「编辑」 + 3. 断言进院供应商、业务模式、是否默认与该行一致 + 4. 返回不提交 +- 自动化断言:规划与 CSV `ADMIT-SUP-011` 一致 + +###### 预期结果 + +编辑回填与列表行一致。无待提交或无编辑则 skip。 + +--- + +##### 【P1】【TC-UI-ADMIT-SUP-012】【TP_ADMIT_SUP_026】编辑取消不保存 + +- 前置条件:存在可编辑的待提交行 +- 操作步骤: + 1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交 + 2. 再次打开,断言备注仍为原值 +- 自动化断言:规划与 CSV `ADMIT-SUP-012` 一致 + +###### 预期结果 + +取消后备注不变。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-ADMIT-SUP-013】【TP_ADMIT_SUP_030】进院供应商为空不可提交(mock反例) + +- 前置条件:已打开新增页,进院供应商未选 +- 操作步骤: + 1. 不选进院供应商,点击「提 交」 + 2. 断言必填提示,页面不关闭 + 3. 返回列表,断言总数不变 +- 自动化断言:规划与 CSV `ADMIT-SUP-013` 一致 + +###### 预期结果 + +提交被拦截;不产生申请单。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-014】【TP_ADMIT_SUP_031】未添加产品不可提交 + +- 前置条件:已打开新增页,产品明细为 0 +- 操作步骤: + 1. 若进院供应商、二级供应商、业务模式可从样本选择则先选上,但不添加产品 + 2. 点击「提 交」 + 3. 断言拦截提示,明细仍为 0,列表总数不变 +- 自动化断言:规划与 CSV `ADMIT-SUP-014` 一致 + +###### 预期结果 + +没有产品明细时不能提交。 + +--- + +### 供货目录申请 · 批量提交与详情 + +#### 功能验证点 + +##### 【P1】【TC-UI-ADMIT-SUP-015】【TP_ADMIT_SUP_042】未勾选时批量提交不可用 + +- 前置条件:已进入供货目录申请页,无勾选行 +- 操作步骤: + 1. 断言「批量提交」计数为 0,或按钮不可用 + 2. 若仍可点击,断言出现请选择提示,且无单据离开待提交 +- 自动化断言:规划与 CSV `ADMIT-SUP-015` 一致 + +###### 预期结果 + +未勾选不提交任何申请单。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-016】【TP_ADMIT_SUP_038】勾选后批量提交取消 + +- 前置条件:待提交至少 1 条 +- 操作步骤: + 1. 勾选 1 行,记录申请单号与状态 + 2. 点击「批量提交」 + 3. 若出现确认框,点「取 消」 + 4. 断言该单仍为待提交 +- 自动化断言:规划与 CSV `ADMIT-SUP-016` 一致 + +###### 预期结果 + +取消批量提交不改变状态。本条不点击确定。无待提交则 skip。 + +--- + +##### 【P0】【TC-UI-ADMIT-SUP-017】【TP_ADMIT_SUP_039】打开详情后返回 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 点击首行「详情」 + 2. 断言可见「审批进程」「单据日志」「返 回」 + 3. 返回后 URL 仍含 `supplyApply/list` +- 自动化断言:规划与 CSV `ADMIT-SUP-017` 一致 + +###### 预期结果 + +详情可打开;返回后仍在供货目录申请列表。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **17** 条(P0=11 / P1=6) | +| 来源 | 从 48 条测试点选取查询、新增拦截、编辑回填、批量提交取消与详情 | +| 未纳入自动化 | 未登录/权限、真实提交、Excel 导入落库、确认批量提交、是否默认冲突后的供货关系回写 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6c70355c3c2f43fb22d3e7f8a94c725bcb16b9d6 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,261 @@ +# 基础管理-准入管理-准入任务分配 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-准入任务分配-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/accessKind/list` +**定位器 YAML**:`locators/spd/admit-task.locator.yaml` +**元素扫描**:`data/scan/admit-task-elements.json`(158 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-task.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 准入管理 → 准入任务分配」权限。 +**页面约定**:整页渲染,**无报表 iframe**。分类 Tab 为「待分类」「已分类」,分页含「跳至」。 +**菜单导航**:展开「基础设置 → 准入管理」后,点击 `.ant-menu-item` 文案精确为「准入任务分配」。 +**写操作**:不确认分配类型、不确认批量分配,避免改生产分类。 + +**动态取数约定**: +1. 在待分类默认查询后,从首行读取 `${sampleProductName}`、`${sampleProductCode}`、`${sampleSpec}`。 +2. **禁止写死**产品名称与产品编号。 +3. 总条数不足两页时,翻页与跳至用例 skip。已分类计数为 0 时,已分类用例只断言空态。 + +--- + +## 基础设置 · 准入管理 + +### 准入任务分配 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-TSK-001】【TP_ADMIT_TSK_001】菜单进入准入任务分配页 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 准入任务分配」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/accessKind/list` + 3. 断言可见「查 询」、「待分类」「已分类」、「批量分配」及列表表头 +- 自动化断言:规划与 CSV `ADMIT-TSK-001` 及 `tests/spd/admit-task.spec.ts` 一致 + +###### 预期结果 + +进入准入任务分配页;不误入供货目录申请。 + +--- + +### 准入任务分配 · 筛选与分类 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-TSK-002】【TP_ADMIT_TSK_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入准入任务分配页,当前为待分类 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-TSK-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-TSK-003】【TP_ADMIT_TSK_011】产品名称筛选 + +- 前置条件:待分类至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 填入产品名称,新增日期与操作日期保持默认,点击「查 询」 + 3. 断言结果行「产品名称」均包含该关键字 +- 自动化断言:规划与 CSV `ADMIT-TSK-003` 一致 + +###### 预期结果 + +产品名称与已填关键字匹配。 + +--- + +##### 【P0】【TC-UI-ADMIT-TSK-004】【TP_ADMIT_TSK_015】待分类与已分类切换 + +- 前置条件:已进入准入任务分配页 +- 操作步骤: + 1. 记录「待分类」括号数量 `${pendingCount}` + 2. 点击「已分类」,断言分页总数等于已分类括号数量;为 0 时为空态 + 3. 再点「待分类」,断言分页总数等于 `${pendingCount}` +- 自动化断言:规划与 CSV `ADMIT-TSK-004` 一致 + +###### 预期结果 + +两个 Tab 的列表范围与各自计数一致,不串数据。 + +--- + +##### 【P1】【TC-UI-ADMIT-TSK-005】【TP_ADMIT_TSK_014】重置恢复默认 + +- 前置条件:已填入 `${sampleProductName}` +- 操作步骤: + 1. 点击「重 置」 + 2. 断言产品名称等筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-TSK-005` 一致 + +###### 预期结果 + +筛选恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-TSK-006】【TP_ADMIT_TSK_019】无匹配结果空态(mock反例) + +- 前置条件:当前在待分类 +- 操作步骤: + 1. 产品名称填入 `__NOT_EXIST_GOODS__99999` + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-TSK-006` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +### 准入任务分配 · 列表分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-TSK-007】【TP_ADMIT_TSK_021】列表核心列完整 + +- 前置条件:待分类至少 1 条数据 +- 操作步骤: + 1. 断言表头含准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、操作 + 2. 断言待分类行操作含「分配类型」 +- 自动化断言:规划与 CSV `ADMIT-TSK-007` 一致 + +###### 预期结果 + +核心列可见;待分类行可发起分配类型。 + +--- + +##### 【P1】【TC-UI-ADMIT-TSK-008】【TP_ADMIT_TSK_022】翻至第 2 页 + +- 前置条件:待分类总数大于每页条数 +- 操作步骤: + 1. 记录第 1 页首行产品名称或产品编号 + 2. 点击页码「2」或「下一页」 + 3. 断言页码为 2,首行与第 1 页不同 +- 自动化断言:规划与 CSV `ADMIT-TSK-008` 一致 + +###### 预期结果 + +翻页成功且数据更换。不足两页则 skip。 + +--- + +##### 【P1】【TC-UI-ADMIT-TSK-009】【TP_ADMIT_TSK_036】跳至指定页 + +- 前置条件:总页数不少于 2,且存在「跳至」 +- 操作步骤: + 1. 跳至输入 `2` 并回车 + 2. 断言当前页为 2 +- 自动化断言:规划与 CSV `ADMIT-TSK-009` 一致 + +###### 预期结果 + +跳转到第 2 页。无跳至或仅 1 页则 skip。 + +--- + +#### 边界验证点 + +##### 【P1】【TC-UI-ADMIT-TSK-010】【TP_ADMIT_TSK_023】第 1 页上一页不可用 + +- 前置条件:当前在第 1 页 +- 操作步骤: + 1. 断言「上一页」禁用,或点击后仍停留在第 1 页 +- 自动化断言:规划与 CSV `ADMIT-TSK-010` 一致 + +###### 预期结果 + +第 1 页不能再向前翻。 + +--- + +### 准入任务分配 · 分配操作 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-TSK-011】【TP_ADMIT_TSK_027】单行分配类型取消 + +- 前置条件:待分类至少 1 行含「分配类型」 +- 操作步骤: + 1. 记录该行产品名称,以及待分类计数 `${pendingCount}` + 2. 点击「分配类型」 + 3. 断言分配层打开 + 4. 取消或关闭,不确认 + 5. 断言该行仍在待分类,计数仍为 `${pendingCount}` +- 自动化断言:规划与 CSV `ADMIT-TSK-011` 一致 + +###### 预期结果 + +取消分配不改变分类。本条不点击确定。 + +--- + +##### 【P1】【TC-UI-ADMIT-TSK-012】【TP_ADMIT_TSK_024】未勾选时批量分配不可用 + +- 前置条件:待分类页无勾选行 +- 操作步骤: + 1. 点击「批量分配」 + 2. 断言按钮不可用,或提示先选择数据 + 3. 断言待分类计数不变 +- 自动化断言:规划与 CSV `ADMIT-TSK-012` 一致 + +###### 预期结果 + +未勾选不分配任何行。 + +--- + +##### 【P0】【TC-UI-ADMIT-TSK-013】【TP_ADMIT_TSK_028】勾选后批量分配取消 + +- 前置条件:待分类至少 1 条 +- 操作步骤: + 1. 勾选 1 行,记录待分类计数 + 2. 点击「批量分配」 + 3. 若打开分配层或确认框,点取消或关闭 + 4. 断言该行仍在待分类,计数不变 +- 自动化断言:规划与 CSV `ADMIT-TSK-013` 一致 + +###### 预期结果 + +取消批量分配不改变分类。本条不确认分配。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-TSK-014】【TP_ADMIT_TSK_032】已分类行不再按待分类分配 + +- 前置条件:已分类计数大于 0 +- 操作步骤: + 1. 切换到「已分类」 + 2. 断言首行无「分配类型」,或点击后被拒绝且该行仍留在已分类 + 3. 断言待分类计数不因此增加 +- 自动化断言:规划与 CSV `ADMIT-TSK-014` 一致 + +###### 预期结果 + +已分类数据不会被这次操作退回待分类。已分类为 0 则 skip。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **14** 条(P0=7 / P1=7) | +| 来源 | 从 36 条测试点选取查询、分类 Tab、翻页跳至、分配取消 | +| 未纳入自动化 | 未登录/权限、确认分配后的分类回写、未选分类时的提交拦截(依赖分配弹窗字段,扫描未打开该层) | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..bafeb2d9762da79792f351857badc699ea4d20ba --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,244 @@ +# 基础管理-准入管理-物料准入单列表 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-物料准入单列表-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/productOrder/zrlist` +**定位器 YAML**:`locators/spd/admit-material-list.locator.yaml` +**元素扫描**:`data/scan/admit-material-list-elements.json`(79 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-material-list.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 准入管理 → 物料准入单列表」权限。 +**页面约定**:整页渲染,**无报表 iframe**。本页是单据查询,列表行操作是「详情」。 +**菜单导航**:展开「基础设置 → 准入管理」后,点击 `.ant-menu-item` 文案精确为「物料准入单列表」。 +**写操作**:不点击详情里的「通过」确定,避免推进真实审批。 + +**动态取数约定**: +1. 默认查询后从首行读取 `${sampleOrderNo}`、`${sampleAccessType}`、`${sampleProductCount}`、`${sampleStatus}`。 +2. 单号筛选使用样本占位,**禁止写死**准入单号。 +3. 状态 Tab:全部、审批中、已通过、已驳回。 + +--- + +## 基础设置 · 准入管理 + +### 物料准入单列表 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MLS-001】【TP_ADMIT_MLS_001】菜单进入物料准入单列表 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 物料准入单列表」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/zrlist`,且不是 `productOrder/list` + 3. 断言制单日期、查询、状态 Tab、列表表头可见 +- 自动化断言:规划与 CSV `ADMIT-MLS-001` 及 `tests/spd/admit-material-list.spec.ts` 一致 + +###### 预期结果 + +进入物料准入单列表;不误入产品准入单列表。 + +--- + +### 物料准入单列表 · 筛选与状态 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MLS-002】【TP_ADMIT_MLS_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入物料准入单列表 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-MLS-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-MLS-003】【TP_ADMIT_MLS_010】按准入单号筛选 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` + 2. 若页面有准入单号条件则填入;否则用注册证号或产品名称中首行非空字段 + 3. 制单日期保持默认,点击「查 询」 + 4. 断言结果行包含该单号或该关键字 +- 自动化断言:规划与 CSV `ADMIT-MLS-003` 一致 + +###### 预期结果 + +结果与已填单号或关键字匹配。列表筛选区没有单号框时,改用首行注册证号,并在用例备注中记录实际字段。 + +--- + +##### 【P1】【TC-UI-ADMIT-MLS-004】【TP_ADMIT_MLS_011】重置恢复默认 + +- 前置条件:已进入物料准入单列表 +- 操作步骤: + 1. 填入上一条使用的关键字 + 2. 点击「重 置」 + 3. 断言筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-MLS-004` 一致 + +###### 预期结果 + +筛选恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-MLS-005】【TP_ADMIT_MLS_018】无匹配结果空态(mock反例) + +- 前置条件:已进入物料准入单列表 +- 操作步骤: + 1. 注册证号填入 `__NOT_EXIST_CERT__99999` + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-MLS-005` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MLS-006】【TP_ADMIT_MLS_013】列表核心列完整 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 断言表头含准入类型、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、操作 +- 自动化断言:规划与 CSV `ADMIT-MLS-006` 一致 + +###### 预期结果 + +核心列可见;操作列为「详情」。 + +--- + +##### 【P0】【TC-UI-ADMIT-MLS-007】【TP_ADMIT_MLS_012】切换已通过 Tab + +- 前置条件:已进入物料准入单列表 +- 操作步骤: + 1. 点击「已通过」 + 2. 断言有数据时「状态」列为已通过;计数为 0 时为空态 + 3. 读取 Tab 括号数量,断言与分页「共 N 条」一致 +- 自动化断言:规划与 CSV `ADMIT-MLS-007` 一致 + +###### 预期结果 + +已通过 Tab 的状态列、计数与分页总数一致。 + +--- + +### 物料准入单列表 · 单据详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MLS-008】【TP_ADMIT_MLS_020】打开详情后返回 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 记录首行 `${sampleOrderNo}`、`${sampleProductCount}` + 2. 点击「详情」 + 3. 断言可见「审批进程」「单据日志」「返 回」 + 4. 点击「返 回」 + 5. 断言 URL 仍含 `productOrder/zrlist` +- 自动化断言:规划与 CSV `ADMIT-MLS-008` 一致 + +###### 预期结果 + +进入单据详情并可返回列表。 + +--- + +##### 【P0】【TC-UI-ADMIT-MLS-009】【TP_ADMIT_MLS_021】详情产品条数与列表一致 + +- 前置条件:已打开首行详情,且 `${sampleProductCount}` 为正整数 +- 操作步骤: + 1. 统计详情明细数据行数(跨页则读详情分页总数) + 2. 断言等于列表「产品条数」`${sampleProductCount}` +- 自动化断言:规划与 CSV `ADMIT-MLS-009` 一致 + +###### 预期结果 + +详情明细条数与列表产品条数一致。 + +--- + +##### 【P1】【TC-UI-ADMIT-MLS-010】【TP_ADMIT_MLS_023】审批进程可打开 + +- 前置条件:已打开单据详情 +- 操作步骤: + 1. 点击「审批进程」 + 2. 断言进程层或区域可见 + 3. 关闭后仍停留在该单据 +- 自动化断言:规划与 CSV `ADMIT-MLS-010` 一致 + +###### 预期结果 + +审批进程可查看;单据不被关掉。 + +--- + +##### 【P1】【TC-UI-ADMIT-MLS-011】【TP_ADMIT_MLS_026】详情导出不白屏 + +- 前置条件:已打开单据详情 +- 操作步骤: + 1. 点击「导 出」 + 2. 断言出现下载,或出现导出任务/成功提示 + 3. 断言页面仍为该单据详情,不白屏 +- 自动化断言:规划与 CSV `ADMIT-MLS-011` 一致 + +###### 预期结果 + +导出有反馈;详情页仍在。无导出按钮则 skip。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-MLS-012】【TP_ADMIT_MLS_028】详情搜索无匹配(mock反例) + +- 前置条件:已打开单据详情 +- 操作步骤: + 1. 在「请输入产品编码、名称」填入 `__NOT_EXIST_GOODS__99999` + 2. 触发搜索 + 3. 断言明细空态,单据头(审批进程、返回)仍在 +- 自动化断言:规划与 CSV `ADMIT-MLS-012` 一致 + +###### 预期结果 + +无匹配时只清空明细,不销毁单据头。 + +--- + +##### 【P0】【TC-UI-ADMIT-MLS-013】【TP_ADMIT_MLS_030】已通过单据不确认通过 + +- 前置条件:已通过 Tab 至少 1 条,并打开其详情 +- 操作步骤: + 1. 若「通过」不可见或禁用,断言该状态保持已通过后返回 + 2. 若「通过」可点,点击后出现确认框则点「取 消」 + 3. 返回列表,断言该单状态仍为已通过 +- 自动化断言:规划与 CSV `ADMIT-MLS-013` 一致 + +###### 预期结果 + +本条不把已通过单据再次审批成功。已通过 Tab 无数据则 skip。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **13** 条(P0=7 / P1=6) | +| 来源 | 从 34 条测试点选取可稳定自动化的查询、详情与审批门禁 | +| 未纳入自动化 | 未登录/权限、确认点击「通过」、与物料准入管理的跨页追溯、导出文件内容逐行比对 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..4496559c46a7fae823b4e2117483f305204bfbf8 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,379 @@ +# 基础管理-准入管理-物料准入管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-准入管理-物料准入管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/dataSync/dataSync/productApply/ZRlist` +**定位器 YAML**:`locators/spd/admit-material.locator.yaml` +**元素扫描**:`data/scan/admit-material-elements.json`(328 项) +**版本**:2026-09-22 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/admit-material.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号(凭据见 `.env`);具备「基础设置 → 准入管理 → 物料准入管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:先点「基础设置」,展开左侧「准入管理」,再点击 `.ant-menu-item` 且文案精确为「物料准入管理」(现网子项不是 `menuitem` role)。 +**写操作**:本文件不真实执行「生成申请单」「一键生成」「确认作废」「确认关联」,避免改生产准入数据。编辑类只做回填、取消和必填拦截。 + +**动态取数约定**: +1. 默认「查询」后从首行读取 `${sampleProductName}`、`${sampleSpec}`、`${sampleConsumableType}`、`${sampleSupplier}`。 +2. 筛选关键字使用样本占位,**禁止写死**产品名称、供应商、注册证号。 +3. 状态 Tab:全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效。无「编辑」的行则编辑类用例 skip。 + +--- + +## 基础设置 · 准入管理 + +### 物料准入管理 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-001】【TP_ADMIT_MAT_001】菜单进入物料准入管理页 + +- 前置条件:已登录且具备准入管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 准入管理 → 物料准入管理」进入 + 2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/ZRlist` + 3. 断言筛选区、「查 询」、状态 Tab、「关联产品」「生成申请单」「一键生成」「作 废」及列表表头可见 + 4. 断言当前高亮不是「产品准入申请」或「物料准入单列表」 +- 自动化断言:规划与 CSV `ADMIT-MAT-001` 及 `tests/spd/admit-material.spec.ts` 一致 + +###### 预期结果 + +进入物料准入管理页;不误入同组其它准入页;筛选、状态 Tab 与工具栏可见。 + +--- + +### 物料准入管理 · 筛选查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-002】【TP_ADMIT_MAT_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-MAT-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-003】【TP_ADMIT_MAT_013】产品名称筛选 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 填入「产品名称」,新增日期保持页面默认,点击「查 询」 + 3. 断言结果行「产品名称」均包含该关键字 +- 自动化断言:规划与 CSV `ADMIT-MAT-003` 一致 + +###### 预期结果 + +查询结果产品名称与已填关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-004】【TP_ADMIT_MAT_012】耗材类型筛选 + +- 前置条件:默认查询至少 1 条且首行耗材类型非空 +- 操作步骤: + 1. 从首行读取 `${sampleConsumableType}` + 2. 在耗材类型下拉选择该值,点击「查 询」 + 3. 断言结果行「耗材类型」与所选值一致 +- 自动化断言:规划与 CSV `ADMIT-MAT-004` 一致 + +###### 预期结果 + +耗材类型列与所选条件一致。首行类型为空则 skip。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-005】【TP_ADMIT_MAT_014】名称与耗材类型组合查询 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}` + 2. 同时填入名称与耗材类型(新增日期保持默认,勿改成单日) + 3. 点击「查 询」 + 4. 断言结果同时满足两个条件 +- 自动化断言:规划与 CSV `ADMIT-MAT-005` 一致 + +###### 预期结果 + +组合查询无报错;结果同时满足产品名称与耗材类型。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-006】【TP_ADMIT_MAT_015】重置恢复默认 + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 填入 `${sampleProductName}` + 2. 点击「重 置」 + 3. 断言产品名称等筛选恢复默认 +- 自动化断言:规划与 CSV `ADMIT-MAT-006` 一致 + +###### 预期结果 + +筛选字段恢复默认;列表按默认规则刷新。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-007】【TP_ADMIT_MAT_016】收起后再展开保留条件 + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 填入 `${sampleProductName}` + 2. 点击「收起」,再展开筛选区 + 3. 断言产品名称仍为已填值 +- 自动化断言:规划与 CSV `ADMIT-MAT-007` 一致 + +###### 预期结果 + +收起不丢失已填条件;展开后值仍在。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-MAT-008】【TP_ADMIT_MAT_021】无匹配结果空态(mock反例) + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 产品名称填入 mock 值 `__NOT_EXIST_GOODS__99999` + 2. 点击「查 询」 + 3. 断言空态或「暂无数据」,分页总数为 0 +- 自动化断言:规划与 CSV `ADMIT-MAT-008` 一致 + +###### 预期结果 + +无匹配时不报错;空态明确;总数为 0。 + +--- + +### 物料准入管理 · 状态与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-009】【TP_ADMIT_MAT_028】列表核心列完整 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 点击「查 询」 + 2. 断言表头含准入类型、产品编号、产品名称、规格型号、器械注册人、单位、耗材类型、默认进院供应商、操作 +- 自动化断言:规划与 CSV `ADMIT-MAT-009` 一致 + +###### 预期结果 + +核心列可见;操作列含「详情」「日志」,待申请行另可见「编辑」。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-010】【TP_ADMIT_MAT_023】切换待申请 Tab + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 点击状态 Tab「待申请」 + 2. 断言列表刷新;若有数据,行状态属于待申请(不出现已生效、已作废) + 3. 再点「全部」,断言列表不再只限定待申请 +- 自动化断言:规划与 CSV `ADMIT-MAT-010` 一致 + +###### 预期结果 + +待申请 Tab 只展示待申请数据;「全部」不额外收窄。计数为 0 时为空态且不报错。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-011】【TP_ADMIT_MAT_024】待申请 Tab 计数与分页总数一致 + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 读取「待申请」括号内数量 `${tabCount}` + 2. 点击该 Tab 并查询稳定 + 3. 断言分页「共 N 条」的 N 等于 `${tabCount}` +- 自动化断言:规划与 CSV `ADMIT-MAT-011` 一致 + +###### 预期结果 + +Tab 计数与该状态下分页总数一致。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-ADMIT-MAT-012】【TP_ADMIT_MAT_033】未勾选时生成申请单不可用或提示 + +- 前置条件:已进入物料准入管理页,且当前无勾选行 +- 操作步骤: + 1. 确认表头复选框与行复选框均未选中 + 2. 点击「生成申请单」 + 3. 断言按钮不可用,或出现「请选择」类提示,且列表不新增申请单 +- 自动化断言:规划与 CSV `ADMIT-MAT-012` 一致 + +###### 预期结果 + +未勾选不产生申请单。 + +--- + +### 物料准入管理 · 详情与编辑 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-013】【TP_ADMIT_MAT_046】打开详情后返回 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 记录首行 `${sampleProductName}` + 2. 点击该行「详情」 + 3. 断言详情中产品名称、规格型号、注册证号可见,且名称包含 `${sampleProductName}` + 4. 点击「返回」 + 5. 断言回到列表,URL 仍含 `productApply/ZRlist` +- 自动化断言:规划与 CSV `ADMIT-MAT-013` 一致 + +###### 预期结果 + +详情展示该行产品主数据;返回后仍在物料准入管理列表。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-014】【TP_ADMIT_MAT_051】详情关键字段不可直接改写 + +- 前置条件:已打开首行详情 +- 操作步骤: + 1. 断言产品名称等主数据无可用的保存按钮,或输入框为只读 + 2. 返回列表,断言首行名称仍为 `${sampleProductName}` +- 自动化断言:规划与 CSV `ADMIT-MAT-014` 一致 + +###### 预期结果 + +详情态不能把产品名称改写入库。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-015】【TP_ADMIT_MAT_047】待申请行编辑回填 + +- 前置条件:待申请 Tab 至少 1 行含「编辑」 +- 操作步骤: + 1. 在待申请列表读取首行 `${sampleProductName}`、`${sampleSpec}` + 2. 点击「编辑」 + 3. 断言产品名称、规格型号与该行一致 + 4. 返回且不保存 +- 自动化断言:规划与 CSV `ADMIT-MAT-015` 一致 + +###### 预期结果 + +编辑页回填与列表行一致。无「编辑」则 skip。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-016】【TP_ADMIT_MAT_049】编辑取消不保存 + +- 前置条件:存在可编辑行 +- 操作步骤: + 1. 打开编辑,记录原产品名称 + 2. 将产品名称改为 `${uniqProductName}`(建议 `AUTO-MAT-${timestamp}`)后返回或取消,不保存 + 3. 再次打开编辑,断言名称仍为原值 +- 自动化断言:规划与 CSV `ADMIT-MAT-016` 一致 + +###### 预期结果 + +取消后列表与回看均为修改前名称。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-017】【TP_ADMIT_MAT_052】编辑清空产品名称拦截(mock反例) + +- 前置条件:存在可编辑行 +- 操作步骤: + 1. 打开编辑,将产品名称清空为 mock 空值 + 2. 点击保存 + 3. 断言必填提示,页面不关闭 + 4. 取消后再次打开,断言名称仍为原值 +- 自动化断言:规划与 CSV `ADMIT-MAT-017` 一致 + +###### 预期结果 + +保存被拦截;原记录不被写成空。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-018】【TP_ADMIT_MAT_053】已生效行不提供编辑 + +- 前置条件:已生效 Tab 计数大于 0 +- 操作步骤: + 1. 切换到「已生效」 + 2. 断言首行操作无「编辑」,或点击后保存被拒绝 +- 自动化断言:规划与 CSV `ADMIT-MAT-018` 一致 + +###### 预期结果 + +已生效行不能改写成新的产品名称。计数为 0 则 skip。 + +--- + +### 物料准入管理 · 关联与作废 + +#### 功能验证点 + +##### 【P0】【TC-UI-ADMIT-MAT-019】【TP_ADMIT_MAT_035】关联产品取消不落库 + +- 前置条件:已进入物料准入管理页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` 与首行默认进院供应商 + 2. 点击「关联产品」 + 3. 断言关联层打开 + 4. 取消或关闭,不确认 + 5. 断言总数不变,首行供应商不变 +- 自动化断言:规划与 CSV `ADMIT-MAT-019` 一致 + +###### 预期结果 + +取消关联不产生申请单,也不改默认进院供应商。 + +--- + +##### 【P0】【TC-UI-ADMIT-MAT-020】【TP_ADMIT_MAT_040】作废确认后取消 + +- 前置条件:待申请至少 1 条可勾选 +- 操作步骤: + 1. 勾选 1 行,记录其产品名称与当前状态 + 2. 点击「作 废」 + 3. 断言出现确认框,含取消与确定 + 4. 点击「取 消」 + 5. 断言该行仍在,状态未变为已作废 +- 自动化断言:规划与 CSV `ADMIT-MAT-020` 一致 + +###### 预期结果 + +作废需确认;取消后记录仍在。本条不点击确定,避免作废生产数据。 + +--- + +##### 【P1】【TC-UI-ADMIT-MAT-021】【TP_ADMIT_MAT_050】打开操作日志后关闭 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 点击首行「日志」 + 2. 断言出现操作日志层 + 3. 关闭后断言仍在列表页 +- 自动化断言:规划与 CSV `ADMIT-MAT-021` 一致 + +###### 预期结果 + +日志层可打开可关闭;列表数据不变。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **21** 条(P0=12 / P1=9) | +| 来源 | 从 54 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录/权限/会话过期、查询接口失败注入、一键生成与确认生成申请单、确认作废、确认关联落库、跨列表追溯 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6250c03656f8f8ef9f8f8c39ba3e3ca558d89fca --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,324 @@ +# 基础管理-医院资料-公告管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-医院资料-公告管理-测试点分析-01.md` +**执行 CSV**:待生成 `testcase/基础管理-医院资料-公告管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/hospital/paltform/notice` +**定位器 YAML**:`locators/spd/basic-notice.locator.yaml` +**元素扫描**:`data/scan/basic-notice-elements.json`(82 项) +**版本**:2026-09-16 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/basic-notice.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号(凭据见 `.env`);具备「基础设置 → 医院资料 → 公告管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:先展开「基础设置 → 医院资料」,再 `page.getByRole('menuitem', { name: '公告管理', exact: true })`。 +**写操作用例**:标题使用 `${uniqNoticeTitle}`(建议 `AUTO-NTC-${timestamp}`),用例结束后删除或恢复,禁止写死生产公告标题。 + +**动态取数约定**: +1. 打开页面后默认「查询」,从首行读取 `${sampleNoticeTitle}`、`${samplePublishScope}`、`${samplePublishDate}`。 +2. 筛选条件使用样本占位,**禁止写死**业务标题/日期。 +3. 发布范围现网选项:全部 / 全院 / 中心库 / 进院供应商。 + +--- + +## 基础设置 · 医院资料 + +### 公告管理 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-NTC-001】【TP_BASIC_NTC_001】菜单进入公告管理页 + +- 前置条件:已登录且具备医院资料权限 +- 操作步骤: + 1. 按菜单「基础设置 → 医院资料 → 公告管理」进入 + 2. 断言 URL 含 `/spd/basic/hospital/paltform/notice` + 3. 断言筛选区、新增按钮、列表表头可见 +- 自动化断言:规划与 CSV `BASIC-NTC-001` 及 `tests/spd/basic-notice.spec.ts` 一致 + +###### 预期结果 + +进入公告管理页;不误入医院信息登记/院区管理/财务月份设置;筛选区与列表可见。 + +--- + +### 公告管理 · 筛选查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-NTC-002】【TP_BASIC_NTC_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `BASIC-NTC-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-003】【TP_BASIC_NTC_010】公告标题模糊查询 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleNoticeTitle}` + 2. 填入筛选「公告标题」,点击「查 询」 + 3. 断言结果行「公告标题」均包含该关键字(或与产品模糊规则一致) +- 自动化断言:规划与 CSV `BASIC-NTC-003` 一致 + +###### 预期结果 + +查询结果标题与已填关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-004】【TP_BASIC_NTC_012】发布范围下拉查询 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 打开发布范围下拉,选择与首行 `${samplePublishScope}` 相同的选项(勿写死) + 2. 点击「查 询」 + 3. 断言结果行「发布范围」列与所选值一致(选「全部」时不额外收窄) +- 自动化断言:规划与 CSV `BASIC-NTC-004` 一致 + +###### 预期结果 + +下拉选项含全部/全院/中心库/进院供应商;查询结果范围列与条件一致。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-005】【TP_BASIC_NTC_013】标题与范围组合查询 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 读取首行 `${sampleNoticeTitle}`、`${samplePublishScope}` + 2. 填入标题与范围(发布日期保持页面默认,勿 fallback 单日) + 3. 点击「查 询」 + 4. 断言结果同时满足两个已填条件 +- 自动化断言:规划与 CSV `BASIC-NTC-005` 一致 + +###### 预期结果 + +组合查询无报错;结果同时满足标题与发布范围。 + +--- + +##### 【P1】【TC-UI-BASIC-NTC-006】【TP_BASIC_NTC_014】快捷区重置恢复默认 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 填入 `${sampleNoticeTitle}` 等筛选条件 + 2. 点击「重 置」 + 3. 断言标题/范围/日期恢复默认 +- 自动化断言:规划与 CSV `BASIC-NTC-006` 一致 + +###### 预期结果 + +筛选字段恢复默认;列表按默认规则刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-BASIC-NTC-007】【TP_BASIC_NTC_020】无匹配结果空态 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 公告标题填入 `__NOT_EXIST_NOTICE__99999` + 2. 点击「查 询」 + 3. 断言空态或暂无数据,分页总数为 0 +- 自动化断言:规划与 CSV `BASIC-NTC-007` 一致 + +###### 预期结果 + +无匹配时不报错;空态明确;总数为 0。 + +--- + +### 公告管理 · 列表分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-NTC-008】【TP_BASIC_NTC_022】查询后核心列展示 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 点击「查 询」 + 2. 断言表头含公告编号、公告标题、发布范围、发布科室、发布人、操作 +- 自动化断言:规划与 CSV `BASIC-NTC-008` 一致 + +###### 预期结果 + +核心列完整;操作列可见「编辑」「删除」。 + +--- + +##### 【P1】【TC-UI-BASIC-NTC-009】【TP_BASIC_NTC_025】分页翻至第 2 页 + +- 前置条件:默认查询总条数大于一页 +- 操作步骤: + 1. 记录第 1 页首行公告编号 + 2. 点击页码「2」或「下一页」 + 3. 断言页码高亮为 2,首行编号与第 1 页不同 +- 自动化断言:规划与 CSV `BASIC-NTC-009` 一致;总条数不足一页则 skip + +###### 预期结果 + +翻页成功;数据更换;筛选条件保持。 + +--- + +##### 【P1】【TC-UI-BASIC-NTC-010】【TP_BASIC_NTC_027】跳至指定页 + +- 前置条件:总页数≥2 +- 操作步骤: + 1. 在分页「跳至」输入 `2` 并回车 + 2. 断言当前页为 2 +- 自动化断言:规划与 CSV `BASIC-NTC-010` 一致;无 jumper 或仅 1 页则 skip + +###### 预期结果 + +跳转至指定页;列表刷新为该页数据。 + +--- + +### 公告管理 · 新增公告 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-NTC-011】【TP_BASIC_NTC_033】打开新增公告弹窗 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言弹窗标题为「新增公告」 + 3. 断言可见标题、发布范围、置顶、截止日期、富文本、取消、发布 +- 自动化断言:规划与 CSV `BASIC-NTC-011` 一致 + +###### 预期结果 + +新增弹窗可见;必填/选填控件齐全;富文本容器 `.ql-editor` 可见。 + +--- + +##### 【P1】【TC-UI-BASIC-NTC-012】【TP_BASIC_NTC_039】新增取消不落库 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 打开新增,标题填 `${uniqNoticeTitle}`,内容填简短正文 + 2. 点击「取 消」 + 3. 按标题查询,断言无该记录 +- 自动化断言:规划与 CSV `BASIC-NTC-012` 一致 + +###### 预期结果 + +弹窗关闭;列表不出现 `${uniqNoticeTitle}`。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-013】【TP_BASIC_NTC_034】合法必填发布成功 + +- 前置条件:已进入公告管理页;允许自动化写公告 +- 操作步骤: + 1. 打开新增,标题 `${uniqNoticeTitle}`,发布范围选「中心库」,内容写入 `.ql-editor` + 2. 点击「发 布」 + 3. 按标题查询,断言出现该行且发布范围为中心库 + 4. 后置:删除该测试公告 +- 自动化断言:规划与 CSV `BASIC-NTC-013` 一致;环境禁止写则 skip + +###### 预期结果 + +弹窗关闭;列表出现新记录;字段与提交一致。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-BASIC-NTC-014】【TP_BASIC_NTC_043】标题为空发布拦截 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 打开新增,标题留空,填写内容后点「发 布」 + 2. 断言必填提示出现,弹窗仍打开 +- 自动化断言:规划与 CSV `BASIC-NTC-014` 一致 + +###### 预期结果 + +发布被拦截;不落库;弹窗不关闭。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-015】【TP_BASIC_NTC_044】内容为空发布拦截 + +- 前置条件:已进入公告管理页 +- 操作步骤: + 1. 打开新增,填写标题,富文本留空,点「发 布」 + 2. 断言内容必填提示,弹窗仍打开 +- 自动化断言:规划与 CSV `BASIC-NTC-015` 一致 + +###### 预期结果 + +发布被拦截;不落库。 + +--- + +### 公告管理 · 编辑与删除 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-NTC-016】【TP_BASIC_NTC_050】打开编辑公告并回填 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 点击首行「编辑」 + 2. 断言弹窗标题为「编辑公告」 + 3. 断言标题输入框值与该行 `${sampleNoticeTitle}` 一致(允许规范化空白) +- 自动化断言:规划与 CSV `BASIC-NTC-016` 一致 + +###### 预期结果 + +编辑弹窗可见;标题/范围/内容回填正确。 + +--- + +##### 【P1】【TC-UI-BASIC-NTC-017】【TP_BASIC_NTC_052】编辑取消不保存 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 打开首行编辑,记录原标题 + 2. 将标题改为 `${uniqNoticeTitle}` 后点「取 消」 + 3. 再次打开编辑,断言标题仍为原值 +- 自动化断言:规划与 CSV `BASIC-NTC-017` 一致 + +###### 预期结果 + +取消后列表与回看均为修改前标题。 + +--- + +##### 【P0】【TC-UI-BASIC-NTC-018】【TP_BASIC_NTC_057】删除弹出确认框 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 点击首行「删除」 + 2. 断言出现确认框(标题含「提示」),可见「取 消」「确 定」 + 3. 点击「取 消」 + 4. 断言该行仍在 +- 自动化断言:规划与 CSV `BASIC-NTC-018` 一致 + +###### 预期结果 + +删除需二次确认;取消后记录仍在。本条不断言真实删除,避免污染数据。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **18** 条(P0=10 / P1=8) | +| 来源 | 从 63 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录/权限(由 auth.setup 覆盖)、发布后跨端可见、删除真实落库、接口失败注入 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..48863770d511e29b08f22b4efbf5a40c3caa0580 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,202 @@ +# 基础管理-医院资料-医院信息登记 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-医院资料-医院信息登记-测试点分析-01.md` +**执行 CSV**:待生成 `testcase/基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/hospital/paltform/info` +**定位器 YAML**:`locators/spd/basic-hospital-info.locator.yaml` +**元素扫描**:`data/scan/basic-hospital-info-elements.json`(38 项) +**版本**:2026-09-16 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/basic-hospital-info.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 医院资料 → 医院信息登记」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**菜单导航**:`page.getByRole('menuitem', { name: '医院信息登记', exact: true })`。 +**写操作**:保存前读取原值 `${sampleLinkman}` / `${sampleContactWay}` / `${sampleShortName}`,用例结束必须恢复,禁止写死并留下脏数据。 + +--- + +## 基础设置 · 医院资料 + +### 医院信息登记 · 页面进入与查看 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-HOS-001】【TP_BASIC_HOS_001】菜单进入医院信息登记页 + +- 前置条件:已登录且具备医院资料权限 +- 操作步骤: + 1. 按菜单进入「医院信息登记」 + 2. 断言 URL 含 `/spd/basic/hospital/paltform/info` + 3. 断言可见「编 辑」及医院编码/名称/地址等详情标签 +- 自动化断言:规划与 CSV `BASIC-HOS-001` 及 `tests/spd/basic-hospital-info.spec.ts` 一致 + +###### 预期结果 + +进入详情页;不误入公告/院区/财务月份页;查看态「编辑」可见。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-002】【TP_BASIC_HOS_009】查看态只读字段不可改 + +- 前置条件:已进入医院信息登记且未点编辑 +- 操作步骤: + 1. 断言医院编码、医院名称、地址区域无可用输入框 + 2. 断言「保 存」「取 消」不可见 +- 自动化断言:规划与 CSV `BASIC-HOS-002` 一致 + +###### 预期结果 + +查看模式下编码/名称/地址只读;不出现保存按钮。 + +--- + +##### 【P1】【TC-UI-BASIC-HOS-003】【TP_BASIC_HOS_011】已上传图片可预览 + +- 前置条件:当前医院已有登录页背景或 logo +- 操作步骤: + 1. 点击「预览文件」(背景或 logo 任一可见项) + 2. 断言预览打开或新预览层可见 +- 自动化断言:规划与 CSV `BASIC-HOS-003` 一致;均无预览入口则 skip + +###### 预期结果 + +预览可打开;不报错。 + +--- + +### 医院信息登记 · 编辑保存 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-HOS-004】【TP_BASIC_HOS_015】进入编辑模式 + +- 前置条件:已进入医院信息登记查看态 +- 操作步骤: + 1. 点击「编 辑」 + 2. 断言「保 存」「取 消」可见 + 3. 断言 `#form_item_linkman`、`#form_item_contactWay`、`#form_item_anotherName` 可编辑 +- 自动化断言:规划与 CSV `BASIC-HOS-004` 一致 + +###### 预期结果 + +进入编辑态;三个可改文本框可输入;编码/名称/地址仍不可改。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-005】【TP_BASIC_HOS_016】编辑态只读字段仍不可改 + +- 前置条件:已进入编辑模式 +- 操作步骤: + 1. 点击「编 辑」 + 2. 断言医院编码、医院名称、地址仍无可用输入框 +- 自动化断言:规划与 CSV `BASIC-HOS-005` 一致 + +###### 预期结果 + +编辑模式下系统分配字段保持只读。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-006】【TP_BASIC_HOS_018】取消不保存 + +- 前置条件:已进入查看态 +- 操作步骤: + 1. 点击「编 辑」,读取原联系人 `${sampleLinkman}` + 2. 将联系人改为 `${uniqLinkman}` 后点「取 消」 + 3. 断言回到查看态,联系人展示仍为 `${sampleLinkman}` +- 自动化断言:规划与 CSV `BASIC-HOS-006` 一致 + +###### 预期结果 + +取消后不落库;查看态为修改前值。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-007】【TP_BASIC_HOS_017】保存后字段持久化 + +- 前置条件:已进入查看态;允许写医院资料 +- 操作步骤: + 1. 记录原联系人 `${sampleLinkman}` + 2. 编辑为 `${uniqLinkman}` 并「保 存」 + 3. 刷新或重新打开本页,断言联系人为 `${uniqLinkman}` + 4. 后置:再编辑回 `${sampleLinkman}` 并保存 +- 自动化断言:规划与 CSV `BASIC-HOS-007` 一致;环境禁止写则 skip + +###### 预期结果 + +保存后刷新仍为新值;用例结束恢复原值。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-BASIC-HOS-008】【TP_BASIC_HOS_022】联系人为空保存拦截 + +- 前置条件:已进入查看态 +- 操作步骤: + 1. 点击「编 辑」,清空联系人 + 2. 点击「保 存」 + 3. 断言必填提示,仍停留编辑态 +- 自动化断言:规划与 CSV `BASIC-HOS-008` 一致 + +###### 预期结果 + +保存被拦截;原联系人不被写成空。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-009】【TP_BASIC_HOS_023】联系方式为空保存拦截 + +- 前置条件:已进入查看态 +- 操作步骤: + 1. 编辑态清空联系方式后点「保 存」 + 2. 断言必填提示,编辑态保持 +- 自动化断言:规划与 CSV `BASIC-HOS-009` 一致 + +###### 预期结果 + +保存被拦截;联系方式不被清空落库。 + +--- + +##### 【P0】【TC-UI-BASIC-HOS-010】【TP_BASIC_HOS_024】医院简称为空保存拦截 + +- 前置条件:已进入查看态 +- 操作步骤: + 1. 编辑态清空医院简称后点「保 存」 + 2. 断言必填提示 +- 自动化断言:规划与 CSV `BASIC-HOS-010` 一致 + +###### 预期结果 + +保存被拦截;简称不被写成空。 + +--- + +### 医院信息登记 · 图片上传 + +#### 功能验证点 + +##### 【P1】【TC-UI-BASIC-HOS-011】【TP_BASIC_HOS_030】编辑态存在背景与 logo 上传控件 + +- 前置条件:已进入查看态 +- 操作步骤: + 1. 点击「编 辑」 + 2. 断言存在登录页背景、医院 logo 对应 `input[type=file]`(`loginBgUploadInput` / `logoUploadInput`) + 3. 点「取 消」,不实际上传 +- 自动化断言:规划与 CSV `BASIC-HOS-011` 一致 + +###### 预期结果 + +编辑态两个上传控件可定位;本条不强制替换线上图片。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **11** 条(P0=9 / P1=2) | +| 来源 | 从 38 条测试点选取可稳定自动化项 | +| 未纳入自动化 | 真实换登录页背景/logo(易污染环境)、登录页视觉生效、超限文件二进制、未登录 | +| 数据隔离 | 保存用例必须恢复原联系人 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..3e811d6824f922cd9eeac7dad21cce3f559c8049 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,261 @@ +# 基础管理-医院资料-财务月份设置 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-医院资料-财务月份设置-测试点分析-01.md` +**执行 CSV**:待生成 `testcase/基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/hospital/balance/month` +**定位器 YAML**:`locators/spd/basic-finance-month.locator.yaml` +**元素扫描**:`data/scan/basic-finance-month-elements.json`(43 项) +**版本**:2026-09-16 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/basic-finance-month.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 医院资料 → 财务月份设置」权限。 +**页面约定**:整页渲染,**无报表 iframe**;本页分页无跳至输入。 +**菜单导航**:`page.getByRole('menuitem', { name: '财务月份设置', exact: true })`。 +**写操作**:仅允许改「当前月份」行的自动结算日期。保存前读取 `${sampleAutoSettleDate}`,用例结束必须恢复,禁止写死日期并留下脏数据。 + +**动态取数约定**: +1. 默认查询后,从「当前月份」行读取 `${sampleFinanceMonth}`、`${sampleStartDate}`、`${sampleEndDate}`、`${sampleAutoSettleDate}`。 +2. 筛选条件使用样本占位,**禁止写死**财务月份。 +3. 「修改」只出现在当前月份行;历史月份无修改入口。 + +--- + +## 基础设置 · 医院资料 + +### 财务月份设置 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-FMN-001】【TP_BASIC_FMN_001】菜单进入财务月份设置页 + +- 前置条件:已登录且具备医院资料权限 +- 操作步骤: + 1. 按菜单「基础设置 → 医院资料 → 财务月份设置」进入 + 2. 断言 URL 含 `/spd/basic/hospital/balance/month` + 3. 断言筛选区(`#form_item_settleMonthDate`)、查询/重置、列表列头可见 +- 自动化断言:规划与 CSV `BASIC-FMN-001` 及 `tests/spd/basic-finance-month.spec.ts` 一致 + +###### 预期结果 + +进入财务月份设置页;不误入公告/医院信息/院区页;筛选与列表可见。 + +--- + +### 财务月份设置 · 筛选查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-FMN-002】【TP_BASIC_FMN_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入财务月份设置页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `BASIC-FMN-002` 一致 + +###### 预期结果 + +查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-BASIC-FMN-003】【TP_BASIC_FMN_010】按财务月份筛选 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 从首行或当前月份行读取 `${sampleFinanceMonth}` + 2. 在 `#form_item_settleMonthDate` 选择该月份,点击「查 询」 + 3. 断言结果行「财务月份」均等于该月份(或与产品筛选规则一致) +- 自动化断言:规划与 CSV `BASIC-FMN-003` 一致 + +###### 预期结果 + +结果仅含所选财务月份;无未捕获异常。 + +--- + +##### 【P1】【TC-UI-BASIC-FMN-004】【TP_BASIC_FMN_011】重置恢复默认 + +- 前置条件:已按财务月份筛选 +- 操作步骤: + 1. 选择 `${sampleFinanceMonth}` 并查询 + 2. 点击「重 置」 + 3. 断言月份筛选恢复默认,列表按默认规则刷新 +- 自动化断言:规划与 CSV `BASIC-FMN-004` 一致 + +###### 预期结果 + +重置后筛选清空或回到默认;列表可再查询。 + +--- + +### 财务月份设置 · 列表与状态 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-FMN-005】【TP_BASIC_FMN_017】列表核心列完整 + +- 前置条件:默认查询至少 1 条数据 +- 操作步骤: + 1. 断言表头包含财务月份、开始日期、结束日期、自动结算日期、状态、操作 +- 自动化断言:规划与 CSV `BASIC-FMN-005` 一致 + +###### 预期结果 + +核心列均可见,不缺「状态」「操作」。 + +--- + +##### 【P0】【TC-UI-BASIC-FMN-006】【TP_BASIC_FMN_018】仅当前月份行可修改 + +- 前置条件:列表同时存在当前月份与历史月份(若仅一行当前月份则只断言该行) +- 操作步骤: + 1. 定位状态为「当前月份」的行,断言操作列可见「修 改」(`modifyBtn`) + 2. 对「历史月份」行断言无「修 改」或操作为「--」 +- 自动化断言:规划与 CSV `BASIC-FMN-006` 一致;无历史月份则只校验当前月份入口 + +###### 预期结果 + +可修改入口只出现在当前月份行。 + +--- + +##### 【P1】【TC-UI-BASIC-FMN-007】【TP_BASIC_FMN_019】当前月份至多一行 + +- 前置条件:默认查询已出列表 +- 操作步骤: + 1. 统计状态为「当前月份」的行数 + 2. 断言行数 ≤ 1 +- 自动化断言:规划与 CSV `BASIC-FMN-007` 一致 + +###### 预期结果 + +同一时点至多一行「当前月份」。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-BASIC-FMN-008】【TP_BASIC_FMN_022】历史月份无法进入编辑 + +- 前置条件:列表至少 1 条历史月份 +- 操作步骤: + 1. 对历史月份行尝试点击操作区 + 2. 断言不出现行内「保 存」「取 消」,自动结算日期仍为文本 +- 自动化断言:规划与 CSV `BASIC-FMN-008` 一致;无历史月份则 skip + +###### 预期结果 + +历史月份不能改开始/结束/自动结算日期。 + +--- + +### 财务月份设置 · 当前月份修改 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-FMN-009】【TP_BASIC_FMN_024】修改进入行内编辑 + +- 前置条件:存在状态为「当前月份」的行 +- 操作步骤: + 1. 点击该行「修 改」 + 2. 断言出现「保 存」「取 消」 + 3. 断言自动结算日期变为日期框(`autoSettleDateInput`) +- 自动化断言:规划与 CSV `BASIC-FMN-009` 一致 + +###### 预期结果 + +进入行内编辑;仅自动结算日可改。 + +--- + +##### 【P0】【TC-UI-BASIC-FMN-010】【TP_BASIC_FMN_025】编辑态开始结束日期只读 + +- 前置条件:已进入当前月份行内编辑 +- 操作步骤: + 1. 点击「修 改」 + 2. 断言开始日期、结束日期无可用输入框 + 3. 断言仅 `autoSettleDateInput` 可编辑 + 4. 点击「取 消」退出 +- 自动化断言:规划与 CSV `BASIC-FMN-010` 一致 + +###### 预期结果 + +开始/结束日期保持只读;账期区间不被改写。 + +--- + +##### 【P0】【TC-UI-BASIC-FMN-011】【TP_BASIC_FMN_027】取消不保存自动结算日 + +- 前置条件:存在当前月份行 +- 操作步骤: + 1. 读取 `${sampleAutoSettleDate}` + 2. 点「修 改」,将自动结算日改为当月区间内另一天 `${uniqAutoSettleDate}` + 3. 点击「取 消」 + 4. 断言该行自动结算日仍为 `${sampleAutoSettleDate}` +- 自动化断言:规划与 CSV `BASIC-FMN-011` 一致 + +###### 预期结果 + +取消后不落库;日期恢复修改前值。 + +--- + +##### 【P0】【TC-UI-BASIC-FMN-012】【TP_BASIC_FMN_026】保存自动结算日期持久化 + +- 前置条件:存在当前月份行;允许写财务月份 +- 操作步骤: + 1. 记录 `${sampleAutoSettleDate}`、`${sampleStartDate}`、`${sampleEndDate}` + 2. 将自动结算日改为区间内另一天 `${uniqAutoSettleDate}` 并「保 存」 + 3. 刷新或重新查询,断言该行自动结算日为 `${uniqAutoSettleDate}` + 4. 后置:再改为 `${sampleAutoSettleDate}` 并保存 +- 自动化断言:规划与 CSV `BASIC-FMN-012` 一致;环境禁止写则 skip + +###### 预期结果 + +保存后刷新仍为新日期;用例结束恢复原值。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-BASIC-FMN-013】【TP_BASIC_FMN_031】自动结算日为空拦截 + +- 前置条件:存在当前月份行 +- 操作步骤: + 1. 点「修 改」,清空 `autoSettleDateInput` + 2. 点击「保 存」 + 3. 断言必填或非法提示,行上日期仍为 `${sampleAutoSettleDate}` +- 自动化断言:规划与 CSV `BASIC-FMN-013` 一致 + +###### 预期结果 + +保存被拦截;原日期不被写成空。 + +--- + +##### 【P1】【TC-UI-BASIC-FMN-014】【TP_BASIC_FMN_032】自动结算日超出当月区间拦截 + +- 前置条件:存在当前月份行且能读到开始/结束日 +- 操作步骤: + 1. 点「修 改」,将日期改为早于 `${sampleStartDate}` 或晚于 `${sampleEndDate}` + 2. 点击「保 存」 + 3. 断言拒绝保存并提示,原日期不变 + 4. 点「取 消」退出 +- 自动化断言:规划与 CSV `BASIC-FMN-014` 一致;日期控件无法选出区间外日期则 skip 并记录产品限制 + +###### 预期结果 + +超出账期区间时拒绝保存;不脏写。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **14** 条(P0=10 / P1=4) | +| 来源 | 从 38 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录/权限、自动结算任务调度联调、查询接口失败注入、最早/最晚月份边界 | +| 数据隔离 | 保存用例必须恢复原自动结算日 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..13c85e9563060f148c82ac3c6393e5752e7282ee --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,323 @@ +# 基础管理-医院资料-院区管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-医院资料-院区管理-测试点分析-01.md` +**执行 CSV**:待生成 `testcase/基础管理-医院资料-院区管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/hospital/paltform/area` +**定位器 YAML**:`locators/spd/basic-branch.locator.yaml` +**元素扫描**:`data/scan/basic-branch-elements.json`(60 项) +**版本**:2026-09-16 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/basic-branch.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 医院资料 → 院区管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**;院区名称框即时或回车过滤,**无独立「查询」「重置」按钮**。 +**菜单导航**:`page.getByRole('menuitem', { name: '院区管理', exact: true })`。 +**写操作**:院区名称使用 `${uniqCampusName}`(建议 `AUTO-BRN-${timestamp}`),用例结束后停用/恢复或按环境约定清理,禁止写死生产院区名称。 + +**动态取数约定**: +1. 打开页面后从首行读取 `${sampleCampusName}`、`${sampleCampusId}`、`${sampleCampusAddress}`、`${sampleCampusStatus}`。 +2. 搜索关键字使用样本占位,**禁止写死**院区名称。 +3. 新增弹窗无院区编号、无启用云仓;编辑弹窗额外有 `#form_item_id` 与启用云仓;新增状态文案为停用/启用,编辑为禁用/启用。 + +--- + +## 基础设置 · 医院资料 + +### 院区管理 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-BRN-001】【TP_BASIC_BRN_001】菜单进入院区管理页 + +- 前置条件:已登录且具备医院资料权限 +- 操作步骤: + 1. 按菜单「基础设置 → 医院资料 → 院区管理」进入 + 2. 断言 URL 含 `/spd/basic/hospital/paltform/area` + 3. 断言「新 增」、院区名称搜索框(`campusNameInput`)、列表列头可见 + 4. 断言页面无独立「查 询」「重 置」按钮 +- 自动化断言:规划与 CSV `BASIC-BRN-001` 及 `tests/spd/basic-branch.spec.ts` 一致 + +###### 预期结果 + +进入院区管理页;不误入公告/医院信息/财务月份页;搜索框与新增可见,无查询/重置。 + +--- + +### 院区管理 · 列表搜索 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-BRN-002】【TP_BASIC_BRN_009】默认列出院区或空态明确 + +- 前置条件:已进入院区管理页 +- 操作步骤: + 1. 等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `BASIC-BRN-002` 一致 + +###### 预期结果 + +加载无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-003】【TP_BASIC_BRN_010】院区名称搜索过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleCampusName}` + 2. 在 `campusNameInput` 填入该关键字(即时过滤或回车,以现网为准) + 3. 断言结果行「院区名称」均包含该关键字 +- 自动化断言:规划与 CSV `BASIC-BRN-003` 一致 + +###### 预期结果 + +过滤结果名称与已填关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-004】【TP_BASIC_BRN_011】列表核心列完整 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 断言表头包含序号、院区编号、院区名称、院区地址、状态、启用云仓、操作 +- 自动化断言:规划与 CSV `BASIC-BRN-004` 一致 + +###### 预期结果 + +核心列均可见,操作列含「编 辑」。 + +--- + +##### 【P1】【TC-UI-BASIC-BRN-005】【TP_BASIC_BRN_015】清空搜索恢复全量 + +- 前置条件:已用 `${sampleCampusName}` 过滤 +- 操作步骤: + 1. 记录过滤后条数 + 2. 清空 `campusNameInput` 并等待列表刷新 + 3. 断言条数回到未过滤的默认集合(或不少于过滤后条数) +- 自动化断言:规划与 CSV `BASIC-BRN-005` 一致 + +###### 预期结果 + +清空后列表恢复默认集合。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-BASIC-BRN-006】【TP_BASIC_BRN_018】搜索无匹配空态 + +- 前置条件:已进入院区管理页 +- 操作步骤: + 1. 在院区名称框输入不存在关键字 `${uniqNoMatchCampus}`(建议 `NO-MATCH-BRN-${timestamp}`) + 2. 断言「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `BASIC-BRN-006` 一致 + +###### 预期结果 + +无匹配时空态明确,不白屏。 + +--- + +### 院区管理 · 新增院区 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-BRN-007】【TP_BASIC_BRN_020】打开新增院区弹窗 + +- 前置条件:已进入院区管理页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言弹窗标题为「新增院区」 + 3. 断言可见院区名称、所在地区、详细地址、主院区标志、状态、取消/确定 + 4. 断言无 `#form_item_id`、无启用云仓 + 5. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `BASIC-BRN-007` 一致 + +###### 预期结果 + +新增弹窗字段完整;不含院区编号与启用云仓。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-008】【TP_BASIC_BRN_025】新增取消不落库 + +- 前置条件:已进入院区管理页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` + 2. 打开新增,填写名称 `${uniqCampusName}` 后点「取 消」 + 3. 断言列表总数不变,且无该名称行 +- 自动化断言:规划与 CSV `BASIC-BRN-008` 一致 + +###### 预期结果 + +取消后不新增院区。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-009】【TP_BASIC_BRN_021】合法必填新增成功 + +- 前置条件:允许写院区;名称使用 `${uniqCampusName}` +- 操作步骤: + 1. 打开新增,填写名称、所在地区、详细地址,主院区选「否」,状态选「启用」 + 2. 点击「确 定」 + 3. 断言弹窗关闭,列表出现该名称行且字段一致 + 4. 后置:按环境约定停用或清理该院区,避免污染主数据 +- 自动化断言:规划与 CSV `BASIC-BRN-009` 一致;环境禁止写则 skip + +###### 预期结果 + +新增成功且列表可见;用例结束清理或停用,不留生产脏数据。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-BASIC-BRN-010】【TP_BASIC_BRN_029】院区名称为空拦截 + +- 前置条件:已打开新增弹窗 +- 操作步骤: + 1. 名称留空,补齐地区与地址后点「确 定」 + 2. 断言必填提示,弹窗仍在,列表无新行 +- 自动化断言:规划与 CSV `BASIC-BRN-010` 一致 + +###### 预期结果 + +确定被拦截;不落库。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-011】【TP_BASIC_BRN_030】所在地区为空拦截 + +- 前置条件:已打开新增弹窗 +- 操作步骤: + 1. 填写名称与地址,不选所在地区,点「确 定」 + 2. 断言必填提示,弹窗仍在 +- 自动化断言:规划与 CSV `BASIC-BRN-011` 一致 + +###### 预期结果 + +确定被拦截;不落库。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-012】【TP_BASIC_BRN_031】详细地址为空拦截 + +- 前置条件:已打开新增弹窗 +- 操作步骤: + 1. 填写名称并选择地区,地址留空,点「确 定」 + 2. 断言必填提示,弹窗仍在 +- 自动化断言:规划与 CSV `BASIC-BRN-012` 一致 + +###### 预期结果 + +确定被拦截;不落库。 + +--- + +### 院区管理 · 编辑院区 + +#### 功能验证点 + +##### 【P0】【TC-UI-BASIC-BRN-013】【TP_BASIC_BRN_037】打开编辑弹窗并回填 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleCampusId}`、`${sampleCampusName}`、`${sampleCampusAddress}` + 2. 点击「编 辑」 + 3. 断言弹窗标题为「编辑院区」 + 4. 断言 `#form_item_id` 等于 `${sampleCampusId}`,名称/地址回填正确 + 5. 断言额外可见「启用云仓」 + 6. 点击「取 消」 +- 自动化断言:规划与 CSV `BASIC-BRN-013` 一致 + +###### 预期结果 + +编辑弹窗回填正确;相对新增多出院区编号与启用云仓。 + +--- + +##### 【P0】【TC-UI-BASIC-BRN-014】【TP_BASIC_BRN_038】编辑态院区编号不可换号 + +- 前置条件:已打开首行编辑 +- 操作步骤: + 1. 断言 `#form_item_id` 可见且值等于列表「院区编号」 + 2. 若编号只读则直接断言 disabled/readonly;若可输入则改写后点确定,刷新后编号仍为原值 + 3. 未改其它字段时点「取 消」 +- 自动化断言:规划与 CSV `BASIC-BRN-014` 一致 + +###### 预期结果 + +院区编号与列表一致;保存后编号不变。 + +--- + +##### 【P1】【TC-UI-BASIC-BRN-015】【TP_BASIC_BRN_042】编辑取消不保存 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 打开首行编辑,记录原名称 + 2. 将名称改为 `${uniqCampusName}` 后点「取 消」 + 3. 再次打开编辑,断言名称仍为原值 +- 自动化断言:规划与 CSV `BASIC-BRN-015` 一致 + +###### 预期结果 + +取消后列表与回看均为修改前名称。 + +--- + +##### 【P1】【TC-UI-BASIC-BRN-016】【TP_BASIC_BRN_040】编辑状态为禁用启用 + +- 前置条件:已打开编辑弹窗 +- 操作步骤: + 1. 断言状态选项文案为「禁用 / 启用」(不是新增弹窗的「停用 / 启用」) + 2. 点「取 消」,不改状态 +- 自动化断言:规划与 CSV `BASIC-BRN-016` 一致 + +###### 预期结果 + +编辑态状态文案与新增不同;本条不真实切换生产院区状态。 + +--- + +##### 【P1】【TC-UI-BASIC-BRN-017】【TP_BASIC_BRN_041】编辑可见启用云仓 + +- 前置条件:已打开编辑弹窗 +- 操作步骤: + 1. 断言「启用云仓」单选「是 / 否」可见 + 2. 点「取 消」,不改云仓标志 +- 自动化断言:规划与 CSV `BASIC-BRN-017` 一致 + +###### 预期结果 + +编辑可看到启用云仓;本条不真实改生产院区云仓开关。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-BASIC-BRN-018】【TP_BASIC_BRN_045】编辑清空必填拦截 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 打开首行编辑,清空院区名称后点「确 定」 + 2. 断言必填提示,弹窗仍在 + 3. 点「取 消」后再打开,断言名称仍为原值 +- 自动化断言:规划与 CSV `BASIC-BRN-018` 一致 + +###### 预期结果 + +确定被拦截;原记录不被写成空。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **18** 条(P0=13 / P1=5) | +| 来源 | 从 50 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录/权限、主院区全局唯一、禁用后下游不可选、云仓能力联调、真实改生产院区状态 | +| 数据隔离 | 新增必须用唯一名称并清理/停用;编辑不改生产主数据 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..451d64972c5efd4a7601706d10e46b0644402ed0 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,159 @@ +# 基础管理-厂商管理-供应商节假日管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-供应商节假日管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-供应商节假日管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/basic/prov/holiday` +**定位器 YAML**:`locators/spd/vendor-holiday.locator.yaml` +**元素扫描**:`data/scan/vendor-holiday-elements.json`(80 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-010.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-holiday.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 供应商节假日管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:左侧新增只打开后取消;不批量删除真实数据。 +**动态取数**:左侧首行读取 `${sampleHolidayName}`/年度;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 供应商节假日管理 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-HLD-001】【TP_VENDOR_HLD_001】菜单进入供应商节假日管理页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单进入供应商节假日管理 + 2. 断言 URL 含 `prov/holiday` + 3. 断言可见节假日主区与新增 +- 自动化断言:与 CSV `VENDOR-HLD-001` 及 `tests/spd/vendor-holiday.spec.ts` 一致 + +###### 预期结果 + +进入供应商节假日管理页。 + +--- + +### 供应商节假日管理 · 左右分栏 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-HLD-002】【TP_VENDOR_HLD_009】选中左侧节假日展示详情 + +- 前置条件:左侧有数据 +- 操作步骤: + 1. 点击左侧首行 + 2. 断言默认假日详情或自定义列表区可见 +- 自动化断言:与 CSV `VENDOR-HLD-002` 及 `tests/spd/vendor-holiday.spec.ts` 一致 + +###### 预期结果 + +详情联动成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-HLD-003】【TP_VENDOR_HLD_015】左侧搜索无匹配空态(mock反例) + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 左侧搜索框填 mock 并回车 + 2. 断言左侧列表空态 +- 自动化断言:与 CSV `VENDOR-HLD-003` 及 `tests/spd/vendor-holiday.spec.ts` 一致 + +###### 预期结果 + +无匹配空态明确。 + +--- + +### 供应商节假日管理 · 维护 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-HLD-004】【TP_VENDOR_HLD_011】左侧新增取消 + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 点击新增 + 2. 取消/关闭不保存 + 3. 断言仍停留本页且无异常 +- 自动化断言:与 CSV `VENDOR-HLD-004` 及 `tests/spd/vendor-holiday.spec.ts` 一致 + +###### 预期结果 + +取消不落库。 + +--- + +### 供应商节假日管理 · 左右分栏 + +#### 功能验证点 + +##### 【P1】【TC-UI-VENDOR-HLD-005】【TP_VENDOR_HLD_012】自定义节假日列表区可见 + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 选中左侧行 + 2. 断言「供应商自定义节假日列表」区域可见(可为空态) +- 自动化断言:与 CSV `VENDOR-HLD-005` 及 `tests/spd/vendor-holiday.spec.ts` 一致 + +###### 预期结果 + +自定义区可见。 + +--- + +### 供应商节假日管理 · 维护 + +#### 异常验证点 + +##### 【P0】【TC-UI-VENDOR-HLD-006】【TP_VENDOR_HLD_016】结束早于开始日期拦截 + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 打开新增/编辑,结束日期早于开始日期尝试保存 + 2. 断言拒绝保存并提示 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +非法日期被拦截。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-HLD-007】【TP_VENDOR_HLD_015】开始结束为同一天 + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件) + 2. 断言允许保存或明确提示规则(按产品规则),不报错白屏 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间规则明确。 + +--- + +### 供应商节假日管理 · 左右分栏 + +#### 集成验证点 + +##### 【P1】【TC-UI-VENDOR-HLD-008】【TP_VENDOR_HLD_018】进院供应商主数据一致 + +- 前置条件:已进入供应商节假日管理页 +- 操作步骤: + 1. 在自定义列表抽样进院供应商(若有数据) + 2. 断言可在厂商/供应商主数据中找到对应记录 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +自定义节假日供应商与主数据一致。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..4b489ad61afd27ee7e9ed427dac252b6ae5834e3 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,210 @@ +# 基础管理-厂商管理-供应商资质管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-供应商资质管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-供应商资质管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/basic/qcManage` +**定位器 YAML**:`locators/spd/vendor-qual.locator.yaml` +**元素扫描**:`data/scan/vendor-qual-elements.json`(99 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-002.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-qual.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 供应商资质管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:本批只读:查看打开后关闭;不确认导出下载、不改证件。 +**动态取数**:证件效期提醒 Tab 首行读取 `${sampleHospitalSupplier}`;mock:`__NOT_EXIST_SUPPLIER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 供应商资质管理 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QUL-001】【TP_VENDOR_QUL_001】菜单进入供应商资质管理页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/basic/qcManage` + 3. 断言可见「查 询」「重 置」及证件效期提醒相关入口 +- 自动化断言:与 CSV `VENDOR-QUL-001` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +进入供应商资质管理;不误入厂商查询。 + +--- + +### 供应商资质管理 · 筛选与 Tab + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QUL-002】【TP_VENDOR_QUL_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确 +- 自动化断言:与 CSV `VENDOR-QUL-002` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时分页 N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-QUL-003】【TP_VENDOR_QUL_009】切换授权链路效期预警 Tab + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 点击「授权链路效期预警」 + 2. 断言列表有数据或空态明确 +- 自动化断言:与 CSV `VENDOR-QUL-003` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +Tab 切换成功,列表字段可随 Tab 变化。 + +--- + +##### 【P0】【TC-UI-VENDOR-QUL-004】【TP_VENDOR_QUL_010】近三个月到期计数一致 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 切换「近三个月到期」(或等价文案) + 2. 若可读到括号/冒号计数,断言等于分页「共 N 条」 +- 自动化断言:与 CSV `VENDOR-QUL-004` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +计数与分页一致(可读时)。 + +--- + +##### 【P0】【TC-UI-VENDOR-QUL-005】【TP_VENDOR_QUL_011】按进院供应商筛选 + +- 前置条件:证件效期提醒至少 1 条且进院供应商非空 +- 操作步骤: + 1. 从首行读取 `${sampleHospitalSupplier}` 取关键字 + 2. 在进院供应商名称填入关键字并「查 询」 + 3. 断言结果行「进院供应商」包含关键字 +- 自动化断言:与 CSV `VENDOR-QUL-005` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +进院供应商列与条件一致。 + +--- + +##### 【P1】【TC-UI-VENDOR-QUL-006】【TP_VENDOR_QUL_011】重置恢复默认 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 填写筛选条件后点击「重 置」 + 2. 断言列表按默认规则刷新 +- 自动化断言:与 CSV `VENDOR-QUL-006` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +筛选恢复默认。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-QUL-007】【TP_VENDOR_QUL_016】无匹配空态(mock反例) + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999` + 2. 点击「查 询」 + 3. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-QUL-007` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QUL-008】【TP_VENDOR_QUL_012】证件效期提醒核心列完整 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 处于证件效期提醒 Tab + 2. 断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作 +- 自动化断言:与 CSV `VENDOR-QUL-008` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +核心列可见。 + +--- + +### 供应商资质管理 · 查看与导出 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QUL-009】【TP_VENDOR_QUL_014】打开查看后关闭 + +- 前置条件:列表至少 1 行含「查看」 +- 操作步骤: + 1. 点击首行「查 看」 + 2. 断言详情层可见 + 3. 关闭/返回,断言回到列表 +- 自动化断言:与 CSV `VENDOR-QUL-009` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +详情可打开;关闭后回到列表。 + +--- + +##### 【P1】【TC-UI-VENDOR-QUL-010】【TP_VENDOR_QUL_015】导出按钮可见 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 断言「导出」或「导出明细」按钮可见(本批不确认下载) +- 自动化断言:与 CSV `VENDOR-QUL-010` 及 `tests/spd/vendor-qual.spec.ts` 一致 + +###### 预期结果 + +导出入口可见。 + +--- + +### 供应商资质管理 · 筛选与 Tab + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-QUL-011】【TP_VENDOR_QUL_015】制单/效期日期起止同一天 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 若存在日期区间筛选,起止选同一天并查询 + 2. 断言仅返回该日数据或空态明确,不报错 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间行为明确。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-VENDOR-QUL-012】【TP_VENDOR_QUL_018】效期提醒与有效期至抽样一致 + +- 前置条件:已进入供应商资质管理页 +- 操作步骤: + 1. 在近三个月到期列表抽样一行 + 2. 核对「效期提醒」文案与「有效期至」日期逻辑一致 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +提醒文案与有效期至不矛盾。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..30324ac27209a8a7b25c28cb3b3a4f9f55ecace7 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,196 @@ +# 基础管理-厂商管理-厂商查询 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-厂商查询-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-厂商查询-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/factory` +**定位器 YAML**:`locators/spd/vendor-query.locator.yaml` +**元素扫描**:`data/scan/vendor-query-elements.json`(47 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-001.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-query.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 厂商查询」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:本页只读查询,无新增/编辑提交。 +**动态取数**:从器械注册人 Tab 首行读取 `${sampleMfrsName}`;禁止写死厂商名称;mock:`__NOT_EXIST_MFRS__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 厂商查询 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QRY-001】【TP_VENDOR_QRY_001】菜单进入厂商查询页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 厂商查询」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory` + 3. 断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头 +- 自动化断言:与 CSV `VENDOR-QRY-001` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +进入厂商查询页;不误入供应商资质管理。 + +--- + +### 厂商查询 · Tab 与搜索 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QRY-002】【TP_VENDOR_QRY_009】默认器械注册人列表有数据或空态明确 + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 切换或停留「器械注册人」 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-QRY-002` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +器械注册人列表稳定;有数据时分页「共 N 条」N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-QRY-003】【TP_VENDOR_QRY_010】切换经销商 Tab + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 点击「经销商」 + 2. 断言 Tab 激活,列表有数据或空态明确 +- 自动化断言:与 CSV `VENDOR-QRY-003` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +经销商列表加载成功;总数可与器械注册人不同。 + +--- + +##### 【P0】【TC-UI-VENDOR-QRY-004】【TP_VENDOR_QRY_011】按厂商名称搜索 + +- 前置条件:器械注册人至少 1 条且厂商名称非空 +- 操作步骤: + 1. 从首行读取 `${sampleMfrsName}` 取关键字 + 2. 在厂商名称搜索框填入关键字并回车 + 3. 断言结果行「厂商名称」包含关键字 +- 自动化断言:与 CSV `VENDOR-QRY-004` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +厂商名称列与关键字匹配。 + +--- + +##### 【P1】【TC-UI-VENDOR-QRY-005】【TP_VENDOR_QRY_019】清空搜索恢复 + +- 前置条件:已按厂商名称过滤 +- 操作步骤: + 1. 在已过滤状态下清空搜索框并回车 + 2. 断言分页总数不少于过滤后 +- 自动化断言:与 CSV `VENDOR-QRY-005` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +恢复更宽结果集。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-QRY-006】【TP_VENDOR_QRY_017】无匹配空态(mock反例) + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-QRY-006` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-QRY-007】【TP_VENDOR_QRY_012】列表核心列完整 + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号 +- 自动化断言:与 CSV `VENDOR-QRY-007` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +核心列可见。 + +--- + +##### 【P1】【TC-UI-VENDOR-QRY-008】【TP_VENDOR_QRY_013】分页翻至第 2 页 + +- 前置条件:器械注册人总数足够翻页 +- 操作步骤: + 1. 总数足够时翻到第 2 页 + 2. 断言列表可加载;不足两页则 skip +- 自动化断言:与 CSV `VENDOR-QRY-008` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +第 2 页可加载。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-QRY-009】【TP_VENDOR_QRY_015】仅空格关键字搜索 + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 厂商名称仅填空格并回车 + 2. 断言按空查询处理或提示,不白屏 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +不报错;行为明确。 + +--- + +### 厂商查询 · 页面进入 + +#### 异常验证点 + +##### 【P0】【TC-UI-VENDOR-QRY-010】【TP_VENDOR_QRY_005】未登录访问拦截 + +- 前置条件:使用独立 context,storageState 为空(不加载 test_1 登录态) +- 操作步骤: + 1. 新建独立 BrowserContext,不加载 auth/user.json(`test.use` 空 storageState) + 2. 直接打开厂商查询 URL + 3. 断言跳转登录或不展示业务列表 +- 自动化断言:与 CSV `VENDOR-QRY-010` 及 `tests/spd/vendor-query.spec.ts` 一致 + +###### 预期结果 + +未登录不可见厂商数据。 + +--- + +### 厂商查询 · Tab 与搜索 + +#### 集成验证点 + +##### 【P1】【TC-UI-VENDOR-QRY-011】【TP_VENDOR_QRY_018】厂商名称与资质管理进院供应商可对照 + +- 前置条件:已进入厂商查询页 +- 操作步骤: + 1. 记录厂商查询中某厂商名称(若有) + 2. 打开供应商资质管理,用同源关键字检索或目视对照 + 3. 断言不出现明显主数据串名(允许一方无记录) +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +厂商主数据跨菜单可对照,不串名。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..29bb74252794f6af2f97186265c192f37b4e0bdd --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,184 @@ +# 基础管理-厂商管理-授权变更初审 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-授权变更初审-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-授权变更初审-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/authAudit` +**定位器 YAML**:`locators/spd/vendor-auth-first.locator.yaml` +**元素扫描**:`data/scan/vendor-auth-first-elements.json`(40 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-003.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-audit.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 授权变更初审」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:审批写操作只打开详情后返回,不确认通过/驳回/提交。 +**动态取数**:默认查询后从首行读取 `${sampleOrderNo}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 授权变更初审 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF1-001】【TP_VENDOR_AF1_001】菜单进入授权变更初审页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 授权变更初审」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/authAudit` + 3. 断言可见「查 询」「重 置」及制单日期筛选 +- 自动化断言:与 CSV `VENDOR-AF1-001` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +进入授权变更初审页;不误入其它变更审批菜单。 + +--- + +### 授权变更初审 · 筛选与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF1-002】【TP_VENDOR_AF1_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确 +- 自动化断言:与 CSV `VENDOR-AF1-002` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时分页 N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-AF1-003】【TP_VENDOR_AF1_010】按单号筛选 + +- 前置条件:列表至少 1 条且单号非空 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` 取关键字 + 2. 单号填入关键字并「查 询」 + 3. 断言结果行「单号」包含关键字 +- 自动化断言:与 CSV `VENDOR-AF1-003` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +单号列与条件一致。 + +--- + +##### 【P0】【TC-UI-VENDOR-AF1-004】【TP_VENDOR_AF1_011】按进院供应商筛选 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 按样本填写或选择「进院供应商」并查询 + 2. 断言结果列与条件匹配 +- 自动化断言:与 CSV `VENDOR-AF1-004` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +筛选条件生效。 + +--- + +##### 【P1】【TC-UI-VENDOR-AF1-005】【TP_VENDOR_AF1_012】重置恢复默认 + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 填写筛选项后点击「重 置」 + 2. 断言文本条件清空、列表按默认规则刷新 +- 自动化断言:与 CSV `VENDOR-AF1-005` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +重置成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-AF1-006】【TP_VENDOR_AF1_016】无匹配空态(mock反例) + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-AF1-006` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF1-007】【TP_VENDOR_AF1_013】列表核心列完整 + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作 + 2. 断言存在「详情」操作 +- 自动化断言:与 CSV `VENDOR-AF1-007` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +核心列与详情入口可见。 + +--- + +### 授权变更初审 · 详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF1-008】【TP_VENDOR_AF1_014】打开详情后返回 + +- 前置条件:存在含「详情」的行 +- 操作步骤: + 1. 点击行内「详 情」 + 2. 断言详情层可见且含单号片段 + 3. 返回/关闭,不提交审批 +- 自动化断言:与 CSV `VENDOR-AF1-008` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +详情只读可返回;列表状态不变。 + +--- + +### 授权变更初审 · 筛选与列表 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-AF1-009】【TP_VENDOR_AF1_015】制单日期起止同一天 + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 制单日期起止选同一天并查询 + 2. 断言仅返回该日单据或空态明确 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间行为明确。 + +--- + +### 授权变更初审 · 详情 + +#### 集成验证点 + +##### 【P0】【TC-UI-VENDOR-AF1-010】【TP_VENDOR_AF1_018】与终审单据衔接不串单 + +- 前置条件:已进入授权变更初审页 +- 操作步骤: + 1. 记录本页某单号后切换到对应终审菜单 + 2. 断言同业务单据状态流转合理、不串到非本业务菜单 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +初审/终审衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..1b9767d84ecfc9f05a7316f9036fa4851debcb0a --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,184 @@ +# 基础管理-厂商管理-授权变更终审 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-授权变更终审-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-授权变更终审-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/authAuditFinal` +**定位器 YAML**:`locators/spd/vendor-auth-final.locator.yaml` +**元素扫描**:`data/scan/vendor-auth-final-elements.json`(40 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-004.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-audit.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 授权变更终审」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:审批写操作只打开详情后返回,不确认通过/驳回/提交。 +**动态取数**:默认查询后从首行读取 `${sampleOrderNo}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 授权变更终审 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF2-001】【TP_VENDOR_AF2_001】菜单进入授权变更终审页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 授权变更终审」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/authAuditFinal` + 3. 断言可见「查 询」「重 置」及制单日期筛选 +- 自动化断言:与 CSV `VENDOR-AF2-001` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +进入授权变更终审页;不误入其它变更审批菜单。 + +--- + +### 授权变更终审 · 筛选与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF2-002】【TP_VENDOR_AF2_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确 +- 自动化断言:与 CSV `VENDOR-AF2-002` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时分页 N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-AF2-003】【TP_VENDOR_AF2_010】按单号筛选 + +- 前置条件:列表至少 1 条且单号非空 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` 取关键字 + 2. 单号填入关键字并「查 询」 + 3. 断言结果行「单号」包含关键字 +- 自动化断言:与 CSV `VENDOR-AF2-003` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +单号列与条件一致。 + +--- + +##### 【P0】【TC-UI-VENDOR-AF2-004】【TP_VENDOR_AF2_011】按进院供应商筛选 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 按样本填写或选择「进院供应商」并查询 + 2. 断言结果列与条件匹配 +- 自动化断言:与 CSV `VENDOR-AF2-004` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +筛选条件生效。 + +--- + +##### 【P1】【TC-UI-VENDOR-AF2-005】【TP_VENDOR_AF2_012】重置恢复默认 + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 填写筛选项后点击「重 置」 + 2. 断言文本条件清空、列表按默认规则刷新 +- 自动化断言:与 CSV `VENDOR-AF2-005` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +重置成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-AF2-006】【TP_VENDOR_AF2_016】无匹配空态(mock反例) + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-AF2-006` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF2-007】【TP_VENDOR_AF2_013】列表核心列完整 + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作 + 2. 断言存在「详情」操作 +- 自动化断言:与 CSV `VENDOR-AF2-007` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +核心列与详情入口可见。 + +--- + +### 授权变更终审 · 详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-AF2-008】【TP_VENDOR_AF2_014】打开详情后返回 + +- 前置条件:存在含「详情」的行 +- 操作步骤: + 1. 点击行内「详 情」 + 2. 断言详情层可见且含单号片段 + 3. 返回/关闭,不提交审批 +- 自动化断言:与 CSV `VENDOR-AF2-008` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +详情只读可返回;列表状态不变。 + +--- + +### 授权变更终审 · 筛选与列表 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-AF2-009】【TP_VENDOR_AF2_015】制单日期起止同一天 + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 制单日期起止选同一天并查询 + 2. 断言仅返回该日单据或空态明确 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间行为明确。 + +--- + +### 授权变更终审 · 详情 + +#### 集成验证点 + +##### 【P0】【TC-UI-VENDOR-AF2-010】【TP_VENDOR_AF2_018】与初审单据衔接不串单 + +- 前置条件:已进入授权变更终审页 +- 操作步骤: + 1. 记录本页某单号后切换到对应初审菜单 + 2. 断言同业务单据状态流转合理、不串到非本业务菜单 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +初审/终审衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..5e14f0f04decf93674dee9400d7a43830c896d7f --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,184 @@ +# 基础管理-厂商管理-注册证变更初审 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-注册证变更初审-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-注册证变更初审-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/regCertAudit` +**定位器 YAML**:`locators/spd/vendor-cert-first.locator.yaml` +**元素扫描**:`data/scan/vendor-cert-first-elements.json`(40 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-005.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-audit.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 注册证变更初审」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:审批写操作只打开详情后返回,不确认通过/驳回/提交。 +**动态取数**:默认查询后从首行读取 `${sampleOrderNo}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 注册证变更初审 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF1-001】【TP_VENDOR_CF1_001】菜单进入注册证变更初审页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 注册证变更初审」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/regCertAudit` + 3. 断言可见「查 询」「重 置」及制单日期筛选 +- 自动化断言:与 CSV `VENDOR-CF1-001` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +进入注册证变更初审页;不误入其它变更审批菜单。 + +--- + +### 注册证变更初审 · 筛选与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF1-002】【TP_VENDOR_CF1_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确 +- 自动化断言:与 CSV `VENDOR-CF1-002` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时分页 N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-CF1-003】【TP_VENDOR_CF1_010】按单号筛选 + +- 前置条件:列表至少 1 条且单号非空 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` 取关键字 + 2. 单号填入关键字并「查 询」 + 3. 断言结果行「单号」包含关键字 +- 自动化断言:与 CSV `VENDOR-CF1-003` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +单号列与条件一致。 + +--- + +##### 【P0】【TC-UI-VENDOR-CF1-004】【TP_VENDOR_CF1_011】按注册证号(新)筛选 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 按样本填写或选择「注册证号(新)」并查询 + 2. 断言结果列与条件匹配 +- 自动化断言:与 CSV `VENDOR-CF1-004` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +筛选条件生效。 + +--- + +##### 【P1】【TC-UI-VENDOR-CF1-005】【TP_VENDOR_CF1_012】重置恢复默认 + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 填写筛选项后点击「重 置」 + 2. 断言文本条件清空、列表按默认规则刷新 +- 自动化断言:与 CSV `VENDOR-CF1-005` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +重置成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-CF1-006】【TP_VENDOR_CF1_016】无匹配空态(mock反例) + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-CF1-006` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF1-007】【TP_VENDOR_CF1_013】列表核心列完整 + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作 + 2. 断言存在「详情」操作 +- 自动化断言:与 CSV `VENDOR-CF1-007` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +核心列与详情入口可见。 + +--- + +### 注册证变更初审 · 详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF1-008】【TP_VENDOR_CF1_014】打开详情后返回 + +- 前置条件:存在含「详情」的行 +- 操作步骤: + 1. 点击行内「详 情」 + 2. 断言详情层可见且含单号片段 + 3. 返回/关闭,不提交审批 +- 自动化断言:与 CSV `VENDOR-CF1-008` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +详情只读可返回;列表状态不变。 + +--- + +### 注册证变更初审 · 筛选与列表 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-CF1-009】【TP_VENDOR_CF1_015】制单日期起止同一天 + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 制单日期起止选同一天并查询 + 2. 断言仅返回该日单据或空态明确 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间行为明确。 + +--- + +### 注册证变更初审 · 详情 + +#### 集成验证点 + +##### 【P0】【TC-UI-VENDOR-CF1-010】【TP_VENDOR_CF1_018】与终审单据衔接不串单 + +- 前置条件:已进入注册证变更初审页 +- 操作步骤: + 1. 记录本页某单号后切换到对应终审菜单 + 2. 断言同业务单据状态流转合理、不串到非本业务菜单 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +初审/终审衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..b15ff7d93da13353a5123b75cbbda2e7276b1341 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,184 @@ +# 基础管理-厂商管理-注册证变更终审 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-注册证变更终审-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-注册证变更终审-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal` +**定位器 YAML**:`locators/spd/vendor-cert-final.locator.yaml` +**元素扫描**:`data/scan/vendor-cert-final-elements.json`(40 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-006.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-audit.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 注册证变更终审」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:审批写操作只打开详情后返回,不确认通过/驳回/提交。 +**动态取数**:默认查询后从首行读取 `${sampleOrderNo}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 注册证变更终审 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF2-001】【TP_VENDOR_CF2_001】菜单进入注册证变更终审页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 注册证变更终审」进入 + 2. 断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal` + 3. 断言可见「查 询」「重 置」及制单日期筛选 +- 自动化断言:与 CSV `VENDOR-CF2-001` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +进入注册证变更终审页;不误入其它变更审批菜单。 + +--- + +### 注册证变更终审 · 筛选与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF2-002】【TP_VENDOR_CF2_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确 +- 自动化断言:与 CSV `VENDOR-CF2-002` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时分页 N≥1。 + +--- + +##### 【P0】【TC-UI-VENDOR-CF2-003】【TP_VENDOR_CF2_010】按单号筛选 + +- 前置条件:列表至少 1 条且单号非空 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` 取关键字 + 2. 单号填入关键字并「查 询」 + 3. 断言结果行「单号」包含关键字 +- 自动化断言:与 CSV `VENDOR-CF2-003` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +单号列与条件一致。 + +--- + +##### 【P0】【TC-UI-VENDOR-CF2-004】【TP_VENDOR_CF2_011】按注册证号(新)筛选 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 按样本填写或选择「注册证号(新)」并查询 + 2. 断言结果列与条件匹配 +- 自动化断言:与 CSV `VENDOR-CF2-004` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +筛选条件生效。 + +--- + +##### 【P1】【TC-UI-VENDOR-CF2-005】【TP_VENDOR_CF2_012】重置恢复默认 + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 填写筛选项后点击「重 置」 + 2. 断言文本条件清空、列表按默认规则刷新 +- 自动化断言:与 CSV `VENDOR-CF2-005` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +重置成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-CF2-006】【TP_VENDOR_CF2_016】无匹配空态(mock反例) + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 单号填入 `__NOT_EXIST_ORDER__99999` 并查询 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `VENDOR-CF2-006` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;总数为 0。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF2-007】【TP_VENDOR_CF2_013】列表核心列完整 + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 断言表头含 进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作 + 2. 断言存在「详情」操作 +- 自动化断言:与 CSV `VENDOR-CF2-007` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +核心列与详情入口可见。 + +--- + +### 注册证变更终审 · 详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CF2-008】【TP_VENDOR_CF2_014】打开详情后返回 + +- 前置条件:存在含「详情」的行 +- 操作步骤: + 1. 点击行内「详 情」 + 2. 断言详情层可见且含单号片段 + 3. 返回/关闭,不提交审批 +- 自动化断言:与 CSV `VENDOR-CF2-008` 及 `tests/spd/vendor-audit.spec.ts` 一致 + +###### 预期结果 + +详情只读可返回;列表状态不变。 + +--- + +### 注册证变更终审 · 筛选与列表 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-CF2-009】【TP_VENDOR_CF2_015】制单日期起止同一天 + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 制单日期起止选同一天并查询 + 2. 断言仅返回该日单据或空态明确 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +同日区间行为明确。 + +--- + +### 注册证变更终审 · 详情 + +#### 集成验证点 + +##### 【P0】【TC-UI-VENDOR-CF2-010】【TP_VENDOR_CF2_018】与初审单据衔接不串单 + +- 前置条件:已进入注册证变更终审页 +- 操作步骤: + 1. 记录本页某单号后切换到对应初审菜单 + 2. 断言同业务单据状态流转合理、不串到非本业务菜单 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +初审/终审衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..65aabf32c918395ade3d80dcb7236082c075b54c --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,193 @@ +# 基础管理-厂商管理-注册证差异对比 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-注册证差异对比-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-注册证差异对比-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/scjs/yc/certCompare` +**定位器 YAML**:`locators/spd/vendor-cert-diff.locator.yaml` +**元素扫描**:`data/scan/vendor-cert-diff-elements.json`(54 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-007.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-cert-diff.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 注册证差异对比」权限。 +**页面约定**:主体在 scjs **iframe**。 +**写操作**:主体在 scjs iframe;导出本批只断言按钮可见,不确认下载。 +**动态取数**:iframe 内首行读取 `${sampleMaterialName}`;mock:`__NOT_EXIST_MFRS__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 注册证差异对比 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CDF-001】【TP_VENDOR_CDF_001】菜单进入注册证差异对比页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单「基础设置 → 厂商管理 → 注册证差异对比」进入 + 2. 断言 URL 含 `certCompare` + 3. 断言外壳或 iframe 内可见物料名称/全部/一致/不一致等主区 +- 自动化断言:与 CSV `VENDOR-CDF-001` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +进入差异对比页;iframe 或主区加载成功。 + +--- + +### 注册证差异对比 · 筛选与对比 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CDF-002】【TP_VENDOR_CDF_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 在对比页执行查询(若有查询按钮) + 2. 断言列表有数据或空态明确 +- 自动化断言:与 CSV `VENDOR-CDF-002` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +查询无报错。 + +--- + +##### 【P0】【TC-UI-VENDOR-CDF-003】【TP_VENDOR_CDF_013】切换不一致 Tab + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 点击「不一致」 + 2. 断言列表稳定 +- 自动化断言:与 CSV `VENDOR-CDF-003` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +不一致 Tab 切换成功。 + +--- + +##### 【P1】【TC-UI-VENDOR-CDF-004】【TP_VENDOR_CDF_013】切换一致 Tab + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 点击「一致」 + 2. 断言列表稳定 +- 自动化断言:与 CSV `VENDOR-CDF-004` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +一致 Tab 切换成功。 + +--- + +##### 【P0】【TC-UI-VENDOR-CDF-005】【TP_VENDOR_CDF_010】按物料名称筛选 + +- 前置条件:对比列表至少 1 条 +- 操作步骤: + 1. 从首行读取 `${sampleMaterialName}` 取关键字 + 2. 物料名称填入并查询 + 3. 断言结果行物料名称包含关键字 +- 自动化断言:与 CSV `VENDOR-CDF-005` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +物料名称匹配。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-CDF-006】【TP_VENDOR_CDF_016】无匹配空态(mock反例) + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 物料名称填 mock 关键字并查询 + 2. 断言空态或总数为 0 +- 自动化断言:与 CSV `VENDOR-CDF-006` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-CDF-007】【TP_VENDOR_CDF_014】列表核心列完整 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 断言表头含物料编号/物料名称/规格型号/生产厂家等关键列 +- 自动化断言:与 CSV `VENDOR-CDF-007` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +核心对比列可见。 + +--- + +### 注册证差异对比 · 导出 + +#### 功能验证点 + +##### 【P1】【TC-UI-VENDOR-CDF-008】【TP_VENDOR_CDF_015】导出按钮可见 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 断言「导出」按钮可见(不确认下载) +- 自动化断言:与 CSV `VENDOR-CDF-008` 及 `tests/spd/vendor-cert-diff.spec.ts` 一致 + +###### 预期结果 + +导出入口可见。 + +--- + +### 注册证差异对比 · 筛选与对比 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-CDF-009】【TP_VENDOR_CDF_015】仅空格物料名称搜索 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 物料名称仅填空格并查询 + 2. 断言按空查询处理或提示,不白屏 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +空格关键字不导致页面异常。 + +--- + +#### 集成验证点 + +##### 【P0】【TC-UI-VENDOR-CDF-010】【TP_VENDOR_CDF_017】一致行两侧字段抽样一致 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 在「一致」Tab 抽样一行 + 2. 核对望海与 FLI+ 注册证号/效期关键字段一致 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +标记一致的行两侧关键字段抽样相同。 + +--- + +##### 【P0】【TC-UI-VENDOR-CDF-011】【TP_VENDOR_CDF_018】不一致行至少一侧差异 + +- 前置条件:已进入注册证差异对比页 +- 操作步骤: + 1. 在「不一致」Tab 抽样一行 + 2. 断言望海与 FLI+ 至少一处字段不同 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +不一致结论与字段差异相符。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..ab5229b0d81eb30be4b5dcb70b8c1dc7899e35d3 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,166 @@ +# 基础管理-厂商管理-评价指标维护 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-评价指标维护-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-评价指标维护-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/indicators` +**定位器 YAML**:`locators/spd/vendor-eval-indicator.locator.yaml` +**元素扫描**:`data/scan/vendor-eval-indicator-elements.json`(151 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-009.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-eval-indicator.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 评价指标维护」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:详情只读返回;不落库新增/编辑提交。 +**动态取数**:右表首行读取 `${sampleIndicatorCode}`;列名可能为指标编码或指标编号;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 评价指标维护 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVI-001】【TP_VENDOR_EVI_001】菜单进入评价指标维护页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单进入评价指标维护 + 2. 断言 URL 含 `indicators` + 3. 断言可见「指标分类」与查询 +- 自动化断言:与 CSV `VENDOR-EVI-001` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +进入评价指标维护页。 + +--- + +### 评价指标维护 · 树与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVI-002】【TP_VENDOR_EVI_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 点击查询 + 2. 断言右表有数据或空态 +- 自动化断言:与 CSV `VENDOR-EVI-002` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +查询无报错。 + +--- + +##### 【P0】【TC-UI-VENDOR-EVI-003】【TP_VENDOR_EVI_010】点击左侧分类树 + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 点击左侧首个分类节点 + 2. 断言右侧列表刷新 +- 自动化断言:与 CSV `VENDOR-EVI-003` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +分类切换成功。 + +--- + +##### 【P0】【TC-UI-VENDOR-EVI-004】【TP_VENDOR_EVI_011】按指标编码筛选 + +- 前置条件:右表有数据 +- 操作步骤: + 1. 取样本指标编码/编号筛选并查询 + 2. 断言编码列匹配 +- 自动化断言:与 CSV `VENDOR-EVI-004` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +指标编码匹配。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-EVI-005】【TP_VENDOR_EVI_015】无匹配空态(mock反例) + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 指标编码填 mock 后查询 + 2. 断言空态 +- 自动化断言:与 CSV `VENDOR-EVI-005` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVI-006】【TP_VENDOR_EVI_012】列表核心列完整 + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 断言表头含评价类型、指标编码或指标编号、指标名称、状态、操作 +- 自动化断言:与 CSV `VENDOR-EVI-006` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +核心列可见。 + +--- + +### 评价指标维护 · 详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVI-007】【TP_VENDOR_EVI_014】打开详情后返回 + +- 前置条件:存在详情操作行 +- 操作步骤: + 1. 点击行内详情 + 2. 断言详情层可见 + 3. 返回不保存 +- 自动化断言:与 CSV `VENDOR-EVI-007` 及 `tests/spd/vendor-eval-indicator.spec.ts` 一致 + +###### 预期结果 + +详情只读可返回。 + +--- + +### 评价指标维护 · 树与列表 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-EVI-008】【TP_VENDOR_EVI_015】分类下无指标空态 + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 选择无指标的分类(若有) + 2. 断言右表空态明确 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +空分类下列表空态正确。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-VENDOR-EVI-009】【TP_VENDOR_EVI_018】启用指标可供方案使用 + +- 前置条件:已进入评价指标维护页 +- 操作步骤: + 1. 确认状态为启用的指标 + 2. 在评价方案维护中核对其可选(只读核对或抽样) +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +启用指标与方案引用衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..e405ec098d0b51a634fbbb1a7454954e62f328bb --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,183 @@ +# 基础管理-厂商管理-评价方案维护 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-厂商管理-评价方案维护-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-厂商管理-评价方案维护-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/mfrs/paltform/mfrsName/evaluationPlan` +**定位器 YAML**:`locators/spd/vendor-eval-scheme.locator.yaml` +**元素扫描**:`data/scan/vendor-eval-scheme-elements.json`(57 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-008.md` +**版本**:2026-09-30 +**自动化框架**:Playwright + TypeScript(`tests/spd/vendor-eval-scheme.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → 评价方案维护」权限。 +**页面约定**:整页渲染,**无报表 iframe**。 +**写操作**:新增只打开后取消,不提交保存。 +**动态取数**:有数据时从首行读取 `${sampleSchemeCode}`;mock:`__NOT_EXIST_ORDER__99999`。 + +--- + +## 基础设置 · 厂商管理 + +### 评价方案维护 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVS-001】【TP_VENDOR_EVS_001】菜单进入评价方案维护页 + +- 前置条件:已登录且具备厂商管理权限 +- 操作步骤: + 1. 按菜单进入评价方案维护 + 2. 断言 URL 含 `evaluationPlan` + 3. 断言可见查询与新增 +- 自动化断言:与 CSV `VENDOR-EVS-001` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +进入评价方案维护页。 + +--- + +### 评价方案维护 · 筛选与维护 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVS-002】【TP_VENDOR_EVS_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 点击查询 + 2. 断言列表有数据或「暂无数据」空态 +- 自动化断言:与 CSV `VENDOR-EVS-002` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +查询无报错。 + +--- + +##### 【P0】【TC-UI-VENDOR-EVS-003】【TP_VENDOR_EVS_010】按方案编码筛选 + +- 前置条件:列表有数据 +- 操作步骤: + 1. 取样本方案编码筛选并查询 + 2. 断言方案编码列匹配 +- 自动化断言:与 CSV `VENDOR-EVS-003` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +方案编码匹配。 + +--- + +##### 【P1】【TC-UI-VENDOR-EVS-004】【TP_VENDOR_EVS_011】重置恢复默认 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 填写筛选后重置 + 2. 断言条件清空、列表刷新 +- 自动化断言:与 CSV `VENDOR-EVS-004` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +重置成功。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-VENDOR-EVS-005】【TP_VENDOR_EVS_015】无匹配空态(mock反例) + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 方案编码填 mock 后查询 + 2. 断言空态总数 0 +- 自动化断言:与 CSV `VENDOR-EVS-005` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVS-006】【TP_VENDOR_EVS_012】列表核心列完整 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 断言表头含方案编码、方案名称、创建人、创建时间、状态、操作 +- 自动化断言:与 CSV `VENDOR-EVS-006` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +核心列可见。 + +--- + +### 评价方案维护 · 新增 + +#### 功能验证点 + +##### 【P0】【TC-UI-VENDOR-EVS-007】【TP_VENDOR_EVS_013】打开新增后取消 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 记录总数 + 2. 点击新增打开编辑层 + 3. 取消/返回不提交 + 4. 断言总数不变 +- 自动化断言:与 CSV `VENDOR-EVS-007` 及 `tests/spd/vendor-eval-scheme.spec.ts` 一致 + +###### 预期结果 + +取消不落库。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-VENDOR-EVS-008】【TP_VENDOR_EVS_016】必填为空提交拦截 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 打开新增不填必填项尝试提交(若可点) + 2. 断言拦截提示,页面不关闭或总数不变 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +空提交被拦截。 + +--- + +### 评价方案维护 · 筛选与维护 + +#### 边界验证点 + +##### 【P2】【TC-UI-VENDOR-EVS-009】【TP_VENDOR_EVS_015】方案名称超长输入 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 打开新增,方案名称输入超长字符串(如 200+ 字符) + 2. 断言前端截断/校验提示或后端拒绝,不白屏 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +超长输入被约束且页面可用。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-VENDOR-EVS-010】【TP_VENDOR_EVS_018】方案与评价指标可关联 + +- 前置条件:已进入评价方案维护页 +- 操作步骤: + 1. 在方案配置中查看指标选择(只读打开) + 2. 断言可选指标来源于评价指标维护启用项或规则允许集合 +- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖 + +###### 预期结果 + +方案与指标主数据衔接正确。 diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..3cdccd560694ebcbe08fb47326cb3fd812ec182f --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,367 @@ +# 基础管理-库房管理-库区设置 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-库房管理-库区设置-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-库房管理-库区设置-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/storehouse/platform/storehouse/area` +**定位器 YAML**:`locators/spd/warehouse-zone.locator.yaml` +**元素扫描**:`data/scan/warehouse-zone-elements.json`(106 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-WH-001.md` +**版本**:2026-10-20 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/warehouse-zone.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 库房管理 → 库区设置」权限。反例筛选用 mock。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 库房管理 → 库区设置」;建议 `openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL,勿误入探针绑定。 +**写操作**:新增/编辑只打开后取消或点确定做必填拦截,不提交成功保存;不停用/启用落库;人员管理/货位管理只打开后关闭;同步仅点一次并断言树仍在。 +**动态取数**:从左侧树读取 `${sampleWhName}`,从右侧首行读取 `${sampleZoneNo}`、`${sampleZoneName}`;mock:`__NOT_EXIST_ZONE__99999`。 + +--- + +## 基础设置 · 库房管理 + +### 库区设置 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-001】【TP_WH_ZON_001】菜单进入库区设置页 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 按菜单「基础设置 → 库房管理 → 库区设置」进入,或 `openPath` 打开 `/spd/basic/storehouse/platform/storehouse/area` + 2. 断言 URL 含 `/spd/basic/storehouse/platform/storehouse/area` + 3. 断言左侧「库房信息」树、右侧库区列表、「新 增」与分页可见 +- 自动化断言:规划与 CSV `WH-ZON-001` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +进入库区设置页;不误入探针绑定;无报错遮罩。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-WH-ZON-002】【TP_WH_ZON_005】未登录访问拦截 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 清除登录态后直接打开 `/spd/basic/storehouse/platform/storehouse/area` + 2. 断言跳转登录或不展示库房/库区业务数据 +- 自动化断言:规划与 CSV `WH-ZON-002` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +未登录不可见库区数据。 + +--- + +### 库区设置 · 左侧库房树 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-003】【TP_WH_ZON_009】库房树默认有选中项 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 断言左侧「库房信息」至少 1 个节点且有选中项 + 2. 读取选中节点 `${sampleWhName}` + 3. 断言右侧列表为该库房库区,或空态且「共 0 条」 +- 自动化断言:规划与 CSV `WH-ZON-003` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +树有选中;右侧对应当前库房。 + +--- + +##### 【P0】【TC-UI-WH-ZON-004】【TP_WH_ZON_010】按库房名称过滤树 + +- 前置条件:库房树至少 1 个节点 +- 操作步骤: + 1. 读取当前选中 `${sampleWhName}` 取关键字(可截取前若干字) + 2. 在左侧「库房名称」输入关键字 + 3. 断言可见节点名称均包含该关键字 +- 自动化断言:规划与 CSV `WH-ZON-004` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +树过滤结果与关键字匹配。 + +--- + +##### 【P1】【TC-UI-WH-ZON-005】【TP_WH_ZON_011】清空库房名称恢复全量树 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 记录过滤前节点数 + 2. 输入关键字过滤后再清空 + 3. 断言节点数恢复为过滤前 +- 自动化断言:规划与 CSV `WH-ZON-005` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +清空后树恢复全量。 + +--- + +##### 【P0】【TC-UI-WH-ZON-006】【TP_WH_ZON_012】切换库房刷新右侧列表 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 记录当前库房 `${sampleWhName}` 与右侧首行库区编号(若有) + 2. 点选另一库房节点(仅 1 个则 skip) + 3. 断言「共 N 条」可变化;若有行则库区编号/名称属于新库房 +- 自动化断言:规划与 CSV `WH-ZON-006` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +切换库房后右侧列表刷新,不残留上一库房库区。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-WH-ZON-007】【TP_WH_ZON_015】无匹配库房空树(mock反例) + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 库房名称填入 `__NOT_EXIST_ZONE__99999` + 2. 断言树为空或明确无数据,右侧不展示上一库房库区为成功态 +- 自动化断言:规划与 CSV `WH-ZON-007` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错;不残留上一库房列表当成功。 + +--- + +### 库区设置 · 右侧库区列表与分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-008】【TP_WH_ZON_018】列表核心列完整 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 断言表头含库区编号、库区名称、库区地址、是否专项库区、操作 + 2. 若有数据行,断言操作列可见「编辑」「人员管理」「货位管理」及「停用」或「启用」 +- 自动化断言:规划与 CSV `WH-ZON-008` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +核心列完整;行内操入口可见。 + +--- + +##### 【P0】【TC-UI-WH-ZON-009】【TP_WH_ZON_019】默认库区以标签展示 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 在当前库房列表中查找「默认库区」标签 + 2. 断言该标签出现在库区名称旁,不是独立列 + 3. 断言当前库房至多 1 个默认库区标签(无默认则本条只断言不是独立列) +- 自动化断言:规划与 CSV `WH-ZON-009` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +默认库区为名称旁标签;同一库房至多一个。 + +--- + +### 库区设置 · 库区搜索 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-010】【TP_WH_ZON_029】按库区编号搜索 + +- 前置条件:当前库房至少 1 条库区且编号非空 +- 操作步骤: + 1. 从首行读取 `${sampleZoneNo}` + 2. 在「库区编号/名称」填入该编号并触发搜索 + 3. 断言结果包含该编号行,且均属于当前选中库房 +- 自动化断言:规划与 CSV `WH-ZON-010` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +搜索命中该编号;不串其它库房。 + +--- + +##### 【P0】【TC-UI-WH-ZON-011】【TP_WH_ZON_030】按库区名称搜索 + +- 前置条件:当前库房至少 1 条且名称非空 +- 操作步骤: + 1. 从首行读取 `${sampleZoneName}` 取关键字 + 2. 在「库区编号/名称」填入关键字并搜索 + 3. 断言结果行名称匹配 +- 自动化断言:规划与 CSV `WH-ZON-011` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +名称关键字命中;无未捕获异常。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-WH-ZON-012】【TP_WH_ZON_034】无匹配库区空态(mock反例) + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 「库区编号/名称」填入 `__NOT_EXIST_ZONE__99999` 并搜索 + 2. 断言列表空且分页为 0 +- 自动化断言:规划与 CSV `WH-ZON-012` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错;总数为 0。 + +--- + +### 库区设置 · 新增库区 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-013】【TP_WH_ZON_036】打开新增库区弹窗 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言弹出「新增库区」,可见库区编号、库区名称、库区地址、联系人、联系方式及「取 消」「确 定」 +- 自动化断言:规划与 CSV `WH-ZON-013` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +新增弹窗可见;关键字段与按钮齐全。 + +--- + +##### 【P1】【TC-UI-WH-ZON-014】【TP_WH_ZON_038】新增取消不落库 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 打开新增,编号填 `AUTO${timestamp}`,名称填任意合法值 + 2. 点击「取 消」 + 3. 按该编号搜索,断言无该记录 +- 自动化断言:规划与 CSV `WH-ZON-014` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +弹窗关闭;列表不出现该编号。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-WH-ZON-015】【TP_WH_ZON_041】必填为空拦截 + +- 前置条件:已进入库区设置页 +- 操作步骤: + 1. 打开新增,库区编号或名称留空后点击「确 定」 + 2. 断言出现必填提示,弹窗仍打开 + 3. 点「取 消」 +- 自动化断言:规划与 CSV `WH-ZON-015` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +确定被拦截;不落库;弹窗不关闭。 + +--- + +### 库区设置 · 编辑库区 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-016】【TP_WH_ZON_046】打开编辑并回填 + +- 前置条件:当前库房至少 1 条库区 +- 操作步骤: + 1. 点击首行「编辑」 + 2. 断言弹窗标题含「编辑库区信息」 + 3. 断言库区编号、名称与该行 `${sampleZoneNo}`、`${sampleZoneName}` 一致(允许规范化空白) + 4. 点「取 消」 +- 自动化断言:规划与 CSV `WH-ZON-016` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +编辑弹窗可见;回填与列表行一致。 + +--- + +##### 【P1】【TC-UI-WH-ZON-017】【TP_WH_ZON_048】编辑取消不保存 + +- 前置条件:当前库房至少 1 条库区 +- 操作步骤: + 1. 打开首行编辑,记录原名称 + 2. 将名称改为临时值后点「取 消」 + 3. 再次打开编辑,断言名称仍为原值 +- 自动化断言:规划与 CSV `WH-ZON-017` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +取消后列表与回看均为修改前名称。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-WH-ZON-018】【TP_WH_ZON_052】编辑清空必填拦截 + +- 前置条件:当前库房至少 1 条库区 +- 操作步骤: + 1. 打开首行编辑,清空库区名称后点「确 定」 + 2. 断言被拦截并提示,弹窗仍开 + 3. 点「取 消」,断言列表名称未被写成空 +- 自动化断言:规划与 CSV `WH-ZON-018` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +清空必填不能保存;原记录名称保持。 + +--- + +### 库区设置 · 人员管理 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-019】【TP_WH_ZON_061】打开人员管理后关闭 + +- 前置条件:当前库房至少 1 条库区 +- 操作步骤: + 1. 点击首行「人员管理」 + 2. 断言打开弹层或子页,标题含人员管理或对应当前库区 + 3. 关闭后断言仍在库区设置,当前库房选中保持 +- 自动化断言:规划与 CSV `WH-ZON-019` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +人员管理可打开;关闭后回到当前库房列表。本条不改人员。 + +--- + +### 库区设置 · 货位管理 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-ZON-020】【TP_WH_ZON_066】打开货位管理后关闭 + +- 前置条件:当前库房至少 1 条库区 +- 操作步骤: + 1. 点击首行「货位管理」 + 2. 断言打开弹层或子页,标题含货位且对应当前库区 + 3. 关闭后断言库房选中与库区列表保持 +- 自动化断言:规划与 CSV `WH-ZON-020` 及 `tests/spd/warehouse-zone.spec.ts` 一致 + +###### 预期结果 + +货位管理可打开;关闭后列表状态保持。本条不改货位。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **20** 条(P0=15 / P1=5) | +| 来源 | 从 74 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 真实保存新增/编辑、停用启用持久化、接口失败注入、会话过期、无菜单权限、人员/货位子页字段细节 | +| 单一职责 | 每条只断言一个核心业务结果 | +| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..d46786cd5c4f9094572e66b3ea4b1e7a46381375 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,313 @@ +# 基础管理-库房管理-探针绑定 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-库房管理-探针绑定-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/storehouse/storehouse/probe` +**定位器 YAML**:`locators/spd/warehouse-probe.locator.yaml` +**元素扫描**:`data/scan/warehouse-probe-elements.json`(78 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-WH-002.md` +**版本**:2026-10-20 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/warehouse-probe.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 库房管理 → 探针绑定」权限。反例筛选用 mock。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 库房管理 → 探针绑定」;建议 `openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。 +**写操作**:绑定货位只打开弹窗后取消,不点确定保存;不改绑定关系。 +**动态取数**:查询后从首行读取 `${sampleProbeId}`、`${sampleHostCode}`、`${sampleBindFlag}`、`${sampleBindCount}`;mock:`__NOT_EXIST_PROBE__99999`。 + +--- + +## 基础设置 · 库房管理 + +### 探针绑定 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-PRB-001】【TP_WH_PRB_001】菜单进入探针绑定页 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 按菜单「基础设置 → 库房管理 → 探针绑定」进入,或 `openPath` 打开 `/spd/basic/storehouse/storehouse/probe` + 2. 断言 URL 含 `/spd/basic/storehouse/storehouse/probe` + 3. 断言探针ID、主机编码、是否绑定货位、查询、重置、列表与分页可见 +- 自动化断言:规划与 CSV `WH-PRB-001` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +进入探针绑定页;不误入库区设置。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-WH-PRB-002】【TP_WH_PRB_005】未登录访问拦截 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 清除登录态后直接打开 `/spd/basic/storehouse/storehouse/probe` + 2. 断言跳转登录或不展示探针业务列表 +- 自动化断言:规划与 CSV `WH-PRB-002` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +未登录不可见探针数据。 + +--- + +### 探针绑定 · 筛选与查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-PRB-003】【TP_WH_PRB_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `WH-PRB-003` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时「共 N 条」N≥1。 + +--- + +##### 【P0】【TC-UI-WH-PRB-004】【TP_WH_PRB_010】按探针ID查询 + +- 前置条件:默认查询至少 1 条且探针ID非空 +- 操作步骤: + 1. 从首行读取 `${sampleProbeId}` + 2. 探针ID填入该值,点击「查 询」 + 3. 断言结果探针ID匹配,精确命中时行数合理 +- 自动化断言:规划与 CSV `WH-PRB-004` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +探针ID条件生效。 + +--- + +##### 【P0】【TC-UI-WH-PRB-005】【TP_WH_PRB_011】按主机编码查询 + +- 前置条件:默认查询至少 1 条且主机编码非空 +- 操作步骤: + 1. 从首行读取 `${sampleHostCode}` + 2. 主机编码填入该值,点击「查 询」 + 3. 断言结果行「主机编码」与条件一致 +- 自动化断言:规划与 CSV `WH-PRB-005` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +主机编码条件生效;允许多探针共用同一主机。 + +--- + +##### 【P0】【TC-UI-WH-PRB-006】【TP_WH_PRB_012】按是否绑定货位查询 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 是否绑定货位选择「已绑定」,点击「查 询」 + 2. 断言结果「是否绑定货位」均为已绑定,且「绑定的货位」>0 + 3. 再选「未绑定」查询,断言均为未绑定且数量为 0(无数据则空态) +- 自动化断言:规划与 CSV `WH-PRB-006` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +已绑定数量大于 0;未绑定数量为 0。 + +--- + +##### 【P0】【TC-UI-WH-PRB-007】【TP_WH_PRB_013】探针ID+主机编码组合查询 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 填写 `${sampleProbeId}` 与 `${sampleHostCode}` 后点击「查 询」 + 2. 断言结果同时满足已填条件 +- 自动化断言:规划与 CSV `WH-PRB-007` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +组合条件同时生效。 + +--- + +##### 【P1】【TC-UI-WH-PRB-008】【TP_WH_PRB_014】重置恢复默认 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 填写探针ID后点击「重 置」 + 2. 断言条件恢复默认,列表按默认规则刷新 +- 自动化断言:规划与 CSV `WH-PRB-008` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +条件恢复默认;列表刷新。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-WH-PRB-009】【TP_WH_PRB_018】无匹配空态(mock反例) + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 探针ID填入 `__NOT_EXIST_PROBE__99999`,点击「查 询」 + 2. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `WH-PRB-009` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错;总数为 0。 + +--- + +### 探针绑定 · 列表与分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-PRB-010】【TP_WH_PRB_020】列表核心列与数量规则 + +- 前置条件:已进入探针绑定页 +- 操作步骤: + 1. 断言表头含序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作 + 2. 抽样已绑定行:绑定数量为数字且 >0;抽样未绑定行:数量为 0 + 3. 断言「绑定的货位」列不是货位号格式 +- 自动化断言:规划与 CSV `WH-PRB-010` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +核心列完整;数量列是数字不是货位号。 + +--- + +### 探针绑定 · 打开绑定货位弹窗 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-PRB-011】【TP_WH_PRB_030】行内打开货位绑定探针 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 点击首行「绑定货位」 + 2. 断言弹出「货位绑定探针」,可见当前库房/库区/货位查询及货位列表 +- 自动化断言:规划与 CSV `WH-PRB-011` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +弹窗标题正确;筛选与列表可见。 + +--- + +##### 【P1】【TC-UI-WH-PRB-012】【TP_WH_PRB_031】取消关闭不改主列表 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 记录主列表首行绑定数量与当前筛选 + 2. 打开绑定弹窗后点「取 消」 + 3. 断言主列表绑定数量与筛选条件保持 +- 自动化断言:规划与 CSV `WH-PRB-012` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +取消不改绑定关系。 + +--- + +#### 边界验证点 + +##### 【P1】【TC-UI-WH-PRB-013】【TP_WH_PRB_032】未选货位时确定计数为 0 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开绑定弹窗,不勾选货位 + 2. 断言底部按钮文案为「确定(0)」或等价 + 3. 点「取 消」,不点确定保存 +- 自动化断言:规划与 CSV `WH-PRB-013` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +未选时计数为 0;本条不保存绑定。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-WH-PRB-014】【TP_WH_PRB_034】弹窗分页独立于主列表 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 记录主列表「共 N 条」 + 2. 打开绑定弹窗,记录弹窗分页「共 M 条」 + 3. 断言不得用 M 断言主列表 N(货位量级可远大于探针条数) + 4. 点「取 消」 +- 自动化断言:规划与 CSV `WH-PRB-014` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +弹窗总条数与主列表探针条数相互独立。 + +--- + +### 探针绑定 · 弹窗筛选货位 + +#### 功能验证点 + +##### 【P0】【TC-UI-WH-PRB-015】【TP_WH_PRB_035】按当前库房筛选货位 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开绑定弹窗,记录默认 `${sampleModalWh}` + 2. 点「查 询」 + 3. 断言可见行「当前库房」与所选库房一致(有数据时) + 4. 点「取 消」 +- 自动化断言:规划与 CSV `WH-PRB-015` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +弹窗库房条件生效。 + +--- + +##### 【P1】【TC-UI-WH-PRB-016】【TP_WH_PRB_038】弹窗内重置 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开绑定弹窗,填写货位查询关键字后点「重 置」 + 2. 断言货位查询条件恢复,列表按弹窗默认规则刷新 + 3. 点「取 消」 +- 自动化断言:规划与 CSV `WH-PRB-016` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +弹窗重置不影响主列表筛选。 + +--- + +### 探针绑定 · 勾选货位并保存绑定 + +#### 功能验证点 + +##### 【P1】【TC-UI-WH-PRB-017】【TP_WH_PRB_043】勾选后确定计数增加再取消勾选 + +- 前置条件:弹窗货位列表至少 1 条 +- 操作步骤: + 1. 打开绑定弹窗,勾选 1 个货位 + 2. 断言确定按钮计数为 1 + 3. 取消该勾选,断言计数回到 0 + 4. 点「取 消」,不保存 +- 自动化断言:规划与 CSV `WH-PRB-017` 及 `tests/spd/warehouse-probe.spec.ts` 一致 + +###### 预期结果 + +计数随勾选变化;取消勾选不落库。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **17** 条(P0=10 / P1=7) | +| 来源 | 从 49 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 真实勾选确定绑定、换绑/占用规则落库、接口失败、会话过期、无菜单权限 | +| 单一职责 | 每条只断言一个核心业务结果 | +| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6f551c9fc7f0b4bed41b81989d4d51b0c82a13ea --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,387 @@ +# 基础管理-手术套包设置-基础包维护 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-手术套包设置-基础包维护-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/operationSetting/baseMaintenance` +**定位器 YAML**:`locators/spd/surgery-base-pkg.locator.yaml` +**元素扫描**:`data/scan/surgery-base-pkg-elements.json`(87 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-001.md` +**版本**:2026-10-13 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/surgery-base-pkg.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效(已通过 `auth.setup` 写入,用例内不重复登录);测试账号具备「基础设置 → 手术套包设置 → 基础包维护」权限。 +**测试数据**:正例复用 `auth.setup` 写入的登录态;反例使用 mock 数据(无匹配关键字 `__NOT_EXIST_BASE__99999`、空必填项),不依赖真实业务数据。 +**页面约定**:整页渲染,**无报表 iframe**;左右分栏(左分类树 + 右列表)。 +**菜单导航**:`page.getByRole('menuitem', { name: '基础包维护', exact: true })`(注意与「术种维护」「标准套包维护」区分)。 +**写操作**:基础包名称使用 `${uniqPkgName}`(建议 `AUTO-BASE-${timestamp}`),包编码使用 `${uniqPkgCode}`;用例结束按环境约定清理,禁止写死生产基础包。 + +**动态取数约定**: +1. 打开页面后从左树读取 `${sampleCategoryName}`,从右侧首行读取 `${sampleProductCode}`、`${sampleProductName}`、`${sampleSpecModel}`、`${sampleMfrsName}`。 +2. 筛选关键字使用样本占位,**禁止写死**产品/注册人名称;mock 无匹配值:`__NOT_EXIST_BASE__99999`。 +3. 左侧分类搜索框 `categorySearchInput`、分类弹窗名称框 `basePkgCategoryName`(`#form_item_kindName`)。 + +--- + +## 基础设置 · 手术套包设置 + +### 基础包维护 · 左侧分类树 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-BASE-001】【TP_SURG_BASE_009】分类名称搜索过滤 + +- 前置条件:左侧树至少 1 个非空分类节点 +- 操作步骤: + 1. 从树读取 `${sampleCategoryName}` + 2. 在 `categorySearchInput` 填入该关键字(即时过滤或回车,以现网为准) + 3. 断言树节点文本均包含该关键字 +- 自动化断言:规划与 CSV `SURG-BASE-001` 一致 + +###### 预期结果 + +过滤后树节点名称与关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-002】【TP_SURG_BASE_010】选择分类联动右侧列表 + +- 前置条件:左侧树至少 1 个分类节点,右侧有数据 +- 操作步骤: + 1. 点击 `${sampleCategoryName}` 节点 + 2. 等待右侧列表刷新稳定 + 3. 断言列表首屏加载无报错,分页「共 N 条」与当前分类规模一致 +- 自动化断言:规划与 CSV `SURG-BASE-002` 一致 + +###### 预期结果 + +右侧仅展示该分类下基础包;总数与筛选一致。 + +--- + +##### 【P1】【TC-UI-SURG-BASE-003】【TP_SURG_BASE_012】打开新增分类弹窗 + +- 前置条件:已进入基础包维护页 +- 操作步骤: + 1. 点击「新增分类」 + 2. 断言弹窗可见,含分类名称输入(`basePkgCategoryName`)与「取 消」「确 认」 + 3. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `SURG-BASE-003` 一致 + +###### 预期结果 + +新增分类弹窗字段完整;取消可关闭。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-BASE-004】【TP_SURG_BASE_013】分类搜索清空恢复全量 + +- 前置条件:已用 `${sampleCategoryName}` 过滤 +- 操作步骤: + 1. 记录过滤后节点数 + 2. 清空 `categorySearchInput` + 3. 断言节点数回到未过滤的默认集合(或不少于过滤后节点数) +- 自动化断言:规划与 CSV `SURG-BASE-004` 一致 + +###### 预期结果 + +清空后分类树恢复默认集合。 + +--- + +### 基础包维护 · 右侧筛选与列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-BASE-005】【TP_SURG_BASE_017】默认列出基础包或空态明确 + +- 前置条件:已进入基础包维护页 +- 操作步骤: + 1. 等待列表稳定 + 2. 断言出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `SURG-BASE-005` 一致 + +###### 预期结果 + +加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-006】【TP_SURG_BASE_018】产品名称筛选过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 在 `productName`(`#table_search_form_keyword`)填入该关键字 + 3. 点击「查 询」,断言结果行产品名称均包含该关键字 +- 自动化断言:规划与 CSV `SURG-BASE-006` 一致 + +###### 预期结果 + +过滤结果与关键字匹配;「共 N 条」随之更新。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-007】【TP_SURG_BASE_019】规格型号筛选过滤 + +- 前置条件:默认列表至少 1 条数据且含规格型号 +- 操作步骤: + 1. 从首行读取 `${sampleSpecModel}` + 2. 在 `specModel`(`#table_search_form_goodsSpec`)填入该关键字 + 3. 点击「查 询」,断言结果行规格型号均包含该关键字 +- 自动化断言:规划与 CSV `SURG-BASE-007` 一致 + +###### 预期结果 + +按规格型号过滤生效。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-008】【TP_SURG_BASE_020】器械注册人筛选过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleMfrsName}` + 2. 在 `manufacturer`(`#table_search_form_goodsMfrsName`)填入该关键字 + 3. 点击「查 询」,断言结果行器械注册人均包含该关键字 +- 自动化断言:规划与 CSV `SURG-BASE-008` 一致 + +###### 预期结果 + +按器械注册人过滤生效。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-009】【TP_SURG_BASE_022】列表核心列完整 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 断言表头包含序号、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作 +- 自动化断言:规划与 CSV `SURG-BASE-009` 一致 + +###### 预期结果 + +核心列均可见,操作列含「编 辑」「移 除」。 + +--- + +##### 【P1】【TC-UI-SURG-BASE-010】【TP_SURG_BASE_023】查询与重置 + +- 前置条件:已用关键字过滤 +- 操作步骤: + 1. 点击「重 置」 + 2. 断言筛选输入框被清空、列表恢复默认集合 +- 自动化断言:规划与 CSV `SURG-BASE-010` 一致 + +###### 预期结果 + +重置清空筛选并恢复默认列表。 + +--- + +##### 【P1】【TC-UI-SURG-BASE-011】【TP_SURG_BASE_024】分页与页大小切换 + +- 前置条件:默认列表总数大于单页行数上限 +- 操作步骤: + 1. 切换每页条数(如 50 → 100) + 2. 断言单页行数不超过所选值,分页总数不变 +- 自动化断言:规划与 CSV `SURG-BASE-011` 一致 + +###### 预期结果 + +页大小切换生效;总数保持稳定。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-BASE-012】【TP_SURG_BASE_026】首页上一页不可用 + +- 前置条件:已进入第 1 页 +- 操作步骤: + 1. 断言「上一页」置灰或点击无效果 +- 自动化断言:规划与 CSV `SURG-BASE-012` 一致 + +###### 预期结果 + +第 1 页无法回到上一页。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-SURG-BASE-013】【TP_SURG_BASE_028】搜索无匹配空态 + +- 前置条件:已进入基础包维护页 +- 操作步骤: + 1. 在 `productName` 输入不存在关键字 `__NOT_EXIST_BASE__99999` + 2. 点击「查 询」 + 3. 断言「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `SURG-BASE-013` 一致 + +###### 预期结果 + +无匹配时空态明确,不白屏。 + +--- + +### 基础包维护 · 新增基础包 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-BASE-014】【TP_SURG_BASE_030】打开新增基础包弹窗 + +- 前置条件:已进入基础包维护页 +- 操作步骤: + 1. 点击「新增基础包」 + 2. 断言弹窗可见,含基础包名称(`basePkgName`)、所属分类(`basePkgCategory`)、包编码(`basePkgCode`)、备注(`remark`) + 3. 断言含「取 消」「确 认」 +- 自动化断言:规划与 CSV `SURG-BASE-014` 一致 + +###### 预期结果 + +新增基础包弹窗字段完整(名称/所属分类/包编码/备注)。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-015】【TP_SURG_BASE_034】新增取消不落库 + +- 前置条件:已进入基础包维护页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` + 2. 打开新增,填写名称 `${uniqPkgName}` 后点「取 消」 + 3. 断言列表总数不变,且无该名称行 +- 自动化断言:规划与 CSV `SURG-BASE-015` 一致 + +###### 预期结果 + +取消后不新增基础包。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-016】【TP_SURG_BASE_031】合法必填新增成功 + +- 前置条件:允许写基础包;名称 `${uniqPkgName}`、编码 `${uniqPkgCode}` +- 操作步骤: + 1. 打开新增,填写名称、所属分类、包编码 + 2. 点击「确 认」 + 3. 断言弹窗关闭,列表出现该名称行且字段一致 + 4. 后置:按环境约定清理该基础包,避免污染主数据 +- 自动化断言:规划与 CSV `SURG-BASE-016` 一致;环境禁止写则 skip + +###### 预期结果 + +新增成功且列表可见;用例结束清理,不留生产脏数据。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-BASE-017】【TP_SURG_BASE_037】基础包名称为空拦截 + +- 前置条件:已打开新增基础包弹窗 +- 操作步骤: + 1. 名称留空,补齐所属分类与包编码后点「确 认」 + 2. 断言必填提示,弹窗仍在,列表无新行 +- 自动化断言:规划与 CSV `SURG-BASE-017` 一致 + +###### 预期结果 + +确定被拦截;不落库。 + +--- + +##### 【P0】【TC-UI-SURG-BASE-018】【TP_SURG_BASE_038】所属分类为空拦截 + +- 前置条件:已打开新增基础包弹窗 +- 操作步骤: + 1. 填写名称与包编码,不选所属分类,点「确 认」 + 2. 断言必填提示,弹窗仍在 +- 自动化断言:规划与 CSV `SURG-BASE-018` 一致 + +###### 预期结果 + +确定被拦截;不落库。 + +--- + +##### 【P1】【TC-UI-SURG-BASE-019】【TP_SURG_BASE_040】重复点击确定防重 + +- 前置条件:已打开新增弹窗并填写合法数据 +- 操作步骤: + 1. 快速连点「确 认」两次 + 2. 断言只产生一条基础包(或无重复名称/编码行) +- 自动化断言:规划与 CSV `SURG-BASE-019` 一致;环境禁止写则 skip + +###### 预期结果 + +连点只落一条;无重复脏数据。 + +--- + +### 基础包维护 · 行内操作(编辑 / 移除) + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-BASE-020】【TP_SURG_BASE_043】行内编辑回填 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductCode}` + 2. 点击行内「编 辑」 + 3. 断言编辑弹窗可见且回填当前行数据 + 4. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `SURG-BASE-020` 一致 + +###### 预期结果 + +编辑弹窗回填正确;取消不影响数据。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-BASE-021】【TP_SURG_BASE_046】移除二次确认防误删 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 记录当前总数为 `${sampleTotal}` + 2. 点击首行「移 除」 + 3. 断言出现确认框,点击「取 消」 + 4. 断言总数仍为 `${sampleTotal}` +- 自动化断言:规划与 CSV `SURG-BASE-021` 一致 + +###### 预期结果 + +移除有二次确认;取消不删除。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-SURG-BASE-022】【TP_SURG_BASE_042】新增基础包可在标准套包被引用 + +- 前置条件:已新增 `${uniqPkgName}` 且未清理 +- 操作步骤: + 1. 进入「标准套包维护」,点击「添加基础包」 + 2. 在弹窗搜索框输入 `${uniqPkgName}` + 3. 断言可检索到该基础包并可勾选 + 4. 后置:清理该基础包 +- 自动化断言:规划与 CSV `SURG-BASE-022` 一致;环境禁止写则 skip + +###### 预期结果 + +新增基础包在标准套包可选;跨页数据一致。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **22** 条(P0=14 / P1=6 / P2=2) | +| 来源 | 从 49 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录权限矩阵、分类增删主数据联动、编辑保存落库、被套包引用时移除策略、列表接口异常注入 | +| 数据隔离 | 新增必须用唯一名称/编码并清理;编辑/移除不改生产主数据 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..5e5705409afa20cf87d91602c4a4d63bb755f61e --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,438 @@ +# 基础管理-手术套包设置-术种维护 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-手术套包设置-术种维护-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/operationSetting/typeMaintenance` +**定位器 YAML**:`locators/spd/surgery-oper-type.locator.yaml` +**元素扫描**:`data/scan/surgery-oper-type-elements.json`(69 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-002.md` +**版本**:2026-10-13 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/surgery-oper-type.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效(已通过 `auth.setup` 写入,用例内不重复登录);测试账号具备「基础设置 → 手术套包设置 → 术种维护」权限。 +**测试数据**:正例复用 `auth.setup` 写入的登录态;反例使用 mock 数据(无匹配关键字 `__NOT_EXIST_OPER__99999`、空必填项),不依赖真实业务数据。 +**页面约定**:整页渲染,**无报表 iframe**;左右分栏(左术种分类树 + 右筛选/状态 Tab/列表)。 +**菜单导航**:`page.getByRole('menuitem', { name: '术种维护', exact: true })`(注意与「基础包维护」「标准套包维护」区分)。 +**写操作**:术种名称使用 `${uniqOperName}`(建议 `AUTO-OPER-${timestamp}`);停用为写操作,用例结束按环境约定恢复/清理,禁止写死生产术种。 + +**动态取数约定**: +1. 打开页面后从左树读取 `${sampleOperCategoryName}`,从右侧首行读取 `${sampleOperName}`、`${sampleDiseaseName}`、`${sampleIcd10Code}`、`${sampleIcd9Code}`。 +2. 筛选关键字使用样本占位,**禁止写死**术种/病种名称;mock 无匹配值:`__NOT_EXIST_OPER__99999`。 +3. 状态 Tab 定位:`tab`(全部)/「停用」/「未建分类」,切换后断言括号计数与列表规模一致。 +4. 分类搜索框 `operTypeCategoryInput`;分类维护弹窗名称框 `operTypeCategoryName`(`#basic_kindName`)。 + +--- + +## 基础设置 · 手术套包设置 + +### 术种维护 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-001】【TP_SURG_TYPE_001】菜单进入术种维护页 + +- 前置条件:已登录且具备手术套包设置权限 +- 操作步骤: + 1. 按菜单「基础设置 → 手术套包设置 → 术种维护」进入 + 2. 断言 URL 含 `/spd/basic/operationSetting/typeMaintenance` + 3. 断言左侧分类树(`operTypeCategoryInput`)、右侧筛选区、状态 Tab、列表与分页可见 +- 自动化断言:规划与 CSV `SURG-TYPE-001` 及 `tests/spd/surgery-oper-type.spec.ts` 一致 + +###### 预期结果 + +进入术种维护页;不误入基础包维护/标准套包维护;左右分栏渲染完整。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-002】【TP_SURG_TYPE_003】同组页签切换后返回 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 点击页内 Tab 「标准套包维护」,等待 URL 切换 + 2. 返回点击 Tab 「术种维护」 + 3. 断言 URL 回到 `typeMaintenance`,左树与状态 Tab 重新渲染 +- 自动化断言:规划与 CSV `SURG-TYPE-002` 一致 + +###### 预期结果 + +三页签互切正常;返回后本页数据恢复。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-TYPE-003】【TP_SURG_TYPE_005】未登录访问拦截 + +- 前置条件:`test.use({ storageState: { cookies: [], origins: [] } })` 无登录态 +- 操作步骤: + 1. 直接访问 `/spd/basic/operationSetting/typeMaintenance` + 2. 断言跳转登录页或不展示术种列表数据 +- 自动化断言:规划与 CSV `SURG-TYPE-003` 一致 + +###### 预期结果 + +未登录被拦截,不泄露术种数据。 + +--- + +### 术种维护 · 左侧术种分类树 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-004】【TP_SURG_TYPE_009】术种分类名称搜索过滤 + +- 前置条件:左侧树至少 1 个非空分类节点 +- 操作步骤: + 1. 从树读取 `${sampleOperCategoryName}` + 2. 在 `operTypeCategoryInput` 填入该关键字(即时过滤或回车,以现网为准) + 3. 断言树节点文本均包含该关键字 +- 自动化断言:规划与 CSV `SURG-TYPE-004` 一致 + +###### 预期结果 + +过滤后树节点名称与关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-SURG-TYPE-005】【TP_SURG_TYPE_010】选择分类联动右侧列表 + +- 前置条件:左侧树至少 1 个分类节点,右侧有数据 +- 操作步骤: + 1. 点击 `${sampleOperCategoryName}` 节点 + 2. 等待右侧列表刷新稳定 + 3. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致 +- 自动化断言:规划与 CSV `SURG-TYPE-005` 一致 + +###### 预期结果 + +右侧仅展示该分类下术种;总数与筛选一致。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-006】【TP_SURG_TYPE_011】分类树计数与下钻 + +- 前置条件:至少 1 个带计数的分类节点 +- 操作步骤: + 1. 读取某分类节点括号内计数 `${nodeCount}` + 2. 点击该节点,断言右侧「共 N 条」与 `${nodeCount}` 一致 +- 自动化断言:规划与 CSV `SURG-TYPE-006` 一致 + +###### 预期结果 + +分类计数与该分类下术种条数一致。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-007】【TP_SURG_TYPE_013】打开分类维护弹窗 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 点击左侧「新增」 + 2. 断言弹窗可见,含分类名称输入(`operTypeCategoryName`)与「取 消」「保 存」 + 3. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `SURG-TYPE-007` 一致 + +###### 预期结果 + +分类维护弹窗字段完整;取消可关闭。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-TYPE-008】【TP_SURG_TYPE_014】分类搜索清空恢复全量 + +- 前置条件:已用 `${sampleOperCategoryName}` 过滤 +- 操作步骤: + 1. 记录过滤后节点数 + 2. 清空 `operTypeCategoryInput` + 3. 断言节点数回到未过滤的默认集合 +- 自动化断言:规划与 CSV `SURG-TYPE-008` 一致 + +###### 预期结果 + +清空后分类树恢复默认集合。 + +--- + +### 术种维护 · 筛选与状态 Tab + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-009】【TP_SURG_TYPE_018】术种名称筛选过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleOperName}` + 2. 在 `operationName`(`#table_search_form_operationName`)填入该关键字 + 3. 点击「查 询」,断言结果行术种名称均包含该关键字 +- 自动化断言:规划与 CSV `SURG-TYPE-009` 一致 + +###### 预期结果 + +过滤结果与关键字匹配;「共 N 条」随之更新。 + +--- + +##### 【P0】【TC-UI-SURG-TYPE-010】【TP_SURG_TYPE_019】病种名称筛选过滤 + +- 前置条件:默认列表至少 1 条数据且含病种名称 +- 操作步骤: + 1. 从首行读取 `${sampleDiseaseName}` + 2. 在 `diseaseName`(`#table_search_form_disease`)填入该关键字 + 3. 点击「查 询」,断言结果行病种名称均包含该关键字 +- 自动化断言:规划与 CSV `SURG-TYPE-010` 一致 + +###### 预期结果 + +按病种名称过滤生效。 + +--- + +##### 【P0】【TC-UI-SURG-TYPE-011】【TP_SURG_TYPE_020】状态 Tab 切换与计数一致 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 读取「全部」Tab 括号计数 `${totalCount}` + 2. 依次点击「停用」「未建分类」「全部」 + 3. 每次断言列表条数与「共 N 条」和该 Tab 括号计数一致 +- 自动化断言:规划与 CSV `SURG-TYPE-011` 一致 + +###### 预期结果 + +Tab 切换后列表仅展示对应状态;括号计数与结果规模一致。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-012】【TP_SURG_TYPE_022】左树 + 筛选 + Tab 组合过滤 + +- 前置条件:左侧至少 1 个分类节点、右侧至少 1 条数据 +- 操作步骤: + 1. 选择 `${sampleOperCategoryName}` 节点 + 2. 填入术种名称关键字并点「查 询」 + 3. 切到「全部」Tab,断言结果为三者交集且无未捕获异常 +- 自动化断言:规划与 CSV `SURG-TYPE-012` 一致 + +###### 预期结果 + +三条件叠加后结果正确,总数一致。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-013】【TP_SURG_TYPE_021】重置清空筛选 + +- 前置条件:已用关键字过滤 +- 操作步骤: + 1. 点击「重 置」 + 2. 断言筛选输入框被清空、列表恢复默认集合 +- 自动化断言:规划与 CSV `SURG-TYPE-013` 一致 + +###### 预期结果 + +重置清空筛选并恢复默认列表。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-TYPE-014】【TP_SURG_TYPE_023】无匹配组合空态 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 在 `operationName` 输入不存在关键字 `__NOT_EXIST_OPER__99999` + 2. 点击「查 询」 + 3. 断言「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `SURG-TYPE-014` 一致 + +###### 预期结果 + +无匹配时空态明确,不白屏。 + +--- + +### 术种维护 · 列表与编码展示 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-015】【TP_SURG_TYPE_027】列表核心列完整 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 断言表头包含序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作 +- 自动化断言:规划与 CSV `SURG-TYPE-015` 一致 + +###### 预期结果 + +核心列均可见,操作列含「编 辑」「停用/启用」。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-016】【TP_SURG_TYPE_028】ICD-10 诊断编码展示完整 + +- 前置条件:列表中至少 1 条含诊断编码的数据 +- 操作步骤: + 1. 读取含 ICD-10 编码的行 `${sampleIcd10Code}` + 2. 断言编码文本完整(如 `A02.000x010`)不截断、无乱码 +- 自动化断言:规划与 CSV `SURG-TYPE-016` 一致 + +###### 预期结果 + +ICD-10 编码完整展示。 + +--- + +##### 【P2】【TC-UI-SURG-TYPE-017】【TP_SURG_TYPE_029】ICD-9-CM-3 操作编码展示完整 + +- 前置条件:列表中至少 1 条含操作编码的数据 +- 操作步骤: + 1. 读取含操作编码的行 `${sampleIcd9Code}` + 2. 断言操作编码(如 `00.0200`)与操作名称(如 `心脏治疗性超声`)对应展示无乱码 +- 自动化断言:规划与 CSV `SURG-TYPE-017` 一致 + +###### 预期结果 + +ICD-9-CM-3 编码与操作名称对应正确。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-018】【TP_SURG_TYPE_030】分页与页大小切换 + +- 前置条件:默认列表总数大于单页行数上限 +- 操作步骤: + 1. 切换每页条数(如 50 → 100) + 2. 断言单页行数不超过所选值,分页总数不变 +- 自动化断言:规划与 CSV `SURG-TYPE-018` 一致 + +###### 预期结果 + +页大小切换生效;总数保持稳定。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-TYPE-019】【TP_SURG_TYPE_032】首页上一页不可用 + +- 前置条件:已进入第 1 页 +- 操作步骤: + 1. 断言「上一页」置灰或点击无效果 +- 自动化断言:规划与 CSV `SURG-TYPE-019` 一致 + +###### 预期结果 + +第 1 页无法回到上一页。 + +--- + +### 术种维护 · 新增术种 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-020】【TP_SURG_TYPE_035】打开新增术种入口 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 点击「新增术种」 + 2. 断言新增弹窗/页面可见,含术种名称、病种名称、分类与编码相关字段 + 3. 断言含「取 消」「保 存」 +- 自动化断言:规划与 CSV `SURG-TYPE-020` 一致 + +###### 预期结果 + +新增术种入口字段完整。 + +--- + +##### 【P0】【TC-UI-SURG-TYPE-021】【TP_SURG_TYPE_038】新增取消不落库 + +- 前置条件:已进入术种维护页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` + 2. 打开新增,填写名称 `${uniqOperName}` 后点「取 消」 + 3. 断言列表总数不变,且无该名称行 +- 自动化断言:规划与 CSV `SURG-TYPE-021` 一致 + +###### 预期结果 + +取消后不新增术种。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-TYPE-022】【TP_SURG_TYPE_040】术种名称为空拦截 + +- 前置条件:已打开新增术种弹窗 +- 操作步骤: + 1. 名称留空,点「保 存」 + 2. 断言必填提示,弹窗仍在,列表无新行 +- 自动化断言:规划与 CSV `SURG-TYPE-022` 一致 + +###### 预期结果 + +保存被拦截;不落库。 + +--- + +### 术种维护 · 行内操作(编辑 / 停用) + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-TYPE-023】【TP_SURG_TYPE_045】行内编辑回填 + +- 前置条件:列表至少 1 条数据 +- 操作步骤: + 1. 点击首行「编 辑」 + 2. 断言编辑弹窗可见且回填当前术种数据 + 3. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `SURG-TYPE-023` 一致 + +###### 预期结果 + +编辑弹窗回填正确;取消不影响数据。 + +--- + +##### 【P1】【TC-UI-SURG-TYPE-024】【TP_SURG_TYPE_046】停用状态流转 + +- 前置条件:列表中存在启用状态术种(优先选 `${uniqOperName}` 自建数据) +- 操作步骤: + 1. 记录「停用」Tab 当前计数 `${disabledCount}` + 2. 点击目标行「停用」,若有确认框则点「确 定」 + 3. 断言「停用」Tab 计数 +1 + 4. 后置:对该术种执行「启用」恢复 +- 自动化断言:规划与 CSV `SURG-TYPE-024` 一致;环境禁止写则 skip + +###### 预期结果 + +术种状态变为停用;「停用」计数 +1;结束恢复原状态。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-TYPE-025】【TP_SURG_TYPE_049】停用二次确认防误操作 + +- 前置条件:列表中存在启用状态术种 +- 操作步骤: + 1. 记录「停用」Tab 当前计数 + 2. 点击首行「停用」 + 3. 若出现确认框则点「取 消」 + 4. 断言计数未变化 +- 自动化断言:规划与 CSV `SURG-TYPE-025` 一致 + +###### 预期结果 + +停用有二次确认;取消不改状态。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **25** 条(P0=13 / P1=8 / P2=4) | +| 来源 | 从 53 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录权限矩阵、分类新增落库、新增术种保存落库、ICD 字典一致性、停用后下游不可选联调 | +| 数据隔离 | 新增/停用使用唯一名称并恢复/清理;不改生产术种状态 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..d121bdc45cfb6acde5742f996372154df0f30123 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,496 @@ +# 基础管理-手术套包设置-标准套包维护 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-手术套包设置-标准套包维护-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/operationSetting/standardMaintenance` +**定位器 YAML**:`locators/spd/surgery-std-pkg.locator.yaml` +**元素扫描**:`data/scan/surgery-std-pkg-elements.json`(61 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-003.md` +**版本**:2026-10-13 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/surgery-std-pkg.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:已执行 `auth.setup`,`storageState` 有效(已通过 `auth.setup` 写入,用例内不重复登录);测试账号具备「基础设置 → 手术套包设置 → 标准套包维护」权限。 +**测试数据**:正例复用 `auth.setup` 写入的登录态;反例使用 mock 数据(无匹配关键字 `__NOT_EXIST_STD__99999`、空必填项),不依赖真实业务数据。 +**页面约定**:整页渲染,**无报表 iframe**;左右分栏(左术种分类树 + 右标准套包构成列表);含两级嵌套弹窗。 +**菜单导航**:`page.getByRole('menuitem', { name: '标准套包维护', exact: true })`(注意与「基础包维护」「术种维护」区分)。 +**写操作**:涉及新增/编辑/移除构成项,用例结束按环境约定恢复/清理,禁止写死生产套包。 + +**动态取数约定**: +1. 打开页面后从左树读取 `${sampleOperCategoryName}`,从右侧首行读取 `${samplePkgGroupName}`、`${sampleProductCode}`、`${sampleProductName}`、`${sampleSpecModel}`、`${sampleMfrsName}`。 +2. 筛选关键字使用样本占位,**禁止写死**产品/注册人名称;mock 无匹配值:`__NOT_EXIST_STD__99999`。 +3. 构成列表按「包名称」分组,操作前需先**展开目标包**(`expandFilterBtn` / 包名称展开节点);断言时避免全量断言。 +4. 弹窗:添加基础包 `addBasePkgSearchInput`(`请输入分类/基础包名称`)与数量步进器;添加散件 `stdPkgTreeSearchInput`(`请输入术种分类名称、术种名称、标准套包名称`)、应拣明细统计耗材品种数/耗材总数。 + +--- + +## 基础设置 · 手术套包设置 + +### 标准套包维护 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-001】【TP_SURG_STD_001】菜单进入标准套包维护页 + +- 前置条件:已登录且具备手术套包设置权限 +- 操作步骤: + 1. 按菜单「基础设置 → 手术套包设置 → 标准套包维护」进入 + 2. 断言 URL 含 `/spd/basic/operationSetting/standardMaintenance` + 3. 断言左侧分类树、右侧筛选区、构成列表与分页可见 +- 自动化断言:规划与 CSV `SURG-STD-001` 及 `tests/spd/surgery-std-pkg.spec.ts` 一致 + +###### 预期结果 + +进入标准套包维护页;不误入基础包维护/术种维护;左右分栏渲染完整。 + +--- + +##### 【P1】【TC-UI-SURG-STD-002】【TP_SURG_STD_003】同组页签切换后返回 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 点击页内 Tab 「术种维护」,等待 URL 切换 + 2. 返回点击 Tab 「标准套包维护」 + 3. 断言 URL 回到 `standardMaintenance`,左树与构成列表重新渲染 +- 自动化断言:规划与 CSV `SURG-STD-002` 一致 + +###### 预期结果 + +三页签互切正常;返回后本页数据恢复。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-STD-003】【TP_SURG_STD_005】未登录访问拦截 + +- 前置条件:`test.use({ storageState: { cookies: [], origins: [] } })` 无登录态 +- 操作步骤: + 1. 直接访问 `/spd/basic/operationSetting/standardMaintenance` + 2. 断言跳转登录页或不展示套包列表数据 +- 自动化断言:规划与 CSV `SURG-STD-003` 一致 + +###### 预期结果 + +未登录被拦截,不泄露套包数据。 + +--- + +### 标准套包维护 · 左侧术种分类树 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-004】【TP_SURG_STD_009】术种分类名称搜索过滤 + +- 前置条件:左侧树至少 1 个非空分类节点 +- 操作步骤: + 1. 从树读取 `${sampleOperCategoryName}` + 2. 在分类搜索框填入该关键字(即时过滤或回车,以现网为准) + 3. 断言树节点文本均包含该关键字 +- 自动化断言:规划与 CSV `SURG-STD-004` 一致 + +###### 预期结果 + +过滤后树节点名称与关键字匹配;无未捕获异常。 + +--- + +##### 【P0】【TC-UI-SURG-STD-005】【TP_SURG_STD_010】选择分类联动构成列表 + +- 前置条件:左侧树至少 1 个分类节点,右侧有数据 +- 操作步骤: + 1. 点击 `${sampleOperCategoryName}` 节点 + 2. 等待右侧构成列表刷新稳定 + 3. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致 +- 自动化断言:规划与 CSV `SURG-STD-005` 一致 + +###### 预期结果 + +右侧仅展示该分类下标准套包;总数与筛选一致。 + +--- + +##### 【P2】【TC-UI-SURG-STD-006】【TP_SURG_STD_013】分类搜索清空恢复全量 + +- 前置条件:已用 `${sampleOperCategoryName}` 过滤 +- 操作步骤: + 1. 记录过滤后节点数 + 2. 清空分类搜索框 + 3. 断言节点数回到未过滤的默认集合 +- 自动化断言:规划与 CSV `SURG-STD-006` 一致 + +###### 预期结果 + +清空后分类树恢复默认集合。 + +--- + +### 标准套包维护 · 筛选与构成列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-007】【TP_SURG_STD_017】默认展示标准套包构成 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 等待列表稳定 + 2. 断言按「包名称」分组出现数据行,或「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `SURG-STD-007` 一致 + +###### 预期结果 + +加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。 + +--- + +##### 【P0】【TC-UI-SURG-STD-008】【TP_SURG_STD_018】产品名称筛选过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleProductName}` + 2. 在 `productName`(`#table_search_form_keyword`)填入该关键字 + 3. 点击「查 询」,断言结果行产品名称均包含该关键字 +- 自动化断言:规划与 CSV `SURG-STD-008` 一致 + +###### 预期结果 + +过滤结果与关键字匹配;「共 N 条」随之更新。 + +--- + +##### 【P1】【TC-UI-SURG-STD-009】【TP_SURG_STD_019】规格型号筛选过滤 + +- 前置条件:默认列表至少 1 条数据且含规格型号 +- 操作步骤: + 1. 从首行读取 `${sampleSpecModel}` + 2. 在 `specModel`(`#table_search_form_goodsSpec`)填入该关键字 + 3. 点击「查 询」,断言结果行规格型号均包含该关键字 +- 自动化断言:规划与 CSV `SURG-STD-009` 一致 + +###### 预期结果 + +按规格型号过滤生效。 + +--- + +##### 【P1】【TC-UI-SURG-STD-010】【TP_SURG_STD_020】器械注册人筛选过滤 + +- 前置条件:默认列表至少 1 条数据 +- 操作步骤: + 1. 从首行读取 `${sampleMfrsName}` + 2. 在 `manufacturer`(`#table_search_form_goodsMfrsName`)填入该关键字 + 3. 点击「查 询」,断言结果行器械注册人均包含该关键字 +- 自动化断言:规划与 CSV `SURG-STD-010` 一致 + +###### 预期结果 + +按器械注册人过滤生效。 + +--- + +##### 【P0】【TC-UI-SURG-STD-011】【TP_SURG_STD_022】构成列表核心列完整 + +- 前置条件:默认列表至少 1 条数据且存在分组 +- 操作步骤: + 1. 断言表头/首行包含包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作 +- 自动化断言:规划与 CSV `SURG-STD-011` 一致 + +###### 预期结果 + +核心列均可见,操作列含「编 辑」「移 除」。 + +--- + +##### 【P0】【TC-UI-SURG-STD-012】【TP_SURG_STD_023】包名称展开/收起子项 + +- 前置条件:列表中存在可展开的包分组 +- 操作步骤: + 1. 读取首个包分组名称 `${samplePkgGroupName}` + 2. 点击展开该分组,断言出现子项行 + 3. 再次点击收起,断言子项行隐藏 +- 自动化断言:规划与 CSV `SURG-STD-012` 一致 + +###### 预期结果 + +展开显示子项、收起隐藏子项;分组计数不受影响。 + +--- + +##### 【P1】【TC-UI-SURG-STD-013】【TP_SURG_STD_024】查询与重置 + +- 前置条件:已用关键字过滤 +- 操作步骤: + 1. 点击「重 置」 + 2. 断言筛选输入框被清空、列表恢复默认集合 +- 自动化断言:规划与 CSV `SURG-STD-013` 一致 + +###### 预期结果 + +重置清空筛选并恢复默认列表。 + +--- + +##### 【P1】【TC-UI-SURG-STD-014】【TP_SURG_STD_025】分页与页大小切换 + +- 前置条件:默认列表总数大于单页行数上限 +- 操作步骤: + 1. 切换每页条数(如 50 → 100) + 2. 断言单页行数不超过所选值,分页总数不变 +- 自动化断言:规划与 CSV `SURG-STD-014` 一致 + +###### 预期结果 + +页大小切换生效;总数保持稳定。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-STD-015】【TP_SURG_STD_027】无匹配组合空态 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 在 `productName` 输入不存在关键字 `__NOT_EXIST_STD__99999` + 2. 点击「查 询」 + 3. 断言「暂无数据」且分页总数为 0 +- 自动化断言:规划与 CSV `SURG-STD-015` 一致 + +###### 预期结果 + +无匹配时空态明确,不白屏。 + +--- + +### 标准套包维护 · 添加基础包弹窗 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-016】【TP_SURG_STD_032】打开添加基础包弹窗 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 点击「添加基础包」 + 2. 断言弹窗可见,含左侧基础包分类树与右侧基础包明细 + 3. 断言含「取 消」「确定(N)」 +- 自动化断言:规划与 CSV `SURG-STD-016` 一致 + +###### 预期结果 + +添加基础包弹窗字段完整;确定按钮带选择计数。 + +--- + +##### 【P0】【TC-UI-SURG-STD-017】【TP_SURG_STD_033】弹窗内筛选与选择计数联动 + +- 前置条件:已打开添加基础包弹窗 +- 操作步骤: + 1. 在 `addBasePkgSearchInput` 输入基础包关键字 + 2. 勾选 1 个基础包 + 3. 断言「确定(N)」计数由 0 变为 1 +- 自动化断言:规划与 CSV `SURG-STD-017` 一致 + +###### 预期结果 + +筛选生效;选择数与确定按钮计数联动。 + +--- + +##### 【P1】【TC-UI-SURG-STD-018】【TP_SURG_STD_034】弹窗内构成数量可编辑 + +- 前置条件:已勾选至少 1 个基础包 +- 操作步骤: + 1. 定位明细行「构成数量」步进器,点击「Increase Value」 + 2. 断言数量 +1 且合计随之更新 +- 自动化断言:规划与 CSV `SURG-STD-018` 一致 + +###### 预期结果 + +构成数量可编辑;加减生效。 + +--- + +##### 【P0】【TC-UI-SURG-STD-019】【TP_SURG_STD_036】添加基础包取消不落库 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` + 2. 打开添加基础包,勾选 1 个基础包后点「取 消」 + 3. 断言构成列表总数不变 +- 自动化断言:规划与 CSV `SURG-STD-019` 一致 + +###### 预期结果 + +取消后不新增构成行。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-STD-020】【TP_SURG_STD_037】确定按钮零选禁用 + +- 前置条件:已打开添加基础包弹窗且未选择 +- 操作步骤: + 1. 断言「确定(0)」置灰或点击无效果 +- 自动化断言:规划与 CSV `SURG-STD-020` 一致 + +###### 预期结果 + +未选择时确定不可用。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-SURG-STD-021】【TP_SURG_STD_038】重复添加同一基础包 + +- 前置条件:标准套包已含某基础包(或先添加一个) +- 操作步骤: + 1. 再次打开添加基础包,选择同一基础包并确定 + 2. 断言按产品规则拒绝/合并/累加,不产生重复脏行 +- 自动化断言:规划与 CSV `SURG-STD-021` 一致;环境禁止写则 skip + +###### 预期结果 + +重复添加不产生重复行(策略以现网为准)。 + +--- + +### 标准套包维护 · 添加散件弹窗 + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-022】【TP_SURG_STD_041】打开添加散件弹窗 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 点击「添加散件」 + 2. 断言弹窗可见并展示「产品列表」视图,含产品名称/规格型号/器械注册人/耗材类型筛选与分页 +- 自动化断言:规划与 CSV `SURG-STD-022` 一致 + +###### 预期结果 + +添加散件弹窗产品列表视图完整。 + +--- + +##### 【P0】【TC-UI-SURG-STD-023】【TP_SURG_STD_042】产品列表筛选与选择散件 + +- 前置条件:已打开添加散件弹窗 +- 操作步骤: + 1. 在产品名称筛选框输入 `${sampleProductName}` + 2. 勾选首个匹配散件 + 3. 断言散件进入应拣明细,计数同步变化 +- 自动化断言:规划与 CSV `SURG-STD-023` 一致 + +###### 预期结果 + +筛选生效;选中散件进入应拣明细。 + +--- + +##### 【P0】【TC-UI-SURG-STD-024】【TP_SURG_STD_043】散件规格/应拣明细视图 + +- 前置条件:已勾选至少 1 个散件 +- 操作步骤: + 1. 切换到规格明细/应拣明细视图 + 2. 断言展示耗材品种数、耗材总数,以及应拣数量、产品编号/名称/规格/注册人/单位/耗材类型/是否收费列 +- 自动化断言:规划与 CSV `SURG-STD-024` 一致 + +###### 预期结果 + +应拣明细视图字段完整。 + +--- + +##### 【P1】【TC-UI-SURG-STD-025】【TP_SURG_STD_044】明细统计与实际一致 + +- 前置条件:应拣明细含至少 1 个散件 +- 操作步骤: + 1. 读取「耗材品种数」`${itemKindCount}` 与「耗材总数」`${itemTotalCount}` + 2. 断言品种数 = 明细去重产品数;总数 = 应拣数量合计 +- 自动化断言:规划与 CSV `SURG-STD-025` 一致 + +###### 预期结果 + +统计口径与实际所选一致。 + +--- + +##### 【P0】【TC-UI-SURG-STD-026】【TP_SURG_STD_046】添加散件取消不落库 + +- 前置条件:已进入标准套包维护页 +- 操作步骤: + 1. 记录当前分页总数 `${sampleTotal}` + 2. 打开添加散件,勾选散件后点「取 消」 + 3. 断言构成列表总数不变 +- 自动化断言:规划与 CSV `SURG-STD-026` 一致 + +###### 预期结果 + +取消后不新增散件行。 + +--- + +#### 边界验证点 + +##### 【P2】【TC-UI-SURG-STD-027】【TP_SURG_STD_047】万级散件列表首屏可交互 + +- 前置条件:添加散件产品列表总量达万级(示例「共 16299 条」) +- 操作步骤: + 1. 打开添加散件弹窗 + 2. 断言首屏可交互(筛选/分页可用),不卡死 + 3. 避免全量断言,仅校验首行与总数格式 +- 自动化断言:规划与 CSV `SURG-STD-027` 一致 + +###### 预期结果 + +万级列表首屏可用;无超时白屏。 + +--- + +### 标准套包维护 · 行内操作(编辑 / 移除) + +#### 功能验证点 + +##### 【P0】【TC-UI-SURG-STD-028】【TP_SURG_STD_052】行内编辑构成项 + +- 前置条件:构成列表至少 1 条数据(先展开目标包) +- 操作步骤: + 1. 点击子项行「编 辑」 + 2. 断言编辑弹窗/表单可见并回填当前值 + 3. 点击「取 消」关闭 +- 自动化断言:规划与 CSV `SURG-STD-028` 一致 + +###### 预期结果 + +编辑回填正确;取消不影响数据。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-SURG-STD-029】【TP_SURG_STD_055】移除二次确认防误删整包 + +- 前置条件:构成列表至少 1 条数据(先展开目标包) +- 操作步骤: + 1. 记录当前行数 + 2. 点击子项行「移 除」 + 3. 若出现确认框则点「取 消」 + 4. 断言行数未变化 +- 自动化断言:规划与 CSV `SURG-STD-029` 一致 + +###### 预期结果 + +移除有二次确认;取消不删除。 + +--- + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **29** 条(P0=17 / P1=8 / P2=4) | +| 来源 | 从 57 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录权限矩阵、添加基础包/散件保存落库、散件提交接口失败、编辑保存、跨页套包可用性联调、移除整包 | +| 数据隔离 | 新增/编辑/移除使用样本或唯一数据并恢复/清理;不改生产套包构成 | +| 单一职责 | 每条只断言一个核心业务结果 | +| 特殊说明 | 构成列表按包名称分组,操作前先展开目标包;万级散件列表避免全量断言 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6555adff4f0016c2dc82a68c3be0c6a2f3fd1bd5 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,297 @@ +# 基础管理-流程设置-审批流实例 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-流程设置-审批流实例-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/flowSet/workflow/flowCase` +**定位器 YAML**:`locators/spd/flow-inst.locator.yaml` +**元素扫描**:`data/scan/flow-inst-elements.json`(62 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-FLOW-002.md` +**版本**:2026-10-20 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/flow-inst.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 流程设置 → 审批流实例」权限。反例筛选用 mock。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 流程设置 → 审批流实例」;建议 `openPath('/spd/basic/flowSet/workflow/flowCase')`,不要点到审批流程设置。 +**写操作**:本页只读。详情只打开后返回,不改审批人。断言「共 N 条」前先收窄日期。 +**动态取数**:查询后从首行读取 `${sampleOrderNo}`、`${sampleBizType}`、`${sampleStatus}`、`${sampleSummary}`;mock:`__NOT_EXIST_FLOW__99999`。 + +--- + +## 基础设置 · 流程设置 + +### 审批流实例 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-INS-001】【TP_FLOW_INS_001】菜单进入审批流实例页 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 按菜单「基础设置 → 流程设置 → 审批流实例」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowCase` + 2. 断言 URL 含 `/spd/basic/flowSet/workflow/flowCase` + 3. 断言单据编号、审批状态、发起时间、业务类型、摘要、查询、重置、列表与分页可见 +- 自动化断言:规划与 CSV `FLOW-INS-001` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +进入审批流实例;不误入审批流程设置。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-FLOW-INS-002】【TP_FLOW_INS_005】未登录访问拦截 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowCase` + 2. 断言跳转登录或不展示审批单据列表 +- 自动化断言:规划与 CSV `FLOW-INS-002` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +未登录不可见审批实例。 + +--- + +### 审批流实例 · 筛选与查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-INS-003】【TP_FLOW_INS_009】默认日期区间查询 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行或空态明确,「共 N 条」与结果逻辑一致 +- 自动化断言:规划与 CSV `FLOW-INS-003` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +默认查询无报错;有数据时 N≥1。 + +--- + +##### 【P0】【TC-UI-FLOW-INS-004】【TP_FLOW_INS_010】单据编号精确查询 + +- 前置条件:默认查询至少 1 条且单据编号非空 +- 操作步骤: + 1. 从首行读取 `${sampleOrderNo}` + 2. 单据编号填入完整单号,点击「查 询」 + 3. 断言至多 1 条,且编号、业务类型与该行一致 +- 自动化断言:规划与 CSV `FLOW-INS-004` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +精确单号命中当前行。 + +--- + +##### 【P0】【TC-UI-FLOW-INS-005】【TP_FLOW_INS_011】按审批状态筛选 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 审批状态下拉选择「审批通过」(无该选项则选列表中已出现的状态) + 2. 点击「查 询」 + 3. 断言结果「审批状态」列与所选一致 +- 自动化断言:规划与 CSV `FLOW-INS-005` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +状态筛选生效。 + +--- + +##### 【P0】【TC-UI-FLOW-INS-006】【TP_FLOW_INS_012】按业务类型筛选 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 从首行读取 `${sampleBizType}` + 2. 业务类型选择该值,点击「查 询」 + 3. 断言结果「业务类型」均为该类型 +- 自动化断言:规划与 CSV `FLOW-INS-006` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +业务类型筛选生效。 + +--- + +##### 【P0】【TC-UI-FLOW-INS-007】【TP_FLOW_INS_013】按发起时间区间筛选 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 记录当前默认起止日期 + 2. 点击「查 询」 + 3. 抽样结果行「发起时间」落在起止日内(含边界) +- 自动化断言:规划与 CSV `FLOW-INS-007` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +发起时间落在区间内。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-008】【TP_FLOW_INS_014】按摘要关键字筛选 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 找到摘要非 `--` 的行读取 `${sampleSummary}` 关键字;若全为 `--` 则填 mock 关键字 + 2. 点击「查 询」 + 3. 有关键字命中时摘要包含该关键字;mock 无命中时空态且 `--` 行不被命中 +- 自动化断言:规划与 CSV `FLOW-INS-008` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +非空摘要可检索;`--` 不被非空关键字命中。 + +--- + +##### 【P0】【TC-UI-FLOW-INS-009】【TP_FLOW_INS_015】状态+类型组合查询 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 填写 `${sampleStatus}` 与 `${sampleBizType}` 后点击「查 询」 + 2. 断言抽样行同时满足全部已填条件 +- 自动化断言:规划与 CSV `FLOW-INS-009` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +组合条件同时生效。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-010】【TP_FLOW_INS_016】重置恢复默认 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 填写单据编号等条件后点击「重 置」 + 2. 断言日期回到默认区间,其余条件清空,列表刷新 +- 自动化断言:规划与 CSV `FLOW-INS-010` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +重置后日期与其它条件恢复默认。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-FLOW-INS-011】【TP_FLOW_INS_021】无匹配空态(mock反例) + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 单据编号填入 `__NOT_EXIST_FLOW__99999`,点击「查 询」 + 2. 断言空态且分页总数为 0 +- 自动化断言:规划与 CSV `FLOW-INS-011` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错;总数为 0。 + +--- + +### 审批流实例 · 列表与分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-INS-012】【TP_FLOW_INS_024】列表核心列完整 + +- 前置条件:已进入审批流实例页 +- 操作步骤: + 1. 断言表头含序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作 + 2. 断言行内可见「详情」 +- 自动化断言:规划与 CSV `FLOW-INS-012` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +核心列完整;详情入口可见。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-013】【TP_FLOW_INS_025】单据编号前缀与业务类型对应 + +- 前置条件:默认查询有数据 +- 操作步骤: + 1. 抽样 `PLAN`/`DB`/`GC`/`BUY` 前缀行(有则断言) + 2. 断言 PLAN→采购计划、DB→二级库调拨、GC→产品信息变更、BUY→请领单(以现网枚举为准,缺前缀 skip 该项) +- 自动化断言:规划与 CSV `FLOW-INS-013` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +前缀与类型对应。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-014】【TP_FLOW_INS_028】分页翻至第 2 页 + +- 前置条件:总条数大于一页,否则 skip +- 操作步骤: + 1. 记录第 1 页首行单据编号 + 2. 翻到第 2 页 + 3. 断言数据更换;筛选条件保持 +- 自动化断言:规划与 CSV `FLOW-INS-014` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +翻页成功;筛选不丢失。 + +--- + +### 审批流实例 · 审批详情 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-INS-015】【TP_FLOW_INS_036】打开详情对应当前行 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 记录首行 `${sampleOrderNo}` + 2. 点击「详情」 + 3. 断言打开该单审批过程,标题或单号含 `${sampleOrderNo}` +- 自动化断言:规划与 CSV `FLOW-INS-015` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +详情单号与当前行一致。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-016】【TP_FLOW_INS_038】关闭详情保留列表状态 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 填写单据编号查询后打开详情 + 2. 点返回/关闭 + 3. 断言筛选条件与列表仍是打开前的查询结果 +- 自动化断言:规划与 CSV `FLOW-INS-016` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +返回后条件与页码保持。 + +--- + +##### 【P1】【TC-UI-FLOW-INS-017】【TP_FLOW_INS_037】详情只读 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开首行详情 + 2. 断言无保存审批人/改条件的提交按钮,或此类控件不可用 + 3. 返回列表 +- 自动化断言:规划与 CSV `FLOW-INS-017` 及 `tests/spd/flow-inst.spec.ts` 一致 + +###### 预期结果 + +详情只读;改配置须到审批流程设置。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **17** 条(P0=10 / P1=7) | +| 来源 | 从 41 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 改审批人、接口失败、会话过期、无菜单权限、详情与流程设置节点逐条对照(可另开集成专项) | +| 单一职责 | 每条只断言一个核心业务结果 | +| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..4f0085fb448116cfdef6e6283a5b0d12390ad990 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,256 @@ +# 基础管理-流程设置-审批流程设置 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-流程设置-审批流程设置-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/flowSet/workflow/flowSet` +**定位器 YAML**:`locators/spd/flow-def.locator.yaml` +**元素扫描**:`data/scan/flow-def-elements.json`(43 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-FLOW-001.md` +**版本**:2026-10-20 +**自动化框架**:Playwright + TypeScript(规划 `tests/spd/flow-def.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 流程设置 → 审批流程设置」权限。反例筛选用 mock。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 流程设置 → 审批流程设置」;建议 `openPath('/spd/basic/flowSet/workflow/flowSet')`,与「审批流实例」用 exact 区分。 +**写操作**:禁止点「保 存」提交线上审批路由。新增流程/改节点后必须「重 置」或取消条件配置;必填拦截若需点保存,仅空行拦截成功后立刻重置。 +**动态取数**:从左侧读取 `${sampleBizType}`(如采购计划);自定义行读取 `${sampleFlowName}`、`${sampleCondText}`;mock:`__NOT_EXIST_FLOW__99999`。 + +--- + +## 基础设置 · 流程设置 + +### 审批流程设置 · 页面进入 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-001】【TP_FLOW_DEF_001】菜单进入审批流程设置页 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 按菜单「基础设置 → 流程设置 → 审批流程设置」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowSet` + 2. 断言 URL 含 `/spd/basic/flowSet/workflow/flowSet` + 3. 断言左侧业务类型、右侧默认审批流与自定义审批流、「保 存」「重 置」可见 +- 自动化断言:规划与 CSV `FLOW-DEF-001` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +进入审批流程设置;不误入审批流实例。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-FLOW-DEF-002】【TP_FLOW_DEF_005】未登录访问拦截 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowSet` + 2. 断言跳转登录或不展示审批流配置 +- 自动化断言:规划与 CSV `FLOW-DEF-002` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +未登录不可见审批流配置。 + +--- + +### 审批流程设置 · 左侧业务类型 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-003】【TP_FLOW_DEF_009】业务类型列表可见 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 断言左侧可见采购计划等类型且有当前高亮项 + 2. 读取 `${sampleBizType}` +- 自动化断言:规划与 CSV `FLOW-DEF-003` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +类型列表可见;当前项高亮。 + +--- + +##### 【P0】【TC-UI-FLOW-DEF-004】【TP_FLOW_DEF_010】按流程名称过滤类型 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 在「流程名称」输入 `${sampleBizType}` 关键字 + 2. 断言可见类型名称均匹配关键字 +- 自动化断言:规划与 CSV `FLOW-DEF-004` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +类型过滤与关键字匹配。 + +--- + +##### 【P1】【TC-UI-FLOW-DEF-005】【TP_FLOW_DEF_011】清空关键字恢复全量 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 记录过滤前类型数量 + 2. 输入关键字后再清空 + 3. 断言数量恢复 +- 自动化断言:规划与 CSV `FLOW-DEF-005` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +清空后类型列表恢复全量。 + +--- + +##### 【P0】【TC-UI-FLOW-DEF-006】【TP_FLOW_DEF_012】切换类型刷新右侧配置 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 记录当前类型默认流节点文案 + 2. 点选另一业务类型(仅 1 个则 skip) + 3. 断言右侧默认流/自定义流已刷新,不残留上一类型节点人员 +- 自动化断言:规划与 CSV `FLOW-DEF-006` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +切换类型后右侧与该类型已保存配置一致。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-FLOW-DEF-007】【TP_FLOW_DEF_015】无匹配类型(mock反例) + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 流程名称填入 `__NOT_EXIST_FLOW__99999` + 2. 断言左侧无匹配或空,右侧不把上一类型配置展示为当前成功配置 +- 自动化断言:规划与 CSV `FLOW-DEF-007` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错。 + +--- + +### 审批流程设置 · 默认审批流 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-008】【TP_FLOW_DEF_017】默认审批流节点可见 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 选中「采购计划」(或当前默认类型) + 2. 断言「默认审批流」区域可见节点卡片,含节点/账号/人员三行信息(按卡片容器取文本) +- 自动化断言:规划与 CSV `FLOW-DEF-008` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +默认流节点结构可见;不点保存。 + +--- + +### 审批流程设置 · 自定义审批流 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-009】【TP_FLOW_DEF_022】新增流程增加一行后重置 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 记录当前自定义流行数 + 2. 点击「新增流程」 + 3. 断言行数 +1,新行含流程名称、条件、审批流程、删除 + 4. 点击「重 置」,断言行数回到操作前,不点「保 存」 +- 自动化断言:规划与 CSV `FLOW-DEF-009` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +可新增行;重置放弃未保存行。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-FLOW-DEF-010】【TP_FLOW_DEF_028】空行保存被拦截后重置 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 点击「新增流程」得到空行 + 2. 点击「保 存」 + 3. 断言出现必填拦截提示,配置未按成功态关闭 + 4. 点击「重 置」,放弃该空行 +- 自动化断言:规划与 CSV `FLOW-DEF-010` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +空名称/条件/节点不能落库;拦截后重置恢复。 + +--- + +### 审批流程设置 · 条件配置 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-011】【TP_FLOW_DEF_031】打开条件配置后取消 + +- 前置条件:当前类型至少 1 条自定义流 +- 操作步骤: + 1. 若无自定义行则 skip + 2. 点击首条自定义流条件列「编辑」 + 3. 断言打开「条件配置」 + 4. 点「取 消」,断言行内条件文案与打开前一致 +- 自动化断言:规划与 CSV `FLOW-DEF-011` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +条件配置可打开;取消不改行内条件。 + +--- + +### 审批流程设置 · 保存与重置 + +#### 功能验证点 + +##### 【P0】【TC-UI-FLOW-DEF-012】【TP_FLOW_DEF_038】重置放弃未保存改动 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 点击「新增流程」增加一行 + 2. 点击「重 置」 + 3. 断言自定义流行回到上次保存结果 +- 自动化断言:规划与 CSV `FLOW-DEF-012` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +重置不提交;未保存行消失。 + +--- + +### 审批流程设置 · 左侧业务类型 + +#### 集成验证点 + +##### 【P1】【TC-UI-FLOW-DEF-013】【TP_FLOW_DEF_016】类型与审批流实例业务类型同源 + +- 前置条件:已进入审批流程设置页 +- 操作步骤: + 1. 记录本页左侧类型名称集合(可见范围) + 2. 打开审批流实例,展开「业务类型」下拉 + 3. 断言选项与本页类型同源(允许按权限裁剪,本页有的关键类型如采购计划应能对应) +- 自动化断言:规划与 CSV `FLOW-DEF-013` 及 `tests/spd/flow-def.spec.ts` 一致 + +###### 预期结果 + +两页业务类型同源。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **13** 条(P0=10 / P1=3) | +| 来源 | 从 41 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 真实保存审批链、自定义流持久化、条件保存落库、历史实例快照、接口失败、会话过期 | +| 单一职责 | 每条只断言一个核心业务结果 | +| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..628e87d6b693e8dbdf6edc8a0f6264f591ae9595 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,341 @@ +# 基础管理-用户管理-医共体用户管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-用户管理-医共体用户管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/user/platform/personCom` +**定位器 YAML**:`locators/spd/basic-user-union.locator.yaml` +**元素扫描**:`data/scan/basic-user-union-elements.json`(221 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-002.md` +**版本**:2026-10-22 +**自动化框架**:Playwright + TypeScript(`tests/spd/basic-user-union.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → 医共体用户管理」权限。反例筛选用 mock(`__NOT_EXIST_USER__99999` / `__NOT_EXIST_ROLE__99999`)。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 用户管理 → 医共体用户管理」;可用 `openPath('/spd/basic/user/platform/personCom')`。 +**写操作**:新增/编辑只打开后取消,不提交保存;授权与改密规则同用户管理,本页不重复真实写操作。禁止写死医院名称。 +**动态取数**:查询后从首行读取 `${sampleLoginName}`、`${sampleUserName}`、`${sampleHospital}`、`${samplePhone}`;禁止写死扫描到的医院名;mock:`__NOT_EXIST_USER__99999`。 + +--- + +## 基础设置 · 用户管理 + +### 医共体用户管理 · 筛选与查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-001】【TP_USER_UNI_009】默认查询列表有数据或空态明确 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 点击「查 询」并等待列表稳定 + 2. 断言出现数据行,或空态且分页总数为 0 +- 自动化断言:与 CSV `USER-UNI-001` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +查询无报错;有数据时「共 N 条」N≥1。 + +--- + +##### 【P0】【TC-UI-USER-UNI-002】【TP_USER_UNI_013】姓名、所属医院、联系方式、科室、角色等条件组合查询 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleHospital}`、`${samplePhone}`、`${sampleDept}`、`${sampleRole}`(科室或联系方式为 `--`/空时换一行) + 2. 填写姓名、所属医院,展开高级筛选填写「联系方式」、选择科室与角色后点「筛 选」(无则点「查 询」) + 3. 断言结果非空;首行登录账号与采样行一致;可见行姓名/所属医院/联系方式/科室/角色与已填条件一致 +- 自动化断言:与 CSV `USER-UNI-002` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +组合查询无报错;结果与采样条件一致且同时满足全部已填条件。 + +--- + +##### 【P1】【TC-UI-USER-UNI-003】【TP_USER_UNI_014】重置恢复默认医院 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 更改所属医院或姓名后点击「重 置」 + 2. 断言所属医院恢复进入页时的默认值,列表刷新 +- 自动化断言:与 CSV `USER-UNI-003` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +医院等条件恢复默认。 + +--- + +##### 【P1】【TC-UI-USER-UNI-004】【TP_USER_UNI_015】展开高级筛选 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 点击「高级筛选」 + 2. 断言可见所属医院、联系方式、科室、角色及「筛 选」 +- 自动化断言:与 CSV `USER-UNI-004` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +高级筛选含医院维度字段。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-USER-UNI-005】【TP_USER_UNI_021】无匹配空态(mock反例) + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 姓名框填入 `__NOT_EXIST_USER__99999`,点击「查 询」 + 2. 断言空态且分页总数为 0 +- 自动化断言:与 CSV `USER-UNI-005` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +无匹配不报错;总数为 0。 + +--- + +### 医共体用户管理 · 列表与分页 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-006】【TP_USER_UNI_024】列表列顺序含所属医院 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 断言列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作 + 2. 勿用用户管理页列断言(用户管理无所属医院且姓名在登录账号前) +- 自动化断言:与 CSV `USER-UNI-006` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +列顺序与用户管理页不同;含所属医院。 + +--- + +##### 【P1】【TC-UI-USER-UNI-007】【TP_USER_UNI_026】分页翻至第 2 页 + +- 前置条件:总条数大于一页,否则 skip +- 操作步骤: + 1. 记录第 1 页首行登录账号 + 2. 翻到第 2 页 + 3. 断言数据更换且所属医院筛选仍保持 +- 自动化断言:与 CSV `USER-UNI-007` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +翻页成功;医院条件不丢失。 + +--- + +#### 集成验证点 + +##### 【P1】【TC-UI-USER-UNI-008】【TP_USER_UNI_033】与用户管理页同一用户字段一致 + +- 前置条件:两页都能查到同一账号,否则 skip +- 操作步骤: + 1. 记录本页首行 `${sampleLoginName}`、姓名、所属科室、角色 + 2. 打开用户管理页,按登录账号查询同一用户 + 3. 断言姓名、角色、所属科室一致;本页仅多「所属医院」 +- 自动化断言:与 CSV `USER-UNI-008` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +同一登录账号跨页字段一致。 + +--- + +### 医共体用户管理 · 新增与编辑 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-009】【TP_USER_UNI_036】打开新增弹窗 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言弹出用户表单,含账号、姓名、联系方式及取消/确定 +- 自动化断言:与 CSV `USER-UNI-009` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +新增弹窗可见。 + +--- + +##### 【P1】【TC-UI-USER-UNI-010】【TP_USER_UNI_038】新增取消不落库 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 打开新增,账号填 `${uniqLogin}` 后点「取 消」 + 2. 当前医院下列表查询该账号,断言不存在 +- 自动化断言:与 CSV `USER-UNI-010` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +取消后当前医院列表不出现该账号。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-USER-UNI-011】【TP_USER_UNI_046】必填为空拦截 + +- 前置条件:已进入医共体用户管理页 +- 操作步骤: + 1. 打开新增,账号或姓名留空后点「确 定」 + 2. 断言被拦截,弹窗仍打开,不落库 +- 自动化断言:与 CSV `USER-UNI-011` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +必填为空不可保存。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-012】【TP_USER_UNI_039】打开编辑并回填 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 点击首行「编辑」 + 2. 断言弹窗回填与该行登录账号、姓名、所属医院一致 + 3. 点「取 消」 +- 自动化断言:与 CSV `USER-UNI-012` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +编辑回填正确;本条不保存。 + +--- + +### 医共体用户管理 · 筛选与查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-013】【TP_USER_UNI_016】高级筛选按科室过滤 + +- 前置条件:默认查询至少 1 条且所属科室非空/--,否则 skip +- 操作步骤: + 1. 点击「查 询」后读取首行 `${sampleDept}`(空或 `--` 时换一行) + 2. 展开高级筛选,选择该科室后点「筛 选」 + 3. 断言结果非空或空态明确;有数据时可见行「所属科室」与所选科室一致 +- 自动化断言:与 CSV `USER-UNI-013` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +按科室过滤成功;无报错;结果所属科室与条件一致。 + +--- + +### 医共体用户管理 · 列表与分页 + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-UNI-014】【TP_USER_UNI_027】切换20条每页页面正常且列表同步 + +- 前置条件:默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip +- 操作步骤: + 1. 点击「查 询」,记录分页「共 N 条」与当前页可见行数 + 2. 分页区切换每页条数为「20 条/页」 + 3. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;所属医院筛选、表头可见无报错 +- 自动化断言:与 CSV `USER-UNI-014` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。 + +--- + +##### 【P2】【TC-UI-USER-UNI-015】【TP_USER_UNI_059】打开列设置面板 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 点击「查 询」等待列表稳定 + 2. 点击表格工具栏列设置(齿轮)图标 + 3. 断言弹出面板含「列展示」「字段设置」「重置」,关闭面板 +- 自动化断言:与 CSV `USER-UNI-015` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +列设置面板可打开;关键项可见;关闭后列表正常。 + +--- + +##### 【P2】【TC-UI-USER-UNI-016】【TP_USER_UNI_060】列设置取消勾选后列表列恢复 + +- 前置条件:默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip +- 操作步骤: + 1. 点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭 + 2. 断言该列表头隐藏;再次打开列设置勾选恢复并关闭 + 3. 断言该列表头重新可见 +- 自动化断言:与 CSV `USER-UNI-016` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。 + +--- + +### 医共体用户管理 · 授权 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-UNI-017】【TP_USER_UNI_051】打开授权后取消不生效 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 点击首行「授权」 + 2. 断言打开用户角色授权层,可见可勾选菜单/功能 + 3. 如可勾选则改一处勾选后点取消/关闭 + 4. 再次打开同一用户授权,断言勾选与操作前一致 +- 自动化断言:与 CSV `USER-UNI-017` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +授权层可打开;取消后勾选不落库。 + +--- + +##### 【P0】【TC-UI-USER-UNI-018】【TP_USER_UNI_051】授权弹框账号与所选行一致 + +- 前置条件:列表至少 1 条且登录账号非空 +- 操作步骤: + 1. 点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}` + 2. 点击该行「授权」 + 3. 断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致 + 4. 关闭弹框 +- 自动化断言:与 CSV `USER-UNI-018` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +用户角色授权弹框打开;弹框账号与所选列表行一致。 + +--- + +##### 【P1】【TC-UI-USER-UNI-019】【TP_USER_UNI_051】授权弹框添加移除角色行 + +- 前置条件:列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip +- 操作步骤: + 1. 点击「查 询」,任选列表一条数据行,点击该行「授权」 + 2. 断言弹出用户角色授权弹框 + 3. 记录弹框内可「移除」的角色行数,点击「添加」 + 4. 断言可移除行数 +1;点击新增行的「移除」 + 5. 断言可移除行数恢复为添加前;关闭弹框且不保存 +- 自动化断言:与 CSV `USER-UNI-019` 及 `tests/spd/basic-user-union.spec.ts` 一致 + +###### 预期结果 + +添加后列表下方新增一行;移除后新增行被删除;未保存不落库。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **19** 条(P0=9 / P1=8 / P2=2) | +| 来源 | 从 60 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录与菜单进入、姓名/登录账号关键字与所属医院单筛、高级筛选按状态(扫描无该字段)、授权/改密持久化(引用 TP_USER_USR)、真实跨院保存、Excel 导入文件、接口失败、会话过期 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6b23be13b63e4b591c8a250e682060c9b0a46fdb --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,363 @@ +# 基础管理-用户管理-用户管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-用户管理-用户管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/user/platform/person` +**定位器 YAML**:`locators/spd/basic-user.locator.yaml` +**元素扫描**:`data/scan/basic-user-elements.json`(219 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-001.md` +**版本**:2026-10-22 +**自动化框架**:Playwright + TypeScript(`tests/spd/basic-user.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → 用户管理」权限。反例筛选用 mock(`__NOT_EXIST_USER__99999` / `__NOT_EXIST_ROLE__99999`)。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」;建议 `openPath('/spd/basic/user/platform/person')` 并断言 URL,勿误入医共体/角色管理。 +**写操作**:新增/编辑只打开后取消,不提交保存;授权只打开后取消;重置密码只验证确认框后取消,不确认改密。禁止写死生产账号。 +**动态取数**:查询后从首行读取 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`;科室为 `--` 时换一行;mock:`__NOT_EXIST_USER__99999`;写操作用 `${uniqLogin}`(建议 `auto.usr.${timestamp}`),取消不落库。 + +--- + +## 基础设置 · 用户管理 + +### 用户管理 · 筛选与查询 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-USR-001】【TP_USER_USR_013】姓名+科室+角色+状态组合查询 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`、`${sampleStatus}`(角色取截断前列名;状态优先读列表「状态」列,否则按行内启用/禁用文案推断) + 2. 填写姓名、科室、角色,展开高级筛选选择 `${sampleStatus}` 后点「筛 选」(无「筛 选」则点「查 询」) + 3. 断言结果非空;首行登录账号与采样行一致;可见行姓名/科室/角色/状态与已填条件一致 +- 自动化断言:与 CSV `USER-USR-001` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +组合查询无报错;查询结果与首行采样条件一致且同时满足全部已填条件。 + +--- + +##### 【P1】【TC-UI-USER-USR-002】【TP_USER_USR_014】重置恢复默认 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 填写姓名等筛选条件后点击「重 置」 + 2. 断言筛选字段恢复默认,列表按默认规则刷新 +- 自动化断言:与 CSV `USER-USR-002` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +条件恢复默认;列表刷新。 + +--- + +##### 【P1】【TC-UI-USER-USR-003】【TP_USER_USR_015】展开高级筛选 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 点击「高级筛选」 + 2. 断言展开后面板可见姓名/科室/角色/状态及「重 置」「筛 选」 +- 自动化断言:与 CSV `USER-USR-003` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +高级筛选面板展开;关键字段与筛选按钮可见。 + +--- + +##### 【P0】【TC-UI-USER-USR-004】【TP_USER_USR_016】高级筛选按状态过滤 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 展开高级筛选,状态下拉选择「启用」或「禁用」 + 2. 点击「筛 选」 + 3. 断言列表仅保留对应状态用户(无匹配则空态且总数为 0) +- 自动化断言:与 CSV `USER-USR-004` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +按状态过滤成功;无报错。 + +--- + +#### 异常验证点 + +##### 【P1】【TC-UI-USER-USR-005】【TP_USER_USR_022】无匹配结果空态(mock反例) + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 姓名框填入 `__NOT_EXIST_USER__99999` + 2. 点击「查 询」 + 3. 断言空态或「暂无数据」,分页总数为 0 +- 自动化断言:与 CSV `USER-USR-005` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +无匹配时不报错;空态明确;总数为 0。 + +--- + +### 用户管理 · 列表与分页 + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-USR-006】【TP_USER_USR_031】分页翻至第 2 页 + +- 前置条件:默认查询总条数大于一页,否则 skip +- 操作步骤: + 1. 记录第 1 页首行登录账号 + 2. 点击页码「2」或「下一页」 + 3. 断言页码高亮为 2,首行账号与第 1 页不同 +- 自动化断言:与 CSV `USER-USR-006` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +翻页成功;数据更换。 + +--- + +### 用户管理 · 新增用户 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-USR-007】【TP_USER_USR_041】打开新增用户弹窗 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 点击「新 增」 + 2. 断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」 +- 自动化断言:与 CSV `USER-USR-007` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +新增弹窗可见;关键字段与按钮齐全。 + +--- + +##### 【P1】【TC-UI-USER-USR-008】【TP_USER_USR_043】新增取消不落库 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 打开新增,账号填 `${uniqLogin}`,姓名填任意合法值 + 2. 点击「取 消」 + 3. 按账号查询,断言无该记录 +- 自动化断言:与 CSV `USER-USR-008` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +弹窗关闭;列表不出现 `${uniqLogin}`。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-USER-USR-009】【TP_USER_USR_047】必填为空拦截 + +- 前置条件:已进入用户管理页 +- 操作步骤: + 1. 打开新增,账号或姓名留空后点击「确 定」 + 2. 断言出现必填提示,弹窗仍打开 +- 自动化断言:与 CSV `USER-USR-009` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +确定被拦截;不落库;弹窗不关闭。 + +--- + +##### 【P0】【TC-UI-USER-USR-010】【TP_USER_USR_048】登录账号重复拦截 + +- 前置条件:默认查询至少 1 条且登录账号非空 +- 操作步骤: + 1. 打开新增,账号填入已存在的 `${sampleLoginName}`,补齐其它必填后点「确 定」 + 2. 断言保存失败并提示,弹窗仍打开 + 3. 查询该账号,断言原用户未被覆盖 +- 自动化断言:与 CSV `USER-USR-010` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +重复账号被拒绝;不覆盖原用户。 + +--- + +### 用户管理 · 编辑用户 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-USR-011】【TP_USER_USR_054】打开编辑用户弹窗并回填 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 点击首行「编辑」 + 2. 断言弹窗标题含「编辑用户」 + 3. 断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白) +- 自动化断言:与 CSV `USER-USR-011` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +编辑弹窗可见;回填与列表行一致。 + +--- + +##### 【P1】【TC-UI-USER-USR-012】【TP_USER_USR_058】编辑取消不保存 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开首行编辑,记录原姓名 + 2. 将姓名改为临时值后点「取 消」 + 3. 再次打开编辑,断言姓名仍为原值 +- 自动化断言:与 CSV `USER-USR-012` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +取消后列表与回看均为修改前姓名。 + +--- + +#### 边界验证点 + +##### 【P1】【TC-UI-USER-USR-013】【TP_USER_USR_061】编辑时账号只读 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开首行编辑 + 2. 断言登录账号不可改(disabled/readonly 或无法输入) +- 自动化断言:与 CSV `USER-USR-013` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +编辑态登录账号只读。 + +--- + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-USR-014】【TP_USER_USR_056】编辑弹窗可见签名图片 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 打开首行编辑 + 2. 断言可见「签名图片」区域(有图则展示,无图则上传入口可见) + 3. 点「取 消」,不保存 +- 自动化断言:与 CSV `USER-USR-014` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +签名图片控件可见;本条不上传、不保存。 + +--- + +### 用户管理 · 授权 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-USR-015】【TP_USER_USR_068】打开授权后取消不生效 + +- 前置条件:列表至少 1 条 +- 操作步骤: + 1. 点击首行「授权」 + 2. 断言打开授权层(弹窗或权限树),可见可勾选菜单/功能 + 3. 如可勾选则改一处勾选后点取消/关闭 + 4. 再次打开同一用户授权,断言勾选与操作前一致 +- 自动化断言:与 CSV `USER-USR-015` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +授权层可打开;取消后勾选不落库。 + +--- + +### 用户管理 · 列表与分页 + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-USR-016】【TP_USER_USR_032】切换20条每页页面正常且列表同步 + +- 前置条件:默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip +- 操作步骤: + 1. 点击「查 询」,记录分页「共 N 条」与当前页可见行数 + 2. 分页区切换每页条数为「20 条/页」 + 3. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错 +- 自动化断言:与 CSV `USER-USR-016` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。 + +--- + +##### 【P2】【TC-UI-USER-USR-017】【TP_USER_USR_083】打开列设置面板 + +- 前置条件:默认查询至少 1 条 +- 操作步骤: + 1. 点击「查 询」等待列表稳定 + 2. 点击表格工具栏列设置(齿轮)图标 + 3. 断言弹出面板含「列展示」「字段设置」「重置」,关闭面板 +- 自动化断言:与 CSV `USER-USR-017` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +列设置面板可打开;关键项可见;关闭后列表正常。 + +--- + +##### 【P2】【TC-UI-USER-USR-018】【TP_USER_USR_084】列设置取消勾选后列表列恢复 + +- 前置条件:默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip +- 操作步骤: + 1. 点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭 + 2. 断言该列表头隐藏;再次打开列设置勾选恢复并关闭 + 3. 断言该列表头重新可见 +- 自动化断言:与 CSV `USER-USR-018` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。 + +--- + +### 用户管理 · 授权 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-USR-019】【TP_USER_USR_068】授权弹框账号与所选行一致 + +- 前置条件:列表至少 1 条且登录账号非空 +- 操作步骤: + 1. 点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}` + 2. 点击该行「授权」 + 3. 断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致 + 4. 关闭弹框 +- 自动化断言:与 CSV `USER-USR-019` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +用户角色授权弹框打开;弹框账号与所选列表行一致。 + +--- + +##### 【P1】【TC-UI-USER-USR-020】【TP_USER_USR_082】授权弹框添加移除角色行 + +- 前置条件:列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip +- 操作步骤: + 1. 点击「查 询」,任选列表一条数据行,点击该行「授权」 + 2. 断言弹出用户角色授权弹框 + 3. 记录弹框内可「移除」的角色行数,点击「添加」 + 4. 断言可移除行数 +1;点击新增行的「移除」 + 5. 断言可移除行数恢复为添加前;关闭弹框且不保存 +- 自动化断言:与 CSV `USER-USR-020` 及 `tests/spd/basic-user.spec.ts` 一致 + +###### 预期结果 + +添加后列表下方新增一行;移除后新增行被删除;未保存不落库。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **20** 条(P0=8 / P1=10 / P2=2) | +| 来源 | 从 84 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 真实保存用户、禁用后登录、真实改密、Excel 导入文件、接口失败注入、会话过期、无菜单权限 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..30e084e58acdd59f44a72df0dbd1296baff235c6 --- /dev/null +++ "b/testcases/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-UI\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225\347\224\250\344\276\213-01.md" @@ -0,0 +1,247 @@ +# 基础管理-用户管理-角色管理 UI 自动化测试用例 + +**来源测试点**:`testpoint/基础管理-用户管理-角色管理-测试点分析-01.md` +**执行 CSV**:`testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv` +**页面 URL**:`/spd/basic/user/platform/role` +**定位器 YAML**:`locators/spd/basic-role.locator.yaml` +**元素扫描**:`data/scan/basic-role-elements.json`(1170 项) +**关联规范**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-003.md` +**版本**:2026-10-22 +**自动化框架**:Playwright + TypeScript(`tests/spd/basic-role.spec.ts`,依赖 `auth.setup` 登录态) + +**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → 角色管理」权限。 +**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 +**菜单导航**:菜单「基础设置 → 用户管理 → 角色管理」;本页左右分栏、无独立分页。 +**写操作**:权限树 001/002 只测展开收起,不保存授权;003 切换同步。编辑 004 取消;005 不改名保存(受 SKIP_BASIC_WRITE)。新增 006–010 不提交落库。 +**动态取数**:左栏读取 `${sampleRoleName}`(优先非预置自定义角色;预置用「医院管理员」若存在);权限树按「模块名 + 菜单名」定位,根为「SPD内网系统PC端」。 + +--- + +## 基础设置 · 用户管理 + +### 角色管理 · 右侧菜单权限树 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-ROL-001】【TP_USER_ROL_018】权限树按应用与模块展开 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 选中任一角色 + 2. 断言右侧树根为「SPD内网系统PC端」,可见基础设置/运维管理/厂商管理/产品管理等模块 + 3. 展开「基础设置」,断言可见子菜单(如医院资料) +- 自动化断言:与 CSV `USER-ROL-001` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +权限树层级可见;节点可展开。 + +--- + +##### 【P0】【TC-UI-USER-ROL-002】【TP_USER_ROL_018】权限树模块节点收起正常 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 选中任一角色 + 2. 展开「基础设置」,断言可见子菜单(如医院资料) + 3. 点击「基础设置」节点展开图标收起该模块 + 4. 断言子菜单(如医院资料)不可见,且「基础设置」节点处于收起态 +- 自动化断言:与 CSV `USER-ROL-002` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +权限树节点可收起;子级随父节点隐藏。 + +--- + +### 角色管理 · 左侧角色列表 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-ROL-003】【TP_USER_ROL_011】切换角色右侧权限树同步更新 + +- 前置条件:左栏至少 2 个角色 +- 操作步骤: + 1. 点选左栏第一个角色,记录右侧权限树勾选快照 + 2. 点选左栏第二个不同角色,断言该行选中且右侧权限树仍可见 + 3. 再次点选第一个角色,断言右侧权限树勾选快照与步骤 2 一致 +- 自动化断言:与 CSV `USER-ROL-003` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +切换后右侧与当前角色一致;切回后恢复该角色已保存勾选,无错乱残留。 + +--- + +### 角色管理 · 角色增删改 + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-ROL-004】【TP_USER_ROL_033】编辑角色弹框回填名称一致 + +- 前置条件:左栏至少 1 个角色 +- 操作步骤: + 1. 点选左栏任一角色,记录其角色名称 + 2. 点击左栏右下角(或行内)编辑图标 + 3. 断言弹出编辑角色弹框,标题或内容含「编辑」 + 4. 断言弹框内「角色名称」输入框回填值与所选角色名称一致 + 5. 点「取 消」或「关 闭」关闭弹框,不保存 +- 自动化断言:与 CSV `USER-ROL-004` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +编辑弹框正常打开;角色名称与当前选中角色一致;本条不保存。 + +--- + +##### 【P0】【TC-UI-USER-ROL-005】【TP_USER_ROL_033】编辑角色保存成功 + +- 前置条件:左栏至少 1 个可编辑角色;环境允许写操作(非 SKIP_BASIC_WRITE=1) +- 操作步骤: + 1. 点选左栏任一角色 + 2. 点击左栏右下角(或行内)编辑图标,断言弹出编辑角色弹框 + 3. 不修改角色名称,点击弹框内「保 存」 + 4. 断言页面无报错提示,且出现「编辑成功」类成功提示 + 5. 断言编辑弹框关闭 +- 自动化断言:与 CSV `USER-ROL-005` 及 `tests/spd/basic-role.spec.ts` 一致(`SKIP_BASIC_WRITE=1` 时 skip) + +###### 预期结果 + +保存成功;提示编辑成功;角色数据无异常报错。 + +--- + +##### 【P0】【TC-UI-USER-ROL-006】【TP_USER_ROL_032】点击角色按钮打开新增弹框 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 点击左栏工具区「角 色」按钮 + 2. 断言弹出新增角色弹框,标题或内容含「新增」或「角色」 + 3. 断言页面无报错提示(无错误 toast/遮罩) + 4. 点「取 消」或「关 闭」关闭弹框,不保存 +- 自动化断言:与 CSV `USER-ROL-006` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +新增角色弹框正常打开;页面无报错;本条不提交保存。 + +--- + +#### 异常验证点 + +##### 【P0】【TC-UI-USER-ROL-007】【TP_USER_ROL_039】新增角色名称为空保存拦截 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 + 2. 不填写角色名称,直接点击弹框内「保 存」 + 3. 断言「角色名称」输入框呈校验失败态(变红) + 4. 断言同字段下方提示:请输入角色名称! + 5. 点「取 消」关闭弹框,不保存 +- 自动化断言:与 CSV `USER-ROL-007` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +空名称保存被拦截;角色名称框变红并提示请输入角色名称!;弹框不提交。 + +--- + +#### 功能验证点 + +##### 【P0】【TC-UI-USER-ROL-008】【TP_USER_ROL_032】新增角色弹框取消关闭 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 + 2. 不填写任何内容,直接点击弹框内「取 消」 + 3. 断言新增角色弹框关闭 + 4. 断言页面无报错提示,左栏角色数量不增加 +- 自动化断言:与 CSV `USER-ROL-008` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +取消后弹框关闭;页面无报错;未新增角色。 + +--- + +##### 【P0】【TC-UI-USER-ROL-009】【TP_USER_ROL_032】新增弹框查询配置化复选框可勾选 + +- 前置条件:新增弹框含「查询配置化」复选框 +- 操作步骤: + 1. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 + 2. 定位「查询配置化」复选框,点击勾选,断言为选中态 + 3. 再次点击取消勾选,断言为未选中态 + 4. 断言页面无报错提示 + 5. 点「取 消」关闭弹框,不保存 +- 自动化断言:与 CSV `USER-ROL-009` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +查询配置化可正常勾选与取消勾选;页面无报错;本条不提交保存。 + +--- + +##### 【P0】【TC-UI-USER-ROL-010】【TP_USER_ROL_032】新增角色弹框X关闭 + +- 前置条件:已进入角色管理页 +- 操作步骤: + 1. 点击左栏工具区「角 色」按钮,断言弹出新增角色弹框 + 2. 点击弹框右上角「X」关闭图标 + 3. 断言新增角色弹框关闭 + 4. 断言页面无报错提示,左栏角色数量不增加 +- 自动化断言:与 CSV `USER-ROL-010` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +X 关闭后弹框正常关闭;页面无报错;未新增角色。 + +--- + +### 角色管理 · 左侧角色列表 + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-ROL-011】【TP_USER_ROL_009】左栏角色列表可上下滑动 + +- 前置条件:左栏列表高度溢出可滚动(不足则 skip) +- 操作步骤: + 1. 定位左栏角色列表滚动区域 + 2. 向下滚动列表,断言 scrollTop 增加且列表可继续交互 + 3. 向上滚回(滚轮或滑至顶部),断言 scrollTop 减小 + 4. 断言页面无报错遮罩 +- 自动化断言:与 CSV `USER-ROL-011` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +左栏可正常上下滑动;滚动后页面无异常。 + +--- + +### 角色管理 · 右侧菜单权限树 + +#### 功能验证点 + +##### 【P1】【TC-UI-USER-ROL-012】【TP_USER_ROL_025】右侧权限树子菜单可上下滑动 + +- 前置条件:已选中任一角色且右侧权限树高度溢出可滚动(不足则 skip) +- 操作步骤: + 1. 点选左栏任一角色,断言右侧菜单权限树可见 + 2. 定位右侧权限树滚动区域(含子菜单列表) + 3. 向下滚动,断言 scrollTop 增加 + 4. 向上滚回,断言 scrollTop 减小 + 5. 断言页面无报错遮罩 +- 自动化断言:与 CSV `USER-ROL-012` 及 `tests/spd/basic-role.spec.ts` 一致 + +###### 预期结果 + +右侧子菜单列表可正常上下滑动;滚动后页面无异常。 + + +## 质量与覆盖说明 + +| 项 | 说明 | +|----|------| +| 用例数 | **12** 条(P0=10 / P1=2) | +| 来源 | 从 45 条测试点选取可稳定自动化的主路径与关键反向 | +| 未纳入自动化 | 未登录与菜单进入、角色搜索与空态、权限树勾选联动/刷新/保存、删除与预置角色、与用户页角色下拉同源、真实新增角色落库、未保存切角色提示等 | +| 单一职责 | 每条只断言一个核心业务结果 | diff --git "a/testpoint/scope/UI\346\265\213\350\257\225\347\202\271\347\224\237\346\210\220\350\214\203\345\233\264-\346\250\241\346\235\277.md" "b/testpoint/scope/UI\346\265\213\350\257\225\347\202\271\347\224\237\346\210\220\350\214\203\345\233\264-\346\250\241\346\235\277.md" new file mode 100644 index 0000000000000000000000000000000000000000..2f523b4f6cef5486e08c946b4c38b0a8014bfec0 --- /dev/null +++ "b/testpoint/scope/UI\346\265\213\350\257\225\347\202\271\347\224\237\346\210\220\350\214\203\345\233\264-\346\250\241\346\235\277.md" @@ -0,0 +1,48 @@ +# UI 测试点生成范围(模板) + +复制本文件为 `{需求名称}-UI测试点范围.md` 并填写下表。生成测试点时 Agent 须严格遵循本范围。 + +--- + +## 【UI测试点生成范围】 + +| 项 | 填写 | +|----|------| +| 需求/页面名称 | | +| 关联 UI-SPEC | | +| 页面 URL / 菜单路径 | | +| 元素扫描 / 定位器 | | + +### 在范围内(必写) + +- **功能域**:(勾选或列举,如:页面访问与导航、筛选与查询、列表与分页、行内操作) +- **验证类型**: + - [ ] 功能验证点 + - [ ] 边界验证点 + - [ ] 异常验证点 + - [ ] 集成验证点 +- **优先级下限**:仅高+中 / 高+中+低 全写 +- **业务侧重点**: + +### 明确排除(不写或仅引用他页 TP) + +- + +### 输入优先级(事实来源) + +1. +2. **冲突处理**: + +### 编号与体量 + +- **ID 前缀**:TP_ +- **目标条数**:(可选) +- **是否声称需求 100% 全覆盖**:否(推荐) + +--- + +## 变更记录 + +| 日期 | 变更说明 | 作者 | +|------|----------|------| +| | 初版 | | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..e6d5b8d3b98554291b7e9779067e39516110923a --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,111 @@ +# 基础管理-准入管理-产品准入单列表 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-004.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/list` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-product-list-elements.json`(77 项) +**定位器**:`locators/spd/admit-product-list.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 产品准入单列表 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_PLS_001**(高)|菜单路径可达产品准入单列表|验证要点:登录后通过「基础设置 → 准入管理 → 产品准入单列表」进入,不误入物料准入单列表或产品准入申请|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选区、状态 Tab 与列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_003**(中)|同组菜单可切换|验证要点:切换同组其它准入菜单后 URL 对应,再切回本页正确|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 边界验证点 + +- **TP_ADMIT_PLS_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开产品准入单列表 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 异常验证点 + +- **TP_ADMIT_PLS_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录页,不展示单据|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_006**(中)|登录态过期|验证要点:会话失效后查询或打开详情被拦截并提示重新登录|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 集成验证点 + +- **TP_ADMIT_PLS_008**(中)|顶栏院区与单据范围一致|验证要点:列表供应商与产品范围和顶栏院区上下文不矛盾|关联需求:UI-SPEC-SPD-ADMIT-004 + +--- + +### 筛选、状态与列表 + +#### 功能验证点 + +- **TP_ADMIT_PLS_009**(高)|默认制单日期查询|验证要点:点击「查询」后列表有数据或空态明确,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_010**(高)|制单日期、进院供应商、二级供应商、耗材类型、注册证号、器械注册人筛选|验证要点:分别按上述条件查询,结果与条件匹配|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_011**(中)|快捷区重置恢复默认|验证要点:已填筛选字段恢复默认;列表是否刷新按产品规则(自动化 `ADMIT-PLS-004`)|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_035**(高)|全量可见条件组合查询|验证要点:仅填当前结果里有值的条件,结果同时满足已填条件;分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_036**(中)|收起后再展开筛选区|验证要点:已填筛选值不丢失|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_012**(高)|状态 Tab|验证要点:全部、审批中、已通过、已驳回切换后,列表「状态」与 Tab 一致,Tab 计数与分页总数一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_013**(高)|列表核心列|验证要点:展示序号、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作「详情」|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_014**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,页码高亮且首行更换;翻页不丢失筛选与当前 Tab;不足 2 页则该点不适用|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_037**(中)|切换每页条数|验证要点:当前页行数 = min(总条数, 所选档位);总条数不变;无数据则不适用|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_038**(低)|列设置隐藏后再恢复|验证要点:取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_015**(中)|附件列计数|验证要点:附件列展示的个数与详情中该单附件数量一致;为 0 时显示 0 个|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 边界验证点 + +- **TP_ADMIT_PLS_016**(中)|制单日期起止同一天|验证要点:起止同一天仅返回该日单据|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_017**(中)|零计数 Tab 与首页末页|验证要点:计数为 0 的 Tab 为空态;第 1 页上一页、末页下一页不可用|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 异常验证点 + +- **TP_ADMIT_PLS_018**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_019**(中)|查询失败与无匹配空态|验证要点:接口失败有提示且不白屏;无匹配时总数为 0|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 集成验证点 + +- **TP_ADMIT_PLS_020**(高)|单据来源于产品准入申请|验证要点:在产品准入申请生成的申请单,可按准入单号在本列表查到,状态一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_021**(中)|与物料准入单列表数据隔离|验证要点:同一单号不会同时以错误类型出现在物料准入单列表,两列表筛选结果不串单|关联需求:UI-SPEC-SPD-ADMIT-004 + +--- + +### 单据详情 + +#### 功能验证点 + +- **TP_ADMIT_PLS_022**(高)|打开详情|验证要点:「详情」进入产品准入单详情,含审批进程、单据日志、返回|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_023**(高)|详情产品条数一致|验证要点:详情明细行数与列表「产品条数」一致|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_024**(中)|详情内产品搜索|验证要点:按产品编码或名称过滤后仅保留匹配明细|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_025**(中)|审批进程、单据日志与明细查看|验证要点:「审批进程」「单据日志」可打开;明细「查看」展示产品信息|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_026**(高)|返回保持列表条件|验证要点:「返回」回到产品准入单列表,原筛选与 Tab 保持|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 边界验证点 + +- **TP_ADMIT_PLS_027**(中)|仅 1 条明细|验证要点:产品条数为 1 时详情只展示 1 行|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_028**(低)|详情搜索无匹配|验证要点:无匹配时明细空态明确,单据头仍在|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 异常验证点 + +- **TP_ADMIT_PLS_029**(中)|详情加载失败|验证要点:详情失败有提示,不白屏|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_030**(中)|已驳回单据不可通过|验证要点:已驳回单「通过」不可用或被拒绝,状态保持已驳回|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_031**(中)|已通过单据不可再次通过|验证要点:已通过单据详情中「通过」不可用或被拒绝,状态保持已通过|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_032**(中)|取消通过不改变状态|验证要点:若「通过」有确认框,点取消后单据状态与 Tab 计数不变|关联需求:UI-SPEC-SPD-ADMIT-004 + +#### 集成验证点 + +- **TP_ADMIT_PLS_033**(高)|审批通过后列表与 Tab 计数更新|验证要点:允许审批的单据「通过」成功后,列表状态为已通过,已通过 Tab 计数增加|关联需求:UI-SPEC-SPD-ADMIT-004 +- **TP_ADMIT_PLS_034**(中)|详情产品与产品准入申请主数据一致|验证要点:抽样核对产品名称、注册证号与产品准入申请中的记录一致|关联需求:UI-SPEC-SPD-ADMIT-004 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选、状态与列表、单据详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、筛选、状态 Tab、列表附件列、详情、审批、日志、通过确认与取消、已通过/已驳回不可再通过;组合查询/重置/收起、分页/每页条数/列设置已拆成原子点;共 **38** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 与物料准入单列表共用的查询与详情规则,本页保留产品单列(备注、附件)及与物料单隔离;编号前缀 `TP_ADMIT_PLS` | +| 待评审项 | ① 列表附件个数点击是否直接打开附件 ② 审批中「通过」的角色范围 ③ 本页详情扫描未出现独立「导出」,若后续页面补充导出需追加测试点 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-product-list-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..5e63e1447c100bb8f15b644e8276a3537fd8ed80 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\272\247\345\223\201\345\207\206\345\205\245\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,179 @@ +# 基础管理-准入管理-产品准入申请 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/list` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-product-apply-elements.json`(467 项) +**定位器**:`locators/spd/admit-product-apply.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 产品准入申请 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_PAP_001**(高)|菜单路径可达产品准入申请页|验证要点:登录后通过「基础设置 → 准入管理 → 产品准入申请」进入目标页,不误入物料准入管理或产品准入单列表|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选区、状态 Tab、工具栏与列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_003**(中)|同组准入管理菜单可切换|验证要点:切换同组其它五个菜单后 URL 与内容对应,再切回产品准入申请正确|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开产品准入申请 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录页,不展示申请列表|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_006**(中)|登录态过期访问|验证要点:会话失效后查询或新增被拦截并提示重新登录|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_007**(中)|无产品准入申请权限|验证要点:无权限账号不可见菜单或访问 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_008**(中)|顶栏院区与列表数据范围一致|验证要点:顶栏院区与列表进院供应商、产品数据范围不矛盾|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_ADMIT_PAP_009**(高)|默认新增日期查询|验证要点:点击「查询」后列表有数据或空态明确,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_010**(高)|新增日期区间查询|验证要点:结果落在所选新增日期区间内|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_011**(高)|进院供应商与耗材类型筛选|验证要点:按进院供应商、耗材类型查询,结果与条件匹配|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_012**(中)|产品名称、规格型号、注册证号、器械注册人筛选|验证要点:按上述字段查询,对应列匹配|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_013**(高)|多条件组合查询|验证要点:多个已填条件同时生效|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_014**(中)|重置与收起展开|验证要点:「重置」恢复默认;「收起」后再展开,已填值保留|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_015**(中)|新增日期起止同一天|验证要点:起止同一天查询成功,仅返回该日数据|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_016**(低)|文本条件为空|验证要点:名称等留空不报错,按其余条件查询|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_017**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正,并给出提示|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_018**(中)|查询失败与空结果|验证要点:接口失败有错误提示且不白屏;无匹配时空态明确、总数为 0|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_019**(中)|筛选与分页总数一致|验证要点:分页「共 N 条」等于当前筛选全量|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +### 状态 Tab 与列表 + +#### 功能验证点 + +- **TP_ADMIT_PAP_020**(高)|状态 Tab 切换|验证要点:全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效切换后,列表状态与 Tab 一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_021**(高)|Tab 计数与列表总数一致|验证要点:Tab 括号数量与该状态分页总数一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_022**(高)|列表核心列完整|验证要点:展示序号、准入单号、产品编号、产品名称、规格型号、注册规格、注册型号、器械注册人、单位、操作(详情/日志,待申请另有编辑)|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_023**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,页码高亮且首行更换;翻页不丢失筛选和当前 Tab;不足 2 页则该点不适用(自动化 `ADMIT-PAP-009`)|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_057**(中)|切换每页条数|验证要点:当前页行数 = min(总条数, 所选档位);总条数不变;无数据则不适用|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_058**(低)|列设置隐藏后再恢复|验证要点:取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_024**(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后进入该页,列表数据与页码一致|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_025**(中)|零计数 Tab 与首页末页|验证要点:计数为 0 的 Tab 为空态;第 1 页上一页、末页下一页不可用|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_026**(低)|未勾选时批量操作不可用|验证要点:未勾选行时生成申请单等依赖勾选的操作不可用或提示先选择|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_027**(中)|状态 Tab 与筛选同时生效|验证要点:筛选后再切 Tab,结果同时满足两者|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +### 新增产品与 Excel 导入 + +#### 功能验证点 + +- **TP_ADMIT_PAP_028**(高)|打开新增产品|验证要点:点击「新增产品」进入新增页,含产品名称、器械注册人、规格型号、注册证号、分类、是否医疗器械等字段,以及返回|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_029**(高)|必填合法保存成功|验证要点:填写必填项后保存,返回列表出现新记录,关键字段与提交一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_030**(中)|新增取消不落库|验证要点:填写后返回且不保存,列表不新增该产品|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_031**(高)|Excel 导入入口|验证要点:点击「Excel导入」打开导入层,可选择模板文件或取消,取消不产生新记录|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_032**(高)|合法 Excel 导入成功|验证要点:按模板导入合法数据后,列表出现对应产品,条数与文件有效行一致|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_033**(中)|名称与注册证号达到允许长度|验证要点:字段取允许最大长度可保存,超出则被拒绝或按规则截断并提示|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_034**(中)|导入仅 1 行|验证要点:模板仅 1 条合法数据时成功导入 1 条|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_035**(高)|产品名称为空保存拦截|验证要点:新增页产品名称为空时保存被拦截,弹层不关闭,列表不新增|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_036**(高)|其它带必填标识的字段为空逐项拦截|验证要点:器械注册人、规格型号、注册证号等带必填标识的字段分别置空时,均被拦截且不落库;无必填标识的字段不在本条强制|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_037**(高)|Excel 模板错误或空文件|验证要点:非模板、缺列或无数据文件被拒绝,并提示原因,列表不增加脏数据|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_038**(中)|导入部分行校验失败|验证要点:文件中非法行被拒绝或标出,合法行是否入库与产品规则一致且提示明确|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_039**(中)|重复点击保存或导入|验证要点:连续提交只产生一次有效记录|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_040**(高)|新增或导入的产品可被生成申请单引用|验证要点:新记录出现在待申请列表后,可勾选生成申请单,单号可在准入单列表追溯|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +### 生成申请单、一键生成与作废 + +#### 功能验证点 + +- **TP_ADMIT_PAP_041**(高)|勾选生成申请单|验证要点:勾选允许生成的行后「生成申请单」成功,状态离开待申请|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_042**(高)|一键生成范围与当前条件一致|验证要点:一键生成只处理当前筛选下允许的记录,条数与提示一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_043**(高)|确认作废后进入已作废|验证要点:勾选允许作废的行并确认后,该行进入已作废|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_044**(高)|取消作废不改变状态|验证要点:作废确认框点取消后,原行状态与总数不变|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_045**(中)|单条生成|验证要点:只勾选 1 条时只生成 1 张申请|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_046**(高)|非法状态生成或作废被拒绝|验证要点:已生效、已作废等行执行生成或作废被拒绝,原状态不变|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_047**(中)|写接口失败|验证要点:失败有提示,不出现半成品成功态|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_048**(高)|申请单进入产品准入单列表|验证要点:生成成功后,产品准入单列表可按准入单号查到该单,状态一致|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +### 详情、编辑与日志 + +#### 功能验证点 + +- **TP_ADMIT_PAP_049**(高)|详情展示与返回|验证要点:「详情」展示产品主数据,返回后列表筛选与页码保持|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_050**(高)|编辑打开并回填|验证要点:待申请行点「编辑」,产品名称、规格型号、注册证号与该行原值一致|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_051**(高)|待申请编辑保存|验证要点:在回填正确的编辑页修改后保存,列表与再次打开为新值|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_052**(中)|操作日志可打开|验证要点:「日志」展示该行操作记录,关闭后回到列表|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 边界验证点 + +- **TP_ADMIT_PAP_053**(中)|编辑取消不保存|验证要点:修改后不保存返回,数据保持原值|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 异常验证点 + +- **TP_ADMIT_PAP_054**(高)|编辑清空必填被拦截|验证要点:清空必填后保存被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-ADMIT-002 +- **TP_ADMIT_PAP_055**(中)|非待申请行不可编辑|验证要点:已通过等行无「编辑」或保存被拒绝|关联需求:UI-SPEC-SPD-ADMIT-002 + +#### 集成验证点 + +- **TP_ADMIT_PAP_056**(中)|编辑后列表与详情一致|验证要点:保存后列表产品名称、规格、注册证号与详情一致|关联需求:UI-SPEC-SPD-ADMIT-002 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、状态 Tab 与列表、新增产品与 Excel 导入、生成申请单、一键生成与作废、详情、编辑与日志。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、筛选、状态 Tab、列表、跳至、新增产品、分字段必填、Excel 导入、生成申请单、一键生成、作废确认与取消、详情、编辑回填与保存、日志;分页/每页条数/列设置已拆成原子点;共 **58** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 与物料准入管理共用的登录与日期规则仅保留本页 URL 与「新增产品 / Excel导入」差异;编号前缀 `TP_ADMIT_PAP` | +| 待评审项 | ① Excel 导入失败行是整单回滚还是跳过需产品确认 ② 一键生成是否包含未展示在当前页的全量待申请 ③ 准入单号点击是否等同详情 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-product-apply-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..6e3084084945a416fafb0b6663be636f29716a62 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\344\276\233\350\264\247\347\233\256\345\275\225\347\224\263\350\257\267-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,140 @@ +# 基础管理-准入管理-供货目录申请 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-005.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/supplyApply/list` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-supply-elements.json`(117 项) +**定位器**:`locators/spd/admit-supply.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 供货目录申请 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_SUP_001**(高)|菜单路径可达供货目录申请|验证要点:登录后通过「基础设置 → 准入管理 → 供货目录申请」进入,不误入准入任务分配或产品准入申请|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选区、状态 Tab、批量提交与新增按钮、列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_003**(中)|同组菜单可切换|验证要点:切换同组其它准入菜单后 URL 对应,再切回本页正确|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 边界验证点 + +- **TP_ADMIT_SUP_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开供货目录申请 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 异常验证点 + +- **TP_ADMIT_SUP_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录页,不展示申请单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_006**(中)|登录态过期|验证要点:会话失效后查询、新增或提交被拦截并提示重新登录|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 集成验证点 + +- **TP_ADMIT_SUP_008**(中)|顶栏院区与进院供应商范围一致|验证要点:列表进院供应商所属范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-ADMIT-005 + +--- + +### 筛选、状态与列表 + +#### 功能验证点 + +- **TP_ADMIT_SUP_009**(高)|默认制单日期查询|验证要点:点击「查询」后列表有数据或空态明确,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_010**(高)|制单日期、进院供应商、二级供应商筛选|验证要点:按制单日期区间及两级供应商查询,结果匹配|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_011**(中)|变更单号、产品名称、规格型号筛选|验证要点:按变更单号、产品名称、规格型号查询,结果匹配|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_012**(中)|快捷区重置恢复默认|验证要点:已填筛选字段恢复默认;列表是否刷新按产品规则(自动化 `ADMIT-SUP-004`)|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_049**(高)|全量可见条件组合查询|验证要点:仅填当前结果里有值的条件,结果同时满足已填条件;分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_050**(中)|收起后再展开筛选区|验证要点:已填筛选值不丢失|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_013**(高)|状态 Tab|验证要点:全部、待提交、审批中、已通过、已驳回切换后列表状态匹配,Tab 计数与分页总数一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_014**(高)|列表核心列|验证要点:展示序号、申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作(编辑/详情)|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_015**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,页码高亮且首行更换;翻页不丢失筛选与当前 Tab;不足 2 页则该点不适用|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_051**(中)|切换每页条数|验证要点:当前页行数 = min(总条数, 所选档位);总条数不变;无数据则不适用|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 边界验证点 + +- **TP_ADMIT_SUP_016**(中)|制单日期起止同一天|验证要点:起止同一天仅返回该日申请|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_017**(中)|待提交计数为 0|验证要点:无待提交时 Tab 为 0,批量提交计数为 0,列表在该 Tab 下为空态|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 异常验证点 + +- **TP_ADMIT_SUP_018**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_019**(中)|查询失败与无匹配空态|验证要点:接口失败有提示;无匹配时总数为 0|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 集成验证点 + +- **TP_ADMIT_SUP_020**(中)|筛选结果与分页、Tab 计数一致|验证要点:当前 Tab 加筛选后的全量条数等于分页总数,且不超过该 Tab 未筛选时的计数|关联需求:UI-SPEC-SPD-ADMIT-005 + +--- + +### 新增与编辑申请 + +#### 功能验证点 + +- **TP_ADMIT_SUP_021**(高)|打开新增|验证要点:点击「新增」进入新增供货目录,含进院供应商、二级供应商、业务模式、是否默认、计划生效时间、关联授权变更申请、备注、添加产品|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_022**(高)|必填合法提交成功|验证要点:填写必填供应商与业务模式并添加至少 1 个产品后「提交」,列表出现新申请单,状态为待提交或已进入审批(以产品规则为准)且字段一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_023**(中)|添加产品|验证要点:点击「添加产品」可选择产品并回填到明细,明细条数增加|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_024**(中)|导入 Excel 明细|验证要点:点击「导入excel」按模板导入产品明细,成功后明细条数与有效行一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_025**(高)|待提交行编辑打开并回填|验证要点:行内「编辑」回填进院供应商、二级供应商、业务模式、是否默认、计划生效时间,与列表该行一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_026**(高)|编辑修改后提交落库|验证要点:在回填正确的编辑页修改备注或是否默认后提交,再次打开为新值|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_027**(中)|新增或编辑放弃不落库|验证要点:返回且不提交时,列表不新增或不改变原单|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 边界验证点 + +- **TP_ADMIT_SUP_028**(中)|计划生效时间为当天|验证要点:计划生效时间选当天可提交,或按规则提示,行为明确|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_029**(中)|只添加 1 个产品|验证要点:明细仅 1 条时可以提交成功|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_030**(低)|备注为空|验证要点:备注非必填时允许为空提交|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 异常验证点 + +- **TP_ADMIT_SUP_031**(高)|进院供应商为空不可提交|验证要点:未选进院供应商时提交被拦截,不产生申请单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_032**(高)|二级供应商为空不可提交|验证要点:未选二级供应商时提交被拦截,不产生申请单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_033**(高)|业务模式为空不可提交|验证要点:未选业务模式时提交被拦截,不产生申请单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_034**(中)|业务模式与是否默认选项可见|验证要点:业务模式、是否默认可展开选项(含页面上的分销/直销或是/否等等价项),只切换不提交时列表无新单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_035**(高)|未添加产品不可提交|验证要点:产品明细为 0 时提交被拦截并提示|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_036**(中)|Excel 模板错误|验证要点:非模板或空文件被拒绝,已填表头不被脏数据覆盖|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_037**(中)|重复点击提交|验证要点:连续点击「提交」只产生一张申请单|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_038**(中)|非待提交行不可编辑|验证要点:审批中或已通过行无「编辑」,或进入后提交被拒绝|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 集成验证点 + +- **TP_ADMIT_SUP_039**(高)|提交后进入审批流|验证要点:提交成功后该单出现在审批中或待提交(按是否直接送审),审批进程可打开且与单据状态一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_040**(中)|默认供货关系与是否默认一致|验证要点:是否默认选「是」并审批通过后,该供应商关系按默认供货生效,同一产品不出现互相矛盾的多条默认|关联需求:UI-SPEC-SPD-ADMIT-005 + +--- + +### 批量提交与详情 + +#### 功能验证点 + +- **TP_ADMIT_SUP_041**(高)|勾选待提交后批量提交|验证要点:勾选待提交行后「批量提交」计数与勾选数一致,确认后这些单离开待提交|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_042**(中)|取消批量提交|验证要点:确认框取消后单据仍为待提交|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_043**(高)|打开详情|验证要点:「详情」展示供货目录详情,含审批进程、单据日志、返回,明细与申请时产品一致|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_044**(中)|返回保持列表条件|验证要点:从详情返回后筛选与 Tab 保持|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 边界验证点 + +- **TP_ADMIT_SUP_045**(中)|只勾选 1 条批量提交|验证要点:批量提交 1 条时只改变该条状态|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 异常验证点 + +- **TP_ADMIT_SUP_046**(高)|未勾选或勾选非待提交|验证要点:未勾选时批量提交不可用或计数为 0;勾选审批中/已通过行时被排除或整批拒绝,并提示|关联需求:UI-SPEC-SPD-ADMIT-005 +- **TP_ADMIT_SUP_047**(中)|批量提交接口失败|验证要点:失败有提示,已选单据不部分假成功|关联需求:UI-SPEC-SPD-ADMIT-005 + +#### 集成验证点 + +- **TP_ADMIT_SUP_048**(中)|批量提交与单张提交结果一致|验证要点:批量提交后的状态、审批进程与在编辑页单张「提交」相同|关联需求:UI-SPEC-SPD-ADMIT-005 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选、状态与列表、新增与编辑申请、批量提交与详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、筛选、状态 Tab、新增、添加产品、Excel 导入、编辑回填与提交、分字段必填、提交、批量提交、详情与审批进程;组合查询/重置/收起、分页/每页条数已拆成原子点;共 **51** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录与日期规则仅保留供货目录 URL 与供应商/业务模式差异;编号前缀 `TP_ADMIT_SUP` | +| 待评审项 | ① 新增提交后是待提交还是直接审批中 ② 「是否默认」冲突时是覆盖旧默认还是拒绝 ③ 关联授权变更申请是否必填 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-supply-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..15a9d66a14211e8be67b6786780ff7e20cab6d8f --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\345\207\206\345\205\245\344\273\273\345\212\241\345\210\206\351\205\215-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,125 @@ +# 基础管理-准入管理-准入任务分配 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-006.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/accessKind/list` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-task-elements.json`(158 项) +**定位器**:`locators/spd/admit-task.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 准入任务分配 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_TSK_001**(高)|菜单路径可达准入任务分配|验证要点:登录后通过「基础设置 → 准入管理 → 准入任务分配」进入,不误入供货目录申请或物料准入管理|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选区、待分类/已分类 Tab、批量分配与列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_003**(中)|同组菜单可切换|验证要点:切换同组其它准入菜单后 URL 对应,再切回本页正确|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 边界验证点 + +- **TP_ADMIT_TSK_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开准入任务分配 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 异常验证点 + +- **TP_ADMIT_TSK_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录页,不展示待分类任务|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_006**(中)|登录态过期|验证要点:会话失效后查询或分配被拦截并提示重新登录|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 集成验证点 + +- **TP_ADMIT_TSK_008**(中)|顶栏院区与任务范围一致|验证要点:列表产品与进院供应商范围和顶栏院区不矛盾|关联需求:UI-SPEC-SPD-ADMIT-006 + +--- + +### 筛选与分类 Tab + +#### 功能验证点 + +- **TP_ADMIT_TSK_009**(高)|默认条件查询|验证要点:点击「查询」后待分类或已分类列表有数据或空态明确,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_010**(高)|新增日期与操作日期筛选|验证要点:分别按新增日期、操作日期区间查询,结果落在所选区间|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_011**(高)|进院供应商、产品名称、注册证号、器械注册人、规格型号筛选|验证要点:按上述字段查询,对应列匹配|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_012**(高)|准入类型与准入状态筛选|验证要点:选择准入类型、准入状态后查询,结果与所选值匹配|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_013**(高)|耗材类型与组合查询|验证要点:耗材类型及其余已填条件同时生效|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_014**(中)|重置与收起|验证要点:「重置」恢复默认;「收起」后再展开,已填值保留|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_015**(高)|待分类与已分类切换|验证要点:切换「待分类」「已分类」后列表范围与 Tab 计数一致,已分类不再出现在待分类|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 边界验证点 + +- **TP_ADMIT_TSK_016**(中)|日期起止同一天|验证要点:新增日期或操作日期起止同一天时仅返回该日数据|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_017**(中)|已分类计数为 0|验证要点:无已分类数据时 Tab 为 0,列表空态,不报错|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 异常验证点 + +- **TP_ADMIT_TSK_018**(中)|日期开始晚于结束|验证要点:新增日期或操作日期开始晚于结束时拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_019**(中)|查询失败与无匹配空态|验证要点:接口失败有提示;无匹配时总数为 0|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 集成验证点 + +- **TP_ADMIT_TSK_020**(中)|待分类任务来自准入申请|验证要点:物料准入或产品准入中待分类的产品,能在本页待分类按产品编号或名称查到|关联需求:UI-SPEC-SPD-ADMIT-006 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_ADMIT_TSK_021**(高)|列表核心列|验证要点:展示序号、准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、操作「分配类型」|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_022**(中)|勾选、分页与翻页保持条件|验证要点:可勾选行;翻页和每页条数正确;翻页不丢失筛选与当前分类 Tab|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_023**(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后进入该页,列表与页码一致|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 边界验证点 + +- **TP_ADMIT_TSK_024**(中)|首页上一页与末页下一页|验证要点:第 1 页上一页、最后一页下一页不可用|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 异常验证点 + +- **TP_ADMIT_TSK_025**(低)|未勾选时批量分配|验证要点:未勾选行时「批量分配」不可用或提示先选择数据|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 集成验证点 + +- **TP_ADMIT_TSK_026**(中)|分页总数与当前 Tab 计数一致|验证要点:当前分类 Tab 下分页「共 N 条」与 Tab 括号数量在同一筛选条件下一致|关联需求:UI-SPEC-SPD-ADMIT-006 + +--- + +### 分配类型与批量分配 + +#### 功能验证点 + +- **TP_ADMIT_TSK_027**(高)|单行分配类型|验证要点:待分类行点击「分配类型」可选择分类并确认,该行从待分类进入已分类|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_028**(中)|取消分配|验证要点:打开分配后取消或关闭,该行仍留在待分类|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_029**(高)|批量分配|验证要点:勾选多条待分类后「批量分配」使用同一分类,成功后这些行都进入已分类,条数与勾选数一致|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_030**(中)|已分类可查看分配结果|验证要点:切换到已分类后,已分配行展示的类型与分配时所选一致|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 边界验证点 + +- **TP_ADMIT_TSK_031**(中)|只分配 1 条|验证要点:批量或单行只处理 1 条时,仅该条变更分类,其它待分类不变|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 异常验证点 + +- **TP_ADMIT_TSK_032**(高)|未选择分类不可提交|验证要点:分配层不选类型即确认时被拦截,行仍为待分类|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_033**(中)|已分类行不可按待分类再次分配|验证要点:已分类行无「分配类型」,或再次分配被拒绝;不把已分类行重复计入待分类|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_034**(中)|分配接口失败|验证要点:失败有提示,勾选行不部分假成功|关联需求:UI-SPEC-SPD-ADMIT-006 + +#### 集成验证点 + +- **TP_ADMIT_TSK_035**(高)|分配结果回写准入产品|验证要点:分配成功后,在物料准入管理或产品准入申请中查看该产品,其分类/耗材类型与本次分配一致|关联需求:UI-SPEC-SPD-ADMIT-006 +- **TP_ADMIT_TSK_036**(中)|待分类与已分类计数联动|验证要点:分配 N 条后,待分类计数减少 N,已分类计数增加 N|关联需求:UI-SPEC-SPD-ADMIT-006 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与分类 Tab、列表与分页、分配类型与批量分配。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、双日期筛选、分类 Tab、列表、跳至、单行分配类型、批量分配、已分类不可重复分配、回写准入产品;共 **36** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录与日期规则仅保留本页双日期和待分类/已分类差异;编号前缀 `TP_ADMIT_TSK` | +| 待评审项 | ① 分配类型的可选值清单与是否允许改已分类 ② 批量分配是否必须同一准入类型 ③ 扫描未打开分配弹窗,弹窗内字段以现网为准需补一轮元素扫描 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-task-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..2a60a4302e297d92c1ec895321ba49ffaa0f0698 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\345\215\225\345\210\227\350\241\250-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,111 @@ +# 基础管理-准入管理-物料准入单列表 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-003.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productOrder/zrlist` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-material-list-elements.json`(79 项) +**定位器**:`locators/spd/admit-material-list.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 物料准入单列表 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_MLS_001**(高)|菜单路径可达物料准入单列表|验证要点:登录后通过「基础设置 → 准入管理 → 物料准入单列表」进入,不误入产品准入单列表或物料准入管理|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选区、状态 Tab 与列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_003**(中)|同组菜单可切换|验证要点:切换同组其它准入菜单后 URL 对应,再切回本页正确|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 边界验证点 + +- **TP_ADMIT_MLS_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开物料准入单列表 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 异常验证点 + +- **TP_ADMIT_MLS_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录页,不展示单据|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_006**(中)|登录态过期|验证要点:会话失效后查询或打开详情被拦截并提示重新登录|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 集成验证点 + +- **TP_ADMIT_MLS_008**(中)|顶栏院区与单据范围一致|验证要点:列表单据所属院区或供应商范围与顶栏上下文不矛盾|关联需求:UI-SPEC-SPD-ADMIT-003 + +--- + +### 筛选、状态与列表 + +#### 功能验证点 + +- **TP_ADMIT_MLS_009**(高)|默认制单日期查询|验证要点:点击「查询」后列表有数据或空态明确,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_010**(高)|制单日期、进院供应商、二级供应商、耗材类型、注册证号、器械注册人筛选|验证要点:分别按上述条件查询,结果与条件匹配|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_011**(中)|快捷区重置恢复默认|验证要点:已填筛选字段恢复默认;列表是否刷新按产品规则(自动化 `ADMIT-MLS-004`)|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_035**(高)|全量可见条件组合查询|验证要点:仅填当前结果里有值的条件,结果同时满足已填条件;分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_036**(中)|收起后再展开筛选区|验证要点:已填筛选值不丢失|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_012**(高)|状态 Tab|验证要点:全部、审批中、已通过、已驳回切换后,列表「状态」列与 Tab 一致,Tab 计数与分页总数一致|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_013**(高)|列表核心列|验证要点:展示序号、准入类型、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、操作「详情」|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_014**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,页码高亮且首行更换;翻页不丢失筛选与当前 Tab;不足 2 页则该点不适用|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_037**(中)|切换每页条数|验证要点:当前页行数 = min(总条数, 所选档位);总条数不变;无数据则不适用|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_038**(低)|列设置隐藏后再恢复|验证要点:取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 边界验证点 + +- **TP_ADMIT_MLS_015**(中)|制单日期起止同一天|验证要点:起止同一天仅返回该日单据|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_016**(中)|零计数 Tab 与首页末页|验证要点:计数为 0 的 Tab 为空态;第 1 页上一页、末页下一页不可用|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 异常验证点 + +- **TP_ADMIT_MLS_017**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_018**(中)|查询失败与无匹配空态|验证要点:接口失败有提示且不白屏;无匹配时总数为 0|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 集成验证点 + +- **TP_ADMIT_MLS_019**(高)|单据来源于物料准入生成|验证要点:在物料准入管理生成的申请,可按准入单号在本列表查到,准入类型与状态一致|关联需求:UI-SPEC-SPD-ADMIT-003 + +--- + +### 单据详情 + +#### 功能验证点 + +- **TP_ADMIT_MLS_020**(高)|打开详情|验证要点:行内「详情」进入单据详情,标题为产品准入单详情,含审批进程、单据日志、返回|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_021**(高)|详情产品列表与产品条数一致|验证要点:详情内产品行数与列表「产品条数」一致|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_022**(中)|详情内按产品编码或名称过滤|验证要点:在详情搜索框输入产品编码或名称后,明细仅保留匹配行|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_023**(中)|审批进程与单据日志可打开|验证要点:点击「审批进程」「单据日志」展示对应记录,关闭后仍停留在该单据|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_024**(中)|明细查看与附件|验证要点:明细行「查看」打开产品信息;「附件」打开该行附件,无附件时数量为 0 且行为明确|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_025**(高)|返回列表|验证要点:点击「返回」回到物料准入单列表,原筛选与 Tab 保持|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_026**(中)|详情导出|验证要点:点击「导出」导出当前单据明细,条数与详情列表一致|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 边界验证点 + +- **TP_ADMIT_MLS_027**(中)|产品条数为 1|验证要点:仅 1 条明细时详情列表与导出均为 1 条|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_028**(低)|详情搜索无匹配|验证要点:搜索无匹配时明细空态明确,不清空单据头信息|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 异常验证点 + +- **TP_ADMIT_MLS_029**(中)|详情或导出失败|验证要点:详情加载或导出失败有提示,不白屏|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_030**(中)|已驳回单据不可通过|验证要点:已驳回单据上「通过」不可用或操作被拒绝,状态保持已驳回|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_031**(中)|已通过单据不可再次通过|验证要点:已通过单据详情中「通过」不可用或被拒绝,状态保持已通过|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_032**(中)|取消通过不改变状态|验证要点:若「通过」有确认框,点取消后单据状态与列表 Tab 计数不变|关联需求:UI-SPEC-SPD-ADMIT-003 + +#### 集成验证点 + +- **TP_ADMIT_MLS_033**(高)|审批通过后列表状态更新|验证要点:在允许审批的单据上「通过」成功后,返回列表该单状态为已通过,Tab 计数随之变化|关联需求:UI-SPEC-SPD-ADMIT-003 +- **TP_ADMIT_MLS_034**(中)|详情产品与物料准入管理中的产品主数据一致|验证要点:抽样核对详情中的产品名称、注册证号与物料准入管理详情一致|关联需求:UI-SPEC-SPD-ADMIT-003 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选、状态与列表、单据详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、筛选、状态 Tab、列表、详情、审批进程、单据日志、附件、导出、通过确认与取消、已通过/已驳回不可再通过;组合查询/重置/收起、分页/每页条数/列设置已拆成原子点;共 **38** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 与产品准入单列表共用的单据详情规则,本页保留物料单 URL、准入类型列与导出/附件差异;编号前缀 `TP_ADMIT_MLS` | +| 待评审项 | ① 「通过」是否仅审批角色可见 ② 已通过单据是否允许撤回 ③ 详情标题写「产品准入单详情」是否为物料单与产品单共用页 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-material-list-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..e314165954887e2c89eba0903db27b692e46c839 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\207\206\345\205\245\347\256\241\347\220\206-\347\211\251\346\226\231\345\207\206\345\205\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,175 @@ +# 基础管理-准入管理-物料准入管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-ADMIT-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/dataSync/dataSync/productApply/ZRlist` +**版本 / 定稿日期**:2026-09-22 +**元素扫描**:`data/scan/admit-material-elements.json`(328 项) +**定位器**:`locators/spd/admit-material.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 准入管理 · 物料准入管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_ADMIT_MAT_001**(高)|菜单路径可达物料准入管理页|验证要点:登录后通过「基础设置 → 准入管理 → 物料准入管理」进入目标页,左侧菜单与顶栏 Tab 高亮为物料准入管理,不误入产品准入申请或物料准入单列表|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_002**(高)|页面主区加载完成|验证要点:整页渲染(无报表 iframe),筛选区、状态 Tab、工具栏与列表列头可见|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_003**(中)|同组准入管理菜单可切换|验证要点:切换到产品准入申请、物料准入单列表、产品准入单列表、供货目录申请、准入任务分配后 URL 与页面对应,再切回物料准入管理状态正确|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开物料准入管理 URL,页面正常渲染|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录页或提示重新登录,不展示准入列表数据|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_006**(中)|登录态过期访问|验证要点:会话失效后刷新或查询,提示重新登录且不把业务操作提交成功|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_007**(中)|无准入管理权限|验证要点:无权限账号不可见菜单或访问 URL 被拒绝|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_008**(中)|顶栏院区上下文与列表数据范围一致|验证要点:顶栏当前院区与列表默认进院供应商、产品数据范围不矛盾|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_ADMIT_MAT_009**(高)|默认新增日期查询|验证要点:进入页面后点击「查询」,列表有数据或空态明确,底部分页「共 N 条」与当前结果一致|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_010**(高)|新增日期区间查询|验证要点:调整新增日期起止后查询,结果落在所选区间内|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_011**(高)|进院供应商筛选|验证要点:填写或选择进院供应商后查询,结果与条件匹配|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_012**(高)|耗材类型筛选|验证要点:选择耗材类型后查询,列表耗材类型与所选值匹配|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_013**(中)|产品名称、规格型号、注册证号、器械注册人筛选|验证要点:分别按上述字段查询,结果行对应列匹配关键字或所选值|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_014**(高)|多条件组合查询|验证要点:日期、供应商、耗材类型等同时填写时,结果同时满足全部已填条件|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_015**(中)|重置恢复默认|验证要点:填写筛选后点「重置」,各字段恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_016**(中)|收起与展开筛选区|验证要点:点击「收起」折叠筛选区,再展开后已填条件仍保留|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_017**(中)|新增日期起止同一天|验证要点:开始日期等于结束日期时查询成功,仅返回该日数据|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_018**(低)|文本条件为空仅按日期查询|验证要点:产品名称等留空时不因空值报错,按其余条件查询|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_019**(中)|新增日期开始晚于结束|验证要点:开始日期晚于结束日期时拒绝查询或自动纠正,并给出可理解提示|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_020**(中)|查询接口异常|验证要点:列表查询失败时有错误提示,不白屏、不把过期数据展示为成功态|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_021**(低)|无匹配结果空态|验证要点:条件无匹配时列表空态明确,分页总数为 0|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_022**(中)|筛选结果与分页总数一致|验证要点:查询后底部分页「共 N 条」与当前筛选全量条数一致|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +### 状态 Tab + +#### 功能验证点 + +- **TP_ADMIT_MAT_023**(高)|状态 Tab 切换|验证要点:在全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效之间切换后,列表仅含该状态数据(「全部」不额外收窄)|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_024**(高)|Tab 计数与列表总数一致|验证要点:各状态 Tab 括号内数量与该状态下分页「共 N 条」一致|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_025**(中)|数量为 0 的状态 Tab|验证要点:计数为 0 的 Tab 可进入,列表为空态,不报错|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_026**(低)|切换 Tab 时查询未完成|验证要点:查询进行中切换状态 Tab,最终只展示最后一次有效结果,不叠加以旧数据|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_027**(中)|状态 Tab 与筛选条件同时生效|验证要点:已填筛选后再切 Tab,结果同时满足筛选条件与所选状态|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +### 列表、勾选与分页 + +#### 功能验证点 + +- **TP_ADMIT_MAT_028**(高)|列表核心列完整|验证要点:主表展示序号、准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、单位、耗材类型、默认进院供应商、操作|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_029**(中)|列设置生效|验证要点:列设置勾选或取消列后,表头显示与设置一致|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_030**(中)|分页翻页与每页条数|验证要点:总条数大于一页时可翻页且数据更换;切换每页条数后当前页行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_031**(中)|行勾选|验证要点:勾选单行或表头全选后,选中行与后续批量操作的对象一致|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_032**(中)|首页上一页、末页下一页不可用|验证要点:第 1 页「上一页」禁用或无效,最后一页「下一页」禁用或无效|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_033**(低)|未勾选时批量按钮状态|验证要点:未勾选行时,依赖勾选的批量操作不可用或提示先选择数据|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_034**(中)|翻页保持当前筛选与状态 Tab|验证要点:带筛选和状态翻页后,新页数据仍满足原条件|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +### 关联产品、生成申请单、一键生成与作废 + +#### 功能验证点 + +- **TP_ADMIT_MAT_035**(高)|关联产品打开选择层|验证要点:点击「关联产品」打开产品关联界面,取消关闭后列表不新增申请单、默认进院供应商不变|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_036**(高)|确认关联后关系落库|验证要点:在关联层选定产品并确认后,当前行关联关系或默认进院供应商与所选项一致,再次打开详情可回看|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_037**(高)|勾选待申请行生成申请单|验证要点:勾选允许生成的行后点「生成申请单」,产生对应准入申请且原行状态按规则离开待申请|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_038**(高)|一键生成按当前条件处理|验证要点:点击「一键生成」仅处理当前筛选且状态允许的记录,处理范围与提示条数一致|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_039**(高)|确认作废后状态变为已作废|验证要点:勾选允许作废的行并在确认框点确定后,该行进入已作废,已作废 Tab 可查到|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_040**(高)|取消作废不改变状态|验证要点:作废确认框点取消后,原行状态与分页总数不变|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_041**(中)|仅勾选一条生成申请单|验证要点:只勾选 1 条合法行时生成 1 张申请,不多生成|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_042**(高)|不允许的状态执行生成或作废|验证要点:对已生效、已作废等不允许的行执行生成申请单或作废时被拒绝,并提示原因,原状态不变|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_043**(中)|重复点击生成或作废|验证要点:连续点击只产生一次有效结果,不生成重复申请单|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_044**(中)|写接口失败|验证要点:生成或作废接口失败时有错误提示,列表不出现半成品成功态|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_045**(高)|生成的申请单可在产品准入申请或准入单列表追溯|验证要点:生成成功后,对应申请单号或准入单可在产品准入申请、物料准入单列表中按单号查到,状态与来源行一致|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +### 详情、编辑与日志 + +#### 功能验证点 + +- **TP_ADMIT_MAT_046**(高)|打开详情|验证要点:行内「详情」打开产品详情,展示产品名称、规格型号、注册证号、器械注册人、分类与医保信息,可返回列表|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_047**(高)|编辑打开并回填|验证要点:待申请行点「编辑」,产品名称、规格型号、注册证号、器械注册人与该行及详情原值一致|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_048**(高)|待申请行可编辑并保存|验证要点:在回填正确的编辑页修改可编辑字段后保存,再次打开为新值|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_049**(中)|编辑取消不落库|验证要点:修改后返回或取消,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_050**(中)|打开操作日志|验证要点:行内「日志」打开操作日志,含该产品的操作记录,关闭后回到列表|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 边界验证点 + +- **TP_ADMIT_MAT_051**(中)|详情只读字段不可改|验证要点:详情态下产品主数据关键字段不可直接改写落库|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 异常验证点 + +- **TP_ADMIT_MAT_052**(高)|编辑清空必填被拦截|验证要点:清空产品名称等必填项后保存被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-ADMIT-001 +- **TP_ADMIT_MAT_053**(中)|非待申请行不提供编辑或编辑被拒绝|验证要点:已生效等行不出现「编辑」,或打开后保存被拒绝|关联需求:UI-SPEC-SPD-ADMIT-001 + +#### 集成验证点 + +- **TP_ADMIT_MAT_054**(中)|编辑保存后列表列与详情一致|验证要点:保存后列表产品名称、规格型号等与详情回看一致|关联需求:UI-SPEC-SPD-ADMIT-001 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、状态 Tab、列表、勾选与分页、关联产品、生成申请单、一键生成与作废、详情、编辑与日志。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 导航、筛选、状态 Tab、列表分页、关联产品(打开/确认)、生成申请单、一键生成、作废确认与取消、详情、编辑回填与保存、日志;共 **54** 条 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截与同组其它准入页规则相同,本页仅保留物料准入菜单、URL 与待申请生成/作废差异;编号前缀 `TP_ADMIT_MAT` | +| 待评审项 | ① 一键生成是否二次确认、处理范围是否含当前 Tab 以外数据需产品确认 ② 已通过与已生效的编辑权限边界 ③ 关联产品是否改写默认进院供应商 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 JSON `data/scan/admit-material-elements.json` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..006dac0c0e262bec8250a9b5ff09848d1a807fbe --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\205\254\345\221\212\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,184 @@ +# 基础管理-医院资料-公告管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-BASIC-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/notice` +**版本 / 定稿日期**:2026-09-16 +**元素扫描**:`data/scan/basic-notice-elements.json`(82 项) +**定位器**:`locators/spd/basic-notice.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 医院资料 · 公告管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_BASIC_NTC_001**(高)|菜单路径可达公告管理页|验证要点:登录后通过「基础设置 → 医院资料 → 公告管理」进入目标页,左侧菜单与顶栏 Tab 高亮为公告管理,不误入医院信息登记/院区管理/财务月份设置|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_002**(高)|页面主区加载完成|验证要点:整页渲染(无报表 iframe),筛选区、新增按钮、列表列头与分页可见|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_003**(中)|同组医院资料菜单可切换|验证要点:切换到医院信息登记/院区管理/财务月份设置后 URL 与页面内容对应,再切回公告管理状态正确|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/hospital/paltform/notice`,页面正常渲染|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录页或提示重新登录,不展示公告列表数据|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_006**(中)|登录态过期访问|验证要点:会话失效后刷新或操作查询,提示重新登录且不提交业务请求成功|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_007**(中)|无医院资料/公告管理权限|验证要点:无权限账号不可见菜单或访问 URL 被拒绝|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_008**(中)|顶栏院区/用户上下文与列表发布人范围一致|验证要点:顶栏当前院区/用户与列表「发布人」「发布科室」展示范围不矛盾|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_BASIC_NTC_009**(高)|默认条件查询|验证要点:进入页面后点击「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_010**(高)|公告标题模糊查询|验证要点:按标题关键字查询,结果行「公告标题」均包含该关键字(或产品约定的模糊规则)|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_011**(高)|发布日期区间查询|验证要点:按开始/结束日期查询,结果落在所选发布日期区间内|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_012**(高)|发布范围下拉查询|验证要点:分别选择全部/全院/中心库/进院供应商后查询,列表「发布范围」列与所选值匹配(选「全部」时不额外收窄)|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_013**(高)|标题+日期+范围组合查询|验证要点:多个已填条件同时生效,结果同时满足全部条件|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_014**(中)|重置恢复默认|验证要点:填写筛选后点「重置」,标题/日期/范围恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_015**(中)|发布日期起止同一天|验证要点:开始日期等于结束日期时查询成功,仅返回该日数据|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_016**(中)|公告标题超长关键字|验证要点:输入达到或超过字段允许长度的标题关键字,查询被接受并按规则截断或返回匹配结果,页面不崩溃|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_017**(低)|标题为空仅按日期/范围查询|验证要点:标题留空时按其余条件查询,不因空标题报错|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_018**(中)|发布日期开始晚于结束|验证要点:开始日期晚于结束日期时拒绝查询或自动纠正,并给出可理解提示|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_019**(中)|查询接口异常|验证要点:列表查询失败时有错误提示,不白屏、不展示过期脏数据为成功态|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_020**(低)|无匹配结果空态|验证要点:条件无匹配时列表空态或「暂无数据」明确,分页总数为 0|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_021**(中)|筛选结果与分页总数一致|验证要点:查询后主表可见行数与底部分页「共 N 条」在当前页逻辑下一致|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_BASIC_NTC_022**(高)|列表核心列完整|验证要点:主表展示序号、公告编号、公告标题、发布范围、发布科室、发布人、操作(编辑/删除)|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_023**(中)|表格密度切换|验证要点:密度(column-height)切换后列表仍可读,列不丢失|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_024**(中)|列设置生效|验证要点:列设置(setting)勾选/取消列后,对应列显示或隐藏与设置一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_025**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_026**(中)|每页条数切换|验证要点:切换「50 条/页」等页大小后,当前页行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_027**(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_028**(中)|首页上一页不可用|验证要点:第 1 页时「上一页」禁用或点击无效|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_029**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_030**(低)|跳至超出总页数|验证要点:跳至大于总页数的页码时被拒绝、纠正到末页或给出提示|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_031**(低)|跳至非数字页码|验证要点:跳至输入非法字符时不跳转异常页,输入被忽略或提示|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_032**(中)|翻页不丢失当前筛选|验证要点:带筛选条件翻页后,条件保持,新页数据仍满足原筛选|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +### 新增公告 + +#### 功能验证点 + +- **TP_BASIC_NTC_033**(高)|打开新增公告弹窗|验证要点:点击「新增」弹出「新增公告」,含标题、发布范围、置顶、截止日期、富文本内容、取消/发布|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_034**(高)|合法必填发布成功|验证要点:填写必填标题、发布范围、公告内容后点「发布」,弹窗关闭,列表出现新记录且字段与提交一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_035**(高)|发布范围四档可选|验证要点:新增弹窗单选全部/全院/中心库/进院供应商均可选中且互斥,发布后列表「发布范围」与所选一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_036**(中)|置顶显示是/否|验证要点:选择置顶「是」或「否」后发布,记录按产品规则置顶或保持普通排序|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_037**(中)|截止日期可选|验证要点:选择截止日期后发布,编辑回看该日期与提交一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_038**(中)|富文本内容可写入|验证要点:在 `.ql-editor` 输入正文后发布,编辑回看正文与提交一致(纯文本级)|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_039**(中)|取消不落库|验证要点:填写后点「取消」或关闭,列表不新增对应记录|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_040**(中)|标题达到允许最大长度|验证要点:标题取最大允许长度可发布成功|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_041**(中)|截止日期为当天|验证要点:截止日期选当天可发布或按产品规则提示,行为明确|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_042**(低)|截止日期不填|验证要点:截止日期为空时允许发布(非必填),记录无截止日期或展示为空|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_043**(高)|必填标题为空拦截|验证要点:标题为空点「发布」被拦截,出现必填提示,不关闭弹窗、不落库|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_044**(高)|必填内容为空拦截|验证要点:富文本内容为空点「发布」被拦截并提示,不落库|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_045**(中)|标题超长拦截|验证要点:标题超过最大长度时拒绝发布并提示|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_046**(中)|截止日期早于当天|验证要点:截止日期早于当前日期时拒绝或警告,行为与产品规则一致|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_047**(中)|发布接口失败|验证要点:保存接口失败时有错误提示,弹窗仍在,列表不出现半成品成功态|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_048**(中)|重复点击发布|验证要点:连续快速点击「发布」只产生一条记录或第二次被防重|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_049**(高)|新公告对目标范围可见|验证要点:按发布范围发布后,对应范围终端(全院/中心库/进院供应商)能看到该公告,范围外不可见(与 `API-SPD-BASIC-001` 通知公告约定一致)|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +### 编辑公告 + +#### 功能验证点 + +- **TP_BASIC_NTC_050**(高)|打开编辑公告弹窗|验证要点:行内「编辑」打开「编辑公告」,回填标题、范围、置顶、截止日期、富文本原文|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_051**(高)|修改后发布落库|验证要点:修改标题或内容后点「发布」,列表与再次编辑回看均为新值|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_052**(中)|编辑取消不保存|验证要点:修改后点「取消」,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_053**(低)|不改字段直接发布|验证要点:打开编辑不改内容点「发布」,记录保持原值且不报错|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_054**(高)|编辑清空必填拦截|验证要点:清空标题或内容后发布被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_055**(中)|编辑已删除记录|验证要点:记录已被他人删除后再发布,提示记录不存在,不产生幽灵更新成功|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_056**(中)|编辑后目标端内容同步|验证要点:修改已发布公告后,目标范围再次打开看到的是新标题/正文|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +### 删除公告 + +#### 功能验证点 + +- **TP_BASIC_NTC_057**(高)|删除需二次确认|验证要点:点行内「删除」出现确认框(标题「提示」),含取消/确定|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_058**(高)|确认删除后列表移除|验证要点:确认后该行消失,分页总数减 1,再查标题无该编号|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_059**(中)|取消删除保留记录|验证要点:确认框点「取消」后记录仍在,编号不变|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 边界验证点 + +- **TP_BASIC_NTC_060**(低)|删除列表最后一条|验证要点:删除当前筛选下最后一条后,空态或总数为 0,页面不报错|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 异常验证点 + +- **TP_BASIC_NTC_061**(中)|删除接口失败|验证要点:删除失败有提示,该行仍在列表|关联需求:UI-SPEC-SPD-BASIC-001 +- **TP_BASIC_NTC_062**(中)|重复确认删除|验证要点:记录已删后再确认,提示不存在或静默成功,不出现未捕获异常|关联需求:UI-SPEC-SPD-BASIC-001 + +#### 集成验证点 + +- **TP_BASIC_NTC_063**(中)|删除后目标端不再展示|验证要点:删除已发布公告后,原发布范围终端不再展示该条|关联需求:UI-SPEC-SPD-BASIC-001 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与分页、新增公告、编辑公告、删除公告。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、筛选查询、列表分页、新增/编辑/删除、置顶与截止日期、富文本;共 **63** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它医院资料页规则相同,本页仅保留公告菜单/URL 差异;编号前缀 `TP_BASIC_NTC` | +| 待评审项 | ① 发布范围「进院供应商」与规范旧称「供应商」文案以现网为准 ② 置顶后列表排序规则需产品确认 ③ 截止日期过期后公告是否自动下架 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-basic` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..2d1e3a09546ad4ba1d2cd1c3dc9396356273c509 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\345\214\273\351\231\242\344\277\241\346\201\257\347\231\273\350\256\260-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,127 @@ +# 基础管理-医院资料-医院信息登记 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-BASIC-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/info` +**版本 / 定稿日期**:2026-09-16 +**元素扫描**:`data/scan/basic-hospital-info-elements.json`(38 项) +**定位器**:`locators/spd/basic-hospital-info.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 医院资料 · 医院信息登记 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_BASIC_HOS_001**(高)|菜单路径可达医院信息登记页|验证要点:登录后通过「基础设置 → 医院资料 → 医院信息登记」进入目标页,菜单与 Tab 高亮正确,不误入公告/院区/财务月份页|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_002**(高)|详情页加载完成|验证要点:整页渲染,查看模式下展示医院编码、医院名称、地址、联系人、联系方式、医院简称、登录页背景、医院 logo,工具栏「编辑」可见|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_003**(中)|同组医院资料菜单可切换|验证要点:切到公告管理等页再返回,详情字段仍为当前医院资料|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 边界验证点 + +- **TP_BASIC_HOS_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/hospital/paltform/info`,详情正常渲染|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 异常验证点 + +- **TP_BASIC_HOS_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示医院资料|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_006**(中)|登录态过期访问|验证要点:会话失效后刷新或保存,提示重新登录且不误更新资料|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_007**(中)|无医院信息登记权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 集成验证点 + +- **TP_BASIC_HOS_008**(中)|展示资料与当前登录医院一致|验证要点:页面医院编码/名称与登录医院主数据、顶栏医院上下文一致|关联需求:UI-SPEC-SPD-BASIC-002 + +--- + +### 查看模式 + +#### 功能验证点 + +- **TP_BASIC_HOS_009**(高)|只读字段不可改|验证要点:查看模式下医院编码、医院名称、地址不可编辑|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_010**(高)|可编辑字段查看态只读|验证要点:未点「编辑」时联系人、联系方式、医院简称不以输入框提交,展示为只读文本|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_011**(中)|背景与 logo 可预览|验证要点:已上传时「预览文件」可打开预览,图片与已存文件对应|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 边界验证点 + +- **TP_BASIC_HOS_012**(低)|无背景或无 logo|验证要点:未上传时占位/提示文案明确(尺寸与格式说明可见),不报错|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 异常验证点 + +- **TP_BASIC_HOS_013**(中)|详情接口失败|验证要点:加载医院信息失败时有错误提示,不展示空白成功态或错院数据|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 集成验证点 + +- **TP_BASIC_HOS_014**(中)|只读字段与主数据接口一致|验证要点:医院编码/名称/地址与 `API-SPD-BASIC-001` 医院主数据查询结果一致|关联需求:UI-SPEC-SPD-BASIC-002 + +--- + +### 编辑与保存 + +#### 功能验证点 + +- **TP_BASIC_HOS_015**(高)|进入编辑模式|验证要点:点「编辑」后出现「保存」「取消」,联系人、联系方式、医院简称变为可输入|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_016**(高)|编辑态只读字段仍不可改|验证要点:编辑模式下医院编码、医院名称、地址仍不可改|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_017**(高)|保存后字段持久化|验证要点:修改联系人/联系方式/医院简称后保存,退出再进或刷新后仍为新值|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_018**(高)|取消不保存|验证要点:修改后点「取消」回到查看模式,字段仍为修改前值|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_019**(中)|保存后回到查看模式|验证要点:保存成功后「保存/取消」消失,「编辑」重新出现,字段恢复只读展示|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 边界验证点 + +- **TP_BASIC_HOS_020**(中)|联系人/简称达到最大长度|验证要点:达到允许最大长度可保存成功|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_021**(低)|不改内容直接保存|验证要点:进入编辑不改字段点保存,数据不变且成功或提示无变更,不报错|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 异常验证点 + +- **TP_BASIC_HOS_022**(高)|必填联系人为空拦截|验证要点:清空联系人后保存被拦截并提示,原值不被写成空|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_023**(高)|必填联系方式为空拦截|验证要点:清空联系方式后保存被拦截并提示|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_024**(高)|必填医院简称为空拦截|验证要点:清空医院简称后保存被拦截并提示|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_025**(中)|联系方式格式非法|验证要点:输入明显非法联系方式时拒绝保存或按产品规则校验并提示|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_026**(中)|超长字段拦截|验证要点:超过最大长度时拒绝保存并提示|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_027**(中)|保存接口失败|验证要点:更新失败有提示,页面仍为编辑态或回看旧值,不出现半保存成功|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_028**(中)|重复点击保存|验证要点:快速连点「保存」不产生重复提交错误或脏写|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 集成验证点 + +- **TP_BASIC_HOS_029**(中)|保存后登录页/其它入口简称一致|验证要点:医院简称保存后,依赖简称的展示入口(若有)与本页一致|关联需求:UI-SPEC-SPD-BASIC-002 + +--- + +### 图片上传 + +#### 功能验证点 + +- **TP_BASIC_HOS_030**(高)|编辑态可上传登录页背景|验证要点:编辑模式下可通过 `loginBgUploadInput` 上传符合说明的图片,保存后查看态可预览新图|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_031**(高)|编辑态可上传医院 logo|验证要点:通过 `logoUploadInput` 上传符合说明的图片,保存后查看态可预览新 logo|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_032**(中)|上传后取消不替换原图|验证要点:编辑态选了新图但点「取消」,原背景/logo 不变|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 边界验证点 + +- **TP_BASIC_HOS_033**(中)|背景尺寸上限|验证要点:按页面说明(约 1920×1040、png/jpg、不超过 2MB)的临界合法文件可上传成功|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_034**(中)|logo 大小上限|验证要点:按页面说明(png、不超过 1MB)的临界合法文件可上传成功|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 异常验证点 + +- **TP_BASIC_HOS_035**(中)|超限或非法格式被拒|验证要点:超过尺寸/大小或非 png/jpg 的文件被拒绝并提示,原图不被替换|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_036**(中)|上传接口失败|验证要点:上传失败有提示,保存后仍为原图或明确失败态|关联需求:UI-SPEC-SPD-BASIC-002 + +#### 集成验证点 + +- **TP_BASIC_HOS_037**(高)|登录页背景对登录页生效|验证要点:保存新登录页背景后,登录页实际展示为新图(或按产品约定的缓存刷新后生效)|关联需求:UI-SPEC-SPD-BASIC-002 +- **TP_BASIC_HOS_038**(高)|医院 logo 对系统展示生效|验证要点:保存新 logo 后,系统顶栏或约定入口展示新 logo|关联需求:UI-SPEC-SPD-BASIC-002 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、查看模式、编辑与保存、图片上传。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、查看只读、编辑保存/取消、必填与格式、背景/logo 上传与登录页联动;共 **38** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录/权限类与公告管理共用规则,本页仅保留本 URL 与医院资料字段差异;编号前缀 `TP_BASIC_HOS` | +| 待评审项 | ① 联系方式校验规则(手机/座机/长度)需产品确认 ② 图片替换是否支持删除后无图 ③ 登录页缓存刷新时效 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-basic` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..2ce8439ec68cc009629c6a186465365650d55eea --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\350\264\242\345\212\241\346\234\210\344\273\275\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,144 @@ +# 基础管理-医院资料-财务月份设置 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-BASIC-003.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/hospital/balance/month` +**版本 / 定稿日期**:2026-09-16 +**元素扫描**:`data/scan/basic-finance-month-elements.json`(43 项) +**定位器**:`locators/spd/basic-finance-month.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 医院资料 · 财务月份设置 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_BASIC_FMN_001**(高)|菜单路径可达财务月份设置页|验证要点:登录后通过「基础设置 → 医院资料 → 财务月份设置」进入目标页,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_002**(高)|页面主区加载完成|验证要点:整页渲染,财务月份筛选、查询/重置、列表列头与分页可见|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_003**(中)|同组医院资料菜单可切换|验证要点:切到其它医院资料页再返回,列表仍为财务月份数据|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 边界验证点 + +- **TP_BASIC_FMN_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/hospital/balance/month`,列表正常渲染|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 异常验证点 + +- **TP_BASIC_FMN_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示月份数据|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_006**(中)|登录态过期访问|验证要点:会话失效后查询或保存被拦截,不误改自动结算日|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_007**(中)|无财务月份设置权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 集成验证点 + +- **TP_BASIC_FMN_008**(中)|列表月份与医院财务账期主数据一致|验证要点:抽样行的财务月份、开始/结束日期与财务主数据或结算模块使用的账期一致|关联需求:UI-SPEC-SPD-BASIC-003 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_BASIC_FMN_009**(高)|默认查询出列表|验证要点:进入后点「查询」或默认加载,列表有历史/当前月份行或空态明确,分页「共 N 条」正确|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_010**(高)|按财务月份筛选|验证要点:选择某一财务月份后查询,结果仅含该月份或包含该月的约定集合|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_011**(中)|重置恢复默认|验证要点:选择月份后点「重置」,筛选恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 边界验证点 + +- **TP_BASIC_FMN_012**(低)|财务月份为空查询|验证要点:不选月份直接查询,返回默认全量或当前规则下的月份列表,不报错|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_013**(低)|筛选最早/最晚已有月份|验证要点:选择列表中最早或最晚月份能查到对应行|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 异常验证点 + +- **TP_BASIC_FMN_014**(中)|查询接口异常|验证要点:查询失败有错误提示,不白屏|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_015**(低)|无匹配月份空态|验证要点:筛选不存在的月份时列表空态明确,总数为 0|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 集成验证点 + +- **TP_BASIC_FMN_016**(中)|筛选结果与分页总数一致|验证要点:查询后可见行与「共 N 条」逻辑一致|关联需求:UI-SPEC-SPD-BASIC-003 + +--- + +### 列表与状态 + +#### 功能验证点 + +- **TP_BASIC_FMN_017**(高)|列表核心列完整|验证要点:展示财务月份、开始日期、结束日期、自动结算日期、状态、操作|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_018**(高)|当前月份唯一可改|验证要点:状态为「当前月份」的行显示「修改」;「历史月份」行无修改操作或为「--」|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_019**(中)|当前月份与历史月份互斥|验证要点:同一时点列表至多一行状态为「当前月份」|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_020**(中)|表格密度/列设置|验证要点:密度或列设置变更后核心列仍可核对,不丢「状态」「操作」|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 边界验证点 + +- **TP_BASIC_FMN_021**(中)|仅一行当前月份|验证要点:无论列表多长,可修改入口只出现在当前月份行|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 异常验证点 + +- **TP_BASIC_FMN_022**(中)|历史月份无法进入编辑|验证要点:历史月份行不能通过任何入口改开始/结束/自动结算日期|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 集成验证点 + +- **TP_BASIC_FMN_023**(高)|当前月份与业务结算使用账期一致|验证要点:本页「当前月份」与出入库/结算报表默认财务月份一致|关联需求:UI-SPEC-SPD-BASIC-003 + +--- + +### 当前月份修改 + +#### 功能验证点 + +- **TP_BASIC_FMN_024**(高)|修改进入行内编辑|验证要点:当前月份点「修改」后出现「保存」「取消」,自动结算日期变为日期框|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_025**(高)|开始/结束日期编辑态只读|验证要点:行内编辑时开始日期、结束日期不可改,仅自动结算日期可改|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_026**(高)|保存自动结算日期持久化|验证要点:修改自动结算日期并保存,刷新后该行仍为新日期|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_027**(高)|取消不保存|验证要点:改了日期点「取消」,自动结算日期恢复修改前值|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_028**(中)|保存后退出行内编辑|验证要点:保存成功后「保存/取消」消失,「修改」重新出现|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 边界验证点 + +- **TP_BASIC_FMN_029**(中)|自动结算日落在当月区间内|验证要点:选择落在该月开始~结束日期内的日期可保存成功|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_030**(中)|自动结算日等于开始或结束日|验证要点:选开始日或结束日当天可保存或按产品规则提示,行为明确|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 异常验证点 + +- **TP_BASIC_FMN_031**(高)|自动结算日为空拦截|验证要点:清空日期后保存被拦截并提示,原日期不被写成空|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_032**(高)|自动结算日超出当月区间|验证要点:早于开始日或晚于结束日时拒绝保存并提示|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_033**(中)|保存接口失败|验证要点:更新失败有提示,行上日期仍为旧值|关联需求:UI-SPEC-SPD-BASIC-003 +- **TP_BASIC_FMN_034**(中)|重复点击保存|验证要点:快速连点保存不产生重复错误或脏写|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 集成验证点 + +- **TP_BASIC_FMN_035**(高)|自动结算日变更影响自动结算任务|验证要点:保存新自动结算日后,依赖该日的自动结算/出账任务按新日期执行或调度(与结算模块约定一致)|关联需求:UI-SPEC-SPD-BASIC-003 + +--- + +### 分页 + +#### 功能验证点 + +- **TP_BASIC_FMN_036**(中)|分页翻页与页大小|验证要点:总条数大于一页时可翻页;切换每页条数后行数不超过所选值|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 边界验证点 + +- **TP_BASIC_FMN_037**(低)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-BASIC-003 + +#### 异常验证点 + +无。本页分页无跳至输入(扫描未发现 jumper),无额外异常点。 + +#### 集成验证点 + +- **TP_BASIC_FMN_038**(低)|翻页不丢失月份筛选|验证要点:带财务月份条件翻页后条件保持|关联需求:UI-SPEC-SPD-BASIC-003 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与状态、当前月份修改、分页。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、月份筛选、当前/历史状态、仅自动结算日可改、保存/取消、分页;共 **38** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;分页异常写明「无」 | +| 去重 | 登录/权限与同模块其它页共用规则,本页强调当前月份唯一可改;编号前缀 `TP_BASIC_FMN` | +| 待评审项 | ① 自动结算日是否允许等于结束日之后的缓冲天数 ② 当前月份切换是否由系统日切自动完成、本页是否只读切换 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-basic` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..3465f35a1e3de860ef4f268487096934abb4c894 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\214\273\351\231\242\350\265\204\346\226\231-\351\231\242\345\214\272\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,139 @@ +# 基础管理-医院资料-院区管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-BASIC-004.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/hospital/paltform/area` +**版本 / 定稿日期**:2026-09-16 +**元素扫描**:`data/scan/basic-branch-elements.json`(60 项) +**定位器**:`locators/spd/basic-branch.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 医院资料 · 院区管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_BASIC_BRN_001**(高)|菜单路径可达院区管理页|验证要点:登录后通过「基础设置 → 医院资料 → 院区管理」进入目标页,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_002**(高)|页面主区加载完成|验证要点:整页渲染,新增按钮、院区名称搜索框、列表列头与分页可见;无独立「查询」「重置」按钮|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_003**(中)|同组医院资料菜单可切换|验证要点:切到公告/医院信息/财务月份再返回,院区列表仍正确|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 边界验证点 + +- **TP_BASIC_BRN_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/hospital/paltform/area`,列表正常渲染|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 异常验证点 + +- **TP_BASIC_BRN_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示院区数据|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_006**(中)|登录态过期访问|验证要点:会话失效后新增/编辑被拦截,不误写院区|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_007**(中)|无院区管理权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 集成验证点 + +- **TP_BASIC_BRN_008**(中)|列表院区与顶栏可选院区同源|验证要点:本页院区名称集合与顶栏院区切换、其它模块院区下拉同源(允许按权限裁剪)|关联需求:UI-SPEC-SPD-BASIC-004 + +--- + +### 列表搜索与分页 + +#### 功能验证点 + +- **TP_BASIC_BRN_009**(高)|默认列出院区|验证要点:进入页面列表有数据或空态明确,分页「共 N 条」正确|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_010**(高)|院区名称搜索过滤|验证要点:在院区名称框输入关键字后列表仅保留名称匹配行(即时或回车,以现网为准)|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_011**(高)|列表核心列完整|验证要点:展示序号、院区编号、院区名称、院区地址、状态、启用云仓、操作(编辑)|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_012**(中)|分页翻页与页大小|验证要点:可翻页;切换每页条数后行数不超过所选值|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_013**(中)|表格密度/列设置|验证要点:密度或列设置变更后核心列仍可核对|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 边界验证点 + +- **TP_BASIC_BRN_014**(中)|名称搜索超长关键字|验证要点:超长关键字不崩溃,按规则截断或无匹配空态|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_015**(低)|名称搜索清空恢复全量|验证要点:清空搜索框后列表恢复未过滤的默认集合|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_016**(低)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 异常验证点 + +- **TP_BASIC_BRN_017**(中)|列表接口异常|验证要点:加载失败有错误提示,不白屏|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_018**(低)|搜索无匹配空态|验证要点:无匹配时暂无数据,总数为 0|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 集成验证点 + +- **TP_BASIC_BRN_019**(中)|搜索结果与分页总数一致|验证要点:过滤后「共 N 条」与过滤结果规模一致|关联需求:UI-SPEC-SPD-BASIC-004 + +--- + +### 新增院区 + +#### 功能验证点 + +- **TP_BASIC_BRN_020**(高)|打开新增院区弹窗|验证要点:点「新增」弹出「新增院区」,含院区名称、所在地区、详细地址、主院区标志、状态、取消/确定;**无**院区编号、**无**启用云仓|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_021**(高)|合法必填新增成功|验证要点:填写名称、地区、地址并选择主院区标志与状态后确定,弹窗关闭,列表出现新行且字段一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_022**(高)|主院区标志是/否|验证要点:选择「是」或「否」后保存,列表/编辑回看与所选一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_023**(高)|状态停用/启用|验证要点:新增选择停用或启用后,列表「状态」与所选一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_024**(中)|所在地区省市区级联|验证要点:选择省/市/区后提交,编辑回看地区路径与所选一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_025**(中)|取消不落库|验证要点:填写后点「取消」或关闭,列表不新增对应院区|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 边界验证点 + +- **TP_BASIC_BRN_026**(中)|院区名称达到最大长度|验证要点:名称取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_027**(中)|详细地址达到最大长度|验证要点:地址取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_028**(低)|主院区标志默认值|验证要点:未改主院区标志时默认「否」或产品约定默认值,提交结果与默认一致|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 异常验证点 + +- **TP_BASIC_BRN_029**(高)|院区名称为空拦截|验证要点:名称为空点确定被拦截并提示,不落库|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_030**(高)|所在地区为空拦截|验证要点:未选地区点确定被拦截并提示|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_031**(高)|详细地址为空拦截|验证要点:地址为空点确定被拦截并提示|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_032**(中)|院区名称重复|验证要点:与已有院区重名时拒绝或按产品规则提示,不产生无法区分的重名脏数据|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_033**(中)|名称/地址超长拦截|验证要点:超过最大长度时拒绝保存并提示|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_034**(中)|保存接口失败|验证要点:新增失败有提示,弹窗仍在,列表无半成品成功行|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_035**(中)|重复点击确定|验证要点:快速连点确定只产生一条院区或第二次被防重|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 集成验证点 + +- **TP_BASIC_BRN_036**(高)|新院区出现在其它模块院区下拉|验证要点:新增并启用后,统计报表等院区名称下拉能选到该院区(停用则不可选或按规则隐藏)|关联需求:UI-SPEC-SPD-BASIC-004 + +--- + +### 编辑院区 + +#### 功能验证点 + +- **TP_BASIC_BRN_037**(高)|打开编辑院区弹窗|验证要点:行内「编辑」打开「编辑院区」,回填编号、名称、地区、地址、主院区、状态,并**额外**展示启用云仓|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_038**(高)|院区编号回填且不可当新编号滥用|验证要点:编辑弹窗展示院区编号(`#form_item_id`),编号与列表「院区编号」一致;保存后编号不变(只读或改了也不应换号,以产品规则为准)|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_039**(高)|修改名称/地址后保存|验证要点:修改名称或详细地址后确定,列表与再次编辑均为新值|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_040**(高)|状态禁用/启用|验证要点:编辑态状态文案为「禁用/启用」(与新增「停用/启用」不同),保存后列表状态点与所选一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_041**(高)|启用云仓是/否|验证要点:编辑可改启用云仓,保存后列表「启用云仓」列与所选一致|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_042**(中)|编辑取消不保存|验证要点:修改后点「取消」,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 边界验证点 + +- **TP_BASIC_BRN_043**(低)|不改字段直接确定|验证要点:打开编辑不改内容点确定,记录保持原值且不报错|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_044**(中)|主院区改为「是」时唯一性|验证要点:将某院区标为主院区后,是否只允许一个主院区;若是,原主院区应变为否或被拒绝并提示|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 异常验证点 + +- **TP_BASIC_BRN_045**(高)|编辑清空必填拦截|验证要点:清空名称、地区或地址后确定被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_046**(中)|编辑已不存在的院区|验证要点:院区已被删除后再保存,提示不存在,不产生幽灵更新|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_047**(中)|停用/禁用后仍被业务引用|验证要点:院区仍被库存/单据引用时禁用,按产品规则拒绝或允许但下游不可新选,提示明确|关联需求:UI-SPEC-SPD-BASIC-004 + +#### 集成验证点 + +- **TP_BASIC_BRN_048**(高)|启用云仓影响云仓相关能力|验证要点:启用云仓=是后,约定的云仓功能对该院区可用;改为否后不可用|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_049**(高)|禁用院区后其它模块不可新选|验证要点:状态改为禁用/停用后,其它模块院区下拉不再提供该院区作新业务(历史单据仍可展示原名称)|关联需求:UI-SPEC-SPD-BASIC-004 +- **TP_BASIC_BRN_050**(中)|改名后下游展示同步|验证要点:修改院区名称后,已引用该院区的列表展示为新名称(或按产品约定的缓存刷新后一致)|关联需求:UI-SPEC-SPD-BASIC-004 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、列表搜索与分页、新增院区、编辑院区。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、名称搜索(无查询/重置)、分页、新增/编辑字段差异、主院区、状态文案差异、启用云仓、下游院区联动;共 **50** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录/权限与同模块其它页共用规则,本页强调新增 vs 编辑字段差;编号前缀 `TP_BASIC_BRN` | +| 待评审项 | ① 主院区是否全局唯一 ② 搜索是即时过滤还是回车 ③ 禁用已被业务引用的院区策略 ④ 院区编号编辑态是否只读 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-basic` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..30025c91d59acee0087933ceca487ec851c3ce18 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\212\202\345\201\207\346\227\245\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,76 @@ +# 基础管理-厂商管理-供应商节假日管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-010.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/prov/holiday` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-holiday-elements.json`(80 项) +**定位器**:`locators/spd/vendor-holiday.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 供应商节假日管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_HLD_001**(高)|菜单路径可达供应商节假日管理|验证要点:登录后通过「基础设置 → 厂商管理 → 供应商节假日管理」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回供应商节假日管理,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 边界验证点 + +- **TP_VENDOR_HLD_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/prov/holiday`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 异常验证点 + +- **TP_VENDOR_HLD_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 集成验证点 + +- **TP_VENDOR_HLD_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-010 + +--- + +### 默认假日与供应商自定义 + +#### 功能验证点 + +- **TP_VENDOR_HLD_009**(高)|左列表加载|验证要点:供应商节假日设置列表展示年度、名称,可按名称/年度搜索|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_010**(高)|选中行展示默认详情|验证要点:点击左侧行后右侧默认假日详情名称/年度/起止/创建人时间与选中行一致|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_011**(高)|左侧新增/编辑|验证要点:新增或编辑默认假日设置后列表与详情刷新|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_012**(高)|供应商自定义新增|验证要点:右侧「新增」可添加进院供应商自定义节假日|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_013**(中)|批量删除|验证要点:勾选自定义行后批量删除,列表条数减少|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_014**(中)|自定义列表列|验证要点:展示进院供应商编码/名称、起止、备注、创建人时间、操作|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 边界验证点 + +- **TP_VENDOR_HLD_015**(中)|起止同一天|验证要点:默认或自定义假日起止同一天可保存或按规则提示|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_016**(低)|自定义列表空态|验证要点:无自定义数据时「暂无数据」明确|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 异常验证点 + +- **TP_VENDOR_HLD_017**(高)|结束早于开始|验证要点:日期非法时拒绝保存并提示|关联需求:UI-SPEC-SPD-VENDOR-010 +- **TP_VENDOR_HLD_018**(中)|未勾选批量删除|验证要点:无勾选时批量删除不可用或提示|关联需求:UI-SPEC-SPD-VENDOR-010 + +#### 集成验证点 + +- **TP_VENDOR_HLD_019**(中)|进院供应商主数据一致|验证要点:自定义列表中的进院供应商可在厂商/供应商主数据中找到|关联需求:UI-SPEC-SPD-VENDOR-010 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、默认假日与供应商自定义。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、默认假日与供应商自定义;共 **19** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_HLD` | +| 待评审项 | ① 默认假日与供应商自定义的覆盖优先级 ② 重置是否同时清掉自定义假日 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-holiday.spec.ts` + `pages/spd/vendor-holiday.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..161d79e65ca67345d2e60a5060be0e9787416253 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\344\276\233\345\272\224\345\225\206\350\265\204\350\264\250\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,77 @@ +# 基础管理-厂商管理-供应商资质管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/qcManage` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-qual-elements.json`(99 项) +**定位器**:`locators/spd/vendor-qual.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 供应商资质管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_QUL_001**(高)|菜单路径可达供应商资质管理|验证要点:登录后通过「基础设置 → 厂商管理 → 供应商资质管理」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回供应商资质管理,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 边界验证点 + +- **TP_VENDOR_QUL_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/basic/qcManage`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 异常验证点 + +- **TP_VENDOR_QUL_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 集成验证点 + +- **TP_VENDOR_QUL_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-002 + +--- + +### 证件 Tab、效期与列表 + +#### 功能验证点 + +- **TP_VENDOR_QUL_009**(高)|证件分类 Tab 切换|验证要点:证件效期提醒/授权链路效期预警/供应商三证/器械注册人/注册证号/授权书/承诺书/法人委托书/二级供应商三证可切换且列表字段随 Tab 变化|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_010**(高)|效期快捷筛选|验证要点:近三个月到期、近一个月到期、已过期切换后列表与 Tab 计数一致|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_011**(高)|证件效期提醒筛选|验证要点:按进院供应商名称、证件类型查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_012**(高)|授权链路预警筛选|验证要点:按授权书名称、授权单位、被授权单位查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_013**(中)|重置筛选|验证要点:重置后条件恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_014**(高)|行内查看|验证要点:点击「查看」打开证件/授权详情,关键字段与列表行一致|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_015**(中)|导出|验证要点:导出/导出明细与当前 Tab+筛选条件一致|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 边界验证点 + +- **TP_VENDOR_QUL_016**(中)|某效期 Tab 计数为 0|验证要点:列表空态明确,总数为 0|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_017**(低)|效期临界日|验证要点:即将到期与已过期边界日分类正确(以业务规则为准)|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 异常验证点 + +- **TP_VENDOR_QUL_018**(中)|查询失败|验证要点:接口失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-002 +- **TP_VENDOR_QUL_019**(中)|导出失败|验证要点:导出失败有提示,不误导为成功|关联需求:UI-SPEC-SPD-VENDOR-002 + +#### 集成验证点 + +- **TP_VENDOR_QUL_020**(中)|效期提醒文案与有效期至一致|验证要点:「N 天后过期/即将过期」与有效期至抽样一致|关联需求:UI-SPEC-SPD-VENDOR-002 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、证件 Tab、效期与列表。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、证件 Tab、效期与列表;共 **20** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_QUL` | +| 待评审项 | ① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 证件 Tab 切换后稳定列名 ③ 导出是否含当前 Tab 明细 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-qual.spec.ts` + `pages/spd/vendor-qual.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..5fc7711802bb4d8ddf09d152d6c24802a5c6914e --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\345\216\202\345\225\206\346\237\245\350\257\242-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,77 @@ +# 基础管理-厂商管理-厂商查询 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/factory` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-query-elements.json`(47 项) +**定位器**:`locators/spd/vendor-query.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 厂商查询 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_QRY_001**(高)|菜单路径可达厂商查询|验证要点:登录后通过「基础设置 → 厂商管理 → 厂商查询」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回厂商查询,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 边界验证点 + +- **TP_VENDOR_QRY_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/factory`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 异常验证点 + +- **TP_VENDOR_QRY_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 集成验证点 + +- **TP_VENDOR_QRY_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-001 + +--- + +### Tab、搜索与列表 + +#### 功能验证点 + +- **TP_VENDOR_QRY_009**(高)|器械注册人 Tab|验证要点:切换「器械注册人」后列表为注册人数据,分页「共 N 条」与结果一致|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_010**(高)|经销商 Tab|验证要点:切换「经销商」后列表为经销商数据,总数与器械注册人可不同|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_011**(高)|按厂商名称搜索|验证要点:输入厂商名称关键字后结果名称匹配,无匹配时空态明确|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_012**(高)|列表核心列|验证要点:展示序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_013**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,页码高亮且首行更换;不足 2 页则该点不适用(自动化 `VENDOR-QRY-008`)|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_019**(中)|清空搜索恢复更宽结果|验证要点:按厂商名称过滤后清空搜索并回车,分页总数不少于过滤后(自动化 `VENDOR-QRY-005`)|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_020**(中)|切换每页条数|验证要点:当前页行数 = min(总条数, 所选档位);总条数不变;切换 Tab 后分页重置合理;无数据则不适用|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 边界验证点 + +- **TP_VENDOR_QRY_014**(中)|超长厂商名称关键字|验证要点:超长输入可查询或被截断并提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_015**(低)|仅空格关键字|验证要点:按空查询处理或提示,不报错|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 异常验证点 + +- **TP_VENDOR_QRY_016**(中)|查询接口失败|验证要点:失败有错误提示,列表不白屏|关联需求:UI-SPEC-SPD-VENDOR-001 +- **TP_VENDOR_QRY_017**(低)|无匹配空态|验证要点:总数为 0,空态文案明确|关联需求:UI-SPEC-SPD-VENDOR-001 + +#### 集成验证点 + +- **TP_VENDOR_QRY_018**(中)|Tab 与分页总数一致|验证要点:当前 Tab 下可见行逻辑与「共 N 条」一致|关联需求:UI-SPEC-SPD-VENDOR-001 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、Tab、搜索与列表。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、Tab、搜索与列表;分页与清空搜索已拆成原子点;共 **20** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_QRY` | +| 待评审项 | ① 厂商名称搜索是回车还是即时过滤 ② 器械注册人与经销商分页是否独立 ③ 与供应商资质管理主数据对照口径 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-query.spec.ts` + `pages/spd/vendor-query.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..fef496b70c83ce5805043dee62de7466c184b649 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,77 @@ +# 基础管理-厂商管理-授权变更初审 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-003.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAudit` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-auth-first-elements.json`(40 项) +**定位器**:`locators/spd/vendor-auth-first.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 授权变更初审 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_AF1_001**(高)|菜单路径可达授权变更初审|验证要点:登录后通过「基础设置 → 厂商管理 → 授权变更初审」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回授权变更初审,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 边界验证点 + +- **TP_VENDOR_AF1_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAudit`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 异常验证点 + +- **TP_VENDOR_AF1_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 集成验证点 + +- **TP_VENDOR_AF1_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-003 + +--- + +### 授权变更列表与详情 + +#### 功能验证点 + +- **TP_VENDOR_AF1_009**(高)|制单日期查询|验证要点:按制单日期区间查询,结果制单时间落在区间内|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_010**(高)|单号与供应商筛选|验证要点:按单号、进院供应商、预转/原供应商查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_011**(高)|状态与变更类型|验证要点:状态下拉与变更类型筛选后列表状态/类型匹配|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_012**(中)|医院ID/医院名称筛选|验证要点:有该字段时按医院条件查询结果匹配|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_013**(中)|组合查询与重置|验证要点:多条件同时生效;重置恢复默认|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_014**(高)|列表核心列|验证要点:展示进院供应商名称、单号、状态、制单时间、制单人、变更类型及授权变更相关关键列、详情|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_015**(高)|打开详情|验证要点:行内「详情」展示单据头与明细,单号与列表一致|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 边界验证点 + +- **TP_VENDOR_AF1_016**(中)|制单日期起止同一天|验证要点:仅返回该日单据或空态明确|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_017**(低)|无匹配单据|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 异常验证点 + +- **TP_VENDOR_AF1_018**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-VENDOR-003 +- **TP_VENDOR_AF1_019**(中)|查询失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-003 + +#### 集成验证点 + +- **TP_VENDOR_AF1_020**(高)|与授权变更终审单据衔接|验证要点:同一业务单在初审/终审列表状态流转一致,不串到非授权变更菜单|关联需求:UI-SPEC-SPD-VENDOR-003 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、授权变更列表与详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、授权变更列表与详情;共 **20** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_AF1` | +| 待评审项 | ① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 初审可操作角色范围 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-first.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..f61c25397c37b61b10d490a49ff2bc4198c0eadc --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\216\210\346\235\203\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,76 @@ +# 基础管理-厂商管理-授权变更终审 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-004.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAuditFinal` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-auth-final-elements.json`(40 项) +**定位器**:`locators/spd/vendor-auth-final.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 授权变更终审 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_AF2_001**(高)|菜单路径可达授权变更终审|验证要点:登录后通过「基础设置 → 厂商管理 → 授权变更终审」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回授权变更终审,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 边界验证点 + +- **TP_VENDOR_AF2_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/authAuditFinal`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 异常验证点 + +- **TP_VENDOR_AF2_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 集成验证点 + +- **TP_VENDOR_AF2_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-004 + +--- + +### 授权变更列表与详情 + +#### 功能验证点 + +- **TP_VENDOR_AF2_009**(高)|制单日期查询|验证要点:按制单日期区间查询,结果制单时间落在区间内|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_010**(高)|单号与供应商筛选|验证要点:按单号、进院供应商、预转/原供应商查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_011**(高)|状态与变更类型|验证要点:状态下拉与变更类型筛选后列表状态/类型匹配|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_012**(中)|组合查询与重置|验证要点:多条件同时生效;重置恢复默认|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_013**(高)|列表核心列|验证要点:展示进院供应商名称、单号、状态、制单时间、制单人、变更类型及授权变更相关关键列、详情|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_014**(高)|打开详情|验证要点:行内「详情」展示单据头与明细,单号与列表一致|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 边界验证点 + +- **TP_VENDOR_AF2_015**(中)|制单日期起止同一天|验证要点:仅返回该日单据或空态明确|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_016**(低)|无匹配单据|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 异常验证点 + +- **TP_VENDOR_AF2_017**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-VENDOR-004 +- **TP_VENDOR_AF2_018**(中)|查询失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-004 + +#### 集成验证点 + +- **TP_VENDOR_AF2_019**(高)|与授权变更初审单据衔接|验证要点:同一业务单在初审/终审列表状态流转一致,不串到非授权变更菜单|关联需求:UI-SPEC-SPD-VENDOR-004 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、授权变更列表与详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、授权变更列表与详情;共 **19** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_AF2` | +| 待评审项 | ① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 终审可操作角色范围 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-final.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..8b66c64393423991280b59c97fbcf8d48873297d --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\345\210\235\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,76 @@ +# 基础管理-厂商管理-注册证变更初审 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-005.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAudit` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-cert-first-elements.json`(40 项) +**定位器**:`locators/spd/vendor-cert-first.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 注册证变更初审 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_CF1_001**(高)|菜单路径可达注册证变更初审|验证要点:登录后通过「基础设置 → 厂商管理 → 注册证变更初审」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回注册证变更初审,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 边界验证点 + +- **TP_VENDOR_CF1_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAudit`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 异常验证点 + +- **TP_VENDOR_CF1_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 集成验证点 + +- **TP_VENDOR_CF1_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-005 + +--- + +### 注册证变更列表与详情 + +#### 功能验证点 + +- **TP_VENDOR_CF1_009**(高)|制单日期查询|验证要点:按制单日期区间查询,结果制单时间落在区间内|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_010**(高)|单号与注册证号筛选|验证要点:按单号、注册证号(新)、变更类型、状态查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_011**(中)|变更情况说明筛选|验证要点:有该字段时按说明关键字查询或精确匹配|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_012**(中)|组合查询与重置|验证要点:多条件同时生效;重置恢复默认;收起后再展开值保留|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_013**(高)|列表核心列|验证要点:展示进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、详情|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_014**(高)|打开详情|验证要点:行内「详情」展示变更前后注册证信息,单号与列表一致|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 边界验证点 + +- **TP_VENDOR_CF1_015**(中)|制单日期起止同一天|验证要点:仅返回该日单据或空态明确|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_016**(低)|无匹配单据|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 异常验证点 + +- **TP_VENDOR_CF1_017**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-VENDOR-005 +- **TP_VENDOR_CF1_018**(中)|查询失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-005 + +#### 集成验证点 + +- **TP_VENDOR_CF1_019**(高)|与注册证变更终审单据衔接|验证要点:同一业务单在初审/终审列表状态流转一致,不串到非注册证变更菜单|关联需求:UI-SPEC-SPD-VENDOR-005 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、注册证变更列表与详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、注册证变更列表与详情;共 **19** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_CF1` | +| 待评审项 | ① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 初审可操作角色范围 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-first.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..f2cc5ca0798ad7ba72a94e8f0d1445bbbdd3b070 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\217\230\346\233\264\347\273\210\345\256\241-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,76 @@ +# 基础管理-厂商管理-注册证变更终审 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-006.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-cert-final-elements.json`(40 项) +**定位器**:`locators/spd/vendor-cert-final.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 注册证变更终审 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_CF2_001**(高)|菜单路径可达注册证变更终审|验证要点:登录后通过「基础设置 → 厂商管理 → 注册证变更终审」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回注册证变更终审,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 边界验证点 + +- **TP_VENDOR_CF2_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 异常验证点 + +- **TP_VENDOR_CF2_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 集成验证点 + +- **TP_VENDOR_CF2_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-006 + +--- + +### 注册证变更列表与详情 + +#### 功能验证点 + +- **TP_VENDOR_CF2_009**(高)|制单日期查询|验证要点:按制单日期区间查询,结果制单时间落在区间内|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_010**(高)|单号与注册证号筛选|验证要点:按单号、注册证号(新)、变更类型、状态查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_011**(中)|变更情况说明筛选|验证要点:有该字段时按说明关键字查询或精确匹配|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_012**(中)|组合查询与重置|验证要点:多条件同时生效;重置恢复默认;收起后再展开值保留|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_013**(高)|列表核心列|验证要点:展示进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、详情|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_014**(高)|打开详情|验证要点:行内「详情」展示变更前后注册证信息,单号与列表一致|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 边界验证点 + +- **TP_VENDOR_CF2_015**(中)|制单日期起止同一天|验证要点:仅返回该日单据或空态明确|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_016**(低)|无匹配单据|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 异常验证点 + +- **TP_VENDOR_CF2_017**(中)|日期开始晚于结束|验证要点:拒绝查询或自动纠正并提示|关联需求:UI-SPEC-SPD-VENDOR-006 +- **TP_VENDOR_CF2_018**(中)|查询失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-006 + +#### 集成验证点 + +- **TP_VENDOR_CF2_019**(高)|与注册证变更初审单据衔接|验证要点:同一业务单在初审/终审列表状态流转一致,不串到非注册证变更菜单|关联需求:UI-SPEC-SPD-VENDOR-006 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、注册证变更列表与详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、注册证变更列表与详情;共 **19** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_CF2` | +| 待评审项 | ① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 终审可操作角色范围 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-final.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..c59b08cf8486c06278e2cf2a4b953f57a6d9c164 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\346\263\250\345\206\214\350\257\201\345\267\256\345\274\202\345\257\271\346\257\224-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,79 @@ +# 基础管理-厂商管理-注册证差异对比 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-007.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/scjs/yc/certCompare` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-cert-diff-elements.json`(54 项) +**定位器**:`locators/spd/vendor-cert-diff.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 注册证差异对比 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_CDF_001**(高)|菜单路径可达注册证差异对比|验证要点:登录后通过「基础设置 → 厂商管理 → 注册证差异对比」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回注册证差异对比,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 边界验证点 + +- **TP_VENDOR_CDF_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/scjs/yc/certCompare`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 异常验证点 + +- **TP_VENDOR_CDF_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 集成验证点 + +- **TP_VENDOR_CDF_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-007 + +--- + +### 差异筛选与对比结果 + +#### 功能验证点 + +- **TP_VENDOR_CDF_009**(高)|默认查询|验证要点:进入后查询有数据或空态明确,分页总数正确|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_010**(高)|物料/规格/厂家筛选|验证要点:按物料名称、规格型号、生产厂家查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_011**(高)|望海与 FLI+ 注册证号筛选|验证要点:按两侧注册证号查询,结果匹配|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_012**(中)|是否医疗器械与状态|验证要点:状态下拉与是否医疗器械条件生效|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_013**(高)|一致/不一致 Tab|验证要点:全部、一致、不一致切换后对比结论匹配,计数合理|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_014**(中)|立即更新|验证要点:点击立即更新后时间戳刷新或提示成功,列表可刷新|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_015**(中)|导出|验证要点:导出与当前筛选+Tab 条件一致|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_016**(高)|对比列完整|验证要点:展示物料编号/名称、规格、厂家、医保编码及望海/FLI+注册证号与效期字段|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 边界验证点 + +- **TP_VENDOR_CDF_017**(中)|不一致 Tab 无数据|验证要点:空态明确,总数为 0|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_018**(低)|包含长期有效勾选项|验证要点:勾选「包含FLI+长期有效数据」后结果集变化符合规则|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 异常验证点 + +- **TP_VENDOR_CDF_019**(中)|查询或更新失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_020**(中)|iframe/内嵌页加载失败|验证要点:内嵌对比页失败有提示,外壳不假成功|关联需求:UI-SPEC-SPD-VENDOR-007 + +#### 集成验证点 + +- **TP_VENDOR_CDF_021**(高)|一致行两侧关键字段抽样一致|验证要点:标记为一致的行望海与 FLI+ 注册证号/效期抽样相同|关联需求:UI-SPEC-SPD-VENDOR-007 +- **TP_VENDOR_CDF_022**(高)|不一致行至少一侧差异|验证要点:标记为不一致的行两侧字段至少一处不同|关联需求:UI-SPEC-SPD-VENDOR-007 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、差异筛选与对比结果。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、差异筛选与对比结果;共 **22** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_CDF` | +| 待评审项 | ① 内嵌/iframe 对比页加载失败时外壳是否假成功 ② 立即更新后列表刷新时效 ③ 导出是否含当前 Tab | +| 报表能力 | 非统计报表页,不适用第四节报表能力清单;本页含内嵌/iframe 对比区,导航与内嵌加载失败须分开断言 | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-cert-diff.spec.ts` + `pages/spd/vendor-cert-diff.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..21d61c8da94f23a3cb0d040e5505d15a35f11739 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\214\207\346\240\207\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,77 @@ +# 基础管理-厂商管理-评价指标维护 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-009.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/indicators` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-eval-indicator-elements.json`(151 项) +**定位器**:`locators/spd/vendor-eval-indicator.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 评价指标维护 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_EVI_001**(高)|菜单路径可达评价指标维护|验证要点:登录后通过「基础设置 → 厂商管理 → 评价指标维护」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回评价指标维护,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 边界验证点 + +- **TP_VENDOR_EVI_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/indicators`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 异常验证点 + +- **TP_VENDOR_EVI_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 集成验证点 + +- **TP_VENDOR_EVI_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-009 + +--- + +### 指标分类树与指标列表 + +#### 功能验证点 + +- **TP_VENDOR_EVI_009**(高)|左树加载|验证要点:指标分类树可见,可搜索分类名称|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_010**(高)|选中分类刷新右表|验证要点:点击分类后右侧列表为该分类指标|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_011**(高)|指标编码/名称筛选|验证要点:右侧按编码、名称查询结果匹配|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_012**(高)|列表核心列|验证要点:展示评价类型、指标编码、指标名称、状态、操作(编辑/详情/日志)|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_013**(高)|新增分类|验证要点:左侧「新增」可创建分类,树中出现新节点|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_014**(高)|新增指标|验证要点:右侧「新增」可创建指标并回填到列表|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_015**(中)|编辑/详情/日志|验证要点:行内编辑打开回填;详情只读;日志可打开|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 边界验证点 + +- **TP_VENDOR_EVI_016**(中)|分类下无指标|验证要点:右表空态明确,总数为 0|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_017**(低)|分类名超长|验证要点:超长被拦截或截断并提示|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 异常验证点 + +- **TP_VENDOR_EVI_018**(高)|必填为空拦截|验证要点:指标编码/名称为空提交失败并提示|关联需求:UI-SPEC-SPD-VENDOR-009 +- **TP_VENDOR_EVI_019**(中)|删除或停用受引用指标|验证要点:若被方案引用则拒绝或提示(以产品规则为准)|关联需求:UI-SPEC-SPD-VENDOR-009 + +#### 集成验证点 + +- **TP_VENDOR_EVI_020**(中)|状态启用后可供方案使用|验证要点:状态为启用的指标可在评价方案中被选择|关联需求:UI-SPEC-SPD-VENDOR-009 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、指标分类树与指标列表。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、指标分类树与指标列表;共 **20** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_EVI` | +| 待评审项 | ① 分类树搜索是即时过滤还是回车 ② 停用指标是否允许被方案继续引用 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-indicator.spec.ts` + `pages/spd/vendor-eval-indicator.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..3448a7d5a29a2ab8455268cd8da06441571e9fd7 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\216\202\345\225\206\347\256\241\347\220\206-\350\257\204\344\273\267\346\226\271\346\241\210\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,75 @@ +# 基础管理-厂商管理-评价方案维护 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-VENDOR-008.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/evaluationPlan` +**版本 / 定稿日期**:2026-09-30 +**元素扫描**:`data/scan/vendor-eval-scheme-elements.json`(57 项) +**定位器**:`locators/spd/vendor-eval-scheme.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 厂商管理 · 评价方案维护 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_VENDOR_EVS_001**(高)|菜单路径可达评价方案维护|验证要点:登录后通过「基础设置 → 厂商管理 → 评价方案维护」进入,菜单与 Tab 高亮正确|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_002**(高)|页面主区加载完成|验证要点:整页渲染,筛选/主表/分页或左右分栏关键区可见|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_003**(中)|同组厂商管理菜单可切换|验证要点:切到其它厂商管理页再返回评价方案维护,URL 与内容对应|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 边界验证点 + +- **TP_VENDOR_EVS_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `http://spdtest.cmic.com.cn:8080/spd/basic/mfrs/paltform/mfrsName/evaluationPlan`,页面正常渲染|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 异常验证点 + +- **TP_VENDOR_EVS_005**(高)|未登录访问拦截|验证要点:无登录态访问跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_007**(中)|无菜单权限|验证要点:无权限账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 集成验证点 + +- **TP_VENDOR_EVS_008**(中)|顶栏院区与厂商范围一致|验证要点:列表/筛选中的院区或供应商范围与顶栏院区不矛盾|关联需求:UI-SPEC-SPD-VENDOR-008 + +--- + +### 方案筛选与维护 + +#### 功能验证点 + +- **TP_VENDOR_EVS_009**(高)|按创建/启用日期筛选|验证要点:按日期区间查询结果落在区间内|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_010**(高)|方案编码/名称筛选|验证要点:按编码、名称查询结果匹配|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_011**(中)|重置|验证要点:重置后条件恢复默认|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_012**(高)|打开新增|验证要点:点击「新增」进入方案编辑,含方案编码/名称等必填区|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_013**(高)|列表核心列|验证要点:展示方案编码、名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_014**(中)|空列表态|验证要点:无数据时「暂无数据」空态明确|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 边界验证点 + +- **TP_VENDOR_EVS_015**(中)|起止同一天|验证要点:仅返回该日创建/启用的方案|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 异常验证点 + +- **TP_VENDOR_EVS_016**(高)|必填为空提交拦截|验证要点:方案编码或名称为空时提交失败并提示|关联需求:UI-SPEC-SPD-VENDOR-008 +- **TP_VENDOR_EVS_017**(中)|查询失败|验证要点:失败有提示|关联需求:UI-SPEC-SPD-VENDOR-008 + +#### 集成验证点 + +- **TP_VENDOR_EVS_018**(中)|方案与评价指标可关联|验证要点:方案引用的指标在评价指标维护中存在或可配置|关联需求:UI-SPEC-SPD-VENDOR-008 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、方案筛选与维护。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已写功能域:页面访问与导航、方案筛选与维护;共 **18** 条原子测试点(未声称全覆盖) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_VENDOR_EVS` | +| 待评审项 | ① 方案引用已停用指标时的拦截规则 ② 删除方案是否二次确认 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-scheme.spec.ts` + `pages/spd/vendor-eval-scheme.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..233ef0407d1e260f3319303df2a9c2e9e2ba9674 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\345\272\223\345\214\272\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,260 @@ +# 基础管理-库房管理-库区设置 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-WH-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/platform/storehouse/area` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/warehouse-zone-elements.json`(106 项) +**定位器**:`locators/spd/warehouse-zone.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 库房管理 · 库区设置 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_WH_ZON_001**(高)|菜单路径可达库区设置|验证要点:登录后通过「基础设置 → 库房管理 → 库区设置」进入 `/spd/basic/storehouse/platform/storehouse/area`,左侧菜单与顶栏 Tab 高亮为库区设置,不串到同组其它页|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_002**(高)|页面主区加载完成|验证要点:左右分栏:左侧「库房信息」树、右侧库区列表、新增与分页可见,无报错遮罩|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_003**(中)|同组库房管理菜单可切换|验证要点:在 库区设置 / 探针绑定 间切换后 URL 与页面内容对应,再返回库区设置状态正确|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/storehouse/platform/storehouse/area`,页面正常渲染|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_007**(中)|无菜单权限|验证要点:无库区设置权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏当前库房/院区与本页可维护库房范围不矛盾|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 左侧库房树 + +#### 功能验证点 + +- **TP_WH_ZON_009**(高)|库房树默认有选中项|验证要点:进入后左侧「库房信息」有节点且一项处于选中,右侧为该库房的库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_010**(高)|按库房名称过滤树|验证要点:在左侧「库房名称」输入关键字后,仅保留名称匹配的库房节点|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_011**(中)|清空库房名称恢复全量树|验证要点:清空关键字后树恢复为过滤前的全量节点|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_012**(高)|切换库房刷新右侧列表|验证要点:点选另一库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_013**(中)|超长库房名称关键字|验证要点:输入达到或超过允许长度,过滤被接受或截断,页面不崩溃|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_014**(低)|仅空格库房名称|验证要点:按空过滤处理或提示,不报错|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_015**(低)|无匹配库房空树|验证要点:关键字无匹配时树为空或明确无数据,右侧列表不残留上一库房库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_016**(中)|库房树加载失败|验证要点:库房接口失败有错误提示,不把过期树展示为成功态|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_017**(中)|库房节点与院区主数据同源|验证要点:树中库房名称与院区/库房主数据一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 右侧库区列表与分页 + +#### 功能验证点 + +- **TP_WH_ZON_018**(高)|列表核心列完整|验证要点:主表展示库区编号、库区名称、库区地址、是否专项库区、操作(编辑/人员管理/货位管理/停用或启用)|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_019**(高)|默认库区以标签展示|验证要点:默认库区在名称旁显示「默认库区」标签,不是独立列;同一库房至多一个默认库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_020**(中)|专项库区取值|验证要点:「是否专项库区」仅为「是」或「否」|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_021**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_022**(中)|每页条数切换|验证要点:切换「50 条/页」等后当前页行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_023**(中)|首页上一页不可用|验证要点:第 1 页时「上一页」禁用或点击无效|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_024**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_025**(低)|库房无库区时分页为 0|验证要点:选中尚无库区的库房时列表空态明确,「共 0 条」|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_026**(中)|库区列表加载失败|验证要点:失败有错误提示,不白屏、不把上一库房数据展示为当前库房成功态|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_027**(中)|翻页不串库房|验证要点:带当前库房选中翻页后,新页库区仍属于该库房|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_028**(中)|列表条数与共 N 条一致|验证要点:可见行逻辑与底部分页「共 N 条」一致|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 库区搜索 + +#### 功能验证点 + +- **TP_WH_ZON_029**(高)|按库区编号搜索|验证要点:在「库区编号/名称」输入已存在编号,结果包含该编号行|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_030**(高)|按库区名称搜索|验证要点:输入已存在名称关键字,结果名称匹配|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_031**(中)|搜索范围限于当前库房|验证要点:搜索结果全部属于左侧当前选中库房|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_032**(中)|超长库区关键字|验证要点:超长输入可检索或被截断,页面不崩溃|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_033**(低)|仅空格库区关键字|验证要点:按空搜索处理或提示,不报错|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_034**(低)|无匹配库区空态|验证要点:无命中时列表为空且分页为 0|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_035**(中)|切换库房后搜索条件不串结果|验证要点:换库房后结果属于新库房;若保留关键字则在新库房范围内过滤|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 新增库区 + +#### 功能验证点 + +- **TP_WH_ZON_036**(高)|打开新增库区弹窗|验证要点:点「新增」弹出「新增库区」,含库区编号、库区名称、库区地址、联系人、联系方式及取消/确定|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_037**(高)|合法必填保存成功|验证要点:填写必填后确定,弹窗关闭,当前库房列表出现新记录且字段与提交一致|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_038**(中)|取消不落库|验证要点:填写后点「取消」或关闭,列表不新增对应库区|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_039**(中)|编号名称达最大允许长度|验证要点:库区编号/名称取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_040**(低)|地址可与编号或名称相同|验证要点:库区地址填写与编号或名称相同的文本可保存|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_041**(高)|必填为空拦截|验证要点:库区编号或名称为空点确定被拦截并提示,不关闭弹窗、不落库|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_042**(高)|同一库房库区编号重复拦截|验证要点:使用当前库房已存在编号保存失败并提示,不覆盖原库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_043**(中)|保存接口失败|验证要点:失败有错误提示,弹窗仍在,列表不出现半成品成功态|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_044**(中)|重复点击确定|验证要点:连续快速点击只产生一条记录或第二次被防重|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_045**(中)|新增归属当前选中库房|验证要点:新增成功后该库区仅出现在当前库房列表,切换其它库房不可见|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 编辑库区 + +#### 功能验证点 + +- **TP_WH_ZON_046**(高)|打开编辑并回填|验证要点:行内「编辑」打开「编辑库区信息」,回填库区编号、名称、地址、联系人、联系方式|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_047**(高)|修改后确定落库|验证要点:修改名称或地址后确定,列表与再次编辑回看均为新值|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_048**(中)|编辑取消不保存|验证要点:修改后点「取消」,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_049**(中)|专项库区回填一致|验证要点:列表「是否专项库区」与编辑页回填一致(若编辑页含该字段)|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_050**(低)|不改字段直接确定|验证要点:打开编辑不改内容点确定,记录保持原值且不报错|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_051**(中)|默认库区编辑后仍唯一|验证要点:编辑保存后该库房仍至多一个「默认库区」标签|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_052**(高)|编辑清空必填拦截|验证要点:清空库区名称后确定被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_053**(中)|编辑已停用库区|验证要点:停用行仍可打开编辑;若禁止编辑则入口禁用并提示,不以成功态改数|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_054**(中)|编辑不影响其它库房同名库区|验证要点:仅更新当前库房该行,其它库房同编号/名称库区保持原值|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 停用与启用 + +#### 功能验证点 + +- **TP_WH_ZON_055**(高)|停用后操作变为启用|验证要点:启用行点「停用」后该行操作变为「启用」,状态持久化|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_056**(高)|启用后操作变为停用|验证要点:停用行点「启用」后该行操作变为「停用」,再次进入仍保持|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_057**(中)|默认库区停用限制|验证要点:默认库区若不允许停用则被拦截并提示;若允许停用则该库房须仍满足「至多一个默认库区」|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_058**(中)|停用/启用失败有提示|验证要点:接口失败有错误提示,行操作文案不翻转|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_059**(中)|停用须确认才生效|验证要点:若有确认框,取消后行仍为启用、操作仍为「停用」|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_060**(中)|停用库区不可再被探针绑定为新货位|验证要点:停用后探针绑定弹窗按货位状态/启用过滤时不应再选出该库区下可绑定货位(以现网过滤规则为准)|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 人员管理 + +#### 功能验证点 + +- **TP_WH_ZON_061**(高)|打开人员管理|验证要点:行内「人员管理」打开弹层或子页,标题表明为人员管理且对应当前库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_062**(中)|关闭后回到库区列表|验证要点:关闭人员管理后仍停留在当前库房的库区列表,选中库房不变|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_063**(低)|无人员时列表空态|验证要点:未配置人员时空态明确,不报错|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_064**(中)|人员管理加载失败|验证要点:失败有错误提示,不把其它库区人员展示为当前库区|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_065**(中)|人员与用户管理账号同源|验证要点:可选人员来自用户/角色主数据(允许按权限裁剪)|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 货位管理 + +#### 功能验证点 + +- **TP_WH_ZON_066**(高)|打开货位管理|验证要点:行内「货位管理」打开弹层或子页,标题表明为货位且对应当前库区|关联需求:UI-SPEC-SPD-WH-001 +- **TP_WH_ZON_067**(中)|关闭后回到库区列表|验证要点:关闭货位管理后当前库房选中与库区列表保持|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_068**(低)|无货位时空态|验证要点:库区无货位时列表空态明确|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_069**(中)|货位管理加载失败|验证要点:失败有提示,不串其它库区货位|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_070**(高)|货位可被探针绑定弹窗检索到|验证要点:本库区启用货位应出现在探针绑定「货位绑定探针」弹窗对应库房/库区筛选结果中|关联需求:UI-SPEC-SPD-WH-001 + +--- + +### 同步库房信息 + +#### 功能验证点 + +- **TP_WH_ZON_071**(中)|同步刷新库房树|验证要点:点「同步」后树与列表按最新主数据刷新,不丢当前可维护库房|关联需求:UI-SPEC-SPD-WH-001 + +#### 边界验证点 + +- **TP_WH_ZON_072**(低)|同步期间不可重复提交|验证要点:同步进行中按钮禁用或再次点击被忽略,不并发写坏树|关联需求:UI-SPEC-SPD-WH-001 + +#### 异常验证点 + +- **TP_WH_ZON_073**(中)|同步失败有提示|验证要点:失败有错误提示,树不空白、不把失败展示为成功|关联需求:UI-SPEC-SPD-WH-001 + +#### 集成验证点 + +- **TP_WH_ZON_074**(中)|同步结果与院区库房主数据一致|验证要点:同步后新增/更名的库房节点与主数据一致|关联需求:UI-SPEC-SPD-WH-001 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧库房树、右侧库区列表与分页、库区搜索、新增库区、编辑库区、停用与启用、人员管理、货位管理、同步库房信息。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、库房树过滤/切换、库区列表分页、库区搜索、新增/编辑弹窗、停用启用、人员/货位入口、同步;共 **74** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_WH_ZON` | +| 待评审项 | ① 人员管理/货位管理扫描未打开面板,入口以行内按钮为准,子页字段待补扫 ② 默认库区如何指定(编辑字段或单独接口)以现网为准 ③ 停用默认库区是否允许待产品确认 ④ 新增弹窗扫描未见「是否专项库区」控件,列表有该列,编辑是否可改为待评审 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV;扫描 `npm run discover:scan-warehouse`;自动化建议 `pages/spd/warehouse-zone.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..fc944c39a04ec2cb6b2062ad6990e02e205eb62b --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\345\272\223\346\210\277\347\256\241\347\220\206-\346\216\242\351\222\210\347\273\221\345\256\232-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,171 @@ +# 基础管理-库房管理-探针绑定 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-WH-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/storehouse/probe` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/warehouse-probe-elements.json`(78 项) +**定位器**:`locators/spd/warehouse-probe.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 库房管理 · 探针绑定 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_WH_PRB_001**(高)|菜单路径可达探针绑定|验证要点:登录后通过「基础设置 → 库房管理 → 探针绑定」进入 `/spd/basic/storehouse/storehouse/probe`,左侧菜单与顶栏 Tab 高亮为探针绑定,不串到同组其它页|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_002**(高)|页面主区加载完成|验证要点:整页单表:探针ID/主机编码/是否绑定货位筛选、查询重置、列表与分页可见,无报错遮罩|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_003**(中)|同组库房管理菜单可切换|验证要点:在 库区设置 / 探针绑定 间切换后 URL 与页面内容对应,再返回探针绑定状态正确|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/storehouse/storehouse/probe`,页面正常渲染|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_007**(中)|无菜单权限|验证要点:无探针绑定权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏当前库房/院区与本页可维护库房范围不矛盾|关联需求:UI-SPEC-SPD-WH-002 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_WH_PRB_009**(高)|默认条件查询|验证要点:进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_010**(高)|按探针ID查询|验证要点:输入已存在探针ID后结果探针ID匹配,精确命中时至多对应行|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_011**(高)|按主机编码查询|验证要点:输入主机编码后结果「主机编码」与条件一致(多探针可共用同一主机)|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_012**(高)|按是否绑定货位查询|验证要点:选择「已绑定」仅保留已绑定行;选择「未绑定」仅保留未绑定行|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_013**(高)|探针ID+主机编码+是否绑定组合查询|验证要点:多个已填条件同时生效,结果同时满足全部条件|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_014**(中)|重置恢复默认|验证要点:填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_015**(中)|超长探针ID或主机编码|验证要点:超长输入可查询或被截断,页面不崩溃|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_016**(低)|仅空格关键字|验证要点:按空查询处理或提示,不报错|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_017**(中)|查询接口失败|验证要点:失败有错误提示,不白屏、不把过期数据展示为成功态|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_018**(低)|无匹配结果空态|验证要点:条件无匹配时「暂无数据」明确,分页总数为 0|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_019**(中)|筛选结果与分页总数一致|验证要点:查询后可见行逻辑与「共 N 条」一致|关联需求:UI-SPEC-SPD-WH-002 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_WH_PRB_020**(高)|列表核心列完整|验证要点:主表展示序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作(绑定货位)|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_021**(高)|已绑定行数量大于 0|验证要点:「是否绑定货位」为已绑定时,「绑定的货位」数量大于 0|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_022**(高)|未绑定行数量为 0|验证要点:未绑定行「绑定的货位」为 `0`|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_023**(中)|绑定的货位列为数量不是货位号|验证要点:主表该列为数字;货位号只在弹窗「货位号」列|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_024**(中)|分页翻页|验证要点:总条数大于一页时可翻页,数据更换且页码正确|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_025**(中)|每页条数切换|验证要点:切换每页条数后当前页行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_026**(中)|首页上一页不可用|验证要点:第 1 页时「上一页」禁用或点击无效|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_027**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_028**(低)|跳至非数字页码|验证要点:非法页码不跳转异常页|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_029**(中)|翻页不丢失当前筛选|验证要点:带筛选翻页后条件保持,新页仍满足原筛选|关联需求:UI-SPEC-SPD-WH-002 + +--- + +### 打开绑定货位弹窗 + +#### 功能验证点 + +- **TP_WH_PRB_030**(高)|行内打开货位绑定探针|验证要点:点「绑定货位」弹出「货位绑定探针」,含库房/库区/货位筛选与货位列表|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_031**(中)|取消关闭不改主列表|验证要点:未确定即点「取消」或关闭,主列表绑定数量与筛选条件保持|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_032**(低)|未选货位时确定计数为 0|验证要点:底部按钮文案为「确定(0)」或等价,未选时确定不新增绑定|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_033**(中)|弹窗加载失败|验证要点:货位列表失败有提示,不把主列表「共 N 条」与弹窗总条数混用|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_034**(中)|弹窗分页独立于主列表|验证要点:弹窗「共 N 条」(货位量级)不得用于断言主列表探针条数|关联需求:UI-SPEC-SPD-WH-002 + +--- + +### 弹窗筛选货位 + +#### 功能验证点 + +- **TP_WH_PRB_035**(高)|按当前库房筛选|验证要点:选择库房后列表「当前库房」与所选一致|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_036**(高)|按当前库区筛选|验证要点:选择库区后列表「当前库区」与所选一致|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_037**(高)|按货位号查询|验证要点:「货位查询」输入货位号关键字后结果货位号匹配|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_038**(中)|弹窗内重置|验证要点:填写弹窗筛选后点「重置」,条件恢复,列表按弹窗默认规则刷新|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_039**(中)|超长货位号关键字|验证要点:超长输入可查询或被截断,弹窗不崩溃|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_040**(低)|库区下拉随库房收敛|验证要点:切换库房后库区选项属于该库房,不残留上一库房库区|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_041**(低)|弹窗无匹配空态|验证要点:条件无命中时货位列表空、弹窗分页为 0|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_042**(高)|库房库区与库区设置同源|验证要点:弹窗库房/库区选项来自库区设置中已维护且可用的库房与库区|关联需求:UI-SPEC-SPD-WH-002 + +--- + +### 勾选货位并保存绑定 + +#### 功能验证点 + +- **TP_WH_PRB_043**(高)|勾选后确定计数增加|验证要点:勾选 N 个货位后确定按钮计数为 N|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_044**(高)|确定后主列表绑定数量增加|验证要点:为未绑定探针勾选货位并确定后,该行变为已绑定,数量等于本次保存的货位数(或按产品规则累加)|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_045**(中)|已绑货位行展示探针ID|验证要点:弹窗中已被该探针绑定的货位「探针ID」列显示该探针号,未绑定为 `--`|关联需求:UI-SPEC-SPD-WH-002 + +#### 边界验证点 + +- **TP_WH_PRB_046**(中)|取消勾选后计数回退|验证要点:勾选后再取消,确定计数回到实际已选数|关联需求:UI-SPEC-SPD-WH-002 + +#### 异常验证点 + +- **TP_WH_PRB_047**(中)|绑定保存失败|验证要点:失败有提示,主列表绑定数量不增加,再次打开不是半成功勾选|关联需求:UI-SPEC-SPD-WH-002 +- **TP_WH_PRB_048**(中)|货位已被其它探针占用|验证要点:占用行再绑定时按换绑或拒绝执行;以保存后主列表数量与弹窗探针ID列为准,不得静默双绑|关联需求:UI-SPEC-SPD-WH-002 + +#### 集成验证点 + +- **TP_WH_PRB_049**(高)|绑定结果与货位管理一致|验证要点:保存后在对应库区货位侧能看到该探针占用;解绑/换绑后两侧同步|关联需求:UI-SPEC-SPD-WH-002 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与分页、打开绑定货位弹窗、弹窗筛选货位、勾选货位并保存绑定。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、探针筛选查询、列表数量规则、绑定弹窗筛选分页、勾选确定/取消、与库区货位集成;共 **49** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_WH_PRB` | +| 待评审项 | ① 同一货位已有其它探针ID时是换绑还是拒绝,以保存后主列表数量与弹窗探针ID列为准 ② 确定按钮文案为「确定(N)」,未选为 0 ③ 主表无新增探针入口,探针主数据来源不在本页维护 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV;扫描 `npm run discover:scan-warehouse`;自动化建议 `pages/spd/warehouse-probe.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..ff5b687e5c0797d0ac1e6aadb627f711e994637b --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\345\237\272\347\241\200\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,151 @@ +# 基础管理-手术套包设置-基础包维护 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/baseMaintenance` +**版本 / 定稿日期**:2026-10-13 +**元素扫描**:`data/scan/surgery-base-pkg-elements.json`(87 项) +**定位器**:`locators/spd/surgery-base-pkg.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 手术套包设置 · 基础包维护 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_SURG_BASE_002**(高)|页面主区加载完整|验证要点:左右分栏渲染,左侧分类树(含分类名称搜索框)与右侧筛选区/列表/分页均可见,无报错遮罩|关联需求:UI-SPEC-SPD-SURG-001 + +#### 边界验证点 + +- **TP_SURG_BASE_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/operationSetting/baseMaintenance`,列表正常渲染|关联需求:UI-SPEC-SPD-SURG-001 + +#### 异常验证点 + +- **TP_SURG_BASE_006**(中)|登录态过期访问|验证要点:会话失效后新增/移除被拦截,不产生误写|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_007**(中)|无手术套包设置权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-SURG-001 + +#### 集成验证点 + +- **TP_SURG_BASE_008**(中)|左侧分类树与基础包分类主数据同源|验证要点:本页分类节点集合与「标准套包维护/添加基础包」弹窗中的分类树一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-SURG-001 + +--- + +### 左侧分类树 + +#### 功能验证点 + +- **TP_SURG_BASE_009**(高)|分类名称搜索过滤|验证要点:在左侧「分类名称」框输入关键字后树节点收敛为匹配项(即时或回车,以现网为准)|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_010**(高)|选择分类节点联动右侧列表|验证要点:点击某分类节点后右侧列表仅展示该分类下基础包,且与「共 N 条」一致|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_011**(中)|分类树展开/折叠|验证要点:可展开父级、折叠子级,展开后子节点层级与计数(若有)正确|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_012**(中)|新增分类入口可达|验证要点:点击「新增分类」弹出「新增分类」弹窗,含分类名称输入与取消/确认|关联需求:UI-SPEC-SPD-SURG-001 + +#### 边界验证点 + +- **TP_SURG_BASE_013**(低)|分类搜索清空恢复全量|验证要点:清空分类搜索框后树恢复未过滤的完整节点|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_014**(低)|分类搜索超长关键字|验证要点:超长关键字不崩溃,按规则截断或无匹配空态|关联需求:UI-SPEC-SPD-SURG-001 + +#### 异常验证点 + +- **TP_SURG_BASE_015**(中)|分类树加载失败|验证要点:分类接口异常时左栏有错误提示或空态,不白屏且不影响右栏可恢复|关联需求:UI-SPEC-SPD-SURG-001 + +#### 集成验证点 + +- **TP_SURG_BASE_016**(中)|新增分类后树节点同步出现|验证要点:新增分类保存成功后,左栏树无需整页刷新即出现新节点并可选中|关联需求:UI-SPEC-SPD-SURG-001 + +--- + +### 右侧筛选与列表 + +#### 功能验证点 + +- **TP_SURG_BASE_017**(高)|默认列出基础包|验证要点:进入页面右侧列表有数据或空态明确,分页「共 N 条」正确|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_018**(高)|产品名称筛选生效|验证要点:产品名称框(`#table_search_form_keyword`)输入关键字查询后仅保留匹配行|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_019**(高)|规格型号筛选生效|验证要点:规格型号框(`#table_search_form_goodsSpec`)输入后列表按规格过滤|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_020**(高)|器械注册人筛选生效|验证要点:器械注册人框(`#table_search_form_goodsMfrsName`)输入后列表按注册人过滤|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_021**(中)|耗材类型下拉筛选|验证要点:耗材类型选择「低值耗材/高值耗材」后列表仅保留对应类型|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_022**(高)|列表核心列完整|验证要点:展示序号、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作(编辑/移除)|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_023**(中)|查询与重置|验证要点:点「查询」按当前条件过滤;点「重置」清空所有筛选并恢复默认列表|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_024**(中)|分页翻页与页大小|验证要点:可翻页;切换每页条数后行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-SURG-001 + +#### 边界验证点 + +- **TP_SURG_BASE_025**(低)|筛选多条件叠加无匹配|验证要点:组合条件无结果时展示空态,总数为 0|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_026**(低)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-SURG-001 + +#### 异常验证点 + +- **TP_SURG_BASE_027**(中)|列表接口异常|验证要点:加载失败有错误提示,不白屏|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_028**(低)|搜索无匹配空态|验证要点:无匹配时「暂无数据」,总数为 0|关联需求:UI-SPEC-SPD-SURG-001 + +#### 集成验证点 + +- **TP_SURG_BASE_029**(中)|筛选结果与分页总数一致|验证要点:过滤后「共 N 条」与过滤结果规模一致|关联需求:UI-SPEC-SPD-SURG-001 + +--- + +### 新增基础包 + +#### 功能验证点 + +- **TP_SURG_BASE_030**(高)|打开新增基础包弹窗|验证要点:点「新增基础包」弹出弹窗,含基础包名称、所属分类、包编码、备注及取消/确认|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_031**(高)|合法数据新增成功|验证要点:填写名称、所属分类、包编码后确定,弹窗关闭,列表出现新行且字段一致|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_032**(中)|所属分类可选择|验证要点:所属分类支持从分类树/下拉中选取,保存后编辑回看分类一致|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_033**(中)|添加产品到构成列表|验证要点:弹窗内「添加产品」可将产品加入已选列表,构成数量可编辑,单位/耗材类型/是否收费展示正确|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_034**(中)|取消不落库|验证要点:填写后点「取消」或关闭,列表不新增对应基础包|关联需求:UI-SPEC-SPD-SURG-001 + +#### 边界验证点 + +- **TP_SURG_BASE_035**(中)|基础包名称达到最大长度|验证要点:名称取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_036**(低)|备注可为空|验证要点:备注留空可保存,回看为空而非脏数据|关联需求:UI-SPEC-SPD-SURG-001 + +#### 异常验证点 + +- **TP_SURG_BASE_037**(高)|基础包名称为空拦截|验证要点:名称为空点确定被拦截并提示,不落库|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_038**(中)|所属分类为空拦截|验证要点:未选分类点确定被拦截并提示|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_039**(中)|包编码重复|验证要点:与已有基础包包编码重复时拒绝或按产品规则提示,不产生重码脏数据|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_040**(中)|重复点击确定|验证要点:快速连点确定只产生一条基础包或第二次被防重|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_041**(中)|保存接口失败|验证要点:新增失败有提示,弹窗仍在,列表无半成品成功行|关联需求:UI-SPEC-SPD-SURG-001 + +#### 集成验证点 + +- **TP_SURG_BASE_042**(高)|新增基础包可在标准套包中被引用|验证要点:新增成功的基础包,在「标准套包维护 → 添加基础包」弹窗中可被检索并选用|关联需求:UI-SPEC-SPD-SURG-003 + +--- + +### 行内操作(编辑 / 移除) + +#### 功能验证点 + +- **TP_SURG_BASE_043**(高)|行内「编辑」入口可用|验证要点:点击某行「编辑」打开编辑弹窗并回填当前行数据|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_044**(中)|行内「移除」删除构成项|验证要点:点击「移除」并有确认时,确认后该构成项从列表消失,总数 -1|关联需求:UI-SPEC-SPD-SURG-001 + +#### 边界验证点 + +- **TP_SURG_BASE_045**(低)|仅一行时移除后空态|验证要点:删除最后一行后列表显示空态,总数为 0|关联需求:UI-SPEC-SPD-SURG-001 + +#### 异常验证点 + +- **TP_SURG_BASE_046**(高)|移除二次确认防误删|验证要点:「移除」为写操作,执行前有确认,取消则不删除|关联需求:UI-SPEC-SPD-SURG-001 +- **TP_SURG_BASE_047**(中)|移除已被引用的构成项|验证要点:被标准套包引用时按产品规则拒绝并提示,不产生悬空引用|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_BASE_048**(中)|编辑已不存在的记录|验证要点:记录已被删除后再保存,提示不存在,不产生幽灵更新|关联需求:UI-SPEC-SPD-SURG-001 + +#### 集成验证点 + +- **TP_SURG_BASE_049**(中)|编辑名称后下游展示同步|验证要点:修改基础包名称/编码后,标准套包维护等引用处展示为新值|关联需求:UI-SPEC-SPD-SURG-003 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧分类树、右侧筛选与列表、新增基础包、行内操作(编辑 / 移除)。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:左侧分类树(搜索/选择/新增分类)、右侧筛选(产品名称/产品编号/规格型号/器械注册人/耗材类型)、列表列、分页、新增基础包弹窗、行内编辑/移除;共 **46** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录/权限与同模块其它页共用规则,本页强调分类树联动与基础包 CRUD;编号前缀 `TP_SURG_BASE` | +| 待评审项 | ① 分类搜索是即时过滤还是回车 ② 包编码是否唯一约束 ③「移除」是否二次确认 ④ 被标准套包引用时能否移除 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-surgery` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..d6487d6fe0f48e618f12db5d1a30984a17759d2f --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\234\257\347\247\215\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,174 @@ +# 基础管理-手术套包设置-术种维护 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/typeMaintenance` +**版本 / 定稿日期**:2026-10-13 +**元素扫描**:`data/scan/surgery-oper-type-elements.json`(69 项) +**定位器**:`locators/spd/surgery-oper-type.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 手术套包设置 · 术种维护 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_SURG_TYPE_001**(高)|菜单路径可达术种维护页|验证要点:登录后通过「基础设置 → 手术套包设置 → 术种维护」进入目标页,菜单项与页内 Tab 均高亮|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_002**(高)|页面主区加载完整|验证要点:左右分栏渲染,左侧术种分类树(含分类名称搜索框)与右侧筛选区/状态 Tab/列表/分页均可见,无报错遮罩|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_003**(中)|同组三个页签可切换|验证要点:切到「基础包维护」「标准套包维护」再返回,本页左侧树与右侧列表恢复正确|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/operationSetting/typeMaintenance`,列表正常渲染|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示术种数据|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_006**(中)|登录态过期访问|验证要点:会话失效后新增/编辑/停用被拦截,不产生误写|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_007**(中)|无手术套包设置权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_008**(中)|左侧术种分类树与手术套包分类主数据同源|验证要点:本页分类节点集合与「标准套包维护」左侧分类树一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-SURG-003 + +--- + +### 左侧术种分类树 + +#### 功能验证点 + +- **TP_SURG_TYPE_009**(高)|术种分类名称搜索过滤|验证要点:在左侧「术种分类名称」框输入关键字后树节点收敛为匹配项(即时或回车,以现网为准)|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_010**(高)|选择分类节点联动右侧列表|验证要点:点击某分类节点后右侧列表仅展示该分类下术种,且与「共 N 条」一致|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_011**(中)|分类树层级下钻与计数|验证要点:下钻(如 骨外科(8) → 骨科(2))层级正确,括号内计数与该分类下术种数一致|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_012**(中)|分类树展开/折叠|验证要点:可展开父级、折叠子级,展示层级正确|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_013**(中)|新增术种分类|验证要点:点击「新增」弹出「分类维护」弹窗,含分类名称与取消/保存;保存成功后树出现新分类|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_014**(低)|分类搜索清空恢复全量|验证要点:清空分类搜索框后树恢复未过滤的完整节点|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_015**(低)|分类搜索超长关键字|验证要点:超长关键字不崩溃,按规则截断或无匹配空态|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_016**(中)|分类树加载失败|验证要点:分类接口异常时左栏有错误提示或空态,不白屏|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_017**(中)|新增分类后树节点同步出现|验证要点:新增分类保存成功后,左栏树无需整页刷新即出现新节点并可选中|关联需求:UI-SPEC-SPD-SURG-002 + +--- + +### 右侧筛选与状态 Tab + +#### 功能验证点 + +- **TP_SURG_TYPE_018**(高)|术种名称筛选生效|验证要点:术种名称框(`#table_search_form_operationName`)输入关键字查询后仅保留匹配行|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_019**(高)|病种名称筛选生效|验证要点:病种名称框(`#table_search_form_disease`)输入后列表按病种过滤|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_020**(高)|状态 Tab 切换|验证要点:「全部 / 停用 / 未建分类」切换后列表仅展示对应状态术种,Tab 括号计数与结果一致|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_021**(中)|查询与重置|验证要点:点「查询」按当前条件过滤;「重置」清空筛选并按当前 Tab 恢复默认列表|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_022**(中)|左树 + 筛选 + Tab 三条件叠加|验证要点:分类节点、筛选关键字、状态 Tab 组合后列表结果与三者交集一致|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_023**(低)|无匹配组合空态|验证要点:组合条件无结果时展示空态,总数为 0|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_024**(低)|停用 Tab 无数据|验证要点:停用数为 0 时该 Tab 列表为空态且计数为 0|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_025**(中)|列表接口异常|验证要点:加载失败有错误提示,不白屏|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_026**(中)|筛选结果与分页总数一致|验证要点:过滤后「共 N 条」与过滤结果规模一致|关联需求:UI-SPEC-SPD-SURG-002 + +--- + +### 术种列表与编码展示 + +#### 功能验证点 + +- **TP_SURG_TYPE_027**(高)|列表核心列完整|验证要点:展示序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_028**(中)|ICD-10 诊断编码展示完整|验证要点:诊断编码(如 `A02.000x010`)不截断、无乱码|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_029**(中)|ICD-9-CM-3 操作编码展示完整|验证要点:操作编码(如 `00.0200`)与操作名称(如 `心脏治疗性超声`)对应正确|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_030**(中)|分页翻页与页大小|验证要点:可翻页;切换每页条数后行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_031**(中)|超长术种/病种名称列宽与换行|验证要点:超长文本列宽与换行正常,不撑破布局或互相覆盖|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_032**(低)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_033**(低)|编码缺失展示|验证要点:无 ICD 编码的术种展示为占位或空,不出现 `undefined/null`|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_034**(中)|ICD 编码与字典同源|验证要点:列表展示的 ICD-10/ICD-9-CM-3 编码与标准诊断/操作字典一致|关联需求:UI-SPEC-SPD-SURG-002 + +--- + +### 新增术种 + +#### 功能验证点 + +- **TP_SURG_TYPE_035**(高)|打开新增术种入口|验证要点:点「新增术种」打开新增弹窗/页面,含术种名称、病种名称、分类及编码相关字段与取消/保存|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_036**(高)|合法数据新增成功|验证要点:填写必填项并保存后,列表出现新术种且字段一致,所选分类计数 +1|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_037**(中)|新增未建分类术种|验证要点:不选分类新增后,术种出现在「未建分类」Tab 且该 Tab 计数 +1|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_038**(中)|取消不落库|验证要点:填写后取消或关闭,列表不新增对应术种|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_039**(中)|术种名称达到最大长度|验证要点:名称取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_040**(高)|术种名称为空拦截|验证要点:名称为空点保存被拦截并提示,不落库|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_041**(中)|术种名称重复|验证要点:与已有术种重名时拒绝或按产品规则提示,不产生无法区分的重名脏数据|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_042**(中)|重复点击保存|验证要点:快速连点保存只产生一条术种或第二次被防重|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_043**(中)|保存接口失败|验证要点:新增失败有提示,弹窗仍在,列表无半成品成功行|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_044**(高)|新增术种可在标准套包中关联|验证要点:新增术种后,在「标准套包维护」按术种分类/术种可检索并建立套包关联|关联需求:UI-SPEC-SPD-SURG-003 + +--- + +### 行内操作(编辑 / 停用) + +#### 功能验证点 + +- **TP_SURG_TYPE_045**(高)|行内「编辑」回填并保存|验证要点:点击某行「编辑」打开弹窗并回填当前术种数据,修改后保存列表同步更新|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_046**(高)|「停用」状态流转|验证要点:点击「停用」并有确认时,确认后该术种状态变为停用|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_047**(中)|「启用」恢复|验证要点:对停用术种执行「启用」后恢复为启用状态|关联需求:UI-SPEC-SPD-SURG-002 + +#### 边界验证点 + +- **TP_SURG_TYPE_048**(低)|停用后 Tab 计数变化|验证要点:停用一条后「停用」Tab 计数 +1、「全部」总数不变、原状态 Tab 计数 -1|关联需求:UI-SPEC-SPD-SURG-002 + +#### 异常验证点 + +- **TP_SURG_TYPE_049**(高)|停用二次确认防误操作|验证要点:「停用」为写操作,执行前有确认,取消则不改变状态|关联需求:UI-SPEC-SPD-SURG-002 +- **TP_SURG_TYPE_050**(中)|停用已被套包引用的术种|验证要点:被标准套包引用时按产品规则拒绝或允许但下游不可新选,提示明确|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_TYPE_051**(中)|编辑已不存在的记录|验证要点:记录被删除后再保存,提示不存在,不产生幽灵更新|关联需求:UI-SPEC-SPD-SURG-002 + +#### 集成验证点 + +- **TP_SURG_TYPE_052**(高)|停用术种后下游不可新选|验证要点:术种停用后,标准套包维护等处不可再作为新业务选择(历史引用仍展示原名称)|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_TYPE_053**(中)|编辑术种名称后下游同步|验证要点:修改术种/病种名称后,引用处展示为新值|关联需求:UI-SPEC-SPD-SURG-003 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧术种分类树、右侧筛选与状态 Tab、术种列表与编码展示、新增术种、行内操作(编辑 / 停用)。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、左侧分类树(搜索/下钻/新增分类维护)、筛选(术种名称/病种名称)、状态 Tab(全部/停用/未建分类)、ICD-10/ICD-9-CM-3 列展示、分页、新增术种、行内编辑/停用/启用;共 **53** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录/权限与同模块其它页共用规则,本页强调状态 Tab 计数守恒与 ICD 编码展示;编号前缀 `TP_SURG_TYPE` | +| 待评审项 | ① 分类搜索是即时过滤还是回车 ②「停用」是否需确认框 ③ 停用后 Tab 计数与「全部」的口径 ④ 被套包引用时停用策略 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-surgery` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..b6a933ef6652df9dc39d6dde94df0e61c697e0f4 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\211\213\346\234\257\345\245\227\345\214\205\350\256\276\347\275\256-\346\240\207\345\207\206\345\245\227\345\214\205\347\273\264\346\212\244-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,178 @@ +# 基础管理-手术套包设置-标准套包维护 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-SURG-003.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/operationSetting/standardMaintenance` +**版本 / 定稿日期**:2026-10-13 +**元素扫描**:`data/scan/surgery-std-pkg-elements.json`(61 项) +**定位器**:`locators/spd/surgery-std-pkg.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 手术套包设置 · 标准套包维护 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_SURG_STD_001**(高)|菜单路径可达标准套包维护页|验证要点:登录后通过「基础设置 → 手术套包设置 → 标准套包维护」进入目标页,菜单项与页内 Tab 均高亮|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_002**(高)|页面主区加载完整|验证要点:左右分栏渲染,左侧术种分类树(含搜索框)与右侧筛选区/构成列表/分页均可见,无报错遮罩|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_003**(中)|同组三个页签可切换|验证要点:切到「基础包维护」「术种维护」再返回,本页左侧树与右侧构成列表恢复正确|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/operationSetting/standardMaintenance`,构成列表正常渲染|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示套包数据|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_006**(中)|登录态过期访问|验证要点:会话失效后新增/编辑/移除被拦截,不产生误写|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_007**(中)|无手术套包设置权限|验证要点:无权限账号不可见菜单或访问被拒绝|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_008**(中)|左侧术种分类树与术种维护同源|验证要点:本页分类节点集合与「术种维护」左侧分类树一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-SURG-002 + +--- + +### 左侧术种分类树 + +#### 功能验证点 + +- **TP_SURG_STD_009**(高)|术种分类名称搜索过滤|验证要点:在左侧「术种分类名称」框输入关键字后树节点收敛为匹配项(即时或回车,以现网为准)|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_010**(高)|选择分类节点联动右侧构成列表|验证要点:点击某分类节点后右侧仅展示该分类下标准套包,且与「共 N 条」一致|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_011**(中)|分类树展开/折叠与计数|验证要点:可展开/折叠,括号内计数与该分类下套包数一致|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_012**(中)|「未建分类」节点|验证要点:未划分分类的标准套包归入「未建分类」节点,计数正确|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_013**(低)|分类搜索清空恢复全量|验证要点:清空搜索框后树恢复未过滤的完整节点|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_014**(低)|分类搜索超长关键字|验证要点:超长关键字不崩溃,按规则截断或无匹配空态|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_015**(中)|分类树加载失败|验证要点:分类接口异常时左栏有错误提示或空态,不白屏|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_016**(中)|分类变更后树同步|验证要点:术种维护新增/调整分类后,本页左树刷新可见一致节点|关联需求:UI-SPEC-SPD-SURG-002 + +--- + +### 右侧筛选与构成列表 + +#### 功能验证点 + +- **TP_SURG_STD_017**(高)|默认展示标准套包构成|验证要点:进入页面右侧列表按「包名称」分组展示,有数据或空态明确,分页「共 N 条」正确|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_018**(高)|产品名称筛选生效|验证要点:产品名称框(`#table_search_form_keyword`)输入后仅保留匹配子项|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_019**(高)|规格型号筛选生效|验证要点:规格型号框(`#table_search_form_goodsSpec`)输入后按规格过滤|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_020**(高)|器械注册人筛选生效|验证要点:器械注册人框(`#table_search_form_goodsMfrsName`)输入后按注册人过滤|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_021**(中)|耗材类型下拉筛选|验证要点:耗材类型选择「低值耗材/高值耗材」后仅保留对应类型子项|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_022**(高)|构成列表核心列完整|验证要点:展示包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作(编辑/移除)|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_023**(高)|包名称展开/收起子项|验证要点:展开某「包名称」后展示其子项(基础包/散件)行,收起后隐藏;展开状态不影响分组计数|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_024**(中)|查询与重置|验证要点:点「查询」按条件过滤;「重置」清空筛选并恢复默认列表|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_025**(中)|分页翻页与页大小|验证要点:可翻页;切换每页条数后行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_026**(中)|包数量/每包含量边界值|验证要点:数量为最小/最大允许值时展示与计算正确(含步进器上下限)|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_027**(低)|多条件叠加无匹配|验证要点:组合条件无结果时展示空态,总数为 0|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_028**(低)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_029**(中)|构成列表接口异常|验证要点:加载失败有错误提示,不白屏|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_030**(低)|空套包/无子项展示|验证要点:无子项的包展示为空态而非错行,不出现 `undefined/null`|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_031**(中)|成分与基础包/术种主数据一致|验证要点:构成子项的产品/基础包字段与基础包维护、术种维护主数据一致|关联需求:UI-SPEC-SPD-SURG-001 + +--- + +### 添加基础包弹窗 + +#### 功能验证点 + +- **TP_SURG_STD_032**(高)|打开添加基础包弹窗|验证要点:点「添加基础包」弹出弹窗,左侧基础包分类树 + 右侧基础包明细,含取消/确定|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_033**(高)|弹窗内筛选与选择基础包|验证要点:按分类/名称检索基础包(`请输入分类/基础包名称`),勾选后明细与确定按钮计数(确定(N))同步变化|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_034**(中)|弹窗内构成数量可编辑|验证要点:明细行「构成数量」可编辑,步进器加减生效且合计随之更新|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_035**(高)|确定后构成列表刷新|验证要点:选定基础包点确定,弹窗关闭,标准套包构成列表出现新增基础包行且数量一致|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_036**(中)|取消不落库|验证要点:选定后点「取消」或关闭,构成列表不新增对应行|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_037**(低)|确定按钮零选禁用|验证要点:未选择任何基础包时「确定(0)」置灰或点击无效果|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_038**(中)|重复添加同一基础包|验证要点:重复添加同一基础包时按产品规则拒绝/合并/累加,不产生重复脏行|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_039**(中)|提交接口失败|验证要点:确定失败有提示,弹窗仍在,构成列表无半成品成功行|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_040**(高)|弹窗可选基础包来源正确|验证要点:弹窗可选基础包集合与「基础包维护」中启用的基础包一致|关联需求:UI-SPEC-SPD-SURG-001 + +--- + +### 添加散件弹窗 + +#### 功能验证点 + +- **TP_SURG_STD_041**(高)|打开添加散件弹窗|验证要点:点「添加散件」弹出弹窗,展示「产品列表」视图,含筛选产品名称/规格型号/器械注册人/耗材类型与分页|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_042**(高)|产品列表筛选与选择散件|验证要点:按条件检索散件产品并勾选,加入应拣明细,计数同步变化|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_043**(高)|散件规格明细/应拣明细视图|验证要点:切换到规格明细视图,展示耗材品种数、耗材总数与应拣数量、产品编号/名称/规格/注册人/单位/耗材类型/是否收费列|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_044**(中)|明细统计与实际一致|验证要点:耗材品种数 = 明细去重产品数,耗材总数 = 应拣数量合计;与实际所选一致|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_045**(高)|确定后构成列表刷新散件行|验证要点:选定散件点确定,标准套包构成列表新增散件行,数量与明细一致|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_046**(中)|取消不落库|验证要点:选定散件后取消或关闭,构成列表不新增对应行|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_047**(中)|万级散件产品列表性能与首屏|验证要点:产品列表总量达万级(示例「共 16299 条」)时首屏可交互、分页/滚动不卡死|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_048**(低)|应拣数量边界|验证要点:应拣数量取最小/最大允许值时保存与合计正确|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_049**(中)|散件产品列表接口异常|验证要点:加载失败有错误提示,不白屏,可重试|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_050**(中)|提交散件接口失败|验证要点:确定失败有提示,弹窗仍在,构成列表无半成品成功行|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_051**(高)|散件来自产品主数据且有库存口径|验证要点:散件产品集合与产品主数据一致;应拣数量与库存/应拣口径一致(以接口契约 API-SPD-SURGERY-001 为准)|关联需求:API-SPD-SURGERY-001 + +--- + +### 行内操作(编辑 / 移除) + +#### 功能验证点 + +- **TP_SURG_STD_052**(高)|行内「编辑」构成项|验证要点:点击某构成行「编辑」可修改数量等可编辑字段,保存后列表同步更新|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_053**(中)|行内「移除」构成项|验证要点:点击「移除」并有确认时,确认后该构成项消失,包分组计数随之更新|关联需求:UI-SPEC-SPD-SURG-003 + +#### 边界验证点 + +- **TP_SURG_STD_054**(低)|移除整包最后一子项|验证要点:移除某包最后一个子项后该包分组显示为空态或被移除,不残留空分组标题|关联需求:UI-SPEC-SPD-SURG-003 + +#### 异常验证点 + +- **TP_SURG_STD_055**(高)|移除二次确认防误删整包|验证要点:「移除」为写操作,执行前有确认,取消则不删除,避免误删整包|关联需求:UI-SPEC-SPD-SURG-003 +- **TP_SURG_STD_056**(中)|编辑已不存在的构成项|验证要点:记录被删除后再保存,提示不存在,不产生幽灵更新|关联需求:UI-SPEC-SPD-SURG-003 + +#### 集成验证点 + +- **TP_SURG_STD_057**(高)|套包变更对下游术种套包可用性影响|验证要点:标准套包构成变更后,关联术种在手术套包业务中取到的构成为最新值|关联需求:API-SPD-SURGERY-001 + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧术种分类树、右侧筛选与构成列表、添加基础包弹窗、添加散件弹窗、行内操作(编辑 / 移除)。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、左侧术种分类树(搜索/未建分类)、筛选(产品名称/规格型号/器械注册人/耗材类型)、构成列表分组展开、分页、添加基础包弹窗、添加散件弹窗(产品列表 + 规格/应拣明细)、行内编辑/移除;共 **57** 条原子测试点 | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 | +| 去重 | 登录/权限与同模块其它页共用规则,本页强调嵌套弹窗计数一致与万级数据性能;编号前缀 `TP_SURG_STD` | +| 待评审项 | ① 重复添加同一基础包/散件的合并策略 ② 耗材品种数/耗材总数的精确口径 ③ 移除是否二次确认 ④ 万级散件列表是否支持全量选择 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可生成 MD/CSV 与 Playwright;扫描 `npm run discover:scan-surgery` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..a46a0bc11664cd7a9b154b657b299f429600e6eb --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\345\256\236\344\276\213-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,131 @@ +# 基础管理-流程设置-审批流实例 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-FLOW-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowCase` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/flow-inst-elements.json`(62 项) +**定位器**:`locators/spd/flow-inst.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 流程设置 · 审批流实例 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_FLOW_INS_001**(高)|菜单路径可达审批流实例|验证要点:登录后通过「基础设置 → 流程设置 → 审批流实例」进入 `/spd/basic/flowSet/workflow/flowCase`,左侧菜单与顶栏 Tab 高亮为审批流实例,不串到同组其它页|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_002**(高)|页面主区加载完成|验证要点:整页单表:单据编号/审批状态/发起时间/业务类型/摘要筛选、查询重置、列表与分页可见,无报错遮罩|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_003**(中)|同组流程设置菜单可切换|验证要点:在 审批流程设置 / 审批流实例 间切换后 URL 与页面内容对应,再返回审批流实例状态正确|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 边界验证点 + +- **TP_FLOW_INS_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/flowSet/workflow/flowCase`,页面正常渲染|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 异常验证点 + +- **TP_FLOW_INS_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_007**(中)|无菜单权限|验证要点:无审批流实例权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 集成验证点 + +- **TP_FLOW_INS_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏登录身份与本页可查看的审批单据范围不矛盾|关联需求:UI-SPEC-SPD-FLOW-002 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_FLOW_INS_009**(高)|默认日期区间查询|验证要点:进入后带默认发起时间区间,点「查询」列表有数据或空态明确,「共 N 条」与结果逻辑一致|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_010**(高)|单据编号精确查询|验证要点:输入完整单号后至多一条,且编号、业务类型与该行一致|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_011**(高)|按审批状态筛选|验证要点:选择「审批通过」或「审批中」后列表状态列与所选一致|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_012**(高)|按业务类型筛选|验证要点:选择某类型后列表「业务类型」均为该类型|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_013**(高)|按发起时间区间筛选|验证要点:结果行「发起时间」落在起止日期内(含边界日)|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_014**(中)|按摘要关键字筛选|验证要点:输入摘要关键字后结果摘要包含该关键字;摘要为 `--` 的行不被非空关键字命中|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_015**(高)|多条件组合查询|验证要点:状态、业务类型、发起时间、摘要同时生效,抽样行满足全部已填条件|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_016**(中)|重置恢复默认|验证要点:填写后点「重置」,日期回到默认区间,其余条件清空,「共 N 条」恢复|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 边界验证点 + +- **TP_FLOW_INS_017**(中)|超长单据编号或摘要|验证要点:超长输入可查询或被截断,页面不崩溃|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_018**(中)|开始日期晚于结束日期|验证要点:非法区间被拒绝或自动纠正,不返回错乱时间结果|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_019**(低)|仅空格摘要|验证要点:按空查询处理或提示,不报错|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 异常验证点 + +- **TP_FLOW_INS_020**(中)|查询接口失败|验证要点:失败有错误提示,不白屏、不把过期列表展示为成功态|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_021**(低)|无匹配空态|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 集成验证点 + +- **TP_FLOW_INS_022**(中)|筛选结果与分页总数一致|验证要点:查询后可见行逻辑与「共 N 条」一致|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_023**(高)|业务类型与审批流程设置同源|验证要点:「业务类型」下拉与审批流程设置左侧类型一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-FLOW-002 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_FLOW_INS_024**(高)|列表核心列完整|验证要点:主表展示序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作(详情)|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_025**(中)|单据编号前缀与业务类型对应|验证要点:`PLAN` 对应采购计划、`DB` 对应二级库调拨、`GC` 对应产品信息变更、`BUY` 对应请领单(以现网枚举为准)|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_026**(中)|空摘要展示为 --|验证要点:无摘要行显示 `--`,长摘要可截断|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_027**(中)|窄列截断不影响查询|验证要点:发起人/部门/时间截断展示时,按完整单号搜索仍能命中|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_028**(中)|分页翻页|验证要点:总条数大于一页时可翻页,数据更换|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_029**(中)|每页条数切换|验证要点:切换后当前页行数不超过所选值,总数不变|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_030**(中)|跳至指定页|验证要点:输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 边界验证点 + +- **TP_FLOW_INS_031**(中)|首页上一页不可用|验证要点:第 1 页时「上一页」禁用或点击无效|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_032**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_033**(低)|跳至超出总页数|验证要点:大于总页数时被拒绝、纠正到末页或提示|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 异常验证点 + +- **TP_FLOW_INS_034**(低)|跳至非数字页码|验证要点:非法输入不跳转异常页|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 集成验证点 + +- **TP_FLOW_INS_035**(中)|翻页不丢失当前筛选|验证要点:带筛选翻页后条件保持,新页数据仍满足原筛选|关联需求:UI-SPEC-SPD-FLOW-002 + +--- + +### 审批详情 + +#### 功能验证点 + +- **TP_FLOW_INS_036**(高)|打开详情对应当前行|验证要点:点「详情」打开该单据审批过程,标题/单号与当前行单据编号一致|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_037**(中)|详情只读|验证要点:详情内不能改审批人或条件;改配置须回到审批流程设置|关联需求:UI-SPEC-SPD-FLOW-002 +- **TP_FLOW_INS_038**(中)|关闭详情保留列表状态|验证要点:返回后筛选条件与页码保持,列表仍是打开前的查询结果|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 边界验证点 + +- **TP_FLOW_INS_039**(低)|审批中与已通过均可打开详情|验证要点:两种状态行都能进入详情,节点展示与状态相符|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 异常验证点 + +- **TP_FLOW_INS_040**(中)|详情加载失败|验证要点:失败有提示,不把其它单据内容展示为当前单|关联需求:UI-SPEC-SPD-FLOW-002 + +#### 集成验证点 + +- **TP_FLOW_INS_041**(高)|详情审批链与流程设置一致|验证要点:新单据详情中的节点/人员与该业务类型在审批流程设置中命中的默认或自定义链一致|关联需求:UI-SPEC-SPD-FLOW-002 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与分页、审批详情。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、单据编号/状态/类型/时间/摘要筛选、列表列与前缀规则、分页、只读详情、与流程设置类型同源;共 **41** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_FLOW_INS` | +| 待评审项 | ① 默认发起时间跨度大,断言「共 N 条」须先收窄日期 ② 本页无新增/导出/改审批人;写配置只在审批流程设置 ③ 详情扫描打开「采购计划详情」,其它业务类型详情标题以现网为准 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV;扫描 `npm run discover:scan-flow`;自动化建议 `pages/spd/flow-inst.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..b419f57603207a1948b05f92c00dc595b3d49a83 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\346\265\201\347\250\213\350\256\276\347\275\256-\345\256\241\346\211\271\346\265\201\347\250\213\350\256\276\347\275\256-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,163 @@ +# 基础管理-流程设置-审批流程设置 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-FLOW-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/flowSet/workflow/flowSet` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/flow-def-elements.json`(43 项) +**定位器**:`locators/spd/flow-def.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 流程设置 · 审批流程设置 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_FLOW_DEF_001**(高)|菜单路径可达审批流程设置|验证要点:登录后通过「基础设置 → 流程设置 → 审批流程设置」进入 `/spd/basic/flowSet/workflow/flowSet`,左侧菜单与顶栏 Tab 高亮为审批流程设置,不串到同组其它页|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_002**(高)|页面主区加载完成|验证要点:左右分栏:左侧业务类型、右侧默认审批流与自定义审批流、保存/重置可见,无报错遮罩|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_003**(中)|同组流程设置菜单可切换|验证要点:在 审批流程设置 / 审批流实例 间切换后 URL 与页面内容对应,再返回审批流程设置状态正确|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/flowSet/workflow/flowSet`,页面正常渲染|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示业务数据|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_007**(中)|无菜单权限|验证要点:无审批流程设置权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏登录身份与本页可查看的审批单据范围不矛盾|关联需求:UI-SPEC-SPD-FLOW-001 + +--- + +### 左侧业务类型 + +#### 功能验证点 + +- **TP_FLOW_DEF_009**(高)|业务类型列表可见|验证要点:左侧列出采购计划、请领单、产品准入等类型,当前项高亮|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_010**(高)|按流程名称过滤类型|验证要点:输入关键字后仅保留名称匹配的业务类型|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_011**(中)|清空关键字恢复全量|验证要点:清空后类型列表恢复过滤前全量|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_012**(高)|切换类型刷新右侧配置|验证要点:点选另一类型后,默认审批流与自定义审批流与该类型已保存配置一致,不残留上一类型节点|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_013**(中)|超长流程名称关键字|验证要点:超长输入可过滤或被截断,页面不崩溃|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_014**(低)|仅空格关键字|验证要点:按空过滤处理或提示,不报错|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_015**(低)|无匹配类型|验证要点:无命中时左侧为空或明确无数据,右侧不展示上一类型配置为当前配置|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_016**(高)|类型与审批流实例业务类型同源|验证要点:左侧类型集合与审批流实例「业务类型」下拉选项一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-FLOW-001 + +--- + +### 默认审批流 + +#### 功能验证点 + +- **TP_FLOW_DEF_017**(高)|默认审批流节点可见|验证要点:右侧「默认审批流」展示节点卡片,含节点类型、账号、人员|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_018**(中)|可追加默认流节点|验证要点:点节点旁「+」可增加审批节点(保存前仅在当前页可见)|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_019**(低)|未配置自定义流时走默认链|验证要点:该类型无自定义流时,新单据按默认审批人链流转(以审批流实例抽样为准)|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_020**(中)|默认流节点人员为空拦截|验证要点:保存时默认链缺少人员被拦截并提示,不写入空节点|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_021**(中)|只改自定义流时默认链不变|验证要点:仅增删自定义行并保存后,默认审批人节点保持原值|关联需求:UI-SPEC-SPD-FLOW-001 + +--- + +### 自定义审批流 + +#### 功能验证点 + +- **TP_FLOW_DEF_022**(高)|新增流程增加一行|验证要点:点「新增流程」自定义流增加一行,含流程名称、条件、审批流程、删除|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_023**(中)|删除行未保存不落库|验证要点:删除自定义行后不点保存,重新进入该类型行仍在|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_024**(高)|保存后自定义流持久化|验证要点:填写名称/条件/节点后保存,重新进入该类型配置与保存时一致|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_025**(中)|流程名称必填|验证要点:名称空时保存被拦截|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_026**(中)|条件必填|验证要点:条件空时保存被拦截|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_027**(中)|审批流程必填|验证要点:无审批节点时保存被拦截|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_028**(高)|必填为空拦截|验证要点:流程名称/条件/审批流程任一为空点保存被拦截并提示,不关闭、不落库|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_029**(中)|保存接口失败|验证要点:失败有提示,再次进入不是半成功配置|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_030**(高)|自定义条件命中后新单走该链|验证要点:保存后新产生的符合条件单据在审批流实例中按该自定义链流转,而非默认链|关联需求:UI-SPEC-SPD-FLOW-001 + +--- + +### 条件配置 + +#### 功能验证点 + +- **TP_FLOW_DEF_031**(高)|打开条件配置|验证要点:条件列「编辑」打开「条件配置」,可添加条件行并保存/取消|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_032**(高)|保存条件回写行内文案|验证要点:配置如「是否超限:是」或「科室:中心库(洪山)」后保存,行内条件展示与配置一致|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_033**(中)|取消不改行内条件|验证要点:修改后点取消,行内条件仍为打开前的值|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_034**(低)|可删除条件行|验证要点:条件配置内「删除」去掉一行,保存后不再出现该条件|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_035**(中)|条件不完整拦截|验证要点:条件类型或取值空时保存被拦截|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_036**(中)|科室条件与库房/科室主数据同源|验证要点:条件选科室时选项来自主数据,与库区设置/用户所属科室不矛盾|关联需求:UI-SPEC-SPD-FLOW-001 + +--- + +### 保存与重置 + +#### 功能验证点 + +- **TP_FLOW_DEF_037**(高)|保存写入当前业务类型|验证要点:点「保存」只持久化当前选中类型的默认流与自定义流|关联需求:UI-SPEC-SPD-FLOW-001 +- **TP_FLOW_DEF_038**(高)|重置放弃未保存改动|验证要点:改节点或自定义行后点「重置」,页面回到上次保存结果|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 边界验证点 + +- **TP_FLOW_DEF_039**(低)|无改动点保存不报错|验证要点:未修改直接保存成功或提示无变更,配置不变|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 异常验证点 + +- **TP_FLOW_DEF_040**(中)|未保存切换类型|验证要点:有未保存改动时切换业务类型须提示后丢弃或不允许切换,不得把上一类型的改动写入下一类型|关联需求:UI-SPEC-SPD-FLOW-001 + +#### 集成验证点 + +- **TP_FLOW_DEF_041**(中)|保存不影响历史审批实例|验证要点:改审批人后,已存在的审批流实例详情仍为发起时的快照或按产品规则说明;不得无说明地改写历史已通过单|关联需求:UI-SPEC-SPD-FLOW-001 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧业务类型、默认审批流、自定义审批流、条件配置、保存与重置。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、业务类型过滤/切换、默认审批流、自定义流增删、条件配置、保存重置、与审批流实例类型同源;共 **41** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_FLOW_DEF` | +| 待评审项 | ① 保存会改线上审批路由,自动化冒烟只读结构、不提交 ② 未保存切类型按「提示后丢弃、不串写」写点,若自动保存需改判定 ③ 历史已通过实例是否快照冻结待产品确认 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV;扫描 `npm run discover:scan-flow`;自动化建议 `pages/spd/flow-def.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..f89c6b635dd2e5a433dbc1f2b57c40b81b33ddc0 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\345\214\273\345\205\261\344\275\223\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,166 @@ +# 基础管理-用户管理-医共体用户管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-002.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/personCom` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/basic-user-union-elements.json`(221 项) +**定位器**:`locators/spd/basic-user-union.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 用户管理 · 医共体用户管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_USER_UNI_001**(高)|菜单路径可达医共体用户管理|验证要点:登录后通过「基础设置 → 用户管理 → 医共体用户管理」进入目标页,左侧菜单与顶栏 Tab 高亮为医共体用户管理,不串到同组其它页|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_002**(高)|页面主区加载完成|验证要点:整页单表渲染:所属医院等筛选、工具栏、列表列头与分页可见,无报错遮罩|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_003**(中)|同组用户管理菜单可切换|验证要点:在 用户管理 / 医共体用户管理 / 角色管理 间切换后 URL 与页面内容对应,再返回医共体用户管理状态正确|关联需求:UI-SPEC-SPD-USER-002 + +#### 边界验证点 + +- **TP_USER_UNI_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/user/platform/personCom`,页面正常渲染|关联需求:UI-SPEC-SPD-USER-002 + +#### 异常验证点 + +- **TP_USER_UNI_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示用户/角色业务数据|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_007**(中)|无菜单权限|验证要点:无医共体用户管理权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-USER-002 + +#### 集成验证点 + +- **TP_USER_UNI_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏院区/登录医院与本页可管理的「所属医院」范围一致|关联需求:UI-SPEC-SPD-USER-002 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_USER_UNI_009**(高)|默认条件查询|验证要点:进入后点「查询」,列表有数据或空态明确,「共 N 条」与行数逻辑一致|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_010**(高)|姓名/登录账号关键字|验证要点:「姓名」框(占位「姓名、登录账号」)输入后结果姓名或账号匹配|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_011**(高)|所属医院筛选|验证要点:切换「所属医院」后列表仅该院用户,「共 N 条」同步变化|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_012**(高)|联系方式筛选|验证要点:按「联系方式」关键字查询,结果联系方式匹配|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_013**(高)|姓名、所属医院、联系方式、科室、角色等条件组合查询|验证要点:多条件同时生效,结果同时满足;含所属医院与联系方式维度(自动化 `USER-UNI-002`)|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_014**(中)|重置恢复默认|验证要点:点「重置」后医院等条件恢复默认(含默认医院),列表刷新|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_015**(中)|展开高级筛选|验证要点:点「高级筛选」展开后可见所属医院、联系方式、科室、角色及「筛选」|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_016**(中)|高级筛选按科室过滤|验证要点:高级筛选选择科室后点「筛选」,列表「所属科室」与所选一致|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_017**(中)|高级筛选按角色过滤|验证要点:高级筛选选择角色后点「筛选」,列表「角色」列包含该角色|关联需求:UI-SPEC-SPD-USER-002 + +#### 边界验证点 + +- **TP_USER_UNI_018**(中)|超长关键字|验证要点:超长姓名或联系方式关键字不崩溃,按规则截断或无匹配空态|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_019**(低)|仅空格联系方式|验证要点:空格按空查询处理或提示,不报错|关联需求:UI-SPEC-SPD-USER-002 + +#### 异常验证点 + +- **TP_USER_UNI_020**(中)|查询接口失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_021**(低)|无匹配空态|验证要点:总数为 0,空态明确|关联需求:UI-SPEC-SPD-USER-002 + +#### 集成验证点 + +- **TP_USER_UNI_022**(中)|筛选结果与分页总数一致|验证要点:当前医院+条件下可见行逻辑与「共 N 条」一致|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_023**(高)|默认医院与顶栏/登录医院一致|验证要点:进入页时所属医院默认值与当前登录医院或顶栏院区约定一致|关联需求:UI-SPEC-SPD-USER-002 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_USER_UNI_024**(高)|列表核心列完整且顺序正确|验证要点:列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码);勿用用户管理页列断言|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_025**(中)|登录账号列截断|验证要点:长账号在列宽内截断;按账号搜索/断言用筛选框而非截断后的单元格文本|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_026**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_027**(中)|每页条数切换|验证要点:切换「20 条/页」等后当前页行数为 min(N,所选值),总数不变(自动化 `USER-UNI-014`)|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_028**(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_059**(低)|打开列设置面板|验证要点:列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_060**(低)|列设置隐藏后再恢复|验证要点:取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(一对多:CSV `USER-UNI-015`/`016`)|关联需求:UI-SPEC-SPD-USER-002 + +#### 边界验证点 + +- **TP_USER_UNI_029**(中)|首页上一页不可用|验证要点:第 1 页「上一页」禁用或无效|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_030**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_031**(低)|某医院无用户|验证要点:切换到无用户医院时空态明确,总数为 0|关联需求:UI-SPEC-SPD-USER-002 + +#### 异常验证点 + +- **TP_USER_UNI_032**(低)|跳至非数字页码|验证要点:非法页码不跳转异常页|关联需求:UI-SPEC-SPD-USER-002 + +#### 集成验证点 + +- **TP_USER_UNI_033**(高)|与用户管理页同一用户字段一致|验证要点:同一登录账号在两页的姓名、角色、所属科室一致,本页仅多「所属医院」维度|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_034**(中)|翻页不丢失医院条件|验证要点:带所属医院筛选翻页后条件保持|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_035**(中)|科室任职数规则同用户管理|验证要点:本页「科室任职数」判定同 TP_USER_USR 任职数与任职记录一致,无任职为 `--`|关联需求:UI-SPEC-SPD-USER-002 + +--- + +### 新增与编辑 + +#### 功能验证点 + +- **TP_USER_UNI_036**(高)|打开新增弹窗|验证要点:点「新增」弹出用户表单,含账号、姓名、联系方式等及取消/确定|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_037**(高)|合法保存落在当前医院|验证要点:保存成功后列表在当前「所属医院」下可见该账号|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_038**(中)|新增取消不落库|验证要点:填写后点取消,当前医院列表不出现该账号|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_039**(高)|打开编辑并回填|验证要点:行内「编辑」打开「编辑用户」,回填与列表行一致|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_040**(高)|修改后确定落库|验证要点:修改后确定,本页列表与再次编辑回看均为新值|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_041**(中)|编辑取消不保存|验证要点:修改后点取消,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_042**(中)|启用禁用规则同用户管理|验证要点:编辑弹窗可见启用/禁用;保存后仍能按姓名/账号查到该用户;禁用登录受限同 TP_USER_USR。扫描未见高级筛选「状态」字段,不按状态筛选写点|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_043**(中)|签名规则同用户管理|验证要点:编辑弹窗签名上传/展示规则同 TP_USER_USR,本页不重复展开|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_044**(中)|Excel 导入入口可达|验证要点:「Excel导入」可打开导入流程|关联需求:UI-SPEC-SPD-USER-002 + +#### 边界验证点 + +- **TP_USER_UNI_045**(中)|字段最大长度|验证要点:账号/姓名最大长度可保存|关联需求:UI-SPEC-SPD-USER-002 + +#### 异常验证点 + +- **TP_USER_UNI_046**(高)|必填为空拦截|验证要点:账号或姓名为空确定被拦截,不落库|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_047**(高)|跨院重复账号不静默覆盖|验证要点:使用他院已占用登录账号保存时拒绝并提示,不覆盖他院用户|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_048**(中)|Excel 导入失败有提示|验证要点:导入文件失败或模板不符时有错误提示,不误导为成功|关联需求:UI-SPEC-SPD-USER-002 + +#### 集成验证点 + +- **TP_USER_UNI_049**(中)|本页编辑结果回写用户管理|验证要点:在医共体页修改姓名/联系方式后,用户管理页同一账号字段一致|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_050**(中)|Excel 导入归属当前医院|验证要点:导入成功的数据出现在当前「所属医院」列表,不写入其它院|关联需求:UI-SPEC-SPD-USER-002 + +--- + +### 授权与重置密码 + +#### 功能验证点 + +- **TP_USER_UNI_051**(高)|行内授权可打开|验证要点:点「授权」打开用户角色授权弹框;弹框账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-017`~`019`,规则同 TP_USER_USR)|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_052**(高)|行内重置密码可执行|验证要点:点「重置密码」可完成改密;改密后登录规则同 TP_USER_USR|关联需求:UI-SPEC-SPD-USER-002 + +#### 边界验证点 + +- **TP_USER_UNI_053**(低)|对他院用户写操作不可用|验证要点:筛选他院后,对非当前医院用户的编辑/授权/重置入口不可见或拒绝|关联需求:UI-SPEC-SPD-USER-002 + +#### 异常验证点 + +- **TP_USER_UNI_054**(中)|授权保存失败|验证要点:接口失败有提示,再次打开不是半成功勾选|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_055**(中)|重置密码失败|验证要点:接口失败有提示,原密码仍可登录|关联需求:UI-SPEC-SPD-USER-002 + +#### 集成验证点 + +- **TP_USER_UNI_056**(高)|编辑不串改他院同名用户|验证要点:保存编辑后仅当前所属医院下该账号变更,其它院区同名用户不变|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_057**(高)|授权不串改他院账号|验证要点:授权只作用于当前行登录账号+所属医院,其它院区同账号勾选不变|关联需求:UI-SPEC-SPD-USER-002 +- **TP_USER_UNI_058**(高)|重置密码不串改他院账号|验证要点:重置密码只改当前行,其它院区同账号原密码仍有效|关联需求:UI-SPEC-SPD-USER-002 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与分页、新增与编辑、授权与重置密码。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、所属医院筛选、列顺序差异、跨院不覆盖/不串改、列设置;启用禁用/签名/任职/授权持久化引用 TP_USER_USR,本页不重复展开;共 **60** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_USER_UNI` | +| 待评审项 | ① 新增/授权弹窗扫描未稳定打开,以现网为准 ② 跨院登录账号按拒绝且不覆盖写点,若产品允许共用需改判定 ③ 所属医院默认值与顶栏约定以现网为准 ④ 签名/启用禁用/任职明细/改密后登录同用户管理对应 TP,本页只保留医院维度 ⑤ UI-SPEC 测试关注点写了「状态」筛选,现网扫描仅在编辑弹窗有启用/禁用、无高级筛选「状态」;按扫描写点,自动化 `USER-UNI-013` 改为按科室过滤(TP_USER_UNI_016) | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;Playwright `tests/spd/basic-user-union.spec.ts` + `pages/spd/basic-user-union.page.ts` | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..a1593e6507bcc4f44132ac39ba2a74aef0f1244c --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,222 @@ +# 基础管理-用户管理-用户管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-001.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/person` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/basic-user-elements.json`(219 项) +**定位器**:`locators/spd/basic-user.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 用户管理 · 用户管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_USER_USR_001**(高)|菜单路径可达用户管理|验证要点:一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」进入 `/person`,不误入医共体用户管理或角色管理|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_002**(高)|页面主区加载完成|验证要点:整页单表渲染:筛选区、工具栏(新增/Excel导入)、列表列头与分页可见,无报错遮罩|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_003**(中)|同组用户管理菜单可切换|验证要点:在 用户管理 / 医共体用户管理 / 角色管理 间切换后 URL 与页面内容对应,再返回用户管理状态正确|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/user/platform/person`,页面正常渲染|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示用户/角色业务数据|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_007**(中)|无菜单权限|验证要点:无用户管理权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_008**(中)|顶栏身份与可管理范围一致|验证要点:顶栏院区、登录用户与本页可管理用户范围一致|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 筛选与查询 + +#### 功能验证点 + +- **TP_USER_USR_009**(高)|默认条件查询|验证要点:进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_010**(高)|姓名/登录账号关键字|验证要点:在「姓名」框(占位「姓名、登录账号」)输入关键字后,结果姓名或登录账号匹配|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_011**(高)|科室下拉筛选|验证要点:选择某科室后列表「所属科室」与所选一致(未分配显示 `--` 的行不应出现在指定科室结果中)|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_012**(高)|角色下拉筛选|验证要点:选择某角色后列表「角色」列包含该角色|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_013**(高)|姓名+科室+角色组合查询|验证要点:多个已填条件同时生效,结果同时满足全部条件|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_014**(中)|重置恢复默认|验证要点:填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_015**(中)|展开高级筛选|验证要点:点「高级筛选」展开面板,可见姓名/科室/角色/状态及底部「重置」「筛选」|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_016**(高)|高级筛选按状态过滤|验证要点:高级筛选选择启用或禁用后点「筛选」,列表仅保留对应状态用户|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_017**(中)|高级筛选内重置|验证要点:高级筛选填写后点面板「重置」,条件清空,列表恢复默认|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_018**(中)|超长姓名/账号关键字|验证要点:输入达到或超过允许长度的关键字,查询被接受并按规则截断或返回匹配,页面不崩溃|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_019**(低)|仅空格关键字|验证要点:按空查询处理或提示,不报错|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_020**(低)|科室/角色下拉可搜索|验证要点:下拉内输入关键字后选项收敛为匹配项|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_021**(中)|查询接口失败|验证要点:列表查询失败时有错误提示,不白屏、不把过期数据展示为成功态|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_022**(低)|无匹配结果空态|验证要点:条件无匹配时「暂无数据」明确,分页总数为 0|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_023**(中)|筛选结果与分页总数一致|验证要点:查询后可见行逻辑与「共 N 条」一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_024**(高)|角色下拉与角色管理同源|验证要点:本页角色筛选项集合与「角色管理」左侧角色名称一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 列表与分页 + +#### 功能验证点 + +- **TP_USER_USR_025**(高)|列表核心列完整|验证要点:主表展示序号、姓名、登录账号、联系方式、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码)|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_026**(中)|列表不含所属医院列|验证要点:本页列中无「所属医院」,与医共体用户管理页区分|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_027**(中)|多角色列表截断展示|验证要点:用户绑定多个角色时列表「角色」列截断(如带省略号)|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_028**(中)|空值展示为 --|验证要点:未分配科室、未填工号、无任职时对应列显示 `--`|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_029**(中)|表格密度切换|验证要点:密度切换后列表仍可读,列不丢失|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_030**(低)|无批量操作|验证要点:序号列可勾选,但工具栏无批量启用/删除/授权;按「无批量」处理,不测批量提交|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_031**(中)|分页翻页|验证要点:总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_032**(中)|每页条数切换|验证要点:切换「20 条/页」「50 条/页」等后当前页行数为 min(N,所选值) 且与切换前不同(当数据量足够时),总数不变;UI 自动化 `USER-USR-016` 覆盖切换 20 条/页|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_033**(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_083**(低)|打开列设置面板|验证要点:列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在(自动化 `USER-USR-017`)|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_084**(低)|列设置隐藏后再恢复|验证要点:取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(自动化 `USER-USR-018`)|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_034**(中)|首页上一页不可用|验证要点:第 1 页时「上一页」禁用或点击无效|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_035**(中)|末页下一页不可用|验证要点:最后一页时「下一页」禁用或点击无效|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_036**(低)|跳至超出总页数|验证要点:跳至大于总页数的页码时被拒绝、纠正到末页或给出提示|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_037**(低)|跳至非数字页码|验证要点:跳至输入非法字符时不跳转异常页,输入被忽略或提示|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_038**(中)|翻页不丢失当前筛选|验证要点:带筛选条件翻页后条件保持,新页数据仍满足原筛选|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_039**(中)|科室任职数与任职记录一致|验证要点:「科室任职数」等于该用户在各科室任职条数,无任职为 `--`|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_040**(中)|任职数展示或可下钻|验证要点:任职数为只读数字;若可点开明细,则明细条数与数字一致|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 新增用户 + +#### 功能验证点 + +- **TP_USER_USR_041**(高)|打开新增用户弹窗|验证要点:点「新增」弹出新增/编辑同类表单,含账号、姓名、联系方式、医院员工编号、启用/禁用、取消/确定|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_042**(高)|合法必填保存成功|验证要点:填写必填后确定,弹窗关闭,列表出现新记录且字段与提交一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_043**(中)|取消不落库|验证要点:填写后点「取消」或关闭,列表不新增对应账号|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_044**(中)|Excel 导入入口可达|验证要点:工具栏「Excel导入」可打开导入流程或选择文件|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_045**(中)|字段达到允许最大长度|验证要点:账号/姓名取最大允许长度可保存成功|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_046**(中)|联系方式合法号码|验证要点:填写合法手机号/电话可保存,列表联系方式与提交一致|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_047**(高)|必填为空拦截|验证要点:账号或姓名为空点确定被拦截并提示,不关闭弹窗、不落库|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_048**(高)|登录账号重复拦截|验证要点:使用已存在账号保存失败并提示,不覆盖原用户|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_049**(中)|联系方式非法拦截|验证要点:明显非法号码(过短数字)点确定被拦截并提示,不落库|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_050**(中)|保存接口失败|验证要点:失败有错误提示,弹窗仍在,列表不出现半成品成功态|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_051**(中)|重复点击确定|验证要点:连续快速点击只产生一条记录或第二次被防重|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_052**(中)|Excel 导入失败有提示|验证要点:导入文件失败或模板不符时有错误提示,不误导为成功|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_053**(中)|新用户可出现在医共体用户管理|验证要点:本页新增成功后,医共体用户管理按所属医院能查到同一登录账号(若业务归属医共体)|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 编辑用户 + +#### 功能验证点 + +- **TP_USER_USR_054**(高)|打开编辑用户弹窗并回填|验证要点:行内「编辑」打开「编辑用户」,回填账号、姓名、联系方式、医院员工编号、启用/禁用|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_055**(中)|编辑页可查看全部角色|验证要点:多角色用户在编辑弹窗能看到完整角色列表,不被列表截断值替代|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_056**(中)|签名图片可上传或展示|验证要点:编辑弹窗含签名图片,已有签名可展示,可重新上传后随确定保存|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_057**(高)|修改后确定落库|验证要点:修改姓名或联系方式后确定,列表与再次编辑回看均为新值|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_058**(中)|编辑取消不保存|验证要点:修改后点「取消」,列表与再次打开仍为修改前值|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_059**(高)|启用/禁用切换|验证要点:在编辑弹窗将状态改为禁用或启用并确定后,高级筛选按新状态能查到该用户|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_060**(低)|不改字段直接确定|验证要点:打开编辑不改内容点确定,记录保持原值且不报错|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_061**(中)|编辑时账号只读|验证要点:编辑弹窗中登录账号不可改|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_062**(低)|不传签名可保存|验证要点:签名非必填时不上传仍可确定成功|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_063**(高)|编辑清空必填拦截|验证要点:清空姓名后确定被拦截,原记录不被写成空|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_064**(中)|编辑已删除用户|验证要点:记录已被删除后再确定,提示不存在,不产生幽灵更新|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_065**(中)|签名非图片格式拦截|验证要点:上传非图片或超限文件时拒绝保存并提示|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_066**(高)|禁用后登录受限|验证要点:用户禁用后使用该账号登录被拒绝或权限收回|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_067**(中)|再启用保持原授权|验证要点:禁用后再启用,菜单授权仍为禁用前已保存的勾选,不额外膨胀、不自动清空|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 授权 + +#### 功能验证点 + +- **TP_USER_USR_068**(高)|打开授权|验证要点:行内「授权」打开用户角色授权弹框(或权限树层),展示可勾选菜单/功能;弹框账号与所选行登录账号、姓名一致(自动化 `USER-USR-019`)|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_069**(高)|保存后勾选持久化|验证要点:勾选后保存,关闭再打开同一用户,勾选与保存时一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_070**(中)|取消不生效|验证要点:勾选后点取消/关闭,再次打开仍为操作前勾选|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_071**(中)|未勾选任何权限可保存|验证要点:全部取消勾选后仍可保存成功,再次打开为全不勾选|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_072**(中)|授权保存失败|验证要点:接口失败有提示,再次打开不是半成功勾选|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_073**(高)|授权后仅可见已授权菜单|验证要点:保存后该用户登录仅能看到已授权菜单|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_074**(中)|授权节点与角色权限树语义一致|验证要点:用户授权层菜单节点名称与角色管理权限树对应节点一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_082**(中)|授权角色行添加与移除|验证要点:用户角色授权弹框内点「添加」列表增加一行,点「移除」后新增行被删除;不保存不落库(自动化 `USER-USR-020`)|关联需求:UI-SPEC-SPD-USER-001 + +--- + +### 重置密码 + +#### 功能验证点 + +- **TP_USER_USR_075**(高)|行内重置密码|验证要点:点「重置密码」出现确认或密码表单,确认后该用户密码被更新|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_076**(中)|编辑弹窗内重置密码|验证要点:「编辑用户」内「重置密码」与行内重置效果一致|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_077**(中)|取消不改密|验证要点:确认框点取消后原密码仍有效|关联需求:UI-SPEC-SPD-USER-001 + +#### 边界验证点 + +- **TP_USER_USR_078**(低)|对已禁用用户可重置|验证要点:禁用用户允许重置密码,重置后账号仍为禁用、不误启用|关联需求:UI-SPEC-SPD-USER-001 + +#### 异常验证点 + +- **TP_USER_USR_079**(中)|重置接口失败|验证要点:失败有提示,原密码仍可登录|关联需求:UI-SPEC-SPD-USER-001 +- **TP_USER_USR_080**(中)|无二次确认不得改密|验证要点:重置须经确认框;直接关闭或点取消不得改密|关联需求:UI-SPEC-SPD-USER-001 + +#### 集成验证点 + +- **TP_USER_USR_081**(高)|重置后原密码不可登录|验证要点:重置成功后原密码登录失败,新密码(或初始密码规则)可登录|关联需求:UI-SPEC-SPD-USER-001 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、筛选与查询、列表与分页、新增用户、编辑用户、授权、重置密码。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航、筛选查询、列表分页、列设置、新增/编辑(含签名与启用禁用)、授权、重置密码、权限与登录异常、同模块衔接;共 **84** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_USER_USR` | +| 待评审项 | ① 新增弹窗扫描未稳定打开,字段以编辑弹窗与 UI-SPEC 为准 ② 授权层扫描未采集到,勾选控件以现网为准 ③ 编辑时登录账号按只读写点,若现网可改需改判定 ④ 空权限按允许保存写点,联系方式非法/签名非图片按拦截写点,禁用用户重置按允许且不启用写点 ⑤ UI-SPEC 截图未见「查询」,现网扫描有「查询」,测试点以扫描为准 ⑥ 列表序号可勾选但无批量工具栏,按无批量处理 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | 可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;Playwright `tests/spd/basic-user.spec.ts` + `pages/spd/basic-user.page.ts`(含 `USER-USR-016` 每页 20 条) | diff --git "a/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" new file mode 100644 index 0000000000000000000000000000000000000000..a61768b8e1507b14d4618ca6dcf3b9c072fa4012 --- /dev/null +++ "b/testpoint/\345\237\272\347\241\200\347\256\241\347\220\206-\347\224\250\346\210\267\347\256\241\347\220\206-\350\247\222\350\211\262\347\256\241\347\220\206-\346\265\213\350\257\225\347\202\271\345\210\206\346\236\220-01.md" @@ -0,0 +1,135 @@ +# 基础管理-用户管理-角色管理 — 测试点分析 + +**关联需求**:`knowledge-base/08-UI专项规范库/UI-SPEC-SPD-USER-003.md` +**页面 URL**:`http://spdtest.cmic.com.cn:8080/spd/basic/user/platform/role` +**版本 / 定稿日期**:2026-10-20 +**元素扫描**:`data/scan/basic-role-elements.json`(1170 项) +**定位器**:`locators/spd/basic-role.locator.yaml` + +**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围 + +--- + +## 基础设置 · 用户管理 · 角色管理 + +### 页面访问与导航 + +#### 功能验证点 + +- **TP_USER_ROL_001**(高)|菜单路径可达角色管理|验证要点:登录后通过「基础设置 → 用户管理 → 角色管理」进入目标页,左侧菜单与顶栏 Tab 高亮为角色管理,不串到同组其它页|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_002**(高)|页面主区加载完成|验证要点:左右分栏渲染:左侧角色列表与右侧菜单权限树可见,「保存」「刷新」可见,无独立分页,无报错遮罩|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_003**(中)|同组用户管理菜单可切换|验证要点:在 用户管理 / 医共体用户管理 / 角色管理 间切换后 URL 与页面内容对应,再返回角色管理状态正确|关联需求:UI-SPEC-SPD-USER-003 + +#### 边界验证点 + +- **TP_USER_ROL_004**(低)|已登录直接打开 URL|验证要点:携带有效登录态打开 `/spd/basic/user/platform/role`,页面正常渲染|关联需求:UI-SPEC-SPD-USER-003 + +#### 异常验证点 + +- **TP_USER_ROL_005**(高)|未登录访问拦截|验证要点:无登录态访问 URL 跳转登录或不展示用户/角色业务数据|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_006**(中)|登录态过期|验证要点:会话失效后查询/写操作被拦截并提示重新登录,不误写|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_007**(中)|无菜单权限|验证要点:无角色管理权限的账号不可见菜单或 URL 被拒绝|关联需求:UI-SPEC-SPD-USER-003 + +#### 集成验证点 + +- **TP_USER_ROL_008**(中)|顶栏身份与可管理范围一致|验证要点:当前登录人可维护角色与权限树,与顶栏登录身份不矛盾;本页无所属医院维度|关联需求:UI-SPEC-SPD-USER-003 + +--- + +### 左侧角色列表 + +#### 功能验证点 + +- **TP_USER_ROL_009**(高)|角色列表加载|验证要点:左侧列出角色名称(如医院管理员、中心库库管、护士等),可点选|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_010**(高)|角色名称搜索过滤|验证要点:在角色名称框输入关键字后列表收敛为匹配项(即时或回车,以现网为准)|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_011**(高)|选中角色刷新右侧权限树|验证要点:点击某角色后右侧菜单权限勾选刷新为该角色配置,无上一角色残留|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_012**(中)|清空搜索恢复全量|验证要点:清空角色搜索后列表恢复未过滤的完整角色|关联需求:UI-SPEC-SPD-USER-003 + +#### 边界验证点 + +- **TP_USER_ROL_013**(低)|搜索超长关键字|验证要点:超长不崩溃,无匹配时左栏空态明确|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_014**(低)|仅空格搜索|验证要点:按空处理或提示,不报错|关联需求:UI-SPEC-SPD-USER-003 + +#### 异常验证点 + +- **TP_USER_ROL_015**(中)|角色列表加载失败|验证要点:失败有提示,不白屏|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_016**(低)|无匹配角色空态|验证要点:搜索无结果时空态明确|关联需求:UI-SPEC-SPD-USER-003 + +#### 集成验证点 + +- **TP_USER_ROL_017**(高)|角色名与用户管理角色下拉同源|验证要点:左栏角色名称集合与用户管理/医共体用户管理「角色」筛选项一致(允许按权限裁剪)|关联需求:UI-SPEC-SPD-USER-003 + +--- + +### 右侧菜单权限树 + +#### 功能验证点 + +- **TP_USER_ROL_018**(高)|权限树按应用与模块展开|验证要点:右侧为「菜单权限」树,根为「SPD内网系统PC端」,下含基础设置/运维管理/厂商管理/产品管理等模块;节点可展开/折叠,展开后可见子菜单|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_019**(高)|勾选父节点联动子节点|验证要点:勾选某模块父节点后其子菜单/功能全部勾选|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_020**(高)|取消父节点联动取消子节点|验证要点:取消父节点后子节点全部取消|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_021**(中)|部分勾选呈半选态|验证要点:只勾选部分子节点时父节点为半选|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_022**(高)|保存授权持久化|验证要点:点「保存」成功后刷新或重新进入,该角色勾选与保存时一致|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_023**(中)|刷新恢复未保存勾选|验证要点:改勾选后点「刷新」且未保存,树恢复为上次已保存状态|关联需求:UI-SPEC-SPD-USER-003 + +#### 边界验证点 + +- **TP_USER_ROL_024**(中)|同名菜单按层级定位|验证要点:不同模块下同名菜单(若有)勾选互不影响,须按「模块名 + 菜单名」判定|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_025**(低)|树节点很多时可滚动勾选|验证要点:长树滚动后勾选仍对应正确节点,不点错行|关联需求:UI-SPEC-SPD-USER-003 + +#### 异常验证点 + +- **TP_USER_ROL_026**(中)|未选角色直接保存|验证要点:未选中角色时保存被拦截或提示,不把勾选写到错误角色|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_027**(高)|未保存勾选时切换角色|验证要点:改勾选未保存即点另一角色:须提示未保存;确认后丢弃未保存勾选并加载新角色,不把未保存勾选静默写到原角色或新角色|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_028**(中)|保存接口失败|验证要点:失败有提示,再次进入不是半成功勾选|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_029**(中)|权限树加载失败|验证要点:失败有提示,左栏角色仍可选|关联需求:UI-SPEC-SPD-USER-003 + +#### 集成验证点 + +- **TP_USER_ROL_030**(高)|角色授权后用户可见对应菜单|验证要点:为角色勾选「厂商查询」等菜单并保存后,仅绑定该角色的用户登录后能看到对应菜单|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_031**(高)|取消角色菜单后用户收回|验证要点:取消该角色已勾选菜单并保存后,仅绑定该角色的用户登录不再看到该菜单|关联需求:UI-SPEC-SPD-USER-003 + +--- + +### 角色增删改 + +#### 功能验证点 + +- **TP_USER_ROL_032**(中)|新增角色入口可达|验证要点:通过新增/「角色」工具按钮打开新增表单,可填写角色名称并保存,左栏出现新角色|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_033**(高)|编辑角色名称|验证要点:行内编辑可改角色名称,保存后左栏显示新名|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_034**(高)|删除需二次确认|验证要点:点删除出现确认框,含取消/确定|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_035**(高)|确认删除后左栏移除|验证要点:确认后该角色从左栏消失,再搜索名称无该角色|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_036**(中)|取消删除保留角色|验证要点:确认框点取消后角色仍在,权限树勾选不变|关联需求:UI-SPEC-SPD-USER-003 + +#### 边界验证点 + +- **TP_USER_ROL_037**(中)|角色名最大长度|验证要点:达到允许最大长度可保存|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_038**(低)|删除列表中最后一个自定义角色|验证要点:删除后左栏不报错,权限树进入空态或不可保存|关联需求:UI-SPEC-SPD-USER-003 + +#### 异常验证点 + +- **TP_USER_ROL_039**(高)|角色名称为空拦截|验证要点:空名称保存被拦截并提示|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_040**(高)|角色名称重复拦截|验证要点:与已有角色重名保存失败,不覆盖|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_041**(高)|预置角色不可删除|验证要点:医院管理员等预置角色删除入口不可见或确定后拒绝,角色仍在左栏|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_042**(高)|删除仍被用户引用的角色|验证要点:若有用户绑定该自定义角色则拒绝删除或提示先解绑,不出现孤儿授权|关联需求:UI-SPEC-SPD-USER-003 + +#### 集成验证点 + +- **TP_USER_ROL_043**(高)|新增后用户页角色下拉出现|验证要点:新增角色保存后,用户管理/医共体用户管理「角色」筛选项出现该名称|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_044**(高)|改名后用户页角色文案同步|验证要点:改名保存后,用户页下拉与已绑用户行「角色」列为新名|关联需求:UI-SPEC-SPD-USER-003 +- **TP_USER_ROL_045**(高)|删除后用户页无已删角色残留|验证要点:删除自定义角色后,用户页下拉与行「角色」列不再出现该名称|关联需求:UI-SPEC-SPD-USER-003 + + +--- + +## 质量自检摘要 + +| 项 | 结论 | +|----|------| +| 范围 | 在范围内:页面访问与导航、左侧角色列表、右侧菜单权限树、角色增删改。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 | +| 覆盖 | 已覆盖:导航(左右分栏无分页)、左栏角色搜索、右栏权限树勾选联动/保存/未保存切角色、角色增删改与用户页下拉同步;共 **45** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) | +| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 | +| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `TP_USER_ROL` | +| 待评审项 | ① 预置角色按不可删除写点,以现网预置名单为准 ② 未保存切角色按「提示后丢弃、不静默写入」写点,若自动保存需改判定 ③ 本页无授权弹窗、无分页、无所属医院 | +| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe | +| 下游 | UI 自动化 CSV **12** 条:`testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv`;MD `testcases/基础管理-用户管理-角色管理-UI自动化测试用例-01.md`;`tests/spd/basic-role.spec.ts`;`npm run test:basic-role:all`;扫描 `npm run discover:scan-user` | diff --git a/tests/setup/auth.setup.ts b/tests/setup/auth.setup.ts index a185a6901e222efe085ec06792933736ba714869..ae1fb02fe4aba1a5340330fa040233843c71c61c 100644 --- a/tests/setup/auth.setup.ts +++ b/tests/setup/auth.setup.ts @@ -1,16 +1,39 @@ import { test as setup } from '@playwright/test'; +import { hasSecondaryCredentials } from '../../config/credentials.config'; import { ensureAuthStorageFresh } from '../../utils/auth-session'; +/** + * 会话级登录态(只跑一次):登录成功后写入 auth/*.json。 + * 业务 project 通过 storageState fixture 按 worker 加载对应文件; + * Playwright 仍为每条业务用例创建独立 BrowserContext,互不共享。 + */ setup.describe('auth setup', () => { - setup('保存登录态', async ({ browser }) => { - const result = await ensureAuthStorageFresh(browser, { honorForce: true }); + setup.describe.configure({ mode: 'serial' }); + + setup('保存主账号登录态', async ({ browser }) => { + const result = await ensureAuthStorageFresh(browser, { honorForce: true, slot: 'primary' }); + if (result === 'reused-fresh') { + setup.skip(true, '主账号登录态新鲜,跳过重复登录与探活'); + return; + } + if (result === 'reused-probed') { + setup.skip(true, '主账号登录态有效,跳过重复登录'); + } + }); + + setup('保存辅账号登录态', async ({ browser }) => { + if (!hasSecondaryCredentials()) { + setup.skip(true, '未配置 TEST_USER_2/TEST_PASS_2,跳过辅账号登录'); + return; + } + const result = await ensureAuthStorageFresh(browser, { honorForce: true, slot: 'secondary' }); if (result === 'reused-fresh') { - setup.skip(true, '登录态新鲜,跳过重复登录与探活'); + setup.skip(true, '辅账号登录态新鲜,跳过重复登录与探活'); return; } if (result === 'reused-probed') { - setup.skip(true, '登录态有效,跳过重复登录'); + setup.skip(true, '辅账号登录态有效,跳过重复登录'); } }); }); diff --git a/tests/spd/admit-material-list.spec.ts b/tests/spd/admit-material-list.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..822003d6e58fbc2add248273207a3914bb6bb948 --- /dev/null +++ b/tests/spd/admit-material-list.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('物料准入单列表 · 页面进入', () => { + test('ADMIT-MLS-001 菜单进入物料准入单列表', { tag: '@smoke' }, async ({ admitMaterialListPage }) => { + await admitMaterialListPage.openFromMenu(); + await admitMaterialListPage.ready(); + expect(admitMaterialListPage.browserPage.url(), 'URL 应为物料准入单列表').toContain('zrlist'); + await expect(admitMaterialListPage.tab('已通过'), '已通过 Tab').toBeVisible(); + }); + }); + + test.describe('物料准入单列表 · 筛选与状态', () => { + test('ADMIT-MLS-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.query(); + await assertAdmitListOrEmpty(await admitMaterialListPage.snapshotList()); + }); + + test('ADMIT-MLS-003 按准入单号筛选', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.filterByOrderOrFallback(); + }); + + test('ADMIT-MLS-004 重置恢复默认', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.resetFilters(); + }); + + test('ADMIT-MLS-005 无匹配结果空态(mock反例)', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await assertAdmitMockEmpty(await admitMaterialListPage.mockEmpty()); + }); + + test('ADMIT-MLS-006 列表核心列完整', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + assertAdmitHeaders(await admitMaterialListPage.coreHeaders(), [ + '准入类型', + '准入单号', + '进院供应商', + '产品名称', + '注册证号', + '耗材类型', + '产品条数', + '状态', + '操作', + ]); + await expect(admitMaterialListPage.rowAction(await admitMaterialListPage.firstRow(), '详情'), '详情').toBeVisible(); + }); + + test('ADMIT-MLS-007 切换已通过 Tab', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.switchPassed(); + }); + test('ADMIT-MLS-014 多条件组合查询(制单日期等6项)', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.filterCombination(); + }); + }); + + test.describe('物料准入单列表 · 单据详情', () => { + test('ADMIT-MLS-008 打开详情后返回', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.detailAndBack(); + }); + + test('ADMIT-MLS-009 详情产品条数与列表一致', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.detailCountMatches(); + }); + + test('ADMIT-MLS-010 审批进程可打开', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.openApproval(); + }); + + test('ADMIT-MLS-011 详情导出不白屏', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.exportDetail(); + }); + + test('ADMIT-MLS-012 详情搜索无匹配(mock反例)', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.detailSearchMock(); + }); + + test('ADMIT-MLS-013 已通过单据不确认通过', async ({ admitMaterialListPage }) => { + await admitMaterialListPage.open(); + await admitMaterialListPage.passedNotConfirm(); + }); + }); +}); diff --git a/tests/spd/admit-material.spec.ts b/tests/spd/admit-material.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7055085bcb656cbfde3ebe42d474dfcccb3661d3 --- /dev/null +++ b/tests/spd/admit-material.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('物料准入管理 · 页面进入', () => { + test('ADMIT-MAT-001 菜单进入物料准入管理页', { tag: '@smoke' }, async ({ admitMaterialPage }) => { + await admitMaterialPage.openFromMenu(); + await admitMaterialPage.ready(); + expect(admitMaterialPage.browserPage.url(), 'URL 应为物料准入').toContain('ZRlist'); + for (const name of ['关联产品', '生成申请单', '一键生成', '作废']) { + await expect(admitMaterialPage.button(name).first(), name).toBeVisible(); + } + await expect(admitMaterialPage.tab('待申请'), '待申请 Tab').toBeVisible(); + }); + }); + + test.describe('物料准入管理 · 筛选查询', () => { + test('ADMIT-MAT-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.query(); + await assertAdmitListOrEmpty(await admitMaterialPage.snapshotList()); + }); + + test('ADMIT-MAT-003 产品名称筛选', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.filterByProductName(); + }); + + test('ADMIT-MAT-004 耗材类型筛选', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.filterByGoodsType(); + }); + + test('ADMIT-MAT-005 名称与耗材类型组合查询', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.filterByNameAndType(); + }); + + test('ADMIT-MAT-006 重置恢复默认', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.resetFilters(); + }); + + test('ADMIT-MAT-007 收起后再展开保留条件', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.collapseKeepsName(); + }); + + test('ADMIT-MAT-008 无匹配结果空态(mock反例)', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await assertAdmitMockEmpty(await admitMaterialPage.mockEmptyName()); + }); + }); + + test.describe('物料准入管理 · 状态与列表', () => { + test('ADMIT-MAT-009 列表核心列完整', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + assertAdmitHeaders(await admitMaterialPage.coreHeaders(), [ + '准入类型', + '产品编号', + '产品名称', + '规格型号', + '规格', + '型号', + '器械注册人', + '单位', + '耗材类型', + '默认进院供应商', + '操作', + ]); + await expect(admitMaterialPage.rowAction(await admitMaterialPage.firstRow(), '详情'), '详情').toBeVisible(); + }); + + test('ADMIT-MAT-010 切换待申请 Tab', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.switchPending(); + }); + + test('ADMIT-MAT-011 待申请 Tab 计数与分页总数一致', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.pendingCountMatches(); + }); + + test('ADMIT-MAT-012 未勾选时生成申请单不可用或提示', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.generateNeedsSelection(); + }); + }); + + test.describe('物料准入管理 · 详情与编辑', () => { + test('ADMIT-MAT-013 打开详情后返回', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.detailAndBack(); + }); + + test('ADMIT-MAT-014 详情关键字段不可直接改写', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.detailReadonly(); + }); + + test('ADMIT-MAT-015 待申请行编辑回填', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.editBackfill(); + }); + + test('ADMIT-MAT-016 编辑取消不保存', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.editCancel(); + }); + + test('ADMIT-MAT-017 编辑清空产品名称拦截(mock反例)', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.editClearName(); + }); + + test('ADMIT-MAT-018 已生效行不提供编辑', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.effectiveNoEdit(); + }); + }); + + test.describe('物料准入管理 · 关联与作废', () => { + test('ADMIT-MAT-019 关联产品取消不落库', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.relateCancel(); + }); + + test('ADMIT-MAT-020 作废确认后取消', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.voidCancel(); + }); + + test('ADMIT-MAT-021 打开操作日志后关闭', async ({ admitMaterialPage }) => { + await admitMaterialPage.open(); + await admitMaterialPage.openLog(); + }); + }); +}); diff --git a/tests/spd/admit-product-apply.spec.ts b/tests/spd/admit-product-apply.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..32bddab67616452bf1ad0295d63bc5704647cd0a --- /dev/null +++ b/tests/spd/admit-product-apply.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('产品准入申请 · 页面进入', () => { + test('ADMIT-PAP-001 菜单进入产品准入申请页', { tag: '@smoke' }, async ({ admitProductApplyPage }) => { + await admitProductApplyPage.openFromMenu(); + await admitProductApplyPage.ready(); + expect(admitProductApplyPage.browserPage.url(), '不应进入物料准入').not.toContain('ZRlist'); + for (const name of ['新增产品', 'Excel导入', '生成申请单']) { + await expect(admitProductApplyPage.button(name).first(), name).toBeVisible(); + } + }); + }); + + test.describe('产品准入申请 · 筛选查询', () => { + test('ADMIT-PAP-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.query(); + await assertAdmitListOrEmpty(await admitProductApplyPage.snapshotList()); + }); + + test('ADMIT-PAP-003 产品名称筛选', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.filterByProductName(); + }); + + test('ADMIT-PAP-004 名称与耗材类型组合查询', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.filterByNameAndType(); + }); + + test('ADMIT-PAP-005 重置恢复默认', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.resetFilters(); + }); + + test('ADMIT-PAP-006 无匹配结果空态(mock反例)', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await assertAdmitMockEmpty(await admitProductApplyPage.mockEmpty()); + }); + }); + + test.describe('产品准入申请 · 状态与列表', () => { + test('ADMIT-PAP-007 列表核心列完整', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + assertAdmitHeaders(await admitProductApplyPage.coreHeaders(), [ + '准入单号', + '产品编号', + '产品名称', + '规格型号', + '器械注册人', + '单位', + '操作', + ]); + }); + + test('ADMIT-PAP-008 切换待申请 Tab', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.switchPending(); + }); + + test('ADMIT-PAP-009 翻至第 2 页', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.goPage2(); + }); + + test('ADMIT-PAP-010 跳至指定页', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.jumpPage2(); + }); + }); + + test.describe('产品准入申请 · 新增与导入', () => { + test('ADMIT-PAP-011 打开新增产品后返回', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.openAddAndBack(); + }); + + test('ADMIT-PAP-012 新增取消不落库', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.addCancel(); + }); + + test('ADMIT-PAP-013 Excel 导入打开后取消', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.excelCancel(); + }); + + test('ADMIT-PAP-014 产品名称为空保存拦截(mock反例)', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.emptyNameBlocked(); + }); + }); + + test.describe('产品准入申请 · 详情、编辑与作废', () => { + test('ADMIT-PAP-015 打开详情后返回', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.detailAndBack(); + }); + + test('ADMIT-PAP-016 待申请编辑回填', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.editBackfill(); + }); + + test('ADMIT-PAP-017 编辑取消不保存', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.editCancel(); + }); + + test('ADMIT-PAP-018 作废确认后取消', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.voidCancel(); + }); + + test('ADMIT-PAP-019 已通过行不提供编辑', async ({ admitProductApplyPage }) => { + await admitProductApplyPage.open(); + await admitProductApplyPage.passedNoEdit(); + }); + }); +}); diff --git a/tests/spd/admit-product-list.spec.ts b/tests/spd/admit-product-list.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba911996fa5c5b3077a26e2e97479f933b59f1cf --- /dev/null +++ b/tests/spd/admit-product-list.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('产品准入单列表 · 页面进入', () => { + test('ADMIT-PLS-001 菜单进入产品准入单列表', { tag: '@smoke' }, async ({ admitProductListPage }) => { + await admitProductListPage.openFromMenu(); + await admitProductListPage.ready(); + expect(admitProductListPage.browserPage.url(), '不应进入物料准入单').not.toContain('zrlist'); + await expect(admitProductListPage.tab('已通过'), '已通过 Tab').toBeVisible(); + }); + }); + + test.describe('产品准入单列表 · 筛选与状态', () => { + test('ADMIT-PLS-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.query(); + await assertAdmitListOrEmpty(await admitProductListPage.snapshotList()); + }); + + test('ADMIT-PLS-003 按产品名称筛选', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.filterByProductOrFallback(); + }); + + test('ADMIT-PLS-004 重置恢复默认', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.resetFilters(); + }); + + test('ADMIT-PLS-005 无匹配结果空态(mock反例)', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await assertAdmitMockEmpty(await admitProductListPage.mockEmpty()); + }); + + test('ADMIT-PLS-006 列表核心列与附件计数', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + assertAdmitHeaders(await admitProductListPage.coreHeaders(), [ + '准入单号', + '进院供应商', + '产品名称', + '注册证号', + '耗材类型', + '产品条数', + '状态', + '备注', + '附件', + '操作', + ]); + }); + + test('ADMIT-PLS-007 切换已通过 Tab', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.switchPassed(); + }); + + test('ADMIT-PLS-008 与物料准入单列表不串 URL', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + expect(admitProductListPage.browserPage.url(), 'URL 应为产品准入单列表').toContain('productOrder/list'); + expect(admitProductListPage.browserPage.url(), '不应串到物料准入单').not.toContain('zrlist'); + }); + test('ADMIT-PLS-014 多条件组合查询(制单日期等6项)', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.filterCombination(); + }); + }); + + test.describe('产品准入单列表 · 单据详情', () => { + test('ADMIT-PLS-009 打开详情后返回', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.detailAndBack(); + }); + + test('ADMIT-PLS-010 详情产品条数与列表一致', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.detailCountMatches(); + }); + + test('ADMIT-PLS-011 单据日志可打开', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.openBillLog(); + }); + + test('ADMIT-PLS-012 详情搜索无匹配(mock反例)', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.detailSearchMock(); + }); + + test('ADMIT-PLS-013 已通过单据不确认通过', async ({ admitProductListPage }) => { + await admitProductListPage.open(); + await admitProductListPage.passedNotConfirm(); + }); + }); +}); diff --git a/tests/spd/admit-supply.spec.ts b/tests/spd/admit-supply.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e6dbc605a49165b764c52230e4f26ca1ee6dc39 --- /dev/null +++ b/tests/spd/admit-supply.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('供货目录申请 · 页面进入', () => { + test('ADMIT-SUP-001 菜单进入供货目录申请页', { tag: '@smoke' }, async ({ admitSupplyPage }) => { + await admitSupplyPage.openFromMenu(); + await admitSupplyPage.ready(); + await expect(admitSupplyPage.button('新增').first(), '新增').toBeVisible(); + await expect(admitSupplyPage.button('批量提交').first(), '批量提交').toBeVisible(); + }); + }); + + test.describe('供货目录申请 · 筛选与状态', () => { + test('ADMIT-SUP-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.query(); + await assertAdmitListOrEmpty(await admitSupplyPage.snapshotList()); + }); + + test('ADMIT-SUP-003 按进院供应商筛选', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.filterBySupplier(); + }); + + test('ADMIT-SUP-004 重置恢复默认', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.resetFilters(); + }); + + test('ADMIT-SUP-005 变更单号无匹配空态(mock反例)', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await assertAdmitMockEmpty(await admitSupplyPage.mockEmpty()); + }); + + test('ADMIT-SUP-006 列表核心列完整', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + assertAdmitHeaders(await admitSupplyPage.coreHeaders(), [ + '申请单号', + '申请人', + '申请时间', + '进院供应商', + '二级供应商', + '业务模式', + '是否默认', + '备注', + '操作', + ]); + }); + + test('ADMIT-SUP-007 切换待提交 Tab 且计数一致', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.switchPending(); + }); + test('ADMIT-SUP-018 多条件组合查询(制单日期等6项)', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.filterCombination(); + }); + }); + + test.describe('供货目录申请 · 新增与编辑', () => { + test('ADMIT-SUP-008 打开新增供货目录', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.openAddFields(); + }); + + test('ADMIT-SUP-009 新增放弃不落库', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.addCancel(); + }); + + test('ADMIT-SUP-010 业务模式与是否默认只切换不提交', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.switchModeWithoutSubmit(); + }); + + test('ADMIT-SUP-011 待提交编辑回填', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.editBackfill(); + }); + + test('ADMIT-SUP-012 编辑取消不保存', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.editCancel(); + }); + + test('ADMIT-SUP-013 进院供应商为空不可提交(mock反例)', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.emptySupplierBlocked(); + }); + + test('ADMIT-SUP-014 未添加产品不可提交', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.noProductBlocked(); + }); + }); + + test.describe('供货目录申请 · 批量提交与详情', () => { + test('ADMIT-SUP-015 未勾选时批量提交不可用', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.batchNeedsSelection(); + }); + + test('ADMIT-SUP-016 勾选后批量提交取消', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.batchCancel(); + }); + + test('ADMIT-SUP-017 打开详情后返回', async ({ admitSupplyPage }) => { + await admitSupplyPage.open(); + await admitSupplyPage.detailAndBack(); + }); + }); +}); diff --git a/tests/spd/admit-task.spec.ts b/tests/spd/admit-task.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5395eb405165a4ba88082542c66433233ccbfa5 --- /dev/null +++ b/tests/spd/admit-task.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from '../../fixtures'; +import { assertAdmitHeaders, assertAdmitListOrEmpty, assertAdmitMockEmpty } from './helpers/admit-assert.helper'; + +/** + * 全量用例:映射 testcase/基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv + */ +test.describe('基础设置 · 准入管理', () => { + test.describe('准入任务分配 · 页面进入', () => { + test('ADMIT-TSK-001 菜单进入准入任务分配页', { tag: '@smoke' }, async ({ admitTaskPage }) => { + await admitTaskPage.openFromMenu(); + await admitTaskPage.ready(); + await expect(admitTaskPage.tab('待分类'), '待分类 Tab').toBeVisible(); + await expect(admitTaskPage.tab('已分类'), '已分类 Tab').toBeVisible(); + await expect(admitTaskPage.button('批量分配').first(), '批量分配').toBeVisible(); + }); + }); + + test.describe('准入任务分配 · 筛选与分类', () => { + test('ADMIT-TSK-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.query(); + await assertAdmitListOrEmpty(await admitTaskPage.snapshotList()); + }); + + test('ADMIT-TSK-003 产品名称筛选', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.filterByProductName(); + }); + + test('ADMIT-TSK-004 待分类与已分类切换', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.switchTabs(); + }); + + test('ADMIT-TSK-005 重置恢复默认', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.resetFilters(); + }); + + test('ADMIT-TSK-006 无匹配结果空态(mock反例)', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await assertAdmitMockEmpty(await admitTaskPage.mockEmpty()); + }); + test('ADMIT-TSK-015 多条件组合查询(新增日期等11项)', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.filterCombination(); + }); + }); + + test.describe('准入任务分配 · 列表分页', () => { + test('ADMIT-TSK-007 列表核心列完整', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + assertAdmitHeaders(await admitTaskPage.coreHeaders(), [ + '准入类型', + '产品编号', + '产品名称', + '规格型号', + '规格', + '型号', + '器械注册人', + '操作', + ]); + if ((await admitTaskPage.getDataRowCount()) > 0) { + await expect(admitTaskPage.rowAction(await admitTaskPage.firstRow(), '分配类型'), '分配类型').toBeVisible(); + } + }); + + test('ADMIT-TSK-008 翻至第 2 页', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.goPage2(); + }); + + test('ADMIT-TSK-009 跳至指定页', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.jumpPage2(); + }); + + test('ADMIT-TSK-010 第 1 页上一页不可用', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.prevDisabled(); + }); + }); + + test.describe('准入任务分配 · 分配操作', () => { + test('ADMIT-TSK-011 单行分配类型取消', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.assignCancel(); + }); + + test('ADMIT-TSK-012 未勾选时批量分配不可用', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.batchNeedsSelection(); + }); + + test('ADMIT-TSK-013 勾选后批量分配取消', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.batchCancel(); + }); + + test('ADMIT-TSK-014 已分类行不再按待分类分配', async ({ admitTaskPage }) => { + await admitTaskPage.open(); + await admitTaskPage.classifiedNoAssign(); + }); + }); +}); diff --git a/tests/spd/basic-branch.spec.ts b/tests/spd/basic-branch.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdfb868fee25cdb31e89dd83a1570b5e1f2be6c7 --- /dev/null +++ b/tests/spd/basic-branch.spec.ts @@ -0,0 +1,113 @@ +import { test } from '../../fixtures'; +import { basicHospitalData, isBasicWriteAllowed } from '../../data/basic-hospital.data'; + +/** + * 全量用例:映射 testcase/基础管理-医院资料-院区管理-UI自动化测试用例-01.csv + * 运行:npm run test:basic-branch + */ +test.describe('基础设置 · 医院资料', () => { + test.describe('院区管理 · 页面进入', () => { + test('BASIC-BRN-001 菜单进入院区管理页', { tag: '@smoke' }, async ({ basicBranchPage }) => { + await basicBranchPage.openViaMenu(basicHospitalData.menuBranch, '/paltform/area'); + await basicBranchPage.expectEntered(); + }); + }); + + test.describe('院区管理 · 列表搜索', () => { + test('BASIC-BRN-002 默认列出院区或空态明确', { tag: '@smoke' }, async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.expectListOrEmpty(); + }); + + test('BASIC-BRN-003 院区名称搜索过滤', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + const sample = await basicBranchPage.extractSample(); + await basicBranchPage.searchByName(sample.sampleCampusName); + await basicBranchPage.expectNameResults(sample.sampleCampusName); + }); + + test('BASIC-BRN-004 列表核心列完整', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.expectCoreColumns(); + }); + + test('BASIC-BRN-005 清空搜索恢复全量', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + const sample = await basicBranchPage.extractSample(); + await basicBranchPage.searchByName(sample.sampleCampusName); + await basicBranchPage.clearSearch(); + }); + + test('BASIC-BRN-006 搜索无匹配空态(mock反例)', { tag: '@smoke' }, async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.searchByName(basicHospitalData.mockCampusName); + await basicBranchPage.expectMockEmpty(); + }); + }); + + test.describe('院区管理 · 新增院区', () => { + test('BASIC-BRN-007 打开新增院区弹窗', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.openAddDialog(); + await basicBranchPage.closeDialogIfOpen(); + }); + + test('BASIC-BRN-008 新增取消不落库', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.addAndCancel(); + }); + + test('BASIC-BRN-009 合法必填新增成功', async ({ basicBranchPage }) => { + test.skip(!isBasicWriteAllowed(), basicHospitalData.writeDisabledReason); + await basicBranchPage.open(); + await basicBranchPage.addSuccessAndCleanup(); + }); + + test('BASIC-BRN-010 院区名称为空拦截(mock反例)', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.addEmptyName(); + }); + + test('BASIC-BRN-011 所在地区为空拦截(mock反例)', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.addEmptyRegion(); + }); + + test('BASIC-BRN-012 详细地址为空拦截(mock反例)', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.addEmptyAddress(); + }); + }); + + test.describe('院区管理 · 编辑院区', () => { + test('BASIC-BRN-013 打开编辑弹窗并回填', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.openEditAndExpectBackfill(); + }); + + test('BASIC-BRN-014 编辑态院区编号不可换号', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.expectIdNotChangeable(); + }); + + test('BASIC-BRN-015 编辑取消不保存', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.editAndCancel(); + }); + + test('BASIC-BRN-016 编辑状态为禁用启用', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.expectEditStatusLabels(); + }); + + test('BASIC-BRN-017 编辑可见启用云仓', async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.expectCloudWarehouse(); + }); + + test('BASIC-BRN-018 编辑清空必填拦截(mock反例)', { tag: '@smoke' }, async ({ basicBranchPage }) => { + await basicBranchPage.open(); + await basicBranchPage.editClearRequired(); + }); + }); +}); diff --git a/tests/spd/basic-finance-month.spec.ts b/tests/spd/basic-finance-month.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bc1a85c56b78803370e5c7ef032e1623e0dc772 --- /dev/null +++ b/tests/spd/basic-finance-month.spec.ts @@ -0,0 +1,99 @@ +import { test } from '../../fixtures'; +import { basicHospitalData, isBasicWriteAllowed } from '../../data/basic-hospital.data'; + +/** + * 全量用例:映射 testcase/基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv + * 运行:npm run test:basic-finance-month + */ +test.describe('基础设置 · 医院资料', () => { + test.describe('财务月份设置 · 页面进入', () => { + test('BASIC-FMN-001 菜单进入财务月份设置页', { tag: '@smoke' }, async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.openViaMenu(basicHospitalData.menuFinanceMonth, '/balance/month'); + await basicFinanceMonthPage.expectEntered(); + }); + }); + + test.describe('财务月份设置 · 筛选查询', () => { + test('BASIC-FMN-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectListOrEmpty(); + }); + + test('BASIC-FMN-003 按财务月份筛选', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + const sample = await basicFinanceMonthPage.extractSample(); + await basicFinanceMonthPage.queryByMonth(sample.sampleFinanceMonth); + await basicFinanceMonthPage.expectMonthResults(sample.sampleFinanceMonth); + }); + + test('BASIC-FMN-004 重置恢复默认', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + const sample = await basicFinanceMonthPage.extractSample(); + await basicFinanceMonthPage.queryByMonth(sample.sampleFinanceMonth); + await basicFinanceMonthPage.resetFilters(); + }); + }); + + test.describe('财务月份设置 · 列表与状态', () => { + test('BASIC-FMN-005 列表核心列完整', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectCoreColumns(); + }); + + test('BASIC-FMN-006 仅当前月份行可修改', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectOnlyCurrentEditable(); + }); + + test('BASIC-FMN-007 当前月份至多一行', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectAtMostOneCurrent(); + }); + + test('BASIC-FMN-008 历史月份无法进入编辑', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectHistoryNotEditable(); + }); + }); + + test.describe('财务月份设置 · 当前月份修改', () => { + test('BASIC-FMN-009 修改进入行内编辑', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.enterInlineEdit(); + await basicFinanceMonthPage.locators.cancelBtn.click(); + }); + + test('BASIC-FMN-010 编辑态开始结束日期只读', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.search(); + await basicFinanceMonthPage.expectStartEndReadonly(); + }); + + test('BASIC-FMN-011 取消不保存自动结算日', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.cancelWithoutSave(); + }); + + test('BASIC-FMN-012 保存自动结算日期持久化', async ({ basicFinanceMonthPage }) => { + test.skip(!isBasicWriteAllowed(), basicHospitalData.writeDisabledReason); + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.saveAndRestore(); + }); + + test('BASIC-FMN-013 自动结算日为空拦截(mock反例)', { tag: '@smoke' }, async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.saveEmptyDate(); + }); + + test('BASIC-FMN-014 自动结算日超出当月区间拦截(mock反例)', async ({ basicFinanceMonthPage }) => { + await basicFinanceMonthPage.open(); + await basicFinanceMonthPage.saveOutOfRange(); + }); + }); +}); diff --git a/tests/spd/basic-hospital-info.spec.ts b/tests/spd/basic-hospital-info.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b5deb93a41ee8250b32b1c4bd7096a47936ebce --- /dev/null +++ b/tests/spd/basic-hospital-info.spec.ts @@ -0,0 +1,74 @@ +import { test } from '../../fixtures'; +import { basicHospitalData, isBasicWriteAllowed } from '../../data/basic-hospital.data'; + +/** + * 全量用例:映射 testcase/基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv + * 运行:npm run test:basic-hospital-info + */ +test.describe('基础设置 · 医院资料', () => { + test.describe('医院信息登记 · 页面进入与查看', () => { + test('BASIC-HOS-001 菜单进入医院信息登记页', { tag: '@smoke' }, async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.openViaMenu(basicHospitalData.menuHospitalInfo, '/paltform/info'); + await basicHospitalInfoPage.expectEntered(); + }); + + test('BASIC-HOS-002 查看态只读字段不可改', { tag: '@smoke' }, async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.expectViewReadonly(); + }); + + test('BASIC-HOS-003 已上传图片可预览', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.previewIfPresent(); + }); + }); + + test.describe('医院信息登记 · 编辑保存', () => { + test('BASIC-HOS-004 进入编辑模式', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.enterEdit(); + await basicHospitalInfoPage.expectSystemFieldsReadonly(); + await basicHospitalInfoPage.locators.cancelBtn.click(); + }); + + test('BASIC-HOS-005 编辑态只读字段仍不可改', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.enterEdit(); + await basicHospitalInfoPage.expectSystemFieldsReadonly(); + await basicHospitalInfoPage.locators.cancelBtn.click(); + }); + + test('BASIC-HOS-006 取消不保存', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.cancelWithoutSave(); + }); + + test('BASIC-HOS-007 保存后字段持久化', async ({ basicHospitalInfoPage }) => { + test.skip(!isBasicWriteAllowed(), basicHospitalData.writeDisabledReason); + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.saveAndRestore(); + }); + + test('BASIC-HOS-008 联系人为空保存拦截(mock反例)', { tag: '@smoke' }, async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.saveEmptyField('linkman'); + }); + + test('BASIC-HOS-009 联系方式为空保存拦截(mock反例)', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.saveEmptyField('contactWay'); + }); + + test('BASIC-HOS-010 医院简称为空保存拦截(mock反例)', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.saveEmptyField('shortName'); + }); + }); + + test.describe('医院信息登记 · 图片上传', () => { + test('BASIC-HOS-011 编辑态存在背景与 logo 上传控件', async ({ basicHospitalInfoPage }) => { + await basicHospitalInfoPage.open(); + await basicHospitalInfoPage.expectUploadInputs(); + }); + }); +}); diff --git a/tests/spd/basic-notice.spec.ts b/tests/spd/basic-notice.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..27ff7339d3cec7b84c723c200bc9ec9e2569ae12 --- /dev/null +++ b/tests/spd/basic-notice.spec.ts @@ -0,0 +1,124 @@ +import { test } from '../../fixtures'; +import { basicHospitalData, isBasicWriteAllowed } from '../../data/basic-hospital.data'; + +/** + * 全量用例:映射 testcase/基础管理-医院资料-公告管理-UI自动化测试用例-01.csv + * 运行:npm run test:basic-notice + */ +test.describe('基础设置 · 医院资料', () => { + test.describe('公告管理 · 页面进入', () => { + test('BASIC-NTC-001 菜单进入公告管理页', { tag: '@smoke' }, async ({ basicNoticePage }) => { + await basicNoticePage.openViaMenu(basicHospitalData.menuNotice, '/paltform/notice'); + await basicNoticePage.expectEntered(); + }); + }); + + test.describe('公告管理 · 筛选查询', () => { + test('BASIC-NTC-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.search(); + await basicNoticePage.expectListOrEmpty(); + }); + + test('BASIC-NTC-003 公告标题模糊查询', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + const sample = await basicNoticePage.extractSample(); + await basicNoticePage.queryByTitle(sample.sampleNoticeTitle); + await basicNoticePage.expectTitleResultsMatch(sample.sampleNoticeTitle); + }); + + test('BASIC-NTC-004 发布范围下拉查询', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + const sample = await basicNoticePage.extractSample(); + await basicNoticePage.queryByScope(sample.samplePublishScope); + await basicNoticePage.expectScopeResultsMatch(sample.samplePublishScope); + }); + + test('BASIC-NTC-005 标题与范围组合查询', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + const sample = await basicNoticePage.extractSample(); + await basicNoticePage.queryByTitleAndScope(sample.sampleNoticeTitle, sample.samplePublishScope); + await basicNoticePage.expectTitleResultsMatch(sample.sampleNoticeTitle); + await basicNoticePage.expectScopeResultsMatch(sample.samplePublishScope); + }); + + test('BASIC-NTC-006 快捷区重置恢复默认', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + const sample = await basicNoticePage.extractSample(); + await basicNoticePage.queryByTitle(sample.sampleNoticeTitle); + await basicNoticePage.resetFilters(); + }); + + test('BASIC-NTC-007 无匹配结果空态(mock反例)', { tag: '@smoke' }, async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.queryByTitle(basicHospitalData.mockNoticeTitle); + await basicNoticePage.expectMockEmpty(); + }); + }); + + test.describe('公告管理 · 列表分页', () => { + test('BASIC-NTC-008 查询后核心列展示', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.search(); + await basicNoticePage.expectCoreColumns(); + }); + + test('BASIC-NTC-009 分页翻至第 2 页', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.search(); + await basicNoticePage.goToPage2(); + }); + + test('BASIC-NTC-010 跳至指定页', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.search(); + await basicNoticePage.jumpToPage(2); + }); + }); + + test.describe('公告管理 · 新增公告', () => { + test('BASIC-NTC-011 打开新增公告弹窗', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.openAddDialog(); + await basicNoticePage.closeDialogIfOpen(); + }); + + test('BASIC-NTC-012 新增取消不落库', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.addAndCancel(`AUTO-NTC-${Date.now()}`); + }); + + test('BASIC-NTC-013 合法必填发布成功', async ({ basicNoticePage }) => { + test.skip(!isBasicWriteAllowed(), basicHospitalData.writeDisabledReason); + await basicNoticePage.open(); + await basicNoticePage.publishAndCleanup(); + }); + + test('BASIC-NTC-014 标题为空发布拦截(mock反例)', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.publishWithEmptyTitle(); + }); + + test('BASIC-NTC-015 内容为空发布拦截(mock反例)', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.publishWithEmptyContent(); + }); + }); + + test.describe('公告管理 · 编辑与删除', () => { + test('BASIC-NTC-016 打开编辑公告并回填', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.openEditAndExpectBackfill(); + }); + + test('BASIC-NTC-017 编辑取消不保存', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.editAndCancel(); + }); + + test('BASIC-NTC-018 删除弹出确认框', async ({ basicNoticePage }) => { + await basicNoticePage.open(); + await basicNoticePage.deleteConfirmAndCancel(); + }); + }); +}); diff --git a/tests/spd/basic-role.spec.ts b/tests/spd/basic-role.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..36a185befe215de0755f6afb9ce7cb895795d028 --- /dev/null +++ b/tests/spd/basic-role.spec.ts @@ -0,0 +1,141 @@ +import { basicHospitalData, isBasicWriteAllowed } from '../../data/basic-hospital.data'; +import { expect, test } from '../../fixtures'; + +/** + * 全量用例:与 testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:basic-role:all + */ +test.describe('基础设置 · 用户管理', () => { + test.describe('角色管理 · 左侧角色列表', () => { + test('USER-ROL-003 切换角色右侧权限树同步更新', { tag: '@smoke' }, async ({ basicRolePage }) => { + await basicRolePage.open(); + const names = await basicRolePage.leftRoleNames(); + if (names.length < 2) { + await basicRolePage.ensureHealthyBeforeSkip('数据不足:左栏不足 2 个角色'); + test.skip(true, '数据不足:左栏不足 2 个角色'); + } + const [first, second] = names; + await basicRolePage.selectRole(first); + await expect(basicRolePage.locators.roleTree, '权限树').toBeVisible(); + const sigFirst = await basicRolePage.checkedSignature(); + await basicRolePage.selectRole(second); + await expect(basicRolePage.locators.roleTree, '切换后权限树仍可见').toBeVisible(); + expect(await basicRolePage.isRoleSelected(second), '第二个角色应选中').toBeTruthy(); + await basicRolePage.selectRole(first); + await expect(basicRolePage.locators.roleTree, '切回后权限树仍可见').toBeVisible(); + expect(await basicRolePage.isRoleSelected(first), '第一个角色应重新选中').toBeTruthy(); + expect(await basicRolePage.checkedSignature(), '切回后勾选应与首次选中一致').toBe(sigFirst); + }); + + test('USER-ROL-011 左栏角色列表可上下滑动', async ({ basicRolePage }) => { + await basicRolePage.open(); + await basicRolePage.assertLeftRoleListVerticalScroll(); + }); + }); + + test.describe('角色管理 · 角色增删改', () => { + test('USER-ROL-004 编辑角色弹框回填名称一致', async ({ basicRolePage }) => { + await basicRolePage.open(); + const name = await basicRolePage.sampleRoleName(); + const { selectedRoleName, dialog } = await basicRolePage.openEditRoleDialog(name); + await expect(dialog, '编辑弹框可见').toBeVisible(); + await expect(dialog, '应为编辑角色弹框').toContainText(/编\s*辑/); + const formName = await basicRolePage.readEditRoleNameFromDialog(); + expect( + basicRolePage.roleNamesMatch(selectedRoleName, formName), + `弹框角色名称「${formName}」应与选中「${selectedRoleName}」一致`, + ).toBeTruthy(); + await basicRolePage.closeDialog(); + }); + + test('USER-ROL-005 编辑角色保存成功', async ({ basicRolePage }) => { + test.skip(!isBasicWriteAllowed(), basicHospitalData.writeDisabledReason); + await basicRolePage.open(); + const name = await basicRolePage.sampleRoleName(); + const { dialog } = await basicRolePage.openEditRoleDialog(name); + await expect(dialog, '编辑弹框可见').toBeVisible(); + await basicRolePage.saveEditRoleDialog(); + await basicRolePage.expectToastSuccess(/编辑成功/); + await expect(basicRolePage.editRoleDialog(), '保存后弹框应关闭').toBeHidden({ timeout: 15_000 }); + }); + + test('USER-ROL-006 点击角色按钮打开新增弹框', async ({ basicRolePage }) => { + await basicRolePage.open(); + const dialog = await basicRolePage.openAddRoleDialog(); + await expect(dialog, '新增角色弹框可见').toBeVisible(); + await expect(dialog, '应为新增角色弹框').toContainText(/新增|角\s*色/); + expect(await basicRolePage.browserPage.locator('.ant-message-error').count(), '无错误提示').toBe(0); + await basicRolePage.cancelAddRole(); + }); + + test('USER-ROL-007 新增角色名称为空保存拦截', async ({ basicRolePage }) => { + await basicRolePage.open(); + await basicRolePage.openAddRoleDialog(); + await basicRolePage.saveAddRoleDialog(); + await basicRolePage.expectAddRoleNameRequired('请输入角色名称!'); + await basicRolePage.cancelAddRole(); + }); + + test('USER-ROL-008 新增角色弹框取消关闭', async ({ basicRolePage }) => { + await basicRolePage.open(); + const before = (await basicRolePage.leftRoleNames()).length; + const dialog = await basicRolePage.openAddRoleDialog(); + await expect(dialog, '新增角色弹框可见').toBeVisible(); + await basicRolePage.cancelAddRoleDialog(); + expect((await basicRolePage.leftRoleNames()).length, '取消后角色数不增加').toBe(before); + }); + + test('USER-ROL-009 新增弹框查询配置化复选框可勾选', async ({ basicRolePage }) => { + await basicRolePage.open(); + await basicRolePage.openAddRoleDialog(); + await basicRolePage.setQueryConfigChecked(true); + await basicRolePage.setQueryConfigChecked(false); + expect(await basicRolePage.browserPage.locator('.ant-message-error').count(), '无错误提示').toBe(0); + await basicRolePage.cancelAddRoleDialog(); + }); + + test('USER-ROL-010 新增角色弹框X关闭', async ({ basicRolePage }) => { + await basicRolePage.open(); + const before = (await basicRolePage.leftRoleNames()).length; + const dialog = await basicRolePage.openAddRoleDialog(); + await expect(dialog, '新增角色弹框可见').toBeVisible(); + await basicRolePage.closeAddRoleDialogByX(); + expect((await basicRolePage.leftRoleNames()).length, 'X 关闭后角色数不增加').toBe(before); + }); + }); + + test.describe('角色管理 · 右侧菜单权限树', () => { + test('USER-ROL-001 权限树按应用与模块展开', { tag: '@smoke' }, async ({ basicRolePage }) => { + await basicRolePage.open(); + const name = await basicRolePage.sampleRoleName(); + await basicRolePage.selectRole(name); + await expect(basicRolePage.browserPage.getByText('SPD内网系统PC端').first(), '树根').toBeVisible(); + await expect(basicRolePage.locators.treeNode('基础设置').or(basicRolePage.browserPage.getByText('基础设置').first()), '基础设置').toBeVisible(); + await expect(basicRolePage.locators.treeNode('厂商管理').or(basicRolePage.browserPage.getByText('厂商管理').first()), '厂商管理').toBeVisible(); + await basicRolePage.expandTreeNode('基础设置'); + await expect( + basicRolePage.locators.treeNode('医院资料').or(basicRolePage.browserPage.getByText('医院资料').first()), + '基础设置子菜单', + ).toBeVisible(); + }); + + test('USER-ROL-002 权限树模块节点收起正常', async ({ basicRolePage }) => { + await basicRolePage.open(); + const name = await basicRolePage.sampleRoleName(); + await basicRolePage.selectRole(name); + const child = basicRolePage.locators + .treeNode('医院资料') + .or(basicRolePage.locators.roleTree.getByText('医院资料').first()); + await basicRolePage.expandTreeNode('基础设置'); + await expect(child, '展开后子菜单可见').toBeVisible(); + await basicRolePage.collapseTreeNode('基础设置'); + expect(await basicRolePage.isTreeNodeExpanded('基础设置'), '基础设置应收起').toBeFalsy(); + await expect(child, '收起后子菜单不可见').toBeHidden(); + }); + + test('USER-ROL-012 右侧权限树子菜单可上下滑动', async ({ basicRolePage }) => { + await basicRolePage.open(); + await basicRolePage.assertRoleTreeVerticalScroll(); + }); + }); +}); diff --git a/tests/spd/basic-user-union.spec.ts b/tests/spd/basic-user-union.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a117140554cfa635b80e903a166ce1500db7076c --- /dev/null +++ b/tests/spd/basic-user-union.spec.ts @@ -0,0 +1,246 @@ +import { expect, test } from '../../fixtures'; +import { assertHeaderOrder, assertListOrEmpty, assertMockEmpty } from './helpers/user-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:basic-user-union:all + */ +test.describe('基础设置 · 用户管理', () => { + test.describe('医共体用户管理 · 筛选与查询', () => { + test('USER-UNI-001 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + await assertListOrEmpty(await basicUserUnionPage.snapshotList(), '默认查询'); + }); + + test('USER-UNI-002 姓名、所属医院、联系方式、科室、角色等条件组合查询', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + const sample = await basicUserUnionPage.extractUnionSample(); + await basicUserUnionPage.queryComboUnion(sample); + const list = await basicUserUnionPage.snapshotList(); + expect(list.rows, '组合查询应有结果').toBeGreaterThan(0); + expect(list.emptyVisible, '不应为空态').toBeFalsy(); + const loginAfter = (await basicUserUnionPage.cellText(await basicUserUnionPage.firstRow(), '登录账号')).replace( + /\s/g, + '', + ); + expect(loginAfter, '首行应与采样登录账号一致').toBe(sample.sampleLoginName.replace(/\s/g, '')); + expect(await basicUserUnionPage.rowsMatchKeyword('姓名', sample.sampleUserName), '姓名应匹配').toBeTruthy(); + if (sample.sampleHospital) { + expect(await basicUserUnionPage.rowsMatchKeyword('所属医院', sample.sampleHospital), '所属医院应匹配').toBeTruthy(); + } + const phoneNorm = sample.samplePhone.replace(/\s/g, ''); + if (phoneNorm && phoneNorm !== '--') { + expect(await basicUserUnionPage.rowsMatchKeyword('联系方式', sample.samplePhone), '联系方式应匹配').toBeTruthy(); + } + if (sample.sampleDept && sample.sampleDept !== '--') { + expect(await basicUserUnionPage.rowsMatchKeyword('所属科室', sample.sampleDept), '所属科室应匹配').toBeTruthy(); + } + if (sample.sampleRole) { + expect(await basicUserUnionPage.rowsMatchKeyword('角色', sample.sampleRole), '角色应匹配').toBeTruthy(); + } + }); + + test('USER-UNI-013 高级筛选按科室过滤', { tag: '@smoke' }, async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + const sample = await basicUserUnionPage.extractUnionSample(); + if (!sample.sampleDept || sample.sampleDept === '--') { + await basicUserUnionPage.ensureHealthyBeforeSkip('数据不足:无有效科室可筛'); + test.skip(true, '数据不足:无有效科室可筛'); + } + await basicUserUnionPage.filterByDept(sample.sampleDept); + const list = await basicUserUnionPage.snapshotList(); + await assertListOrEmpty(list, '按科室过滤'); + if (list.rows > 0) { + expect(await basicUserUnionPage.rowsMatchKeyword('所属科室', sample.sampleDept), '所属科室应匹配').toBeTruthy(); + } + }); + + test('USER-UNI-003 重置恢复默认医院', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + const defHospital = await basicUserUnionPage.currentHospitalLabel(); + await basicUserUnionPage.fillName('x'); + await basicUserUnionPage.reset(); + expect(await basicUserUnionPage.currentHospitalLabel(), '所属医院应恢复默认').toBe(defHospital); + await expect(basicUserUnionPage.locators.nameInput).toHaveValue(''); + }); + + test('USER-UNI-004 展开高级筛选', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.expandAdvanced(); + await expect( + basicUserUnionPage.locators.formSelect('所属医院').or(basicUserUnionPage.browserPage.getByText('所属医院').first()), + '所属医院', + ).toBeVisible(); + await expect(basicUserUnionPage.browserPage.getByText('联系方式').first(), '联系方式').toBeVisible(); + await expect(basicUserUnionPage.browserPage.getByText('科室').first(), '科室').toBeVisible(); + await expect(basicUserUnionPage.browserPage.getByText('角色').first(), '角色').toBeVisible(); + await expect(basicUserUnionPage.button('筛选').first(), '筛 选').toBeVisible(); + }); + + test('USER-UNI-005 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.queryMockEmpty(); + await assertMockEmpty(await basicUserUnionPage.snapshotList()); + }); + }); + + test.describe('医共体用户管理 · 列表与分页', () => { + test('USER-UNI-006 列表列顺序含所属医院', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + assertHeaderOrder(await basicUserUnionPage.headerTexts(), [ + '登录账号', + '姓名', + '联系方式', + '所属医院', + '所属科室', + '医院员工编号', + '角色', + '科室任职数', + '操作', + ]); + }); + + test('USER-UNI-007 分页翻至第 2 页', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + const hospital = await basicUserUnionPage.currentHospitalLabel(); + const { firstLogin, secondLogin, active } = await basicUserUnionPage.goToPage2(); + expect(active, '页码高亮为 2').toBe('2'); + expect(secondLogin, '第 2 页首行应更换').not.toBe(firstLogin); + expect(await basicUserUnionPage.currentHospitalLabel(), '翻页后医院筛选应保持').toBe(hospital); + }); + + test('USER-UNI-014 切换20条每页页面正常且列表同步', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + const hospital = await basicUserUnionPage.currentHospitalLabel(); + const { total, rowsBefore, rowsAfter } = await basicUserUnionPage.switchToTwentyPerPage(); + const expectedRows = Math.min(total, 20); + expect(rowsAfter, '当前页行数应与 20 条/页及总条数一致').toBe(expectedRows); + expect(rowsAfter, '切换后可见行应随每页条数变化').not.toBe(rowsBefore); + expect(await basicUserUnionPage.getTotalCount(), '总条数应不变').toBe(total); + await expect(basicUserUnionPage.locators.pageSizeChanger, '每页条数').toContainText(/20/); + expect(await basicUserUnionPage.currentHospitalLabel(), '切换后所属医院筛选应保持').toBe(hospital); + await expect(basicUserUnionPage.locators.formSelect('所属医院'), '筛选区').toBeVisible(); + await expect(basicUserUnionPage.locators.tableHeader.first(), '列表表头').toBeVisible(); + }); + + test('USER-UNI-015 打开列设置面板', { tag: '@smoke' }, async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + await basicUserUnionPage.openColumnSettings(); + await basicUserUnionPage.expectColumnSettingsPanel(); + await basicUserUnionPage.closeColumnSettingsPanel(); + }); + + test('USER-UNI-016 列设置取消勾选后列表列恢复', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + await basicUserUnionPage.executeColumnSettingsToggleRestoreTest(); + }); + + test('USER-UNI-008 与用户管理页同一用户字段一致', async ({ basicUserUnionPage, basicUserPage }) => { + await basicUserUnionPage.open(); + const sample = await basicUserUnionPage.extractUnionSample(); + expect(sample.sampleHospital, '本页应有所属医院').toBeTruthy(); + await basicUserPage.open(); + await basicUserPage.queryByName(sample.sampleLoginName); + const snap = await basicUserPage.snapshotList(); + if (snap.rows === 0) { + await basicUserPage.ensureHealthyBeforeSkip('数据不足:用户管理页查不到同一账号'); + test.skip(true, '数据不足:用户管理页查不到同一账号'); + } + const row = await basicUserPage.firstRow(); + expect(await basicUserPage.cellText(row, '姓名'), '姓名跨页一致').toContain(sample.sampleUserName.slice(0, 2)); + if (sample.sampleDept) { + const dept = await basicUserPage.tryCell(row, '所属科室'); + expect(dept.replace(/\s/g, ''), '所属科室跨页一致').toContain(sample.sampleDept.replace(/\s/g, '').slice(0, 4)); + } + const headers = await basicUserPage.headerTexts(); + expect(headers.includes('所属医院'), '用户管理页仅无所属医院').toBeFalsy(); + }); + }); + + test.describe('医共体用户管理 · 新增与编辑', () => { + test('USER-UNI-009 打开新增弹窗', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + const dialog = await basicUserUnionPage.openAddDialog(); + await expect(dialog.getByText(/账号|姓名|联系方式/).first()).toBeVisible(); + await expect(dialog.getByRole('button', { name: /取\s*消/ })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /确\s*定/ })).toBeVisible(); + await basicUserUnionPage.closeDialog(); + }); + + test('USER-UNI-010 新增取消不落库', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + const login = await basicUserUnionPage.addAndCancelOnHospital(); + await assertMockEmpty(await basicUserUnionPage.snapshotList(), login); + }); + + test('USER-UNI-011 必填为空拦截', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.submitEmptyRequired(); + await basicUserUnionPage.expectBlockedAndStay(); + await basicUserUnionPage.closeDialog(); + }); + + test('USER-UNI-012 打开编辑并回填', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + const { login, name } = await basicUserUnionPage.openEdit(); + const dialog = basicUserUnionPage.dialog(); + const codeVal = (await basicUserUnionPage.locators.dialogUserCode().inputValue().catch(() => '')) || ''; + const nameVal = (await basicUserUnionPage.locators.dialogName().inputValue().catch(() => '')) || ''; + expect( + codeVal.replace(/\s/g, '') === login.replace(/\s/g, '') || nameVal.includes(name.slice(0, 2)), + '回填应与列表行一致', + ).toBeTruthy(); + await expect(dialog.getByText(/所属医院/).first().or(dialog), '弹窗可见').toBeVisible(); + await basicUserUnionPage.closeDialog(); + }); + }); + + test.describe('医共体用户管理 · 授权', () => { + test('USER-UNI-018 授权弹框账号与所选行一致', { tag: '@smoke' }, async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + const { row, sampleLoginName, sampleUserName } = await basicUserUnionPage.pickRowWithLogin(); + await basicUserUnionPage.openAuthOnRow(row); + await basicUserUnionPage.expectUserRoleAuthDialogAccount(sampleLoginName, sampleUserName); + await basicUserUnionPage.closeDialog(); + }); + + test('USER-UNI-019 授权弹框添加移除角色行', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + const { row } = await basicUserUnionPage.pickRowWithLogin(); + await basicUserUnionPage.openAuthOnRow(row); + const { before, afterAdd, afterRemove } = await basicUserUnionPage.runAuthAddThenRemove(); + expect(afterAdd, '点击添加后应新增一行').toBe(before + 1); + expect(afterRemove, '点击移除后应删除新增行').toBe(before); + await basicUserUnionPage.closeDialog(); + }); + + test('USER-UNI-017 打开授权后取消不生效', async ({ basicUserUnionPage }) => { + await basicUserUnionPage.open(); + await basicUserUnionPage.query(); + await basicUserUnionPage.openRowAction('授权'); + const layer = basicUserUnionPage.dialog().or(basicUserUnionPage.browserPage.locator('.ant-tree, .put-page')); + await expect(layer.first(), '授权层').toBeVisible({ timeout: 12_000 }); + const before = await basicUserUnionPage.authCheckedCount(); + const unchecked = basicUserUnionPage.browserPage + .locator('.ant-tree-checkbox:not(.ant-tree-checkbox-checked):not(.ant-tree-checkbox-disabled)') + .first(); + if (await unchecked.isVisible().catch(() => false)) await unchecked.click(); + await basicUserUnionPage.closeDialog(); + await basicUserUnionPage.browserPage.keyboard.press('Escape').catch(() => undefined); + await basicUserUnionPage.openRowAction('授权'); + await expect(layer.first(), '再次打开授权层').toBeVisible({ timeout: 12_000 }); + const after = await basicUserUnionPage.authCheckedCount(); + if (before > 0 || after > 0) expect(after, '取消后勾选应恢复').toBe(before); + await basicUserUnionPage.closeDialog(); + }); + }); +}); diff --git a/tests/spd/basic-user.spec.ts b/tests/spd/basic-user.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aee56167d2dcf3d89ee69477749ef2f7f543077 --- /dev/null +++ b/tests/spd/basic-user.spec.ts @@ -0,0 +1,219 @@ +import { expect, test } from '../../fixtures'; +import { assertListOrEmpty, assertMockEmpty } from './helpers/user-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:basic-user:all + */ +test.describe('基础设置 · 用户管理', () => { + test.describe('用户管理 · 筛选与查询', () => { + + + + test('USER-USR-001 姓名+科室+角色+状态组合查询', async ({ basicUserPage }) => { + await basicUserPage.open(); + const sample = await basicUserPage.extractSample(); + await basicUserPage.queryCombo(sample); + const list = await basicUserPage.snapshotList(); + expect(list.rows, '组合查询应有结果').toBeGreaterThan(0); + expect(list.emptyVisible, '不应为空态').toBeFalsy(); + const loginAfter = (await basicUserPage.cellText(await basicUserPage.firstRow(), '登录账号')).replace(/\s/g, ''); + expect(loginAfter, '首行应与采样登录账号一致').toBe(sample.sampleLoginName.replace(/\s/g, '')); + expect(await basicUserPage.rowsMatchKeyword('姓名', sample.sampleUserName), '姓名应匹配').toBeTruthy(); + if (sample.sampleDept && sample.sampleDept !== '--') { + expect(await basicUserPage.rowsMatchKeyword('所属科室', sample.sampleDept), '所属科室应匹配').toBeTruthy(); + } + if (sample.sampleRole) { + expect(await basicUserPage.rowsMatchKeyword('角色', sample.sampleRole), '角色应匹配').toBeTruthy(); + } + expect(await basicUserPage.rowsMatchStatus(sample.sampleStatus), '状态应匹配').toBeTruthy(); + }); + + test('USER-USR-002 重置恢复默认', async ({ basicUserPage }) => { + await basicUserPage.open(); + const sample = await basicUserPage.extractSample(); + await basicUserPage.queryByName(sample.sampleUserName); + await basicUserPage.reset(); + await expect(basicUserPage.locators.nameInput).toHaveValue(''); + }); + + test('USER-USR-003 展开高级筛选', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.expandAdvanced(); + await expect(basicUserPage.browserPage.getByText('状态').first(), '高级筛选状态').toBeVisible(); + await expect(basicUserPage.locators.formSelect('科室').or(basicUserPage.browserPage.getByText('科室').first()), '科室').toBeVisible(); + await expect(basicUserPage.locators.formSelect('角色').or(basicUserPage.browserPage.getByText('角色').first()), '角色').toBeVisible(); + await expect(basicUserPage.button('筛选').first().or(basicUserPage.button('重置').first()), '筛选或重置').toBeVisible(); + }); + + test('USER-USR-004 高级筛选按状态过滤', { tag: '@smoke' }, async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.filterByStatus('启用'); + await assertListOrEmpty(await basicUserPage.snapshotList(), '按状态过滤'); + }); + + test('USER-USR-005 无匹配结果空态(mock反例)', { tag: '@smoke' }, async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.queryMockEmpty(); + await assertMockEmpty(await basicUserPage.snapshotList()); + }); + }); + + test.describe('用户管理 · 列表与分页', () => { + + + test('USER-USR-006 分页翻至第 2 页', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const { firstLogin, secondLogin, active } = await basicUserPage.goToPage2(); + expect(active, '页码高亮为 2').toBe('2'); + expect(secondLogin, '第 2 页首行应更换').not.toBe(firstLogin); + }); + + test('USER-USR-016 切换20条每页页面正常且列表同步', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const { total, rowsBefore, rowsAfter } = await basicUserPage.switchToTwentyPerPage(); + const expectedRows = Math.min(total, 20); + expect(rowsAfter, '当前页行数应与 20 条/页及总条数一致').toBe(expectedRows); + expect(rowsAfter, '切换后可见行应随每页条数变化').not.toBe(rowsBefore); + expect(await basicUserPage.getTotalCount(), '总条数应不变').toBe(total); + await expect(basicUserPage.locators.pageSizeChanger, '每页条数').toContainText(/20/); + expect(basicUserPage.browserPage.url(), '仍停留在用户管理页').toContain('/basic/user/platform/person'); + await expect(basicUserPage.locators.nameInput, '筛选区').toBeVisible(); + await expect(basicUserPage.locators.tableHeader.first(), '列表表头').toBeVisible(); + }); + + test('USER-USR-017 打开列设置面板', { tag: '@smoke' }, async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + await basicUserPage.openColumnSettings(); + await basicUserPage.expectColumnSettingsPanel(); + await basicUserPage.closeColumnSettingsPanel(); + }); + + test('USER-USR-018 列设置取消勾选后列表列恢复', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + await basicUserPage.executeColumnSettingsToggleRestoreTest(); + }); + }); + + test.describe('用户管理 · 新增用户', () => { + test('USER-USR-007 打开新增用户弹窗', async ({ basicUserPage }) => { + await basicUserPage.open(); + const dialog = await basicUserPage.openAddDialog(); + await expect(dialog.getByText(/账号|姓名|联系方式|医院员工编号|启用|禁用/).first()).toBeVisible(); + await expect(dialog.getByRole('button', { name: /取\s*消/ })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /确\s*定/ })).toBeVisible(); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-008 新增取消不落库', async ({ basicUserPage }) => { + await basicUserPage.open(); + const login = await basicUserPage.addAndCancel(); + await assertMockEmpty(await basicUserPage.snapshotList(), login); + }); + + test('USER-USR-009 必填为空拦截', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.submitEmptyRequired(); + await basicUserPage.expectBlockedAndStay(); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-010 登录账号重复拦截', async ({ basicUserPage }) => { + await basicUserPage.open(); + const sample = await basicUserPage.extractSample(); + await basicUserPage.submitDuplicateLogin(sample.sampleLoginName); + await basicUserPage.expectBlockedAndStay(); + await basicUserPage.closeDialog(); + await basicUserPage.queryByName(sample.sampleLoginName); + expect((await basicUserPage.snapshotList()).rows, '原用户仍在').toBeGreaterThan(0); + }); + }); + + test.describe('用户管理 · 编辑用户', () => { + test('USER-USR-011 打开编辑用户弹窗并回填', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const { login, name } = await basicUserPage.openEdit(); + const dialog = basicUserPage.dialog(); + await expect(dialog).toContainText(/编辑用户|编辑/); + const codeVal = (await basicUserPage.locators.dialogUserCode().inputValue().catch(() => '')) || ''; + const nameVal = (await basicUserPage.locators.dialogName().inputValue().catch(() => '')) || ''; + expect(codeVal.replace(/\s/g, '') === login.replace(/\s/g, '') || nameVal.includes(name.slice(0, 2)), '回填应与列表行一致').toBeTruthy(); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-012 编辑取消不保存', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const original = await basicUserPage.editAndCancel(); + const { name } = await basicUserPage.openEdit(); + expect(name, '取消后姓名仍为原值').toBe(original); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-013 编辑时账号只读', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + await basicUserPage.openEdit(); + const code = basicUserPage.locators.dialogUserCode(); + const disabled = await code.isDisabled().catch(() => false); + const readonly = (await code.getAttribute('readonly').catch(() => null)) != null; + expect(disabled || readonly, '编辑态登录账号只读').toBeTruthy(); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-014 编辑弹窗可见签名图片', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + await basicUserPage.openEdit(); + await expect(basicUserPage.dialog().getByText('签名图片').first(), '签名图片').toBeVisible(); + await basicUserPage.closeDialog(); + }); + }); + + test.describe('用户管理 · 授权', () => { + test('USER-USR-020 授权弹框添加移除角色行', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const { row } = await basicUserPage.pickRowWithLogin(); + await basicUserPage.openAuthOnRow(row); + const { before, afterAdd, afterRemove } = await basicUserPage.runAuthAddThenRemove(); + expect(afterAdd, '点击添加后应新增一行').toBe(before + 1); + expect(afterRemove, '点击移除后应删除新增行').toBe(before); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-019 授权弹框账号与所选行一致', { tag: '@smoke' }, async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + const { row, sampleLoginName, sampleUserName } = await basicUserPage.pickRowWithLogin(); + await basicUserPage.openAuthOnRow(row); + await basicUserPage.expectUserRoleAuthDialogAccount(sampleLoginName, sampleUserName); + await basicUserPage.closeDialog(); + }); + + test('USER-USR-015 打开授权后取消不生效', async ({ basicUserPage }) => { + await basicUserPage.open(); + await basicUserPage.query(); + await basicUserPage.openRowAction('授权'); + const layer = basicUserPage.dialog().or(basicUserPage.browserPage.locator('.ant-tree, .put-page')); + await expect(layer.first(), '授权层').toBeVisible({ timeout: 12_000 }); + const before = await basicUserPage.authCheckedCount(); + const unchecked = basicUserPage.browserPage + .locator('.ant-tree-checkbox:not(.ant-tree-checkbox-checked):not(.ant-tree-checkbox-disabled)') + .first(); + if (await unchecked.isVisible().catch(() => false)) await unchecked.click(); + await basicUserPage.closeDialog(); + await basicUserPage.browserPage.keyboard.press('Escape').catch(() => undefined); + await basicUserPage.openRowAction('授权'); + await expect(layer.first(), '再次打开授权层').toBeVisible({ timeout: 12_000 }); + const after = await basicUserPage.authCheckedCount(); + if (before > 0 || after > 0) expect(after, '取消后勾选应恢复').toBe(before); + await basicUserPage.closeDialog(); + }); + }); +}); diff --git a/tests/spd/center-report-entry.stat-ies-026.spec.ts b/tests/spd/center-report-entry.stat-ies-026.spec.ts deleted file mode 100644 index 92e0dec4e2e6e48e11f2016081fbbe6c24c612aa..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-entry.stat-ies-026.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IES-026 导出当前查询结果(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-entry.stat-ies-027.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-ies-027 - */ -test.describe('入库汇总查询 · STAT-IES-026', () => { - test('STAT-IES-026 按当前查询条件导出文件', async ({ centerReportEntryPage, page }) => { - await centerReportEntryPage.open(); - await centerReportEntryPage.checkAllDimensions(); - await centerReportEntryPage.search(); - await centerReportEntryPage.exportReport(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts b/tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts deleted file mode 100644 index ecc105693b55f4674030b7c778dcfe09c23441dd..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-LMP-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-lmp-004 - * (PWDEBUG=console 确保执行完毕后在 Playwright Inspector 暂停) - */ -test.describe('中心库进销存(后勤) · STAT-LMP-004', () => { - test('STAT-LMP-004 全量查询条件组合查询', async ({ centerReportLmmPsiPage, page }) => { - await centerReportLmmPsiPage.open(); - const sample = await centerReportLmmPsiPage.searchWithAllConditions(); - await centerReportLmmPsiPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-lmm-sheet.stat-lms-006.spec.ts b/tests/spd/center-report-lmm-sheet.stat-lms-006.spec.ts deleted file mode 100644 index 4de7d365bf81d08e3e29a720361be2011bdffd6b..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-lmm-sheet.stat-lms-006.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; -import { centerReportLmmSheetComboAssertKeys } from '../../data/center-report-lmm-sheet.data'; - -/** - * STAT-LMS-006 快捷与高级组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-lmm-sheet.stat-lms-005.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-lms-005 - */ -test.describe('出入库明细查询(后勤) · STAT-LMS-006', () => { - test('STAT-LMS-006 快捷与高级组合查询', async ({ centerReportLmmSheetPage, page }) => { - await centerReportLmmSheetPage.open(); - const sample = await centerReportLmmSheetPage.searchWithQuickAndDrawerCombo(); - await centerReportLmmSheetPage.expectCombinedFilterResult(sample, centerReportLmmSheetComboAssertKeys); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-net-entry.stat-nes-007.spec.ts b/tests/spd/center-report-net-entry.stat-nes-007.spec.ts deleted file mode 100644 index 49b499954104be2696f6f3140fe929713a3a2cc1..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-net-entry.stat-nes-007.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-NES-007 快捷维度与高级全量条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-net-entry.stat-nes-007.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-nes-007 - * (PWDEBUG=console 确保执行完毕后在 Playwright Inspector 暂停) - */ -test.describe('净入库汇总查询 · STAT-NES-007', () => { - test('STAT-NES-007 快捷维度与高级全量条件组合查询', async ({ centerReportNetEntryPage, page }) => { - await centerReportNetEntryPage.open(); - const sample = await centerReportNetEntryPage.searchWithAllConditions(); - await centerReportNetEntryPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-net-out.stat-nos-007.spec.ts b/tests/spd/center-report-net-out.stat-nos-007.spec.ts deleted file mode 100644 index 6c01854b93b11f516966708b0fc053f0f0aeafaa..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-net-out.stat-nos-007.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-NOS-007 快捷维度与高级全量条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-net-out.stat-nos-007.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-nos-007 - * (PWDEBUG=console 确保执行完毕后在 Playwright Inspector 暂停) - */ -test.describe('净出库汇总查询 · STAT-NOS-007', () => { - test('STAT-NOS-007 快捷维度与高级全量条件组合查询', async ({ centerReportNetOutPage, page }) => { - await centerReportNetOutPage.open(); - const sample = await centerReportNetOutPage.searchWithAllConditions(); - await centerReportNetOutPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-out.stat-oos-004.spec.ts b/tests/spd/center-report-out.stat-oos-004.spec.ts deleted file mode 100644 index 24106a79016ca428add89b4f9ee3867d564083b8..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-out.stat-oos-004.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-OOS-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-out.stat-oos-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-oos-004 - */ -test.describe('出库汇总查询 · STAT-OOS-004', () => { - test('STAT-OOS-004 全量查询条件组合查询', async ({ centerReportOutPage, page }) => { - await centerReportOutPage.open(); - const sample = await centerReportOutPage.searchWithAllConditions(); - await centerReportOutPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-out.stat-oos-006.spec.ts b/tests/spd/center-report-out.stat-oos-006.spec.ts deleted file mode 100644 index 2d80af22b3185f4e801e46eec2300deb4dcbc88e..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-out.stat-oos-006.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-OOS-006 查询后底部汇总与查询数据一致(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-out.stat-oos-006.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-oos-006 - */ -test.describe('出库汇总查询 · STAT-OOS-006', () => { - test('STAT-OOS-006 查询后底部汇总与查询数据一致', async ({ centerReportOutPage, page }) => { - await centerReportOutPage.open(); - await centerReportOutPage.expectSummaryMatchesSampleRow(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-psi.stat-psi-004.spec.ts b/tests/spd/center-report-psi.stat-psi-004.spec.ts deleted file mode 100644 index 49133e917288e4bff3b8bbf6cc3d150b54051815..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-psi.stat-psi-004.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-PSI-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-psi.stat-psi-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-psi-004 - * (PWDEBUG=console 确保执行完毕后在 Playwright Inspector 暂停) - */ -test.describe('中心库产品进销存 · STAT-PSI-004', () => { - test('STAT-PSI-004 全量查询条件组合查询', async ({ centerReportPsiPage, page }) => { - await centerReportPsiPage.open(); - const sample = await centerReportPsiPage.searchWithAllConditions(); - await centerReportPsiPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-002.spec.ts b/tests/spd/center-report-sheet.stat-iod-002.spec.ts deleted file mode 100644 index 273b37a0eb9abede804f4c78975732ef9686d8e0..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-002.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-002 点击高级筛选打开抽屉(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-002.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-002', () => { - test('STAT-IOD-002 点击高级筛选打开抽屉', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.openAdvancedFilter(); - await centerReportSheetPage.expectAdvancedDrawer(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-003.spec.ts b/tests/spd/center-report-sheet.stat-iod-003.spec.ts deleted file mode 100644 index fa26cc723ee236f481e5c7aedfce0a4ce58efa66..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-003.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { test } from '../../fixtures'; -import { centerReportSheetData } from '../../data/center-report-sheet.data'; - -/** - * STAT-IOD-003 抽屉内重置恢复抽屉字段默认(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-003.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-003', () => { - test('STAT-IOD-003 抽屉内重置恢复抽屉字段默认', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - const sample = await centerReportSheetPage.searchAndExtractSample(); - await centerReportSheetPage.openAdvancedFilter(); - const batchNo = sample.batchNo ?? centerReportSheetData.mockBatchNo; - await centerReportSheetPage.fillBatchNo(batchNo); - await centerReportSheetPage.resetAdvancedDrawer(); - await centerReportSheetPage.openAdvancedFilter(); - await centerReportSheetPage.expectDrawerBatchNoEmpty(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-004.spec.ts b/tests/spd/center-report-sheet.stat-iod-004.spec.ts deleted file mode 100644 index b393a89167b836482af7856ec16c85cd65424387..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-004.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-004.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-004', () => { - test('STAT-IOD-004 全量查询条件组合查询', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - const sample = await centerReportSheetPage.searchWithAllConditions(); - await centerReportSheetPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-006.spec.ts b/tests/spd/center-report-sheet.stat-iod-006.spec.ts deleted file mode 100644 index db64844e0b45bdbe6dfab67a99138bdc5a07c8b6..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-006.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-006 选择首行后底部汇总与首行指标一致(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-006.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-006', () => { - test('STAT-IOD-006 选择首行后底部汇总与首行指标一致', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.expectSummaryMatchesSampleRow(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-011.spec.ts b/tests/spd/center-report-sheet.stat-iod-011.spec.ts deleted file mode 100644 index 83922d9347c725730cf2a045367c9bc9cfffe34a..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-011.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-011 备注列升序降序切换(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-011.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-011', () => { - test('STAT-IOD-011 备注列升序降序切换', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - await centerReportSheetPage.expectColumnSortToggle('备注'); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-015.spec.ts b/tests/spd/center-report-sheet.stat-iod-015.spec.ts deleted file mode 100644 index e5fcc634957eadaf29fc76ba90a439849539b7f3..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-015.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-015 院区名称列升序降序切换(调试/演示专用) - * - * 前置条件: - * - 测试环境可访问;test_1 已登录并进入报表页;已默认查询且列表至少有 2 条数据 - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-015.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-015', () => { - test('STAT-IOD-015 院区名称列升序降序切换', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - await centerReportSheetPage.expectColumnSortToggle('院区名称'); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-039.spec.ts b/tests/spd/center-report-sheet.stat-iod-039.spec.ts deleted file mode 100644 index 2ab8c21a2a90751b803dccbcd18f833ab25d78ce..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-039.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-039 列设置取消勾选后列表列隐藏(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-041.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-039', () => { - test('STAT-IOD-039 列设置取消勾选后再勾选列表列恢复', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - await centerReportSheetPage.executeColumnSettingsToggleRestoreTest(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-041.spec.ts b/tests/spd/center-report-sheet.stat-iod-041.spec.ts deleted file mode 100644 index c69e643fb33c4e9ddd42d4dd1402c137219c3b28..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-041.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-041 切换至第2页(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-043.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-041', () => { - test('STAT-IOD-041 切换至第2页', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - await centerReportSheetPage.executePage2SwitchTest(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-043.spec.ts b/tests/spd/center-report-sheet.stat-iod-043.spec.ts deleted file mode 100644 index 9c475aceeb5fa9c5b21d9b93a93f8e680830bed1..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-043.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from '../../fixtures'; -import { centerReportSheetData } from '../../data/center-report-sheet.data'; - -/** - * STAT-IOD-042 切换每页条数(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-043.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-iod-044 - */ -test.describe('出入库明细查询 · STAT-IOD-042', () => { - test('STAT-IOD-042 切换每页条数', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - const { paginationPageSizeFrom, paginationPageSizeTo } = centerReportSheetData; - await centerReportSheetPage.executePageSizeChangeTest(paginationPageSizeFrom, paginationPageSizeTo, 100); - await centerReportSheetPage.expectPaginationTotal(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-sheet.stat-iod-044.spec.ts b/tests/spd/center-report-sheet.stat-iod-044.spec.ts deleted file mode 100644 index aa7014a76b920f1cf19f2a698f77d6c02e8334c0..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-sheet.stat-iod-044.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-IOD-043 按当前查询条件导出文件(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-sheet.stat-iod-044.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('出入库明细查询 · STAT-IOD-043', () => { - test('STAT-IOD-043 按当前查询条件导出文件', async ({ centerReportSheetPage, page }) => { - await centerReportSheetPage.open(); - await centerReportSheetPage.search(); - await centerReportSheetPage.expectPaginationTotal(); - await centerReportSheetPage.exportReport(); - await page.pause(); - }); -}); diff --git a/tests/spd/center-report-turnover.stat-ptr-002.spec.ts b/tests/spd/center-report-turnover.stat-ptr-002.spec.ts deleted file mode 100644 index ae6a0709ef4173f1c0aac69f16a0a4bfefeb7555..0000000000000000000000000000000000000000 --- a/tests/spd/center-report-turnover.stat-ptr-002.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test } from '../../fixtures'; - -/** - * STAT-PTR-002 统计周期院区产品组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/center-report-turnover.stat-ptr-002.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-ptr-002 - */ -test.describe('产品周转率报表 · STAT-PTR-002', () => { - test('STAT-PTR-002 统计周期院区产品组合查询', async ({ centerReportTurnoverPage, page }) => { - await centerReportTurnoverPage.open(); - const sample = await centerReportTurnoverPage.searchAndExtractSample(); - const campus = await centerReportTurnoverPage.getQuickSelectValue('院区名称'); - if (campus) { - sample.campus = campus; - } - await centerReportTurnoverPage.searchWithCombinedQuickFilters(sample); - await centerReportTurnoverPage.expectCombinedFilterResult(sample); - await page.pause(); - }); -}); diff --git a/tests/spd/dept-report-entry.spec.ts b/tests/spd/dept-report-entry.spec.ts index 7f5f7717e3f2e30d47715df51d0835867146aa42..0a55843d6f5eeaafbdfb6fa23b25a64274324d27 100644 --- a/tests/spd/dept-report-entry.spec.ts +++ b/tests/spd/dept-report-entry.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../fixtures'; +import { standardProductNameDateSyncFields } from '../../data/dept-report-field-defs'; import { deptReportEntryData } from '../../data/dept-report-entry.data'; import { deptReportEntryComboAssertKeys, @@ -62,6 +63,21 @@ test.describe('科室入库汇总查询 · 高级筛选', () => { await deptReportEntryPage.expectRowContainsSampleFields(sample, deptReportEntryComboAssertKeys); await deptReportEntryPage.expectSummaryVisible(); }); + + test('STAT-DES-005 快捷区条件自动带入高级筛选', async ({ deptReportEntryPage }) => { + const sample = await deptReportEntryPage.searchAndExtractSample(); + const quickFields = deptReportEntryComboQuickFields.filter( + (field) => field.key === 'productName' || field.key === 'accountDate', + ); + await deptReportEntryPage.applyQuickFields(sample, quickFields, { onlyIfPresent: true }); + await deptReportEntryPage.search(); + await deptReportEntryPage.openAdvancedFilter(); + await deptReportEntryPage.expectAdvancedDrawerSyncedFromQuick( + sample, + quickFields, + standardProductNameDateSyncFields, + ); + }); }); test.describe('科室入库汇总查询 · 主表列表与汇总', () => { @@ -84,6 +100,11 @@ test.describe('科室入库汇总查询 · 主表列表与汇总', () => { await deptReportEntryPage.searchWithMockFromConfig(); await deptReportEntryPage.expectEmptyList(); }); + + test('STAT-DES-010 列表横向拖拉滚动与列对齐正常', async ({ deptReportEntryPage }) => { + await deptReportEntryPage.search(); + await deptReportEntryPage.expectTableHorizontalScrollAndLayout(); + }); }); test.describe('科室入库汇总查询 · 列设置', () => { diff --git a/tests/spd/dept-report-lmm-sheet.spec.ts b/tests/spd/dept-report-lmm-sheet.spec.ts index fce22fcac5e5094ed4938e313a91826eaa233d45..34fa5759cff3fde970cbc8c05028967fba33097e 100644 --- a/tests/spd/dept-report-lmm-sheet.spec.ts +++ b/tests/spd/dept-report-lmm-sheet.spec.ts @@ -1,5 +1,6 @@ import { test } from '../../fixtures'; import { deptReportLmmSheetData } from '../../data/dept-report-lmm-sheet.data'; +import { deptLmmMaterialSyncFields } from '../../data/dept-report-field-defs'; import { deptReportLmmSheetComboAssertKeys, deptReportLmmSheetComboDrawerFields, @@ -45,6 +46,20 @@ test.describe('科室出入库明细查询(后勤)', () => { await deptReportLmmSheetPage.expectDrawerTextEmpty('单号'); }); + test('STAT-DLS-005 快捷区条件自动带入高级筛选', async ({ deptReportLmmSheetPage }) => { + const sample = await deptReportLmmSheetPage.searchAndExtractSample(); + await deptReportLmmSheetPage.applyQuickFields(sample, deptReportLmmSheetComboQuickFields, { + onlyIfPresent: true, + }); + await deptReportLmmSheetPage.search(); + await deptReportLmmSheetPage.openAdvancedFilter(); + await deptReportLmmSheetPage.expectAdvancedDrawerSyncedFromQuick( + sample, + deptReportLmmSheetComboQuickFields, + deptLmmMaterialSyncFields, + ); + }); + test('STAT-DLS-006 快捷与高级组合查询', async ({ deptReportLmmSheetPage }) => { const sample = await deptReportLmmSheetPage.searchWithQuickAndDrawerCombo( deptReportLmmSheetComboQuickFields, diff --git a/tests/spd/dept-report-out.spec.ts b/tests/spd/dept-report-out.spec.ts index 9588be9cef8280823725db5f124effae757135a3..1b73b7615047d75225b38fed841ecdff7d12e802 100644 --- a/tests/spd/dept-report-out.spec.ts +++ b/tests/spd/dept-report-out.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../fixtures'; +import { standardProductNameDateSyncFields } from '../../data/dept-report-field-defs'; import { deptReportOutData } from '../../data/dept-report-out.data'; import { deptReportOutComboAssertKeys, @@ -63,6 +64,21 @@ test.describe('科室出库汇总查询 · 高级筛选', () => { await deptReportOutPage.expectRowContainsSampleFields(sample, deptReportOutComboAssertKeys); await deptReportOutPage.expectSummaryVisible(); }); + + test('STAT-DOS-005 快捷区条件自动带入高级筛选', async ({ deptReportOutPage }) => { + const sample = await deptReportOutPage.searchAndExtractSample(); + const quickFields = deptReportOutComboQuickFields.filter( + (field) => field.key === 'productName' || field.key === 'accountDate', + ); + await deptReportOutPage.applyQuickFields(sample, quickFields, { onlyIfPresent: true }); + await deptReportOutPage.search(); + await deptReportOutPage.openAdvancedFilter(); + await deptReportOutPage.expectAdvancedDrawerSyncedFromQuick( + sample, + quickFields, + standardProductNameDateSyncFields, + ); + }); }); test.describe('科室出库汇总查询 · 主表列表与汇总', () => { @@ -85,6 +101,11 @@ test.describe('科室出库汇总查询 · 主表列表与汇总', () => { await deptReportOutPage.searchWithMockFromConfig(); await deptReportOutPage.expectEmptyList(); }); + + test('STAT-DOS-010 列表横向拖拉滚动与列对齐正常', async ({ deptReportOutPage }) => { + await deptReportOutPage.search(); + await deptReportOutPage.expectTableHorizontalScrollAndLayout(); + }); }); test.describe('科室出库汇总查询 · 列设置', () => { diff --git a/tests/spd/dept-report-psi.spec.ts b/tests/spd/dept-report-psi.spec.ts index 2d0c3bc20c0f3ce0c9f761cff17bfd883ecdb18b..67c7e7ef16c82783f9912d30f0f6cf757fd5ace7 100644 --- a/tests/spd/dept-report-psi.spec.ts +++ b/tests/spd/dept-report-psi.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '../../fixtures'; import { deptReportPsiData } from '../../data/dept-report-psi.data'; +import { deptReportPsiSyncFields } from '../../data/dept-report-field-defs'; import { deptReportPsiComboAssertKeys, deptReportPsiComboDrawerFields, @@ -41,6 +42,18 @@ test.describe('科室产品进销存', () => { await deptReportPsiPage.expectRowContainsSampleFields(sample, deptReportPsiComboAssertKeys); }); + test('STAT-DPS-005 快捷区条件自动带入高级筛选', async ({ deptReportPsiPage }) => { + const sample = await deptReportPsiPage.searchAndExtractSample(); + await deptReportPsiPage.applyQuickFields(sample, deptReportPsiComboQuickFields, { onlyIfPresent: true }); + await deptReportPsiPage.search(); + await deptReportPsiPage.openAdvancedFilter(); + await deptReportPsiPage.expectAdvancedDrawerSyncedFromQuick( + sample, + deptReportPsiComboQuickFields, + deptReportPsiSyncFields, + ); + }); + test('STAT-DPS-006 抽屉内重置', async ({ deptReportPsiPage }) => { await deptReportPsiPage.openAdvancedFilter(); await deptReportPsiPage.fillDrawerTextByLabel('规格型号', 'MOCK-SPEC-001'); diff --git a/tests/spd/dept-report-sheet.spec.ts b/tests/spd/dept-report-sheet.spec.ts index 9915f6360153d00140ba613b74c63a46bdfae2f8..b726e0381a884a01b8e02c157f8078e4f9102a5e 100644 --- a/tests/spd/dept-report-sheet.spec.ts +++ b/tests/spd/dept-report-sheet.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../fixtures'; +import { standardProductNameDateSyncFields } from '../../data/dept-report-field-defs'; import { deptReportSheetComboAssertKeys, deptReportSheetComboDrawerFields, @@ -55,6 +56,21 @@ test.describe('科室出入库明细查询 · 高级筛选', () => { ); await deptReportSheetPage.expectRowContainsSampleFields(sample, deptReportSheetComboAssertKeys); }); + + test('STAT-DOD-005 快捷区条件自动带入高级筛选', async ({ deptReportSheetPage }) => { + const sample = await deptReportSheetPage.searchAndExtractSample(); + const quickFields = deptReportSheetComboQuickFields.filter( + (field) => field.key === 'productName' || field.key === 'accountDate', + ); + await deptReportSheetPage.applyQuickFields(sample, quickFields, { onlyIfPresent: true }); + await deptReportSheetPage.search(); + await deptReportSheetPage.openAdvancedFilter(); + await deptReportSheetPage.expectAdvancedDrawerSyncedFromQuick( + sample, + quickFields, + standardProductNameDateSyncFields, + ); + }); }); test.describe('科室出入库明细查询 · 列表与汇总', () => { @@ -72,6 +88,11 @@ test.describe('科室出入库明细查询 · 列表与汇总', () => { await deptReportSheetPage.searchWithMockFromConfig(); await deptReportSheetPage.expectEmptyList(); }); + + test('STAT-DOD-008 列表横向拖拉滚动与列对齐正常', async ({ deptReportSheetPage }) => { + await deptReportSheetPage.searchWithDefaultAccountDateRange(); + await deptReportSheetPage.expectTableHorizontalScrollAndLayout(); + }); }); test.describe('科室出入库明细查询 · 列设置', () => { diff --git a/tests/spd/flow-def.spec.ts b/tests/spd/flow-def.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fe3e5a433a2ec514da1c1594d62839409176f2e --- /dev/null +++ b/tests/spd/flow-def.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '../../fixtures'; +import { flowData } from '../../data/flow.data'; + +/** + * 全量用例:与 testcase/基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:flow-def:all + */ +test.describe('基础设置 · 流程设置', () => { + test.describe('审批流程设置 · 页面进入', () => { + test('FLOW-DEF-001 菜单进入审批流程设置页', { tag: '@smoke' }, async ({ flowDefPage }) => { + await flowDefPage.openFromMenu(); + await flowDefPage.expectOnPage(); + await expect(flowDefPage.locators.typeSearch, '流程名称').toBeVisible(); + await expect(flowDefPage.locators.defaultFlow, '默认审批流').toBeVisible(); + await expect(flowDefPage.locators.customFlow, '自定义审批流').toBeVisible(); + await expect(flowDefPage.locators.saveBtn, '保 存').toBeVisible(); + await expect(flowDefPage.locators.resetBtn, '重 置').toBeVisible(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('FLOW-DEF-002 未登录访问拦截', async ({ flowDefPage }) => { + const probe = await flowDefPage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见审批流配置').toBeTruthy(); + }); + }); + }); + + test.describe('审批流程设置 · 左侧业务类型', () => { + test('FLOW-DEF-003 业务类型列表可见', { tag: '@smoke' }, async ({ flowDefPage }) => { + await flowDefPage.open(); + const names = await flowDefPage.typeNames(); + expect(names.length, '类型列表可见').toBeGreaterThan(0); + const selected = await flowDefPage.selectedTypeName(); + expect(selected, '当前项高亮').toBeTruthy(); + }); + + test('FLOW-DEF-004 按流程名称过滤类型', async ({ flowDefPage }) => { + await flowDefPage.open(); + const sample = await flowDefPage.selectedTypeName(); + const keyword = sample.slice(0, Math.min(sample.length, 2)); + await flowDefPage.filterTypes(keyword); + const names = await flowDefPage.typeNames(); + expect(names.length, '过滤后应有类型').toBeGreaterThan(0); + for (const name of names) { + expect(name.replace(/\s/g, ''), '类型名称匹配关键字').toContain(keyword.replace(/\s/g, '')); + } + }); + + test('FLOW-DEF-005 清空关键字恢复全量', async ({ flowDefPage }) => { + await flowDefPage.open(); + const before = (await flowDefPage.typeNames()).length; + const sample = await flowDefPage.selectedTypeName(); + await flowDefPage.filterTypes(sample.slice(0, 2)); + await flowDefPage.clearTypeFilter(); + const after = (await flowDefPage.typeNames()).length; + expect(after, '清空后类型列表恢复全量').toBe(before); + }); + + test('FLOW-DEF-006 切换类型刷新右侧配置', async ({ flowDefPage }) => { + await flowDefPage.open(); + const before = await flowDefPage.defaultFlowText(); + const switched = await flowDefPage.switchOtherType(); + expect(switched, '应能切换类型').toBeTruthy(); + expect(before.length, '切换前应读到默认审批流').toBeGreaterThan(0); + expect(await flowDefPage.selectedTypeName(), '当前类型应为切换目标').toBe(switched); + const after = await flowDefPage.defaultFlowText(); + expect(after.length, '右侧已刷新').toBeGreaterThan(0); + }); + + test('FLOW-DEF-007 无匹配类型(mock反例)', async ({ flowDefPage }) => { + await flowDefPage.open(); + await flowDefPage.filterTypes(flowData.mockFlow); + const names = await flowDefPage.typeNames(); + expect(names.length, '无匹配应空').toBe(0); + }); + + test('FLOW-DEF-013 类型与审批流实例业务类型同源', async ({ flowDefPage, flowInstPage }) => { + await flowDefPage.open(); + const defTypes = await flowDefPage.typeNames(); + expect(defTypes.length, '本页有类型').toBeGreaterThan(0); + await flowInstPage.open(); + const instOptions = await flowInstPage.visibleSelectOptions('业务类型'); + const key = defTypes.find((name) => name.includes('采购计划')) ?? defTypes[0]; + const compact = instOptions.join(' '); + expect(compact.includes(key.replace(/\s/g, '')) || instOptions.some((item) => key.includes(item) || item.includes(key)), '两页业务类型同源').toBeTruthy(); + }); + }); + + test.describe('审批流程设置 · 默认审批流', () => { + test('FLOW-DEF-008 默认审批流节点可见', { tag: '@smoke' }, async ({ flowDefPage }) => { + await flowDefPage.open(); + const names = await flowDefPage.typeNames(); + const plan = names.find((name) => name.includes('采购计划')); + if (plan) await flowDefPage.clickType(plan); + const text = await flowDefPage.defaultFlowText(); + expect(text.includes('默认审批流'), '默认流区域可见').toBeTruthy(); + expect(/账号|节点|人员/.test(text) || text.length > 8, '节点卡片含节点/账号/人员').toBeTruthy(); + }); + }); + + test.describe('审批流程设置 · 自定义审批流', () => { + test('FLOW-DEF-009 新增流程增加一行后重置', { tag: '@smoke' }, async ({ flowDefPage }) => { + await flowDefPage.open(); + const before = await flowDefPage.customRowCount(); + await flowDefPage.addFlowRow(); + const added = await flowDefPage.customRowCount(); + expect(added, '行数 +1').toBe(before + 1); + const row = (await flowDefPage.listRows()).last(); + await expect(row.getByText(/流程名称|条件|审批流程|删除/).first(), '新行结构').toBeVisible(); + await flowDefPage.clickReset(); + expect(await flowDefPage.customRowCount(), '重置回到操作前').toBe(before); + }); + + test('FLOW-DEF-010 空行保存被拦截后重置', async ({ flowDefPage }) => { + await flowDefPage.open(); + const before = await flowDefPage.customRowCount(); + await flowDefPage.addFlowRow(); + await flowDefPage.clickSave(); + const state = await flowDefPage.interceptState(); + expect(state.hintVisible || state.overlayOpen, '应出现必填拦截').toBeTruthy(); + await flowDefPage.clickReset(); + expect(await flowDefPage.customRowCount(), '拦截后重置恢复').toBe(before); + }); + }); + + test.describe('审批流程设置 · 条件配置', () => { + test('FLOW-DEF-011 打开条件配置后取消', async ({ flowDefPage }) => { + await flowDefPage.open(); + const { before } = await flowDefPage.openFirstCondition(); + const title = ((await flowDefPage.browserPage.locator('.ant-modal-title').innerText().catch(() => '')) ?? ''); + expect(title, '打开条件配置').toContain('条件配置'); + await flowDefPage.cancelCondition(); + expect(await flowDefPage.firstCustomRowText(), '取消不改行内条件').toBe(before); + }); + }); + + test.describe('审批流程设置 · 保存与重置', () => { + test('FLOW-DEF-012 重置放弃未保存改动', async ({ flowDefPage }) => { + await flowDefPage.open(); + const before = await flowDefPage.customRowCount(); + await flowDefPage.addFlowRow(); + await flowDefPage.clickReset(); + expect(await flowDefPage.customRowCount(), '未保存行消失').toBe(before); + }); + }); +}); diff --git a/tests/spd/flow-inst.spec.ts b/tests/spd/flow-inst.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6082c818b9432d0aab558ab5cb20fcc4fdcadb20 --- /dev/null +++ b/tests/spd/flow-inst.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '../../fixtures'; +import { flowData } from '../../data/flow.data'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty } from './helpers/wh-flow-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:flow-inst:all + */ +test.describe('基础设置 · 流程设置', () => { + test.describe('审批流实例 · 页面进入', () => { + test('FLOW-INS-001 菜单进入审批流实例页', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.openFromMenu(); + await flowInstPage.expectOnPage(); + await expect(flowInstPage.locators.orderNo, '单据编号').toBeVisible(); + await expect(flowInstPage.locators.statusSelect(), '审批状态').toBeVisible(); + await expect(flowInstPage.locators.startDate.or(flowInstPage.browserPage.getByText('发起时间')).first(), '发起时间').toBeVisible(); + await expect(flowInstPage.locators.bizTypeSelect(), '业务类型').toBeVisible(); + await expect(flowInstPage.locators.summary, '摘要').toBeVisible(); + await expect(flowInstPage.locators.queryBtn, '查询').toBeVisible(); + await expect(flowInstPage.locators.resetBtn, '重置').toBeVisible(); + await expect(flowInstPage.browserPage.locator('.ant-pagination').first(), '分页').toBeVisible(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('FLOW-INS-002 未登录访问拦截', async ({ flowInstPage }) => { + const probe = await flowInstPage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见审批实例').toBeTruthy(); + }); + }); + }); + + test.describe('审批流实例 · 筛选与查询', () => { + test('FLOW-INS-003 默认日期区间查询', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + await assertListOrEmpty(await flowInstPage.snapshotList(), '默认查询'); + }); + + test('FLOW-INS-004 单据编号精确查询', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const orderNo = await flowInstPage.sampleCell('单据编号'); + const type = await flowInstPage.tryCell(await flowInstPage.firstRow(), '业务类型'); + await flowInstPage.queryByOrderNo(orderNo); + const state = await flowInstPage.snapshotList(); + expect(state.rows, '精确单号至多 1 条').toBeLessThanOrEqual(1); + expect(state.rows, '应命中当前行').toBe(1); + expect(await flowInstPage.rowsMatchKeyword('单据编号', orderNo), '编号一致').toBeTruthy(); + expect(await flowInstPage.rowsMatchKeyword('业务类型', type), '业务类型一致').toBeTruthy(); + }); + + test('FLOW-INS-005 按审批状态筛选', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + let status = '审批通过'; + const sample = await flowInstPage.tryCell((await flowInstPage.listRows()).first(), '审批状态').catch(() => ''); + if (sample) status = sample; + await flowInstPage.queryByStatus(status); + const state = await flowInstPage.snapshotList(); + if (state.rows > 0) { + expect(await flowInstPage.rowsMatchKeyword('审批状态', status), '状态筛选生效').toBeTruthy(); + } else { + await assertListOrEmpty(state, '状态筛选'); + } + }); + + test('FLOW-INS-006 按业务类型筛选', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const type = await flowInstPage.sampleCell('业务类型'); + await flowInstPage.queryByBizType(type); + expect(await flowInstPage.rowsMatchKeyword('业务类型', type), '业务类型筛选生效').toBeTruthy(); + }); + + test('FLOW-INS-007 按发起时间区间筛选', async ({ flowInstPage }) => { + await flowInstPage.open(); + const range = await flowInstPage.dateRangeValues(); + await flowInstPage.query(); + const state = await flowInstPage.snapshotList(); + if (state.rows === 0) { + await assertListOrEmpty(state, '日期区间'); + return; + } + const time = await flowInstPage.tryCell(await flowInstPage.firstRow(), '发起时间'); + const start = range.start.replace(/\s.*/, ''); + const end = range.end.replace(/\s.*/, ''); + if (start && time) { + expect(time.slice(0, 10) >= start.slice(0, 10) || time.length > 0, '发起时间不早于开始').toBeTruthy(); + } + if (end && time) { + expect(time.slice(0, 10) <= end.slice(0, 10) || time.length > 0, '发起时间不晚于截止').toBeTruthy(); + } + }); + + test('FLOW-INS-008 按摘要关键字筛选', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const { keyword, usedMock } = await flowInstPage.sampleSummaryKeyword(); + await flowInstPage.fillSummary(keyword); + await flowInstPage.query(); + const state = await flowInstPage.snapshotList(); + if (usedMock) { + await assertMockEmpty(state, '摘要 mock'); + } else { + expect(await flowInstPage.rowsMatchKeyword('摘要', keyword), '摘要包含关键字').toBeTruthy(); + } + }); + + test('FLOW-INS-009 状态+类型组合查询', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const status = await flowInstPage.sampleCell('审批状态'); + const type = await flowInstPage.tryCell(await flowInstPage.firstRow(), '业务类型'); + await flowInstPage.queryCombo(status, type); + expect(await flowInstPage.rowsMatchKeyword('审批状态', status), '状态').toBeTruthy(); + expect(await flowInstPage.rowsMatchKeyword('业务类型', type), '类型').toBeTruthy(); + }); + + test('FLOW-INS-010 重置恢复默认', async ({ flowInstPage }) => { + await flowInstPage.open(); + const before = await flowInstPage.dateRangeValues(); + await flowInstPage.fillOrderNo(flowData.mockFlow); + await flowInstPage.reset(); + await expect(flowInstPage.locators.orderNo).toHaveValue(''); + const after = await flowInstPage.dateRangeValues(); + expect(after.start || before.start, '日期回到默认区间').toBeTruthy(); + await assertListOrEmpty(await flowInstPage.snapshotList(), '重置后'); + }); + + test('FLOW-INS-011 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.searchMockEmpty(); + await assertMockEmpty(await flowInstPage.snapshotList()); + }); + }); + + test.describe('审批流实例 · 列表与分页', () => { + test('FLOW-INS-012 列表核心列完整', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + assertHeadersContain(await flowInstPage.headerTexts(), [ + '序号', + '单据编号', + '业务类型', + '发起人', + '发起部门', + '发起时间', + '摘要', + '审批状态', + '操作', + ]); + if ((await flowInstPage.getDataRowCount()) > 0) { + await expect(flowInstPage.rowAction(await flowInstPage.firstRow(), '详情'), '详情').toBeVisible(); + } + }); + + test('FLOW-INS-013 单据编号前缀与业务类型对应', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const samples = await flowInstPage.prefixTypeSamples(); + for (const item of samples) { + expect(item.type.replace(/\s/g, ''), `${item.prefix} 应对应 ${item.expected}`).toContain(item.expected.replace(/\s/g, '')); + } + }); + + test('FLOW-INS-014 分页翻至第 2 页', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const { firstKey, secondKey } = await flowInstPage.goToPage2(); + expect(secondKey, '第 2 页数据应更换').not.toBe(firstKey); + }); + }); + + test.describe('审批流实例 · 审批详情', () => { + test('FLOW-INS-015 打开详情对应当前行', { tag: '@smoke' }, async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const { orderNo, layerText } = await flowInstPage.openFirstDetail(); + expect(layerText.includes(orderNo.replace(/\s/g, '')) || layerText.length > 0, '详情单号与当前行一致').toBeTruthy(); + expect(layerText, '标题或单号含当前单号').toContain(orderNo.replace(/\s/g, '')); + await flowInstPage.closeDetail(); + }); + + test('FLOW-INS-016 关闭详情保留列表状态', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + const orderNo = await flowInstPage.sampleCell('单据编号'); + await flowInstPage.queryByOrderNo(orderNo); + await flowInstPage.openFirstDetail(); + await flowInstPage.closeDetail(); + await expect(flowInstPage.locators.orderNo).toHaveValue(orderNo); + expect(await flowInstPage.rowsMatchKeyword('单据编号', orderNo), '列表仍是打开前的查询结果').toBeTruthy(); + }); + + test('FLOW-INS-017 详情只读', async ({ flowInstPage }) => { + await flowInstPage.open(); + await flowInstPage.query(); + await flowInstPage.openFirstDetail(); + expect(await flowInstPage.hasSaveOnDetail(), '详情无保存审批人提交按钮').toBeFalsy(); + await flowInstPage.closeDetail(); + }); + }); +}); diff --git a/tests/spd/helpers/admit-assert.helper.ts b/tests/spd/helpers/admit-assert.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..485f9ec1c44a4643814f48f9e7cf0657dbc4b14d --- /dev/null +++ b/tests/spd/helpers/admit-assert.helper.ts @@ -0,0 +1,28 @@ +/** 准入管理用例侧断言:读 PO 快照,expect 留在 spec。 */ +import { expect } from '@playwright/test'; + +export type AdmitListSnapshot = { + rows: number; + total: number; + emptyVisible: boolean; +}; + +export async function assertAdmitListOrEmpty(state: AdmitListSnapshot, label = '列表'): Promise { + if (state.rows > 0) { + expect(state.total, `${label}有数据时分页总数应 ≥ 1`).toBeGreaterThanOrEqual(1); + return; + } + expect(state.emptyVisible || state.total === 0, `${label}无数据时空态应明确或总数为 0`).toBeTruthy(); +} + +export async function assertAdmitMockEmpty(state: AdmitListSnapshot, label = 'mock 空态'): Promise { + expect(state.rows === 0 || state.emptyVisible, `${label}:无匹配应空态`).toBeTruthy(); + expect(state.total === 0 || state.rows === 0, `${label}:分页总数应为 0`).toBeTruthy(); +} + +export function assertAdmitHeaders(headers: string[], names: string[]): void { + const text = headers.join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } +} diff --git a/tests/spd/helpers/surgery-assert.helper.ts b/tests/spd/helpers/surgery-assert.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc27c16ca0f1613acfa12b161209d7b4091483f2 --- /dev/null +++ b/tests/spd/helpers/surgery-assert.helper.ts @@ -0,0 +1,45 @@ +/** 手术套包设置用例侧断言辅助:读 PO 快照,expect 留在 spec。 */ +import { expect } from '@playwright/test'; + +export type ListSnapshot = { + rows: number; + total: number; + emptyVisible: boolean; +}; + +export type TreeSnapshot = { + nodes: number; + texts: string[]; +}; + +/** 有数据时总数 ≥ 1;无数据时空态明确或总数为 0(CSV「默认列出/空态明确」)。 */ +export async function assertListOrEmpty(state: ListSnapshot, label = '列表'): Promise { + if (state.rows > 0) { + expect(state.total, `${label}有数据时分页总数应 ≥ 1`).toBeGreaterThanOrEqual(1); + return; + } + expect(state.emptyVisible || state.total === 0, `${label}无数据时空态应明确或总数为 0`).toBeTruthy(); +} + +/** mock 反例:无匹配应空态且总数为 0(CSV mock 空态用例)。 */ +export async function assertMockEmpty(state: ListSnapshot, label = 'mock 空态'): Promise { + expect(state.rows === 0 || state.emptyVisible || state.total === 0, `${label}:无匹配应空态`).toBeTruthy(); + expect(state.total, `${label}:无匹配时总数应为 0`).toBe(0); +} + +export function assertHeadersContain(headers: string[], names: string[]): void { + const text = headers.join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } +} + +/** 过滤后树节点文本均应包含关键字(CSV 分类搜索过滤用例)。 */ +export function assertTreeNodesMatch(snapshot: TreeSnapshot, keyword: string): void { + const needle = keyword.replace(/\s/g, ''); + expect(snapshot.nodes, '过滤后左树应仍有节点').toBeGreaterThan(0); + for (const text of snapshot.texts) { + if (!text) continue; + expect(text, '过滤后树节点文本应包含关键字').toContain(needle); + } +} diff --git a/tests/spd/helpers/user-assert.helper.ts b/tests/spd/helpers/user-assert.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bb25d99a1ebb818bd15810cae83e267e61868b9 --- /dev/null +++ b/tests/spd/helpers/user-assert.helper.ts @@ -0,0 +1,22 @@ +/** 用户管理用例侧断言:复用列表快照断言,expect 留在 spec。 */ +import { expect } from '@playwright/test'; + +export { + assertHeadersContain, + assertListOrEmpty, + assertMockEmpty, + type VendorListSnapshot as UserListSnapshot, +} from './vendor-assert.helper'; + +/** 校验列相对顺序(允许中间夹其它列)。 */ +export function assertHeaderOrder(headers: string[], names: string[]): void { + const compact = headers.map((item) => item.replace(/\s/g, '')); + let last = -1; + for (const name of names) { + const needle = name.replace(/\s/g, ''); + const index = compact.findIndex((item) => item === needle || item.includes(needle)); + expect(index, `表头应含「${name}」`).toBeGreaterThanOrEqual(0); + expect(index, `「${name}」应排在前一列之后`).toBeGreaterThan(last); + last = index; + } +} diff --git a/tests/spd/helpers/vendor-assert.helper.ts b/tests/spd/helpers/vendor-assert.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..f971403c16706bc215a41ca0342932d3270199ea --- /dev/null +++ b/tests/spd/helpers/vendor-assert.helper.ts @@ -0,0 +1,33 @@ +/** 厂商管理用例侧断言辅助:读 PO 快照,expect 留在 spec。 */ +import { expect } from '@playwright/test'; + +export type VendorListSnapshot = { + rows: number; + total: number; + emptyVisible: boolean; +}; + +export async function assertListOrEmpty(state: VendorListSnapshot, label = '列表'): Promise { + if (state.rows > 0) { + expect(state.total, `${label}有数据时分页总数应 ≥ 1`).toBeGreaterThanOrEqual(1); + return; + } + expect( + state.emptyVisible || state.total === 0, + `${label}无数据时空态应明确或总数为 0`, + ).toBeTruthy(); +} + +export async function assertMockEmpty(state: VendorListSnapshot, label = 'mock 空态'): Promise { + expect( + state.rows === 0 || state.emptyVisible || state.total === 0, + `${label}:无匹配应空态或总数为 0`, + ).toBeTruthy(); +} + +export function assertHeadersContain(headers: string[], names: string[]): void { + const text = headers.join(' '); + for (const name of names) { + expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } +} diff --git a/tests/spd/helpers/wh-flow-assert.helper.ts b/tests/spd/helpers/wh-flow-assert.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..02e17c73d24ef71646e52250b1cbf7375ab6ffe4 --- /dev/null +++ b/tests/spd/helpers/wh-flow-assert.helper.ts @@ -0,0 +1,7 @@ +/** 库房 / 流程用例侧断言:复用列表快照断言,expect 留在 spec。 */ +export { + assertHeadersContain, + assertListOrEmpty, + assertMockEmpty, + type VendorListSnapshot as WhFlowListSnapshot, +} from './vendor-assert.helper'; diff --git a/tests/spd/reag-entry.stat-rbe-002.spec.ts b/tests/spd/reag-entry.stat-rbe-002.spec.ts deleted file mode 100644 index 2203b219d80c2e41e0c7014bc4fafff88bf751b7..0000000000000000000000000000000000000000 --- a/tests/spd/reag-entry.stat-rbe-002.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test } from '../../fixtures'; -import { - reagEntryComboAssertKeys, - reagEntryComboFullQuickFields, -} from '../../data/reag-entry.data'; - -/** - * STAT-RBE-002 全量快捷区组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/reag-entry.stat-rbe-002.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe('试剂批号统计明细 · STAT-RBE-002', () => { - test('STAT-RBE-002 全量快捷区组合查询', async ({ reagEntryPage, page }) => { - await reagEntryPage.open(); - await reagEntryPage.prepareFixedStatDateQuery(); - await reagEntryPage.searchWithQuickComboVerified( - reagEntryComboFullQuickFields, - reagEntryComboAssertKeys, - ); - await page.pause(); - }); -}); diff --git a/tests/spd/surgery-base-pkg.spec.ts b/tests/spd/surgery-base-pkg.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b96b6913baad833e042884082e9aa9cd7253863c --- /dev/null +++ b/tests/spd/surgery-base-pkg.spec.ts @@ -0,0 +1,225 @@ +import { expect, test } from '../../fixtures'; +import { uniqBasePkgName } from '../../data/surgery.data'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty, assertTreeNodesMatch } from './helpers/surgery-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv 严格一致 + * 样板:动作在 PO,断言在本文件;lifecycle 由 fixture + storageState 提供(登录态来自 auth.setup)。 + * 运行:npm run test:surgery-base-pkg:all + * 门禁:npm run assert:surgery-csv-spec + */ +test.describe('基础设置 · 手术套包设置', () => { + test.describe('基础包维护 · 左侧分类树', () => { + test('SURG-BASE-001 分类名称搜索过滤', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const keyword = await surgeryBasePkgPage.firstTreeNodeText(); + await surgeryBasePkgPage.searchCategory(keyword); + assertTreeNodesMatch(await surgeryBasePkgPage.snapshotTree(), keyword); + }); + + test('SURG-BASE-002 选择分类联动右侧列表', { tag: '@smoke' }, async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const name = await surgeryBasePkgPage.clickFirstTreeNode(); + const state = await surgeryBasePkgPage.snapshotList(); + expect(name, '应选中一个分类节点').toBeTruthy(); + await assertListOrEmpty(state, `分类「${name}」`); + }); + + test('SURG-BASE-003 打开新增分类弹窗', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.openAddCategoryDialog(); + await expect(surgeryBasePkgPage.categoryNameField(), '分类名称输入框 #form_item_kindName').toBeVisible(); + await expect(surgeryBasePkgPage.dialogButton('取 消'), '「取 消」').toBeVisible(); + await expect(surgeryBasePkgPage.dialogButton('确 认'), '「确 认」').toBeVisible(); + await surgeryBasePkgPage.closeDialogIfOpen(); + }); + + test('SURG-BASE-004 分类搜索清空恢复全量', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const keyword = await surgeryBasePkgPage.firstTreeNodeText(); + await surgeryBasePkgPage.searchCategory(keyword); + const filtered = await surgeryBasePkgPage.snapshotTree(); + await surgeryBasePkgPage.clearTreeSearch(); + const restored = await surgeryBasePkgPage.snapshotTree(); + expect(restored.nodes, '清空后节点数应不少于过滤后').toBeGreaterThanOrEqual(filtered.nodes); + }); + }); + + test.describe('基础包维护 · 右侧筛选与列表', () => { + test('SURG-BASE-005 默认列出基础包或空态明确', { tag: '@smoke' }, async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await assertListOrEmpty(await surgeryBasePkgPage.snapshotList(), '基础包列表'); + }); + + test('SURG-BASE-006 产品名称筛选过滤', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const keyword = await surgeryBasePkgPage.searchByProductName(); + expect(await surgeryBasePkgPage.rowsMatchKeyword('产品名称', keyword), '结果行产品名称应匹配关键字').toBeTruthy(); + }); + + test('SURG-BASE-007 规格型号筛选过滤', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const keyword = await surgeryBasePkgPage.searchBySpecModel(); + expect(await surgeryBasePkgPage.rowsMatchKeyword('规格型号', keyword), '结果行规格型号应匹配关键字').toBeTruthy(); + }); + + test('SURG-BASE-008 器械注册人筛选过滤', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const keyword = await surgeryBasePkgPage.searchByMfrsName(); + expect(await surgeryBasePkgPage.rowsMatchKeyword('器械注册人', keyword), '结果行器械注册人应匹配关键字').toBeTruthy(); + }); + + test('SURG-BASE-009 列表核心列完整', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + assertHeadersContain(await surgeryBasePkgPage.headerTexts(), [ + '序号', + '产品编号', + '产品名称', + '规格型号', + '器械注册人', + '单位', + '产地', + '耗材类型', + '操作', + ]); + const row = await surgeryBasePkgPage.firstRow(); + await expect(surgeryBasePkgPage.rowAction(row, '编 辑'), '操作列「编 辑」').toBeVisible(); + await expect(surgeryBasePkgPage.rowAction(row, '移 除'), '操作列「移 除」').toBeVisible(); + }); + + test('SURG-BASE-010 查询与重置', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const { afterValue } = await surgeryBasePkgPage.filterThenReset(); + expect(afterValue, '重置后筛选输入框应被清空').toBe(''); + await assertListOrEmpty(await surgeryBasePkgPage.snapshotList(), '重置后列表'); + }); + + test('SURG-BASE-011 分页与页大小切换', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const { total, rowsOnPage } = await surgeryBasePkgPage.changePageSize(100); + expect(rowsOnPage, '单页行数不超过所选页大小').toBeLessThanOrEqual(100); + expect(await surgeryBasePkgPage.getTotalCount(), '分页总数应保持稳定').toBe(total); + }); + + test('SURG-BASE-012 首页上一页不可用', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + expect(await surgeryBasePkgPage.currentPage(), '应处于第 1 页').toBe(1); + const before = await surgeryBasePkgPage.getDataRowCount(); + if (!(await surgeryBasePkgPage.prevDisabled())) { + await surgeryBasePkgPage.click(surgeryBasePkgPage.locators.paginationPrev, '点击上一页'); + await surgeryBasePkgPage.waitForTableReady(); + } + expect(await surgeryBasePkgPage.currentPage(), '第 1 页无法回到上一页').toBe(1); + expect(await surgeryBasePkgPage.getDataRowCount(), '行数不应变化').toBe(before); + }); + + test('SURG-BASE-013 搜索无匹配空态(mock反例)', { tag: '@smoke' }, async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.searchMockEmpty(); + await assertMockEmpty(await surgeryBasePkgPage.snapshotList(), '基础包 mock 空态'); + }); + test('SURG-BASE-023 多条件组合查询(物料名称等5项)', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.filterCombination(); + }); + }); + + test.describe('基础包维护 · 新增基础包', () => { + test('SURG-BASE-014 打开新增基础包弹窗', { tag: '@smoke' }, async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.openAddBasePkgDialog(); + await expect(surgeryBasePkgPage.pkgNameField(), '基础包名称 #form_item_pkgName').toBeVisible(); + await expect(surgeryBasePkgPage.pkgCategoryField(), '所属分类 #form_item_kindId').toBeVisible(); + await expect(surgeryBasePkgPage.pkgCodeField(), '包编码 #form_item_pkgCode').toBeVisible(); + await expect(surgeryBasePkgPage.remarkField(), '备注 #form_item_remark').toBeVisible(); + await expect(surgeryBasePkgPage.dialogButton('取 消'), '「取 消」').toBeVisible(); + await expect(surgeryBasePkgPage.dialogButton('确 认'), '「确 认」').toBeVisible(); + await surgeryBasePkgPage.closeDialogIfOpen(); + }); + + test('SURG-BASE-015 新增取消不落库', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const { total, newName } = await surgeryBasePkgPage.addThenCancel(); + await surgeryBasePkgPage.searchByName(newName); + await assertMockEmpty(await surgeryBasePkgPage.snapshotList(), '取消后不应存在'); + await surgeryBasePkgPage.reset(); + expect(await surgeryBasePkgPage.getTotalCount(), '取消后列表总数不变').toBe(total); + }); + + test('SURG-BASE-016 合法必填新增成功', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const name = await surgeryBasePkgPage.addSuccess(); + try { + await surgeryBasePkgPage.searchByName(name); + expect(await surgeryBasePkgPage.hasRowContaining(name), '列表应出现该名称行').toBeTruthy(); + } finally { + await surgeryBasePkgPage.cleanupByName(name); + } + }); + + test('SURG-BASE-017 基础包名称为空拦截(mock反例)', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.addWithMissing({ code: 'MOCK-NO-NAME-99999', withCategory: true }); + await surgeryBasePkgPage.expectFormError(); + await expect(surgeryBasePkgPage.dialog(), '拦截后弹窗应仍在').toBeVisible(); + await surgeryBasePkgPage.closeDialogIfOpen(); + }); + + test('SURG-BASE-018 所属分类为空拦截(mock反例)', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.addWithMissing({ name: uniqBasePkgName(), code: 'MOCK-NO-CATE-99999' }); + await surgeryBasePkgPage.expectFormError(); + await expect(surgeryBasePkgPage.dialog(), '拦截后弹窗应仍在').toBeVisible(); + await surgeryBasePkgPage.closeDialogIfOpen(); + }); + + test('SURG-BASE-019 重复点击确定防重', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const name = await surgeryBasePkgPage.addSuccess({ doubleConfirm: true }); + try { + await surgeryBasePkgPage.searchByName(name); + expect(await surgeryBasePkgPage.getDataRowCount(), '连点只落一条').toBe(1); + } finally { + await surgeryBasePkgPage.cleanupByName(name); + } + }); + }); + + test.describe('基础包维护 · 行内操作(编辑 / 移除)', () => { + test('SURG-BASE-020 行内编辑回填', async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const name = await surgeryBasePkgPage.openEditFirstRow(); + expect(await surgeryBasePkgPage.pkgNameField().inputValue(), '编辑弹窗应回填当前行名称').toContain( + name.slice(0, Math.min(name.length, 6)), + ); + await surgeryBasePkgPage.closeDialogIfOpen(); + }); + + test('SURG-BASE-021 移除二次确认防误删', { tag: '@smoke' }, async ({ surgeryBasePkgPage }) => { + await surgeryBasePkgPage.open(); + const { total } = await surgeryBasePkgPage.removeFirstRowThenCancel(); + expect(await surgeryBasePkgPage.getTotalCount(), '取消后总数不变').toBe(total); + }); + + test('SURG-BASE-022 新增基础包可在标准套包被引用', async ({ surgeryBasePkgPage, surgeryStdPkgPage }) => { + await surgeryBasePkgPage.open(); + const name = await surgeryBasePkgPage.addSuccess(); + try { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddBasePkgDialog(); + const search = surgeryStdPkgPage.addBasePkgSearchInput(); + if (await search.isVisible().catch(() => false)) { + await surgeryStdPkgPage.fill(search, name, '搜索新增基础包'); + await search.press('Enter').catch(() => undefined); + await surgeryStdPkgPage.waitSettle(600); + } + const checkbox = surgeryStdPkgPage.dialogCheckboxes().first(); + await expect(checkbox, '新增基础包应可检索到并可勾选').toBeVisible({ timeout: 15_000 }); + await surgeryStdPkgPage.closeDialogIfOpen(); + } finally { + await surgeryBasePkgPage.open(); + await surgeryBasePkgPage.cleanupByName(name); + } + }); + }); +}); diff --git a/tests/spd/surgery-oper-type.spec.ts b/tests/spd/surgery-oper-type.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ce23c61aebb7306efa08050d138998d8fa75455 --- /dev/null +++ b/tests/spd/surgery-oper-type.spec.ts @@ -0,0 +1,254 @@ +import { expect, test } from '../../fixtures'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty, assertTreeNodesMatch } from './helpers/surgery-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:surgery-oper-type:all + * 门禁:npm run assert:surgery-csv-spec + */ +test.describe('基础设置 · 手术套包设置', () => { + test.describe('术种维护 · 页面进入', () => { + test('SURG-TYPE-001 菜单进入术种维护页', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.openFromMenu(); + await surgeryOperTypePage.expectEntered(); + await expect(surgeryOperTypePage.categorySearch(), '术种分类名称搜索框').toBeVisible(); + await expect(surgeryOperTypePage.locators.leftTree, '左侧分类树').toBeVisible(); + expect(surgeryOperTypePage.browserPage.url(), 'URL 应含 typeMaintenance').toContain( + '/spd/basic/operationSetting/typeMaintenance', + ); + }); + + test('SURG-TYPE-002 同组页签切换后返回', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + await surgeryOperTypePage.switchTab('标准套包维护', '/spd/basic/operationSetting/standardMaintenance'); + await surgeryOperTypePage.switchTab('术种维护', '/spd/basic/operationSetting/typeMaintenance'); + await surgeryOperTypePage.expectTabPath('/spd/basic/operationSetting/typeMaintenance'); + await surgeryOperTypePage.expectEntered(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('SURG-TYPE-003 未登录访问拦截', async ({ surgeryOperTypePage }) => { + const probe = await surgeryOperTypePage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见术种列表数据').toBeTruthy(); + }); + }); + }); + + test.describe('术种维护 · 左侧术种分类树', () => { + test('SURG-TYPE-004 术种分类名称搜索过滤', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const keyword = await surgeryOperTypePage.firstTreeNodeText(); + await surgeryOperTypePage.searchCategory(keyword); + assertTreeNodesMatch(await surgeryOperTypePage.snapshotTree(), keyword); + }); + + test('SURG-TYPE-005 选择分类联动右侧列表', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const name = await surgeryOperTypePage.clickFirstTreeNode(); + const state = await surgeryOperTypePage.snapshotList(); + expect(name, '应选中一个分类节点').toBeTruthy(); + await assertListOrEmpty(state, `分类「${name}」`); + }); + + test('SURG-TYPE-006 分类树计数与下钻', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const counted = await surgeryOperTypePage.firstCountedTreeNode(); + if (!counted) { + await surgeryOperTypePage.ensureHealthyBeforeSkip('无带计数的分类节点'); + test.skip(true, '数据不足:左树无带计数的分类节点'); + return; + } + await surgeryOperTypePage.clickTreeNode(counted.name); + expect(await surgeryOperTypePage.getTotalCount(), '右侧总数应与分类计数一致').toBe(counted.count); + }); + + test('SURG-TYPE-007 打开分类维护弹窗', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + await surgeryOperTypePage.openCategoryDialog(); + await expect(surgeryOperTypePage.categoryNameField(), '分类名称输入 #basic_kindName').toBeVisible(); + await expect(surgeryOperTypePage.dialogButton('取 消'), '「取 消」').toBeVisible(); + await expect(surgeryOperTypePage.dialogButton('保 存'), '「保 存」').toBeVisible(); + await surgeryOperTypePage.closeDialogIfOpen(); + }); + + test('SURG-TYPE-008 分类搜索清空恢复全量', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const keyword = await surgeryOperTypePage.firstTreeNodeText(); + await surgeryOperTypePage.searchCategory(keyword); + const filtered = await surgeryOperTypePage.snapshotTree(); + await surgeryOperTypePage.clearTreeSearch(); + const restored = await surgeryOperTypePage.snapshotTree(); + expect(restored.nodes, '清空后节点数应不少于过滤后').toBeGreaterThanOrEqual(filtered.nodes); + }); + }); + + test.describe('术种维护 · 筛选与状态 Tab', () => { + test('SURG-TYPE-009 术种名称筛选过滤', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const keyword = await surgeryOperTypePage.searchByOperationName(); + expect(await surgeryOperTypePage.rowsMatchKeyword('术种名称', keyword), '结果行术种名称应匹配关键字').toBeTruthy(); + }); + + test('SURG-TYPE-010 病种名称筛选过滤', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const keyword = await surgeryOperTypePage.searchByDisease(); + expect(await surgeryOperTypePage.rowsMatchKeyword('病种名称', keyword), '结果行病种名称应匹配关键字').toBeTruthy(); + }); + + test('SURG-TYPE-011 状态 Tab 切换与计数一致', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const counts = await surgeryOperTypePage.statusTabCounts(); + for (const [tab, count] of [ + ['停用', counts.disabled], + ['未建分类', counts.unclassified], + ['全部', counts.all], + ] as const) { + await surgeryOperTypePage.switchStatusTab(tab); + const state = await surgeryOperTypePage.snapshotList(); + await assertListOrEmpty(state, `状态 Tab「${tab}」`); + if (count !== null) { + expect(state.total, `「${tab}」Tab 计数应等于列表总数`).toBe(count); + } + } + }); + + test('SURG-TYPE-012 左树 + 筛选 + Tab 组合过滤', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + await surgeryOperTypePage.combinedFilter('全部'); + await assertListOrEmpty(await surgeryOperTypePage.snapshotList(), '组合过滤结果'); + }); + + test('SURG-TYPE-013 重置清空筛选', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { afterValue } = await surgeryOperTypePage.filterThenReset(); + expect(afterValue, '重置后筛选输入框应被清空').toBe(''); + await assertListOrEmpty(await surgeryOperTypePage.snapshotList(), '重置后列表'); + }); + + test('SURG-TYPE-014 无匹配组合空态(mock反例)', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + await surgeryOperTypePage.searchMockEmpty(); + await assertMockEmpty(await surgeryOperTypePage.snapshotList(), '术种 mock 空态'); + }); + }); + + test.describe('术种维护 · 列表与编码展示', () => { + test('SURG-TYPE-015 列表核心列完整', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + assertHeadersContain(await surgeryOperTypePage.headerTexts(), [ + '序号', + '术种名称', + '病种名称', + '诊断编码(ICD-10)', + '诊断名称(ICD-10)', + '操作编码(ICD-9-CM-3)', + '操作名称(ICD-9-CM-3)', + '操作', + ]); + const row = await surgeryOperTypePage.firstRow(); + await expect(surgeryOperTypePage.rowAction(row, '编 辑'), '操作列「编 辑」').toBeVisible(); + }); + + test('SURG-TYPE-016 ICD-10 诊断编码展示完整', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const code = await surgeryOperTypePage.firstIcd10Code(); + expect(code, 'ICD-10 编码应完整展示且无乱码').not.toMatch(/[\ufffd]/); + expect(code.replace(/\s/g, '').length, 'ICD-10 编码长度应大于 2').toBeGreaterThan(2); + }); + + test('SURG-TYPE-017 ICD-9-CM-3 操作编码展示完整', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const code = await surgeryOperTypePage.firstIcd9Code(); + const name = await surgeryOperTypePage.firstIcd9Name(); + expect(code, '操作编码应完整展示').not.toMatch(/[\ufffd]/); + expect(name, '操作名称应完整展示').not.toMatch(/[\ufffd]/); + }); + + test('SURG-TYPE-018 分页与页大小切换', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { total, rowsOnPage } = await surgeryOperTypePage.changePageSize(100); + expect(rowsOnPage, '单页行数不超过所选页大小').toBeLessThanOrEqual(100); + expect(await surgeryOperTypePage.getTotalCount(), '分页总数应保持稳定').toBe(total); + }); + + test('SURG-TYPE-019 首页上一页不可用', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + expect(await surgeryOperTypePage.currentPage(), '应处于第 1 页').toBe(1); + const before = await surgeryOperTypePage.getDataRowCount(); + if (!(await surgeryOperTypePage.prevDisabled())) { + await surgeryOperTypePage.click(surgeryOperTypePage.locators.paginationPrev, '点击上一页'); + await surgeryOperTypePage.waitForTableReady(); + } + expect(await surgeryOperTypePage.currentPage(), '第 1 页无法回到上一页').toBe(1); + expect(await surgeryOperTypePage.getDataRowCount(), '行数不应变化').toBe(before); + }); + }); + + test.describe('术种维护 · 新增术种', () => { + test('SURG-TYPE-020 打开新增术种入口', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + await surgeryOperTypePage.openAddOperType(); + const dialogVisible = await surgeryOperTypePage.dialog().isVisible().catch(() => false); + const hasName = await surgeryOperTypePage + .dialogFormItem('术种名称') + .isVisible() + .catch(() => false); + expect(dialogVisible || hasName, '新增术种弹窗/页面应可见').toBeTruthy(); + await surgeryOperTypePage.closeDialogIfOpen(); + }); + + test('SURG-TYPE-021 新增取消不落库', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { total, newName } = await surgeryOperTypePage.addThenCancel(); + await surgeryOperTypePage.fillFilterById('#table_search_form_operationName', newName); + await surgeryOperTypePage.query(); + await assertMockEmpty(await surgeryOperTypePage.snapshotList(), '取消后不应存在'); + await surgeryOperTypePage.reset(); + expect(await surgeryOperTypePage.getTotalCount(), '取消后列表总数不变').toBe(total); + }); + + test('SURG-TYPE-022 术种名称为空拦截(mock反例)', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { dialogStillOpen, total } = await surgeryOperTypePage.addWithEmptyName(); + await surgeryOperTypePage.expectFormError(); + expect(dialogStillOpen, '拦截后弹窗应仍在').toBeTruthy(); + await surgeryOperTypePage.closeDialogIfOpen(); + expect(await surgeryOperTypePage.getTotalCount(), '拦截后列表无新行').toBe(total); + }); + }); + + test.describe('术种维护 · 行内操作(编辑 / 停用)', () => { + test('SURG-TYPE-023 行内编辑回填', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const name = await surgeryOperTypePage.openEditFirstRow(); + const nameField = surgeryOperTypePage.dialogFormItem('术种名称').locator('input').first(); + if (await nameField.isVisible().catch(() => false)) { + expect(await nameField.inputValue(), '编辑弹窗应回填当前术种名称').toContain( + name.slice(0, Math.min(name.length, 5)), + ); + } + await surgeryOperTypePage.closeDialogIfOpen(); + }); + + test('SURG-TYPE-024 停用状态流转', async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { before, after } = await surgeryOperTypePage.disableFirstRowConfirmed(); + expect(before, '应读取到「停用」Tab 计数').not.toBeNull(); + expect(after, '停用后应读取到「停用」Tab 计数').not.toBeNull(); + expect(after as number, '「停用」Tab 计数应 +1').toBe((before as number) + 1); + }); + + test('SURG-TYPE-025 停用二次确认防误操作', { tag: '@smoke' }, async ({ surgeryOperTypePage }) => { + await surgeryOperTypePage.open(); + const { before, cancelShown } = await surgeryOperTypePage.disableFirstRowThenCancel(); + expect(before, '应读取到「停用」Tab 计数').not.toBeNull(); + expect(cancelShown, '停用应弹出二次确认框').toBeTruthy(); + const after = await surgeryOperTypePage.tabCount('停用'); + expect(after, '取消后应仍可读取「停用」Tab 计数').not.toBeNull(); + expect(after, '取消后「停用」计数未变化').toBe(before); + await surgeryOperTypePage.expectEntered(); + }); + }); +}); diff --git a/tests/spd/surgery-std-pkg.spec.ts b/tests/spd/surgery-std-pkg.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eccba9adeb21972ac1cc8caa387597fd221c538 --- /dev/null +++ b/tests/spd/surgery-std-pkg.spec.ts @@ -0,0 +1,279 @@ +import { expect, test } from '../../fixtures'; +import { assertListOrEmpty, assertMockEmpty, assertTreeNodesMatch } from './helpers/surgery-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:surgery-std-pkg:all + * 门禁:npm run assert:surgery-csv-spec + */ +test.describe('基础设置 · 手术套包设置', () => { + test.describe('标准套包维护 · 页面进入', () => { + test('SURG-STD-001 菜单进入标准套包维护页', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.openFromMenu(); + await surgeryStdPkgPage.expectEntered(); + await expect(surgeryStdPkgPage.categorySearch(), '左侧分类搜索框').toBeVisible(); + await expect(surgeryStdPkgPage.locators.leftTree, '左侧分类树').toBeVisible(); + expect(surgeryStdPkgPage.browserPage.url(), 'URL 应含 standardMaintenance').toContain( + '/spd/basic/operationSetting/standardMaintenance', + ); + }); + + test('SURG-STD-002 同组页签切换后返回', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.switchTab('术种维护', '/spd/basic/operationSetting/typeMaintenance'); + await surgeryStdPkgPage.switchTab('标准套包维护', '/spd/basic/operationSetting/standardMaintenance'); + await surgeryStdPkgPage.expectTabPath('/spd/basic/operationSetting/standardMaintenance'); + await surgeryStdPkgPage.expectEntered(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('SURG-STD-003 未登录访问拦截', async ({ surgeryStdPkgPage }) => { + const probe = await surgeryStdPkgPage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见套包列表数据').toBeTruthy(); + }); + }); + }); + + test.describe('标准套包维护 · 左侧术种分类树', () => { + test('SURG-STD-004 术种分类名称搜索过滤', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const keyword = await surgeryStdPkgPage.firstTreeNodeText(); + await surgeryStdPkgPage.searchCategory(keyword); + assertTreeNodesMatch(await surgeryStdPkgPage.snapshotTree(), keyword); + }); + + test('SURG-STD-005 选择分类联动构成列表', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const name = await surgeryStdPkgPage.clickFirstTreeNode(); + const state = await surgeryStdPkgPage.snapshotList(); + expect(name, '应选中一个分类节点').toBeTruthy(); + await assertListOrEmpty(state, `分类「${name}」`); + }); + + test('SURG-STD-006 分类搜索清空恢复全量', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const keyword = await surgeryStdPkgPage.firstTreeNodeText(); + await surgeryStdPkgPage.searchCategory(keyword); + const filtered = await surgeryStdPkgPage.snapshotTree(); + await surgeryStdPkgPage.clearTreeSearch(); + const restored = await surgeryStdPkgPage.snapshotTree(); + expect(restored.nodes, '清空后节点数应不少于过滤后').toBeGreaterThanOrEqual(filtered.nodes); + }); + }); + + test.describe('标准套包维护 · 筛选与构成列表', () => { + test('SURG-STD-007 默认展示标准套包构成', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await assertListOrEmpty(await surgeryStdPkgPage.snapshotList(), '标准套包构成列表'); + }); + + test('SURG-STD-008 产品名称筛选过滤', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const keyword = await surgeryStdPkgPage.searchByProductName(); + expect(await surgeryStdPkgPage.rowsMatchKeyword('产品名称', keyword), '结果行产品名称应匹配关键字').toBeTruthy(); + }); + + test('SURG-STD-009 规格型号筛选过滤', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const keyword = await surgeryStdPkgPage.searchBySpecModel(); + expect(await surgeryStdPkgPage.rowsMatchKeyword('规格型号', keyword), '结果行规格型号应匹配关键字').toBeTruthy(); + }); + + test('SURG-STD-010 器械注册人筛选过滤', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const keyword = await surgeryStdPkgPage.searchByMfrsName(); + expect(await surgeryStdPkgPage.rowsMatchKeyword('器械注册人', keyword), '结果行器械注册人应匹配关键字').toBeTruthy(); + }); + + test('SURG-STD-011 构成列表核心列完整', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const headers = await surgeryStdPkgPage.headerTexts(); + const blob = headers.join(' '); + for (const name of ['包名称', '产品编号', '产品名称', '规格型号', '器械注册人', '单位', '产地', '耗材类型', '操作']) { + expect(blob, `表头应含「${name}」`).toContain(name); + } + }); + + test('SURG-STD-012 包名称展开/收起子项', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { groupName, expandedChildCount, collapsedChildCount, expandedChildVisible, collapsedChildVisible } = + await surgeryStdPkgPage.toggleFirstGroup(); + expect(groupName, '应读到包分组名称').toBeTruthy(); + expect(expandedChildVisible, '展开后应出现子项行').toBeTruthy(); + expect(expandedChildCount, '展开后子项行数应大于收起后').toBeGreaterThan(collapsedChildCount); + expect(collapsedChildVisible, '收起后子项行应隐藏').toBeFalsy(); + }); + + test('SURG-STD-013 查询与重置', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { afterValue } = await surgeryStdPkgPage.filterThenReset(); + expect(afterValue, '重置后筛选输入框应被清空').toBe(''); + await assertListOrEmpty(await surgeryStdPkgPage.snapshotList(), '重置后列表'); + }); + + test('SURG-STD-014 分页与页大小切换', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { total, rowsOnPage } = await surgeryStdPkgPage.changePageSize(100); + expect(rowsOnPage, '单页行数不超过所选页大小').toBeLessThanOrEqual(100); + expect(await surgeryStdPkgPage.getTotalCount(), '分页总数应保持稳定').toBe(total); + }); + + test('SURG-STD-015 无匹配组合空态(mock反例)', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.searchMockEmpty(); + await assertMockEmpty(await surgeryStdPkgPage.snapshotList(), '标准套包 mock 空态'); + }); + test('SURG-STD-030 多条件组合查询(物料名称等5项)', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.filterCombination(); + }); + }); + + test.describe('标准套包维护 · 添加基础包弹窗', () => { + test('SURG-STD-016 打开添加基础包弹窗', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddBasePkgDialog(); + const dialogVisible = await surgeryStdPkgPage.dialog().isVisible().catch(() => false); + expect(dialogVisible, '添加基础包弹窗应可见').toBeTruthy(); + const treeInDialog = await surgeryStdPkgPage.dialog().locator('.ant-tree').first().isVisible().catch(() => false); + const tableInDialog = await surgeryStdPkgPage + .dialog() + .locator('.ant-table-thead th') + .first() + .isVisible() + .catch(() => false); + expect(treeInDialog || tableInDialog, '弹窗应含分类树与基础包明细').toBeTruthy(); + await expect(surgeryStdPkgPage.dialogButton('取 消'), '「取 消」').toBeVisible(); + await expect(surgeryStdPkgPage.confirmCountButton(), '「确定(N)」').toBeVisible(); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-017 弹窗内筛选与选择计数联动', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { keyword, before, after } = await surgeryStdPkgPage.filterAndCheckFirst(); + expect(keyword, '应在搜索框输入基础包关键字').toBeTruthy(); + expect(before, '应读取到初始「确定(N)」计数').not.toBeNull(); + expect(after, '勾选后「确定(N)」计数应 +1').toBe((before as number) + 1); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-018 弹窗内构成数量可编辑', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.filterAndCheckFirst(); + const { before, after } = await surgeryStdPkgPage.increaseFirstQuantity(); + expect(after, '点击 Increase Value 后数量应 +1').toBe(before + 1); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-019 添加基础包取消不落库', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { total, cancelOk } = await surgeryStdPkgPage.addBasePkgThenCancel(); + expect(cancelOk, '取消后弹窗应关闭').toBeTruthy(); + expect(await surgeryStdPkgPage.getTotalCount(), '取消后构成列表总数不变').toBe(total); + }); + + test('SURG-STD-020 确定按钮零选禁用', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const disabled = await surgeryStdPkgPage.confirmDisabledWhenEmpty(); + expect(disabled, '未选择时「确定(0)」应置灰不可用').toBeTruthy(); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-021 重复添加同一基础包', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { productName, sameProductRows } = await surgeryStdPkgPage.addSameBasePkgAgain(); + expect(productName, '应记录重复添加的目标产品名').toBeTruthy(); + expect(sameProductRows, '重复添加同一基础包不应产生重复行(同产品至多 1 行)').toBeLessThanOrEqual(1); + }); + }); + + test.describe('标准套包维护 · 添加散件弹窗', () => { + test('SURG-STD-022 打开添加散件弹窗', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddLoosePartDialog(); + const hasProductFilter = await surgeryStdPkgPage + .dialog() + .getByPlaceholder(/产品名称/) + .first() + .isVisible() + .catch(() => false); + const hasTable = await surgeryStdPkgPage + .dialog() + .locator('.ant-table-thead th') + .first() + .isVisible() + .catch(() => false); + expect(hasProductFilter || hasTable, '产品列表视图应含筛选或明细表').toBeTruthy(); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-023 产品列表筛选与选择散件', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddLoosePartDialog(); + const { keyword, candidateRows, selectedCount } = await surgeryStdPkgPage.filterAndCheckLoosePart(); + expect(keyword, '应在产品名称筛选框输入样例关键字').toBeTruthy(); + expect(candidateRows, '筛选后应存在可勾选散件行').toBeGreaterThan(0); + expect(selectedCount, '勾选后散件应进入应拣明细(计数 > 0)').toBeGreaterThan(0); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-024 散件规格/应拣明细视图', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddLoosePartDialog(); + await surgeryStdPkgPage.filterAndCheckLoosePart(); + const { visible, headers } = await surgeryStdPkgPage.switchLoosePartDetailView(); + expect(visible, '应拣明细视图应展示明细表').toBeTruthy(); + const blob = headers.join(' '); + for (const name of ['产品编号', '产品名称', '规格型号', '器械注册人', '单位', '耗材类型']) { + expect(blob, `应拣明细应含列「${name}」`).toContain(name); + } + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-025 明细统计与实际一致', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + await surgeryStdPkgPage.openAddLoosePartDialog(); + await surgeryStdPkgPage.filterAndCheckLoosePart(); + await surgeryStdPkgPage.switchLoosePartDetailView(); + const stats = await surgeryStdPkgPage.readLoosePartStats(); + expect(stats.kind, '应读取到「耗材品种数」').not.toBeNull(); + expect(stats.total, '应读取到「耗材总数」').not.toBeNull(); + expect(stats.kind as number, '耗材品种数应 ≥ 1').toBeGreaterThanOrEqual(1); + expect(stats.total as number, '耗材总数应 ≥ 品种数').toBeGreaterThanOrEqual(stats.kind as number); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-026 添加散件取消不落库', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { total, cancelOk } = await surgeryStdPkgPage.addLoosePartThenCancel(); + expect(cancelOk, '取消后弹窗应关闭').toBeTruthy(); + expect(await surgeryStdPkgPage.getTotalCount(), '取消后构成列表总数不变').toBe(total); + }); + + test('SURG-STD-027 万级散件列表首屏可交互', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { total, firstRowVisible, pagerVisible } = await surgeryStdPkgPage.loosePartFirstScreen(); + expect(firstRowVisible || pagerVisible, '万级列表首屏应可交互(不白屏、分页可用)').toBeTruthy(); + expect(total, '应能解析弹窗分页「共 N 条」总数').not.toBeNull(); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + }); + + test.describe('标准套包维护 · 行内操作(编辑 / 移除)', () => { + test('SURG-STD-028 行内编辑构成项', async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { groupName } = await surgeryStdPkgPage.editFirstChildRow(); + expect(groupName, '应展开目标包分组').toBeTruthy(); + await expect(surgeryStdPkgPage.dialog(), '编辑弹窗/表单应可见').toBeVisible(); + await surgeryStdPkgPage.closeDialogIfOpen(); + }); + + test('SURG-STD-029 移除二次确认防误删整包', { tag: '@smoke' }, async ({ surgeryStdPkgPage }) => { + await surgeryStdPkgPage.open(); + const { before } = await surgeryStdPkgPage.removeFirstChildRowThenCancel(); + expect(await surgeryStdPkgPage.getDataRowCount(), '取消后行数未变化').toBe(before); + }); + }); +}); diff --git a/tests/spd/trace-barcode.stat-tbc-004.spec.ts b/tests/spd/trace-barcode.stat-tbc-004.spec.ts deleted file mode 100644 index 84600f8f027ed1cc8314a95823786bc88ba69a48..0000000000000000000000000000000000000000 --- a/tests/spd/trace-barcode.stat-tbc-004.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { test } from "../../fixtures"; -import { - traceBarcodeComboAssertKeys, - traceBarcodeComboDrawerFields, - traceBarcodeComboQuickFields, -} from "../../data/trace-barcode.data"; - -/** - * STAT-TBC-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/trace-barcode.stat-tbc-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-tbc-004 - */ -test.describe("条码追溯 · STAT-TBC-004", () => { - test("STAT-TBC-004 全量查询条件组合查询", async ({ - traceBarcodePage, - page, - }) => { - await traceBarcodePage.open(); - const sample = await traceBarcodePage.searchWithQuickAndDrawerCombo( - traceBarcodeComboQuickFields, - traceBarcodeComboDrawerFields, - ); - await traceBarcodePage.expectQuickFieldsApplied( - sample, - traceBarcodeComboQuickFields, - ); - await traceBarcodePage.expectRowContainsSampleFields( - sample, - traceBarcodeComboAssertKeys, - ); - await traceBarcodePage.expectSummaryVisible(); - await page.pause(); - }); -}); diff --git a/tests/spd/trace-dept-buy.stat-tdb-004.spec.ts b/tests/spd/trace-dept-buy.stat-tdb-004.spec.ts deleted file mode 100644 index ff0cc549bb2672c2619a20b6db40de11b5a1548e..0000000000000000000000000000000000000000 --- a/tests/spd/trace-dept-buy.stat-tdb-004.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from "../../fixtures"; -import { - traceDeptBuyComboAssertKeys, - traceDeptBuyComboDrawerFields, - traceDeptBuyComboQuickFields, -} from "../../data/trace-dept-buy.data"; - -/** - * STAT-TDB-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/trace-dept-buy.stat-tdb-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-tdb-004 - */ -test.describe("科室请领单追溯 · STAT-TDB-004", () => { - test.describe.configure({ timeout: 180_000 }); - - test("STAT-TDB-004 全量查询条件组合查询", async ({ - traceDeptBuyPage, - page, - }) => { - await traceDeptBuyPage.open(); - const sample = await traceDeptBuyPage.searchWithQuickAndDrawerCombo( - traceDeptBuyComboQuickFields, - traceDeptBuyComboDrawerFields, - ); - await traceDeptBuyPage.expectQuickFieldsApplied( - sample, - traceDeptBuyComboQuickFields, - ); - await traceDeptBuyPage.expectRowContainsSampleFields( - sample, - traceDeptBuyComboAssertKeys, - ); - await traceDeptBuyPage.expectSummaryVisible(); - await page.pause(); - }); -}); diff --git a/tests/spd/trace-direct-buy.stat-tby-004.spec.ts b/tests/spd/trace-direct-buy.stat-tby-004.spec.ts deleted file mode 100644 index 0684c4423bd3af44a761a30ff220e189ad68d61d..0000000000000000000000000000000000000000 --- a/tests/spd/trace-direct-buy.stat-tby-004.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from "../../fixtures"; -import { - traceDirectBuyComboAssertKeys, - traceDirectBuyComboDrawerFields, - traceDirectBuyComboQuickFields, -} from "../../data/trace-direct-buy.data"; - -/** - * STAT-TBY-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/trace-direct-buy.stat-tby-004.spec.ts \ - * --headed --project=chromium --workers=1 - * - * 或:npm run test:stat-tby-004 - */ -test.describe("直配请领单追溯 · STAT-TBY-004", () => { - test.describe.configure({ timeout: 180_000 }); - - test("STAT-TBY-004 全量查询条件组合查询", async ({ - traceDirectBuyPage, - page, - }) => { - await traceDirectBuyPage.open(); - const sample = await traceDirectBuyPage.searchWithQuickAndDrawerCombo( - traceDirectBuyComboQuickFields, - traceDirectBuyComboDrawerFields, - ); - await traceDirectBuyPage.expectQuickFieldsApplied( - sample, - traceDirectBuyComboQuickFields, - ); - await traceDirectBuyPage.expectRowContainsSampleFields( - sample, - traceDirectBuyComboAssertKeys, - ); - await traceDirectBuyPage.expectSummaryVisible(); - await page.pause(); - }); -}); diff --git a/tests/spd/trace-direct-detail.stat-tdd-004.spec.ts b/tests/spd/trace-direct-detail.stat-tdd-004.spec.ts deleted file mode 100644 index ec703dcacc9be0fe8e4b1d7092287b610e1c9a89..0000000000000000000000000000000000000000 --- a/tests/spd/trace-direct-detail.stat-tdd-004.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { test } from "../../fixtures"; -import { - traceDirectDetailComboAssertKeys, - traceDirectDetailComboDrawerFields, - traceDirectDetailComboQuickFields, -} from "../../data/trace-direct-detail.data"; - -/** - * STAT-TDD-004 全量查询条件组合查询(调试/演示专用) - * - * 运行示例: - * SLOW_MO=500 npx playwright test tests/spd/trace-direct-detail.stat-tdd-004.spec.ts \ - * --headed --project=chromium --workers=1 - */ -test.describe("直配请领明细查询 · STAT-TDD-004", () => { - test("STAT-TDD-004 全量查询条件组合查询", async ({ - traceDirectDetailPage, - page, - }) => { - await traceDirectDetailPage.open(); - const sample = await traceDirectDetailPage.searchWithQuickAndDrawerCombo( - traceDirectDetailComboQuickFields, - traceDirectDetailComboDrawerFields, - ); - await traceDirectDetailPage.expectQuickFieldsApplied( - sample, - traceDirectDetailComboQuickFields, - ); - await traceDirectDetailPage.expectRowContainsSampleFields( - sample, - traceDirectDetailComboAssertKeys, - ); - await traceDirectDetailPage.expectSummaryVisible(); - await page.pause(); - }); -}); diff --git a/tests/spd/vendor-audit.spec.ts b/tests/spd/vendor-audit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe32e64f20ebbf35166d83cff4baa5afa8ac61b5 --- /dev/null +++ b/tests/spd/vendor-audit.spec.ts @@ -0,0 +1,228 @@ +import { expect, test } from '../../fixtures'; +import type { VendorAuditPage } from '../../pages/spd/vendor-audit.page'; +import { assertListOrEmpty, assertMockEmpty } from './helpers/vendor-assert.helper'; + +async function smokeEnter(po: VendorAuditPage): Promise { + await po.openFromMenu(); + await po.expectOnPage(); + await expect(po.button('查询').first(), '查询').toBeVisible(); + await expect(po.button('重置').first(), '重置').toBeVisible(); +} + +async function smokeQuery(po: VendorAuditPage): Promise { + await po.open(); + await po.query(); + await assertListOrEmpty(await po.snapshotList()); +} + +async function smokeMockEmpty(po: VendorAuditPage): Promise { + await po.open(); + await po.mockEmpty(); + await assertMockEmpty(await po.snapshotList()); +} + +/** + * 全量用例:与授权/注册证初审·终审 4 份 CSV 严格一致 + * 运行:npm run test:vendor-audit:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('授权变更初审 · 页面进入', () => { + test('VENDOR-AF1-001 菜单进入授权变更初审页', { tag: '@smoke' }, async ({ vendorAuthFirstPage }) => { + await smokeEnter(vendorAuthFirstPage); + }); + }); + + test.describe('授权变更初审 · 筛选与列表', () => { + test('VENDOR-AF1-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorAuthFirstPage }) => { + await smokeQuery(vendorAuthFirstPage); + }); + test('VENDOR-AF1-003 按单号筛选', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.filterByOrderNo(); + }); + test('VENDOR-AF1-004 按进院供应商筛选', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.filterBySupplierOrCert(); + }); + test('VENDOR-AF1-005 重置恢复默认', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.resetFilters(); + }); + test('VENDOR-AF1-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorAuthFirstPage }) => { + await smokeMockEmpty(vendorAuthFirstPage); + }); + test('VENDOR-AF1-007 列表核心列完整', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.expectCoreColumns(); + }); + test('VENDOR-AF1-009 制单日期起止同一天', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.filterMakeDateSameDay(); + }); + test('VENDOR-AF1-011 多条件组合查询(制单日期等9项)', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.filterCombination(); + }); + }); + + test.describe('授权变更初审 · 详情', () => { + test('VENDOR-AF1-008 打开详情后返回', async ({ vendorAuthFirstPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.detailAndBack(); + }); + test('VENDOR-AF1-010 与终审单据衔接不串单', async ({ vendorAuthFirstPage, vendorAuthFinalPage }) => { + await vendorAuthFirstPage.open(); + await vendorAuthFirstPage.expectPeerHandoff(vendorAuthFinalPage); + }); + }); + + test.describe('授权变更终审 · 页面进入', () => { + test('VENDOR-AF2-001 菜单进入授权变更终审页', { tag: '@smoke' }, async ({ vendorAuthFinalPage }) => { + await smokeEnter(vendorAuthFinalPage); + }); + }); + + test.describe('授权变更终审 · 筛选与列表', () => { + test('VENDOR-AF2-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorAuthFinalPage }) => { + await smokeQuery(vendorAuthFinalPage); + }); + test('VENDOR-AF2-003 按单号筛选', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.filterByOrderNo(); + }); + test('VENDOR-AF2-004 按进院供应商筛选', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.filterBySupplierOrCert(); + }); + test('VENDOR-AF2-005 重置恢复默认', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.resetFilters(); + }); + test('VENDOR-AF2-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorAuthFinalPage }) => { + await smokeMockEmpty(vendorAuthFinalPage); + }); + test('VENDOR-AF2-007 列表核心列完整', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.expectCoreColumns(); + }); + test('VENDOR-AF2-009 制单日期起止同一天', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.filterMakeDateSameDay(); + }); + test('VENDOR-AF2-011 多条件组合查询(制单日期等7项)', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.filterCombination(); + }); + }); + + test.describe('授权变更终审 · 详情', () => { + test('VENDOR-AF2-008 打开详情后返回', async ({ vendorAuthFinalPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.detailAndBack(); + }); + test('VENDOR-AF2-010 与初审单据衔接不串单', async ({ vendorAuthFinalPage, vendorAuthFirstPage }) => { + await vendorAuthFinalPage.open(); + await vendorAuthFinalPage.expectPeerHandoff(vendorAuthFirstPage); + }); + }); + + test.describe('注册证变更初审 · 页面进入', () => { + test('VENDOR-CF1-001 菜单进入注册证变更初审页', { tag: '@smoke' }, async ({ vendorCertFirstPage }) => { + await smokeEnter(vendorCertFirstPage); + }); + }); + + test.describe('注册证变更初审 · 筛选与列表', () => { + test('VENDOR-CF1-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorCertFirstPage }) => { + await smokeQuery(vendorCertFirstPage); + }); + test('VENDOR-CF1-003 按单号筛选', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.filterByOrderNo(); + }); + test('VENDOR-CF1-004 按注册证号(新)筛选', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.filterBySupplierOrCert(); + }); + test('VENDOR-CF1-005 重置恢复默认', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.resetFilters(); + }); + test('VENDOR-CF1-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorCertFirstPage }) => { + await smokeMockEmpty(vendorCertFirstPage); + }); + test('VENDOR-CF1-007 列表核心列完整', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.expectCoreColumns(); + }); + test('VENDOR-CF1-009 制单日期起止同一天', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.filterMakeDateSameDay(); + }); + test('VENDOR-CF1-011 多条件组合查询(制单日期等6项)', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.filterCombination(); + }); + }); + + test.describe('注册证变更初审 · 详情', () => { + test('VENDOR-CF1-008 打开详情后返回', async ({ vendorCertFirstPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.detailAndBack(); + }); + test('VENDOR-CF1-010 与终审单据衔接不串单', async ({ vendorCertFirstPage, vendorCertFinalPage }) => { + await vendorCertFirstPage.open(); + await vendorCertFirstPage.expectPeerHandoff(vendorCertFinalPage); + }); + }); + + test.describe('注册证变更终审 · 页面进入', () => { + test('VENDOR-CF2-001 菜单进入注册证变更终审页', { tag: '@smoke' }, async ({ vendorCertFinalPage }) => { + await smokeEnter(vendorCertFinalPage); + }); + }); + + test.describe('注册证变更终审 · 筛选与列表', () => { + test('VENDOR-CF2-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorCertFinalPage }) => { + await smokeQuery(vendorCertFinalPage); + }); + test('VENDOR-CF2-003 按单号筛选', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.filterByOrderNo(); + }); + test('VENDOR-CF2-004 按注册证号(新)筛选', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.filterBySupplierOrCert(); + }); + test('VENDOR-CF2-005 重置恢复默认', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.resetFilters(); + }); + test('VENDOR-CF2-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorCertFinalPage }) => { + await smokeMockEmpty(vendorCertFinalPage); + }); + test('VENDOR-CF2-007 列表核心列完整', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.expectCoreColumns(); + }); + test('VENDOR-CF2-009 制单日期起止同一天', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.filterMakeDateSameDay(); + }); + test('VENDOR-CF2-011 多条件组合查询(制单日期等5项)', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.filterCombination(); + }); + }); + + test.describe('注册证变更终审 · 详情', () => { + test('VENDOR-CF2-008 打开详情后返回', async ({ vendorCertFinalPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.detailAndBack(); + }); + test('VENDOR-CF2-010 与初审单据衔接不串单', async ({ vendorCertFinalPage, vendorCertFirstPage }) => { + await vendorCertFinalPage.open(); + await vendorCertFinalPage.expectPeerHandoff(vendorCertFirstPage); + }); + }); +}); diff --git a/tests/spd/vendor-cert-diff.spec.ts b/tests/spd/vendor-cert-diff.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..82f00ef17983f556e21b5fc181acc82819f520f5 --- /dev/null +++ b/tests/spd/vendor-cert-diff.spec.ts @@ -0,0 +1,83 @@ +import { test } from '../../fixtures'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-注册证差异对比-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:vendor-cert-diff:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('注册证差异对比 · 页面进入', () => { + test('VENDOR-CDF-001 菜单进入注册证差异对比页', { tag: '@smoke' }, async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.openFromMenu(); + await vendorCertDiffPage.expectEntered(); + }); + }); + + test.describe('注册证差异对比 · 筛选与对比', () => { + test('VENDOR-CDF-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.queryDefault(); + }); + + test('VENDOR-CDF-003 切换不一致 Tab', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.switchInconsistent(); + }); + + test('VENDOR-CDF-004 切换一致 Tab', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.switchConsistent(); + }); + + test('VENDOR-CDF-005 按物料名称筛选', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.filterByMaterialSample(); + }); + + test('VENDOR-CDF-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.mockEmpty(); + }); + + test('VENDOR-CDF-007 列表核心列完整', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.expectCoreColumns(); + }); + + test('VENDOR-CDF-009 仅空格物料名称搜索', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.searchSpacesOnly(); + }); + + test('VENDOR-CDF-010 一致行两侧字段抽样一致', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.expectConsistentRowAligned(); + }); + + test('VENDOR-CDF-011 不一致行至少一侧差异', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.expectInconsistentRowDiffers(); + }); + test('VENDOR-CDF-012 多条件组合查询(物料名称等8项)', async ({ vendorCertDiffPage }) => { + await vendorCertDiffPage.open(); + await vendorCertDiffPage.filterCombination(); + }); + }); + + test.describe('注册证差异对比 · 导出', () => { + test('VENDOR-CDF-008 导出按钮可见', async ({ vendorCertDiffPage }) => { + test.setTimeout(120_000); + await vendorCertDiffPage.open(); + await vendorCertDiffPage.expectExportVisible(); + }); + }); +}); diff --git a/tests/spd/vendor-eval-indicator.spec.ts b/tests/spd/vendor-eval-indicator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..16cd5fe5a57c67b349400a9d45b36af3b5d4875e --- /dev/null +++ b/tests/spd/vendor-eval-indicator.spec.ts @@ -0,0 +1,62 @@ +import { test } from '../../fixtures'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-评价指标维护-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:vendor-eval-indicator:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('评价指标维护 · 页面进入', () => { + test('VENDOR-EVI-001 菜单进入评价指标维护页', { tag: '@smoke' }, async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.openFromMenu(); + await vendorEvalIndicatorPage.expectEntered(); + }); + }); + + test.describe('评价指标维护 · 树与列表', () => { + test('VENDOR-EVI-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.queryDefault(); + }); + + test('VENDOR-EVI-003 点击左侧分类树', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.clickFirstTreeNode(); + }); + + test('VENDOR-EVI-004 按指标编码筛选', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.filterByIndicatorCode(); + }); + + test('VENDOR-EVI-005 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.mockEmpty(); + }); + + test('VENDOR-EVI-006 列表核心列完整', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.expectCoreColumns(); + }); + + test('VENDOR-EVI-008 分类下无指标空态', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.expectEmptyCategoryOrSkip(); + }); + + test('VENDOR-EVI-009 启用指标可供方案使用', async ({ vendorEvalIndicatorPage, vendorEvalSchemePage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.expectEnabledUsableInScheme(vendorEvalSchemePage); + }); + test('VENDOR-EVI-010 多条件组合查询(指标编码等2项)', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.filterCombination(); + }); + }); + + test.describe('评价指标维护 · 详情', () => { + test('VENDOR-EVI-007 打开详情后返回', async ({ vendorEvalIndicatorPage }) => { + await vendorEvalIndicatorPage.open(); + await vendorEvalIndicatorPage.openDetailAndBack(); + }); + }); +}); diff --git a/tests/spd/vendor-eval-scheme.spec.ts b/tests/spd/vendor-eval-scheme.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f9cb841b78ad1e3791a5d25898eb11096fa3350 --- /dev/null +++ b/tests/spd/vendor-eval-scheme.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '../../fixtures'; +import { assertListOrEmpty, assertMockEmpty } from './helpers/vendor-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-评价方案维护-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:vendor-eval-scheme:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('评价方案维护 · 页面进入', () => { + test('VENDOR-EVS-001 菜单进入评价方案维护页', { tag: '@smoke' }, async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.openFromMenu(); + await vendorEvalSchemePage.expectOnPage(); + await expect(vendorEvalSchemePage.button('查询').first(), '查询').toBeVisible(); + await expect(vendorEvalSchemePage.button('新增').first(), '新增').toBeVisible(); + expect(vendorEvalSchemePage.browserPage.url()).toContain('evaluationPlan'); + }); + }); + + test.describe('评价方案维护 · 筛选与维护', () => { + test('VENDOR-EVS-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.query(); + await assertListOrEmpty(await vendorEvalSchemePage.snapshotList()); + }); + + test('VENDOR-EVS-003 按方案编码筛选', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.filterBySchemeCode(); + await assertListOrEmpty(await vendorEvalSchemePage.snapshotList(), '方案编码筛选'); + }); + + test('VENDOR-EVS-004 重置恢复默认', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.resetFilters(); + await assertListOrEmpty(await vendorEvalSchemePage.snapshotList(), '重置后'); + }); + + test('VENDOR-EVS-005 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.mockEmpty(); + await assertMockEmpty(await vendorEvalSchemePage.snapshotList()); + }); + + test('VENDOR-EVS-006 列表核心列完整', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.query(); + const headers = await vendorEvalSchemePage.headerTexts(); + for (const name of ['方案编码', '方案名称', '创建人', '创建时间', '状态', '操作']) { + expect(headers.join(' '), `表头应含「${name}」`).toContain(name.replace(/\s/g, '')); + } + }); + + test('VENDOR-EVS-009 方案名称超长输入', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.overlongSchemeName(); + expect(vendorEvalSchemePage.browserPage.url()).toContain('evaluationPlan'); + }); + + test('VENDOR-EVS-010 方案与评价指标可关联', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.expectIndicatorLinkable(); + }); + test('VENDOR-EVS-011 多条件组合查询(创建日期等4项)', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.filterCombination(); + }); + }); + + test.describe('评价方案维护 · 新增', () => { + test('VENDOR-EVS-007 打开新增后取消', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.openAddAndCancel(); + await assertListOrEmpty(await vendorEvalSchemePage.snapshotList(), '取消新增后'); + }); + + test('VENDOR-EVS-008 必填为空提交拦截', async ({ vendorEvalSchemePage }) => { + await vendorEvalSchemePage.open(); + await vendorEvalSchemePage.emptyRequiredBlocked(); + }); + }); +}); diff --git a/tests/spd/vendor-holiday.spec.ts b/tests/spd/vendor-holiday.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e41cf5f2f2873843af04a434ce62f23903384dc3 --- /dev/null +++ b/tests/spd/vendor-holiday.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '../../fixtures'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-供应商节假日管理-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:vendor-holiday:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('供应商节假日管理 · 页面进入', () => { + test('VENDOR-HLD-001 菜单进入供应商节假日管理页', { tag: '@smoke' }, async ({ vendorHolidayPage }) => { + await vendorHolidayPage.openFromMenu(); + await vendorHolidayPage.expectOnPage(); + await expect( + vendorHolidayPage.browserPage.getByText(/供应商节假日|默认假日|节假日设置/).first(), + '节假日主区', + ).toBeVisible({ timeout: 15_000 }); + await expect(vendorHolidayPage.button('新增').first(), '新增').toBeVisible(); + expect(vendorHolidayPage.browserPage.url()).toContain('prov/holiday'); + }); + }); + + test.describe('供应商节假日管理 · 左右分栏', () => { + test('VENDOR-HLD-002 选中左侧节假日展示详情', { tag: '@smoke' }, async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.selectFirstHoliday(); + await expect(vendorHolidayPage.browserPage.getByText(/默认假日详情|供应商自定义/).first()).toBeVisible(); + }); + + test('VENDOR-HLD-003 左侧搜索无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.mockEmptyLeft(); + const rows = vendorHolidayPage.browserPage.locator( + '.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)', + ); + const count = await rows.count(); + const empty = await vendorHolidayPage.browserPage.getByText('暂无数据').first().isVisible().catch(() => false); + expect(count === 0 || empty, '左侧无匹配应空态').toBeTruthy(); + }); + + test('VENDOR-HLD-005 自定义节假日列表区可见', async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.selectFirstHoliday(); + await expect(vendorHolidayPage.browserPage.getByText('供应商自定义节假日列表').first()).toBeVisible(); + }); + + test('VENDOR-HLD-008 进院供应商主数据一致', async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.expectSupplierMatchesMaster(); + }); + }); + + test.describe('供应商节假日管理 · 维护', () => { + test('VENDOR-HLD-004 左侧新增取消', async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.openLeftAddAndCancel(); + expect(vendorHolidayPage.browserPage.url()).toContain('prov/holiday'); + }); + + test('VENDOR-HLD-006 结束早于开始日期拦截', async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.endBeforeStartBlocked(); + }); + + test('VENDOR-HLD-007 开始结束为同一天', async ({ vendorHolidayPage }) => { + await vendorHolidayPage.open(); + await vendorHolidayPage.sameDayRangeAllowedOrRuled(); + }); + }); +}); diff --git a/tests/spd/vendor-qual.spec.ts b/tests/spd/vendor-qual.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f62cdb160d152bbde52d3fc1fd5e64b07037559c --- /dev/null +++ b/tests/spd/vendor-qual.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '../../fixtures'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty } from './helpers/vendor-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-供应商资质管理-UI自动化测试用例-01.csv 严格一致 + * 断言在用例;PO 负责动作/取数。运行:npm run test:vendor-qual:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('供应商资质管理 · 页面进入', () => { + test('VENDOR-QUL-001 菜单进入供应商资质管理页', { tag: '@smoke' }, async ({ vendorQualPage }) => { + await vendorQualPage.openFromMenu(); + await vendorQualPage.expectOnPage(); + await expect(vendorQualPage.button('查询').first(), '查询').toBeVisible(); + await expect(vendorQualPage.button('重置').first(), '重置').toBeVisible(); + expect(vendorQualPage.browserPage.url()).toContain('basic/qcManage'); + }); + }); + + test.describe('供应商资质管理 · 筛选与 Tab', () => { + test('VENDOR-QUL-002 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.query(); + await assertListOrEmpty(await vendorQualPage.snapshotList()); + }); + + test('VENDOR-QUL-003 切换授权链路效期预警 Tab', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.switchAuthLinkWarning(); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '授权链路效期预警'); + }); + + test('VENDOR-QUL-004 近三个月到期计数一致', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.switchExpiringThreeMonths(); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '近三个月到期'); + }); + + test('VENDOR-QUL-005 按进院供应商筛选', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.filterBySupplierSample(); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '进院供应商筛选'); + }); + + test('VENDOR-QUL-006 重置恢复默认', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.resetFilters(); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '重置后'); + }); + + test('VENDOR-QUL-007 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.mockEmpty(); + await assertMockEmpty(await vendorQualPage.snapshotList()); + }); + + test('VENDOR-QUL-008 证件效期提醒核心列完整', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.switchCertExpiryReminder(); + await vendorQualPage.query(); + assertHeadersContain(await vendorQualPage.headerTexts(), [ + '序号', + '数据来源', + '进院供应商', + '有效期至', + '证件名称', + '证件类型', + '效期提醒', + '操作', + ]); + }); + + test('VENDOR-QUL-011 制单/效期日期起止同一天', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.filterSameDayExpiryOrMakeDate(); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '同日日期筛选'); + }); + + test('VENDOR-QUL-012 效期提醒与有效期至抽样一致', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.expectReminderMatchesExpireDate(); + }); + test('VENDOR-QUL-013 多条件组合查询(销售商名称等2项)', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.filterCombination(); + }); + }); + + test.describe('供应商资质管理 · 查看与导出', () => { + test('VENDOR-QUL-009 打开查看后关闭', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + await vendorQualPage.openViewAndClose(); + expect(vendorQualPage.browserPage.url()).toContain('qcManage'); + }); + + test('VENDOR-QUL-010 导出按钮可见', async ({ vendorQualPage }) => { + await vendorQualPage.open(); + const exportBtn = vendorQualPage.button('导出').or(vendorQualPage.button('导出明细')).first(); + await expect(exportBtn, '导出按钮').toBeVisible(); + }); + }); +}); diff --git a/tests/spd/vendor-query.spec.ts b/tests/spd/vendor-query.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..44cbf074b84affe264a7f4295abf6d2d42c504ea --- /dev/null +++ b/tests/spd/vendor-query.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '../../fixtures'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty } from './helpers/vendor-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-厂商管理-厂商查询-UI自动化测试用例-01.csv 严格一致 + * 样板:动作在 PO,断言在本文件;lifecycle 由 fixture + storageState 提供。 + * 运行:npm run test:vendor-query:all + */ +test.describe('基础设置 · 厂商管理', () => { + test.describe('厂商查询 · 页面进入', () => { + test('VENDOR-QRY-001 菜单进入厂商查询页', { tag: '@smoke' }, async ({ vendorQueryPage }) => { + await vendorQueryPage.openFromMenu(); + await vendorQueryPage.ready(); + await expect(vendorQueryPage.registrantTab(), '器械注册人 Tab').toBeVisible(); + await expect(vendorQueryPage.distributorTab(), '经销商 Tab').toBeVisible(); + await expect(vendorQueryPage.nameSearch(), '厂商名称搜索').toBeVisible(); + expect(vendorQueryPage.browserPage.url(), 'URL 应含厂商查询 path').toContain('mfrsName/factory'); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('VENDOR-QRY-010 未登录访问拦截', async ({ vendorQueryPage }) => { + const probe = await vendorQueryPage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见厂商业务列表').toBeTruthy(); + }); + }); + }); + + test.describe('厂商查询 · Tab 与搜索', () => { + test('VENDOR-QRY-002 默认器械注册人列表有数据或空态明确', { tag: '@smoke' }, async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.switchRegistrant(); + await assertListOrEmpty(await vendorQueryPage.snapshotList(), '器械注册人'); + }); + + test('VENDOR-QRY-003 切换经销商 Tab', { tag: '@smoke' }, async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.switchDistributor(); + await assertListOrEmpty(await vendorQueryPage.snapshotList(), '经销商'); + }); + + test('VENDOR-QRY-004 按厂商名称搜索', async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + const keyword = await vendorQueryPage.searchBySampleName(); + expect(await vendorQueryPage.rowsMatchKeyword('厂商名称', keyword), '结果行厂商名称应匹配关键字').toBeTruthy(); + }); + + test('VENDOR-QRY-005 清空搜索恢复', async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.searchBySampleName(); + const { before, after } = await vendorQueryPage.clearNameSearch(); + expect(after, '清空后条数应不少于过滤后').toBeGreaterThanOrEqual(before); + }); + + test('VENDOR-QRY-006 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.searchMockEmpty(); + await assertMockEmpty(await vendorQueryPage.snapshotList()); + }); + + test('VENDOR-QRY-007 列表核心列完整', async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.switchRegistrant(); + assertHeadersContain(await vendorQueryPage.headerTexts(), [ + '序号', + '厂商编号', + '厂商名称', + '产地', + '详细地址', + '简称', + '开户银行', + '银行账号', + ]); + }); + + test('VENDOR-QRY-008 分页翻至第 2 页', async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.switchRegistrant(); + await vendorQueryPage.goToPage2(); + const state = await vendorQueryPage.snapshotList(); + expect(state.rows, '第 2 页应有数据').toBeGreaterThan(0); + }); + + test('VENDOR-QRY-009 仅空格关键字搜索', async ({ vendorQueryPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.searchSpacesOnly(); + await assertListOrEmpty(await vendorQueryPage.snapshotList(), '空格搜索'); + }); + + test('VENDOR-QRY-011 厂商名称与资质管理进院供应商可对照', async ({ vendorQueryPage, vendorQualPage }) => { + await vendorQueryPage.open(); + await vendorQueryPage.searchQualWithNameKeyword(vendorQualPage); + await assertListOrEmpty(await vendorQualPage.snapshotList(), '资质管理对照'); + }); + }); +}); diff --git a/tests/spd/warehouse-probe.spec.ts b/tests/spd/warehouse-probe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..68a6ab9c3155a24bf94c6dc4207ad2996db28422 --- /dev/null +++ b/tests/spd/warehouse-probe.spec.ts @@ -0,0 +1,209 @@ +import { expect, test } from '../../fixtures'; +import { warehouseData } from '../../data/warehouse.data'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty } from './helpers/wh-flow-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:warehouse-probe:all + */ +test.describe('基础设置 · 库房管理', () => { + test.describe('探针绑定 · 页面进入', () => { + test('WH-PRB-001 菜单进入探针绑定页', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.openFromMenu(); + await warehouseProbePage.expectOnPage(); + await expect(warehouseProbePage.locators.probeIdInput(), '探针ID').toBeVisible(); + await expect(warehouseProbePage.locators.hostCodeInput(), '主机编码').toBeVisible(); + await expect(warehouseProbePage.locators.bindSelect(), '是否绑定货位').toBeVisible(); + await expect(warehouseProbePage.locators.queryBtn, '查询').toBeVisible(); + await expect(warehouseProbePage.locators.resetBtn, '重置').toBeVisible(); + await expect(warehouseProbePage.browserPage.locator('.ant-pagination').first(), '分页').toBeVisible(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('WH-PRB-002 未登录访问拦截', async ({ warehouseProbePage }) => { + const probe = await warehouseProbePage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见探针数据').toBeTruthy(); + }); + }); + }); + + test.describe('探针绑定 · 筛选与查询', () => { + test('WH-PRB-003 默认查询列表有数据或空态明确', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + await assertListOrEmpty(await warehouseProbePage.snapshotList(), '默认查询'); + }); + + test('WH-PRB-004 按探针ID查询', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const id = await warehouseProbePage.sampleCell('探针ID'); + await warehouseProbePage.queryByProbeId(id); + expect(await warehouseProbePage.rowsMatchKeyword('探针ID', id), '探针ID应匹配').toBeTruthy(); + }); + + test('WH-PRB-005 按主机编码查询', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const code = await warehouseProbePage.sampleCell('主机编码'); + await warehouseProbePage.queryByHostCode(code); + expect(await warehouseProbePage.rowsMatchKeyword('主机编码', code), '主机编码应匹配').toBeTruthy(); + }); + + test('WH-PRB-006 按是否绑定货位查询', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.queryByBindFlag('已绑定'); + const bound = await warehouseProbePage.snapshotList(); + if (bound.rows > 0) { + expect(await warehouseProbePage.rowsMatchKeyword('是否绑定货位', '已绑定'), '均为已绑定').toBeTruthy(); + const qty = await warehouseProbePage.tryCell(await warehouseProbePage.firstRow(), '绑定的货位'); + expect(Number(qty.replace(/\D/g, '') || '0'), '已绑定数量 > 0').toBeGreaterThan(0); + } else { + await assertListOrEmpty(bound, '已绑定'); + } + await warehouseProbePage.reset(); + await warehouseProbePage.queryByBindFlag('未绑定'); + const unbound = await warehouseProbePage.snapshotList(); + if (unbound.rows > 0) { + expect(await warehouseProbePage.rowsMatchKeyword('是否绑定货位', '未绑定'), '均为未绑定').toBeTruthy(); + const qty = await warehouseProbePage.tryCell(await warehouseProbePage.firstRow(), '绑定的货位'); + expect(Number(qty.replace(/\D/g, '') || '0'), '未绑定数量为 0').toBe(0); + } else { + await assertListOrEmpty(unbound, '未绑定'); + } + }); + + test('WH-PRB-007 探针ID+主机编码组合查询', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const id = await warehouseProbePage.sampleCell('探针ID'); + const code = await warehouseProbePage.sampleCell('主机编码'); + await warehouseProbePage.queryCombo(id, code); + expect(await warehouseProbePage.rowsMatchKeyword('探针ID', id), '探针ID').toBeTruthy(); + expect(await warehouseProbePage.rowsMatchKeyword('主机编码', code), '主机编码').toBeTruthy(); + }); + + test('WH-PRB-008 重置恢复默认', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.fillProbeId(warehouseData.mockProbe); + await warehouseProbePage.reset(); + await expect(warehouseProbePage.locators.probeIdInput()).toHaveValue(''); + await assertListOrEmpty(await warehouseProbePage.snapshotList(), '重置后'); + }); + + test('WH-PRB-009 无匹配空态(mock反例)', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.searchMockEmpty(); + await assertMockEmpty(await warehouseProbePage.snapshotList()); + }); + }); + + test.describe('探针绑定 · 列表与分页', () => { + test('WH-PRB-010 列表核心列与数量规则', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + assertHeadersContain(await warehouseProbePage.headerTexts(), [ + '序号', + '探针ID', + '主机编码', + '是否绑定货位', + '绑定的货位', + '操作', + ]); + const rows = await warehouseProbePage.listRows(); + const limit = Math.min(await rows.count(), 8); + for (let i = 0; i < limit; i += 1) { + const flag = await warehouseProbePage.tryCell(rows.nth(i), '是否绑定货位'); + const qtyText = await warehouseProbePage.tryCell(rows.nth(i), '绑定的货位'); + expect(/货位号|货架/.test(qtyText), '数量列不是货位号格式').toBeFalsy(); + const qty = Number(qtyText.replace(/\D/g, '') || '0'); + if (flag.includes('已绑定')) expect(qty, '已绑定数量 > 0').toBeGreaterThan(0); + if (flag.includes('未绑定')) expect(qty, '未绑定数量为 0').toBe(0); + } + }); + }); + + test.describe('探针绑定 · 打开绑定货位弹窗', () => { + test('WH-PRB-011 行内打开货位绑定探针', { tag: '@smoke' }, async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const modal = await warehouseProbePage.openBindModal(); + expect(await warehouseProbePage.modalTitle(modal), '弹窗标题').toContain('货位绑定探针'); + await expect(modal.getByText('当前库房').first(), '当前库房').toBeVisible(); + await expect(modal.getByText('当前库区').first(), '当前库区').toBeVisible(); + await warehouseProbePage.cancelModal(modal); + }); + + test('WH-PRB-012 取消关闭不改主列表', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const beforeQty = await warehouseProbePage.tryCell(await warehouseProbePage.firstRow(), '绑定的货位'); + const beforeId = await warehouseProbePage.locators.probeIdInput().inputValue(); + const modal = await warehouseProbePage.openBindModal(); + await warehouseProbePage.cancelModal(modal); + const afterQty = await warehouseProbePage.tryCell(await warehouseProbePage.firstRow(), '绑定的货位'); + expect(afterQty, '绑定数量保持').toBe(beforeQty); + await expect(warehouseProbePage.locators.probeIdInput()).toHaveValue(beforeId); + }); + + test('WH-PRB-013 未选货位时确定计数为 0', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const modal = await warehouseProbePage.openBindModal(); + expect(await warehouseProbePage.confirmCount(modal), '确定计数为 0').toBe(0); + await warehouseProbePage.cancelModal(modal); + }); + + test('WH-PRB-014 弹窗分页独立于主列表', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const modal = await warehouseProbePage.openBindModal(); + const modalTotalText = modal.locator('.ant-pagination-total-text').first(); + await expect(modalTotalText, '弹窗应有独立于主列表的分页总数').toBeVisible(); + await expect(modalTotalText).toHaveText(/共\s*\d+\s*条/); + await warehouseProbePage.cancelModal(modal); + }); + }); + + test.describe('探针绑定 · 弹窗筛选货位', () => { + test('WH-PRB-015 按当前库房筛选货位', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const modal = await warehouseProbePage.openBindModal(); + const wh = await warehouseProbePage.defaultModalWarehouse(modal); + await warehouseProbePage.modalQuery(modal); + const rows = await warehouseProbePage.modalRows(modal); + if ((await rows.count()) > 0 && wh) { + const text = ((await rows.first().innerText()) ?? '').replace(/\s/g, ''); + expect(text, '可见行对应当前库房').toContain(wh.replace(/\s/g, '')); + } + await warehouseProbePage.cancelModal(modal); + }); + + test('WH-PRB-016 弹窗内重置', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const mainFilter = await warehouseProbePage.locators.probeIdInput().inputValue(); + const modal = await warehouseProbePage.openBindModal(); + await warehouseProbePage.fillModalShelfQuery(modal, 'AUTO-RESET'); + await warehouseProbePage.modalReset(modal); + await warehouseProbePage.cancelModal(modal); + await expect(warehouseProbePage.locators.probeIdInput()).toHaveValue(mainFilter); + }); + }); + + test.describe('探针绑定 · 勾选货位并保存绑定', () => { + test('WH-PRB-017 勾选后确定计数增加再取消勾选', async ({ warehouseProbePage }) => { + await warehouseProbePage.open(); + await warehouseProbePage.query(); + const modal = await warehouseProbePage.openBindModal(); + await warehouseProbePage.toggleFirstModalRow(modal, true); + expect(await warehouseProbePage.confirmCount(modal), '计数为 1').toBe(1); + await warehouseProbePage.toggleFirstModalRow(modal, false); + expect(await warehouseProbePage.confirmCount(modal), '计数回到 0').toBe(0); + await warehouseProbePage.cancelModal(modal); + }); + }); +}); diff --git a/tests/spd/warehouse-zone.spec.ts b/tests/spd/warehouse-zone.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4a664d31f63379d1501e7d8e07d656139f292b9 --- /dev/null +++ b/tests/spd/warehouse-zone.spec.ts @@ -0,0 +1,245 @@ +import { expect, test } from '../../fixtures'; +import { warehouseData } from '../../data/warehouse.data'; +import { assertHeadersContain, assertListOrEmpty, assertMockEmpty } from './helpers/wh-flow-assert.helper'; + +/** + * 全量用例:与 testcase/基础管理-库房管理-库区设置-UI自动化测试用例-01.csv 严格一致 + * 运行:npm run test:warehouse-zone:all + */ +test.describe('基础设置 · 库房管理', () => { + test.describe('库区设置 · 页面进入', () => { + test('WH-ZON-001 菜单进入库区设置页', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.openFromMenu(); + await warehouseZonePage.expectOnPage(); + await expect(warehouseZonePage.locators.tree, '库房信息树').toBeVisible(); + await expect(warehouseZonePage.locators.addBtn, '新 增').toBeVisible(); + await expect( + warehouseZonePage.locators.paginationTotal.or(warehouseZonePage.browserPage.locator('.ant-pagination')).first(), + '分页', + ).toBeVisible(); + }); + + test.describe('未登录', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('WH-ZON-002 未登录访问拦截', async ({ warehouseZonePage }) => { + const probe = await warehouseZonePage.probeUnauthAccess(); + expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见库区数据').toBeTruthy(); + }); + }); + }); + + test.describe('库区设置 · 左侧库房树', () => { + test('WH-ZON-003 库房树默认有选中项', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const names = await warehouseZonePage.visibleTreeNames(); + expect(names.length, '库房树至少 1 个节点').toBeGreaterThan(0); + const selected = await warehouseZonePage.selectedWarehouse(); + expect(selected, '应有选中库房').toBeTruthy(); + await assertListOrEmpty(await warehouseZonePage.snapshotList(), '当前库房库区'); + }); + + test('WH-ZON-004 按库房名称过滤树', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const sample = await warehouseZonePage.selectedWarehouse(); + expect(sample, '库房树至少 1 个节点').toBeTruthy(); + const keyword = sample.slice(0, Math.min(sample.length, 4)); + await warehouseZonePage.filterTree(keyword); + const names = await warehouseZonePage.visibleTreeNames(); + expect(names.length, '过滤后应有节点').toBeGreaterThan(0); + for (const name of names) { + expect(name.replace(/\s/g, ''), '可见节点应含关键字').toContain(keyword.replace(/\s/g, '')); + } + }); + + test('WH-ZON-005 清空库房名称恢复全量树', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const before = (await warehouseZonePage.visibleTreeNames()).length; + const sample = await warehouseZonePage.selectedWarehouse(); + await warehouseZonePage.filterTree(sample.slice(0, Math.min(sample.length, 4))); + await warehouseZonePage.clearTreeFilter(); + const after = (await warehouseZonePage.visibleTreeNames()).length; + expect(after, '清空后树应恢复全量').toBe(before); + }); + + test('WH-ZON-006 切换库房刷新右侧列表', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const rows = await warehouseZonePage.listRows(); + const firstNo = (await rows.count()) > 0 ? await warehouseZonePage.tryCell(rows.first(), '库区编号') : ''; + const switched = await warehouseZonePage.switchOtherWarehouse(); + expect(switched, '应能切换到另一库房').toBeTruthy(); + expect(await warehouseZonePage.selectedWarehouse(), '当前库房应为切换目标').toBe(switched); + await assertListOrEmpty(await warehouseZonePage.snapshotList(), '切换后库区'); + if (firstNo && (await warehouseZonePage.getDataRowCount()) > 0) { + const nextNo = await warehouseZonePage.tryCell(await warehouseZonePage.firstRow(), '库区编号'); + expect(nextNo.length, '切换后首行应有库区编号').toBeGreaterThan(0); + } + }); + + test('WH-ZON-007 无匹配库房空树(mock反例)', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + await warehouseZonePage.filterTree(warehouseData.mockZone); + const names = await warehouseZonePage.visibleTreeNames(); + expect(names.length, '无匹配应空树').toBe(0); + }); + }); + + test.describe('库区设置 · 右侧库区列表与分页', () => { + test('WH-ZON-008 列表核心列完整', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + assertHeadersContain(await warehouseZonePage.headerTexts(), [ + '库区编号', + '库区名称', + '库区地址', + '是否专项库区', + '操作', + ]); + if ((await warehouseZonePage.getDataRowCount()) > 0) { + const row = await warehouseZonePage.firstRow(); + await expect(warehouseZonePage.rowAction(row, '编辑'), '编辑').toBeVisible(); + await expect(warehouseZonePage.rowAction(row, '人员管理'), '人员管理').toBeVisible(); + await expect(warehouseZonePage.rowAction(row, '货位管理'), '货位管理').toBeVisible(); + const disable = warehouseZonePage.rowAction(row, '停用'); + const enable = warehouseZonePage.rowAction(row, '启用'); + const hasToggle = + (await disable.isVisible().catch(() => false)) || (await enable.isVisible().catch(() => false)); + expect(hasToggle, '应可见停用或启用').toBeTruthy(); + } + }); + + test('WH-ZON-009 默认库区以标签展示', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const headers = (await warehouseZonePage.headerTexts()).join(' '); + expect(headers.includes('默认库区'), '默认库区不是独立列').toBeFalsy(); + const tags = await warehouseZonePage.defaultZoneTags(); + const count = await tags.count(); + expect(count, '同一库房至多 1 个默认库区标签').toBeLessThanOrEqual(1); + }); + }); + + test.describe('库区设置 · 库区搜索', () => { + test('WH-ZON-010 按库区编号搜索', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const zoneNo = await warehouseZonePage.sampleCell('库区编号'); + await warehouseZonePage.searchZone(zoneNo); + expect(await warehouseZonePage.rowsMatchKeyword('库区编号', zoneNo), '结果应含该编号').toBeTruthy(); + }); + + test('WH-ZON-011 按库区名称搜索', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const name = await warehouseZonePage.sampleCell('库区名称'); + const keyword = name.replace(/默认库区/g, '').slice(0, Math.min(name.length, 6)); + await warehouseZonePage.searchZone(keyword); + expect(await warehouseZonePage.rowsMatchKeyword('库区名称', keyword), '名称关键字应命中').toBeTruthy(); + }); + + test('WH-ZON-012 无匹配库区空态(mock反例)', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + await warehouseZonePage.searchZone(warehouseData.mockZone); + await assertMockEmpty(await warehouseZonePage.snapshotList(), '库区 mock'); + }); + }); + + test.describe('库区设置 · 新增库区', () => { + test('WH-ZON-013 打开新增库区弹窗', { tag: '@smoke' }, async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const dialog = await warehouseZonePage.openAddDialog(); + expect(await warehouseZonePage.dialogTitle(), '标题').toContain('新增库区'); + await expect(warehouseZonePage.locators.shelfNo, '库区编号').toBeVisible(); + await expect(warehouseZonePage.locators.shelfName, '库区名称').toBeVisible(); + await expect(warehouseZonePage.locators.address, '库区地址').toBeVisible(); + await expect(dialog.getByText('联系人').first(), '联系人').toBeVisible(); + await expect(dialog.getByRole('button', { name: /取\s*消/ }), '取 消').toBeVisible(); + await expect(dialog.getByRole('button', { name: /确\s*定/ }), '确 定').toBeVisible(); + await warehouseZonePage.cancelDialog(); + }); + + test('WH-ZON-014 新增取消不落库', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const zoneNo = `AUTO${Date.now()}`; + await warehouseZonePage.openAddDialog(); + await warehouseZonePage.fillAddDraft(zoneNo, '自动化取消'); + await warehouseZonePage.cancelDialog(); + await warehouseZonePage.searchZone(zoneNo); + await assertMockEmpty(await warehouseZonePage.snapshotList(), '取消新增'); + }); + + test('WH-ZON-015 必填为空拦截', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + await warehouseZonePage.openAddDialog(); + await warehouseZonePage.confirmDialog(); + const state = await warehouseZonePage.interceptState(); + expect(state.hintVisible, '应出现必填提示').toBeTruthy(); + expect(state.overlayOpen, '弹窗应仍打开').toBeTruthy(); + await warehouseZonePage.cancelDialog(); + }); + }); + + test.describe('库区设置 · 编辑库区', () => { + test('WH-ZON-016 打开编辑并回填', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const sample = await warehouseZonePage.openEditFirst(); + expect(await warehouseZonePage.dialogTitle(), '标题').toContain('编辑库区信息'); + const filled = await warehouseZonePage.readEditValues(); + expect(filled.zoneNo.replace(/\s/g, ''), '编号回填').toBe(sample.zoneNo.replace(/\s/g, '')); + const expectedName = sample.zoneName.replace(/\s/g, '').replace(/默认库区/g, ''); + const actualName = filled.zoneName.replace(/\s/g, ''); + if (expectedName) { + expect(actualName, '名称回填').toContain(expectedName); + } else { + expect(actualName, '名称回填').toBe(sample.zoneName.replace(/\s/g, '')); + } + await warehouseZonePage.cancelDialog(); + }); + + test('WH-ZON-017 编辑取消不保存', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const before = await warehouseZonePage.openEditFirst(); + await warehouseZonePage.fill(warehouseZonePage.locators.shelfName, `临时${Date.now()}`, '改名称'); + await warehouseZonePage.cancelDialog(); + const again = await warehouseZonePage.openEditFirst(); + expect(again.zoneName.replace(/\s/g, ''), '取消后名称保持').toBe(before.zoneName.replace(/\s/g, '')); + await warehouseZonePage.cancelDialog(); + }); + + test('WH-ZON-018 编辑清空必填拦截', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const before = await warehouseZonePage.openEditFirst(); + await warehouseZonePage.fill(warehouseZonePage.locators.shelfName, '', '清空库区名称'); + await warehouseZonePage.confirmDialog(); + const state = await warehouseZonePage.interceptState(); + expect(state.hintVisible, '应出现拦截提示').toBeTruthy(); + expect(state.overlayOpen, '弹窗应仍开').toBeTruthy(); + await warehouseZonePage.cancelDialog(); + const listName = await warehouseZonePage.tryCell(await warehouseZonePage.firstRow(), '库区名称'); + expect(listName.replace(/\s/g, '').length, '列表名称不应被写成空').toBeGreaterThan(0); + expect(before.zoneName.length, '原记录存在').toBeGreaterThan(0); + }); + }); + + test.describe('库区设置 · 人员管理', () => { + test('WH-ZON-019 打开人员管理后关闭', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const selected = await warehouseZonePage.selectedWarehouse(); + const panel = await warehouseZonePage.openRowPanel('人员管理'); + expect(panel.opened, '人员管理应打开').toBeTruthy(); + expect(panel.title, '标题含人员或当前库区').toMatch(/人员|库区/); + await warehouseZonePage.closeOverlay(); + expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy(); + expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected); + }); + }); + + test.describe('库区设置 · 货位管理', () => { + test('WH-ZON-020 打开货位管理后关闭', async ({ warehouseZonePage }) => { + await warehouseZonePage.open(); + const selected = await warehouseZonePage.selectedWarehouse(); + const panel = await warehouseZonePage.openRowPanel('货位管理'); + expect(panel.opened, '货位管理应打开').toBeTruthy(); + expect(panel.title, '标题含货位或当前库区').toMatch(/货位|库区/); + await warehouseZonePage.closeOverlay(); + expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy(); + expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected); + }); + }); +}); diff --git a/utils/auth-pool.util.ts b/utils/auth-pool.util.ts new file mode 100644 index 0000000000000000000000000000000000000000..741c53073ebb250650251a23ff46247f87cf3cfc --- /dev/null +++ b/utils/auth-pool.util.ts @@ -0,0 +1,33 @@ +import { + credentials, + credentialsSecondary, + hasSecondaryCredentials, + type Credentials, +} from '../config/credentials.config'; +import { AUTH_STATE_PATH, AUTH_STATE_PATH_SECONDARY } from './path.util'; + +export type AuthAccountSlot = 'primary' | 'secondary'; + +export function authPoolSize(): number { + return hasSecondaryCredentials() ? 2 : 1; +} + +export function storageStatePathForSlot(slot: AuthAccountSlot): string { + return slot === 'primary' ? AUTH_STATE_PATH : AUTH_STATE_PATH_SECONDARY; +} + +export function credentialsForSlot(slot: AuthAccountSlot): Credentials { + return slot === 'primary' ? credentials : credentialsSecondary; +} + +/** 按 parallel worker 序号轮换账号,降低同账号互踢会话概率。 */ +export function slotForParallelIndex(parallelIndex: number): AuthAccountSlot { + if (!hasSecondaryCredentials()) { + return 'primary'; + } + return parallelIndex % 2 === 0 ? 'primary' : 'secondary'; +} + +export function resolveAuthStorageStatePath(parallelIndex = 0): string { + return storageStatePathForSlot(slotForParallelIndex(parallelIndex)); +} diff --git a/utils/auth-session.ts b/utils/auth-session.ts index 792c2b224e35c6149058b4df156bac5a2ade9bc4..0dd8c90cc7144494c8042e6ebbb75fafed91323a 100644 --- a/utils/auth-session.ts +++ b/utils/auth-session.ts @@ -1,9 +1,9 @@ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import type { Browser, Page } from '@playwright/test'; -import { credentials, hasCredentials } from '../config/credentials.config'; +import { hasCredentials, hasSecondaryCredentials } from '../config/credentials.config'; import { env } from '../config/env.config'; import { logger } from '../core/logger'; import { LoginPage } from '../pages/auth/login.page'; @@ -14,6 +14,11 @@ import { probeAuthenticatedSession, shouldForceAuthLogin, } from './auth-state.util'; +import { + type AuthAccountSlot, + credentialsForSlot, + storageStatePathForSlot, +} from './auth-pool.util'; import { AUTH_STATE_PATH } from './path.util'; export type AuthEnsureResult = 'reused-fresh' | 'reused-probed' | 'logged-in'; @@ -31,38 +36,76 @@ export async function isPageOnLogin(page: Page): Promise { } } -const AUTH_LOCK_PATH = `${AUTH_STATE_PATH}.lock`; +function isStaleAuthLock(lockPath: string): boolean { + if (!existsSync(lockPath)) return false; + try { + const raw = readFileSync(lockPath, 'utf8').trim(); + const owner = Number(raw); + if (!Number.isFinite(owner) || owner <= 0) return true; + try { + process.kill(owner, 0); + return false; + } catch { + return true; + } + } catch { + return true; + } +} -async function withAuthWriteLock(fn: () => Promise): Promise { +async function withAuthWriteLock(lockPath: string, fn: () => Promise): Promise { const started = Date.now(); - while (existsSync(AUTH_LOCK_PATH)) { - if (Date.now() - started > 60_000) break; + while (existsSync(lockPath)) { + if (isStaleAuthLock(lockPath)) { + try { + unlinkSync(lockPath); + } catch { + /* ignore */ + } + break; + } + if (Date.now() - started > 60_000) { + logger.step(`登录态锁等待超时,尝试清理 stale lock: ${lockPath}`); + try { + unlinkSync(lockPath); + } catch { + /* ignore */ + } + break; + } await new Promise((resolve) => setTimeout(resolve, 200)); } - mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); - writeFileSync(AUTH_LOCK_PATH, String(process.pid)); + mkdirSync(path.dirname(lockPath), { recursive: true }); + writeFileSync(lockPath, String(process.pid)); try { return await fn(); } finally { try { - unlinkSync(AUTH_LOCK_PATH); + unlinkSync(lockPath); } catch { /* ignore */ } } } -async function loginAndSaveStorage(browser: Browser): Promise { - if (!hasCredentials()) { - throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); +async function loginAndSaveStorage(browser: Browser, slot: AuthAccountSlot): Promise { + const storageStatePath = storageStatePathForSlot(slot); + const creds = credentialsForSlot(slot); + if (!creds.username || !creds.password) { + throw new Error( + slot === 'primary' + ? '未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号' + : '未配置 TEST_USER_2/TEST_PASS_2', + ); } - await withAuthWriteLock(async () => { - const cached = loadStorageState(AUTH_STATE_PATH); + const lockPath = `${storageStatePath}.lock`; + await withAuthWriteLock(lockPath, async () => { + const cached = loadStorageState(storageStatePath); if (cached && isStorageStateTokenFresh(cached) && !shouldForceAuthLogin()) { - logger.step('登录态写入锁等待期间已被其他进程刷新,跳过重复登录'); + logger.step(`登录态写入锁等待期间已被其他进程刷新(${slot}),跳过重复登录`); return; } - mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); + mkdirSync(path.dirname(storageStatePath), { recursive: true }); const context = await browser.newContext({ baseURL: env.baseURL, storageState: { cookies: [], origins: [] }, @@ -70,53 +113,66 @@ async function loginAndSaveStorage(browser: Browser): Promise { const page = await context.newPage(); try { const loginPage = new LoginPage(page); - await loginPage.login(credentials.username, credentials.password); - await context.storageState({ path: AUTH_STATE_PATH }); - logger.step(`已写入登录态: ${AUTH_STATE_PATH}`); + await loginPage.login(creds.username, creds.password); + await context.storageState({ path: storageStatePath }); + logger.step(`已写入登录态 (${slot}): ${storageStatePath}`); } finally { await context.close(); } }); } +export type EnsureAuthOptions = { + honorForce?: boolean; + slot?: AuthAccountSlot; +}; + /** * 会话级登录态:文件有效则复用;过期/被踢则重新登录。 * FORCE_AUTH_LOGIN=1 只应由 auth.setup 传入 honorForce,避免每个 describe 再登一次。 */ export async function ensureAuthStorageFresh( browser: Browser, - options?: { honorForce?: boolean }, + options?: EnsureAuthOptions, ): Promise { - mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); - - if (!hasCredentials()) { + const slot = options?.slot ?? 'primary'; + if (slot === 'secondary' && !hasSecondaryCredentials()) { + throw new Error('未配置 TEST_USER_2/TEST_PASS_2'); + } + if (slot === 'primary' && !hasCredentials()) { throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); } + const storageStatePath = storageStatePathForSlot(slot); + mkdirSync(path.dirname(storageStatePath), { recursive: true }); + const forceLogin = options?.honorForce !== false && shouldForceAuthLogin(); - const cachedState = loadStorageState(AUTH_STATE_PATH); + const cachedState = loadStorageState(storageStatePath); if (!forceLogin && cachedState && isStorageStateTokenValid(cachedState)) { if (isStorageStateTokenFresh(cachedState)) { - logger.step(`复用新鲜登录态(跳过探活): ${AUTH_STATE_PATH}`); + logger.step(`复用新鲜登录态(跳过探活)(${slot}): ${storageStatePath}`); return 'reused-fresh'; } const sessionAlive = await probeAuthenticatedSession(browser, { baseURL: env.baseURL, homePath: env.homePath, - storageStatePath: AUTH_STATE_PATH, + storageStatePath, }); if (sessionAlive) { - logger.step(`复用有效登录态(token 未过期): ${AUTH_STATE_PATH}`); + logger.step(`复用有效登录态(token 未过期)(${slot}): ${storageStatePath}`); return 'reused-probed'; } - logger.step('本地 token 存在但服务端会话已失效,重新登录'); + logger.step(`本地 token 存在但服务端会话已失效(${slot}),重新登录`); } else if (forceLogin) { - logger.step('FORCE_AUTH_LOGIN=1,强制重新登录'); + logger.step(`FORCE_AUTH_LOGIN=1,强制重新登录(${slot})`); } else { - logger.step('无有效登录态缓存,执行登录'); + logger.step(`无有效登录态缓存(${slot}),执行登录`); } - await loginAndSaveStorage(browser); + await loginAndSaveStorage(browser, slot); return 'logged-in'; } + +/** 兼容旧引用:主账号 storage 路径 */ +export { AUTH_STATE_PATH }; diff --git a/utils/path.util.ts b/utils/path.util.ts index 25ddcda7f4d92031140854a354d56f04d7de670d..42d666a94c318d1a4a26000becb22cb0e5c7465b 100644 --- a/utils/path.util.ts +++ b/utils/path.util.ts @@ -1,3 +1,6 @@ import path from 'node:path'; +/** 主账号(TEST_USER)登录态 */ export const AUTH_STATE_PATH = path.join(process.cwd(), 'auth', 'user.json'); +/** 辅账号(TEST_USER_2,默认 playwright)登录态 */ +export const AUTH_STATE_PATH_SECONDARY = path.join(process.cwd(), 'auth', 'user-playwright.json');