Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

554 рядки
30 KiB

  1. import type { APIRequestContext, TestInfo } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  3. import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin, visibleDialog } from './helpers'
  4. test.describe.configure({ mode: 'serial' })
  5. test.use({ trace: 'off', video: 'off' })
  6. type JsonRecord = Record<string, unknown>
  7. type Headers = Record<string, string>
  8. interface Activity {
  9. module: string
  10. action: string
  11. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  12. httpStatus?: number
  13. resourceId?: string
  14. detail?: string
  15. }
  16. interface ResponseLike {
  17. status(): number
  18. json(): Promise<unknown>
  19. }
  20. interface DictionaryItem {
  21. id?: string
  22. kind: 'term' | 'homophone'
  23. term?: string
  24. wrongText?: string
  25. rightText?: string
  26. enabled: boolean
  27. builtIn?: boolean
  28. }
  29. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  30. ? value as JsonRecord
  31. : {}
  32. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  33. async function responseData(response: ResponseLike, expected: number | number[], label: string) {
  34. const statuses = Array.isArray(expected) ? expected : [expected]
  35. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  36. return asRecord(envelopeData(await response.json()))
  37. }
  38. function addPass(
  39. activities: Activity[],
  40. module: string,
  41. action: string,
  42. response?: ResponseLike,
  43. resourceId?: string,
  44. detail?: string,
  45. ) {
  46. activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail })
  47. }
  48. async function attachLifecycleReport(
  49. testInfo: TestInfo,
  50. title: string,
  51. activities: Activity[],
  52. cleanupErrors: string[],
  53. ) {
  54. await attachJson(testInfo, `${title}-页面生命周期与清理`, {
  55. runId,
  56. runPrefix,
  57. title,
  58. activities,
  59. cleanupComplete: cleanupErrors.length === 0,
  60. cleanupErrors,
  61. security: '报告未记录管理员密码、访问令牌或外部服务密钥。',
  62. })
  63. }
  64. async function waitForDocumentReady(
  65. request: APIRequestContext,
  66. headers: Headers,
  67. documentId: string,
  68. timeoutMs = 60_000,
  69. ) {
  70. const deadline = Date.now() + timeoutMs
  71. let latest: JsonRecord = {}
  72. while (Date.now() < deadline) {
  73. const response = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers })
  74. latest = await responseData(response, 200, '等待知识资料建立索引')
  75. const status = String(latest.status ?? '').toLowerCase()
  76. if (status === 'ready') return latest
  77. if (status === 'failed') throw new Error(`知识资料建立索引失败:${String(latest.errorMessage ?? '未返回原因')}`)
  78. await new Promise((resolve) => setTimeout(resolve, 500))
  79. }
  80. throw new Error(`知识资料在 ${timeoutMs}ms 内未进入 ready,最后状态:${String(latest.status ?? 'unknown')}`)
  81. }
  82. async function cleanupKnowledgeByPrefix(
  83. request: APIRequestContext,
  84. headers: Headers,
  85. activities: Activity[],
  86. cleanupErrors: string[],
  87. ) {
  88. try {
  89. const listed = await request.get('/api/v1/knowledge/bases', {
  90. headers,
  91. params: { keyword: runPrefix, page: '1', pageSize: '200' },
  92. })
  93. const page = await responseData(listed, 200, '清理前读取知识库')
  94. for (const base of asRecords(page.items)) {
  95. const baseId = String(base.id ?? '')
  96. if (!baseId || !String(base.name ?? '').startsWith(runPrefix)) continue
  97. const documentsResponse = await request.get('/api/v1/knowledge/documents', {
  98. headers,
  99. params: { knowledgeBaseId: baseId, page: '1', pageSize: '200' },
  100. })
  101. if (documentsResponse.status() === 200) {
  102. const documents = asRecord(envelopeData(await documentsResponse.json()))
  103. for (const item of asRecords(documents.items)) {
  104. const documentId = String(item.id ?? '')
  105. if (!documentId) continue
  106. const detailResponse = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers })
  107. if (detailResponse.status() !== 200) continue
  108. const detail = asRecord(envelopeData(await detailResponse.json()))
  109. const removed = await request.delete(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, {
  110. headers,
  111. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  112. })
  113. if (![200, 404].includes(removed.status())) throw new Error(`资料 ${documentId} 删除失败:HTTP ${removed.status()}`)
  114. activities.push({ module: '维修知识', action: '资料失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: documentId })
  115. }
  116. }
  117. const currentList = await request.get('/api/v1/knowledge/bases', {
  118. headers,
  119. params: { keyword: String(base.name ?? runPrefix), page: '1', pageSize: '200' },
  120. })
  121. const currentPage = asRecord(envelopeData(await currentList.json()))
  122. const current = asRecords(currentPage.items).find((item) => String(item.id) === baseId)
  123. if (!current) continue
  124. const removedBase = await request.delete(`/api/v1/knowledge/bases/${encodeURIComponent(baseId)}`, {
  125. headers,
  126. params: { dataVersion: String(Number(current.dataVersion ?? 0)) },
  127. })
  128. if (![200, 404].includes(removedBase.status())) throw new Error(`知识库 ${baseId} 删除失败:HTTP ${removedBase.status()}`)
  129. activities.push({ module: '维修知识', action: '知识库失败兜底清理', result: 'CLEANED', httpStatus: removedBase.status(), resourceId: baseId })
  130. }
  131. } catch (error) {
  132. const message = `知识库兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  133. cleanupErrors.push(message)
  134. activities.push({ module: '维修知识', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  135. }
  136. }
  137. const dictionaryItem = (value: JsonRecord): DictionaryItem => ({
  138. id: String(value.id ?? '') || undefined,
  139. kind: String(value.kind) === 'homophone' ? 'homophone' : 'term',
  140. term: value.term == null ? undefined : String(value.term),
  141. wrongText: value.wrongText == null ? undefined : String(value.wrongText),
  142. rightText: value.rightText == null ? undefined : String(value.rightText),
  143. enabled: value.enabled !== false,
  144. builtIn: value.builtIn == null ? undefined : Boolean(value.builtIn),
  145. })
  146. const dictionaryKey = (item: DictionaryItem) => item.kind === 'term'
  147. ? `term:${item.term ?? ''}`
  148. : `homophone:${item.wrongText ?? ''}=>${item.rightText ?? ''}`
  149. const dictionarySummary = (items: DictionaryItem[]) => items
  150. .map((item) => ({
  151. kind: item.kind,
  152. term: item.term ?? '',
  153. wrongText: item.wrongText ?? '',
  154. rightText: item.rightText ?? '',
  155. enabled: item.enabled,
  156. builtIn: Boolean(item.builtIn),
  157. }))
  158. .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))
  159. async function getDictionary(request: APIRequestContext, headers: Headers) {
  160. const response = await request.get('/api/v1/asr-dictionaries', { headers })
  161. const payload = await responseData(response, 200, '读取 ASR 词典')
  162. return {
  163. items: asRecords(payload.items).map(dictionaryItem),
  164. dataVersion: Number(payload.dataVersion ?? 0),
  165. }
  166. }
  167. async function saveDictionarySnapshot(
  168. request: APIRequestContext,
  169. headers: Headers,
  170. desired: DictionaryItem[],
  171. current: { items: DictionaryItem[]; dataVersion: number },
  172. ) {
  173. const ids = new Map(current.items.map((item) => [dictionaryKey(item), item.id]))
  174. return request.put('/api/v1/asr-dictionaries', {
  175. headers,
  176. data: {
  177. items: desired.map((item) => ({
  178. ...(ids.get(dictionaryKey(item)) ? { id: ids.get(dictionaryKey(item)) } : {}),
  179. kind: item.kind,
  180. ...(item.kind === 'term'
  181. ? { term: item.term }
  182. : { wrongText: item.wrongText, rightText: item.rightText }),
  183. enabled: item.enabled,
  184. builtIn: item.builtIn,
  185. })),
  186. dataVersion: current.dataVersion,
  187. },
  188. })
  189. }
  190. async function restoreDictionary(
  191. request: APIRequestContext,
  192. headers: Headers,
  193. snapshot: DictionaryItem[],
  194. ) {
  195. let current = await getDictionary(request, headers)
  196. let response = await saveDictionarySnapshot(request, headers, snapshot, current)
  197. await responseData(response, 200, '恢复 ASR 词典原始内容')
  198. // 页面保存会重新生成词典行。若原快照含内置标记,先让服务恢复标记,再按快照做最后一次精确裁剪。
  199. if (snapshot.some((item) => item.builtIn)) {
  200. response = await request.post('/api/v1/asr-dictionaries/restore-built-in', { headers, data: {} })
  201. await responseData(response, 200, '恢复 ASR 内置词条标记')
  202. current = await getDictionary(request, headers)
  203. response = await saveDictionarySnapshot(request, headers, snapshot, current)
  204. await responseData(response, 200, '按原快照精确裁剪 ASR 词典')
  205. }
  206. return response
  207. }
  208. async function cleanupCapabilityByPrefix(
  209. request: APIRequestContext,
  210. headers: Headers,
  211. activities: Activity[],
  212. cleanupErrors: string[],
  213. ) {
  214. try {
  215. const response = await request.get('/api/v1/capabilities', {
  216. headers,
  217. params: { capabilityType: 'ASR', keyword: runPrefix, page: '1', pageSize: '200' },
  218. })
  219. const page = await responseData(response, 200, '清理前读取 ASR 能力')
  220. for (const item of asRecords(page.items ?? page.records)) {
  221. const id = String(item.id ?? '')
  222. if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue
  223. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers })
  224. if (detailResponse.status() === 404) continue
  225. const detail = await responseData(detailResponse, 200, '清理前刷新 ASR 能力版本')
  226. const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, {
  227. headers,
  228. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  229. })
  230. if (![200, 404].includes(removed.status())) throw new Error(`ASR 能力 ${id} 删除失败:HTTP ${removed.status()}`)
  231. activities.push({ module: 'ASR模型', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  232. }
  233. } catch (error) {
  234. const message = `ASR 能力兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  235. cleanupErrors.push(message)
  236. activities.push({ module: 'ASR模型', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  237. }
  238. }
  239. test('维修知识页面:个人库、资料重建、启停、问答引用、下载与删除完整闭环', async ({ page, request }, testInfo) => {
  240. test.setTimeout(150_000)
  241. const headers = await authHeaders(request)
  242. const activities: Activity[] = []
  243. const cleanupErrors: string[] = []
  244. const baseName = `${runPrefix}-液压安全知识库`.slice(0, 80)
  245. const documentName = `${runPrefix}-液压安全规程.txt`.slice(0, 150)
  246. const editedDocumentName = `${runPrefix}-液压安全规程-已重建.txt`.slice(0, 150)
  247. const uniqueRule = `${runPrefix}液压安全口令:拆卸测试液压管路前必须先停机、断电、验电、完全卸压、挂牌上锁,并确认压力表归零。`
  248. let baseId = ''
  249. let documentId = ''
  250. let primaryError: unknown
  251. await cleanupKnowledgeByPrefix(request, headers, activities, cleanupErrors)
  252. cleanupErrors.length = 0
  253. activities.length = 0
  254. try {
  255. await loginAsAdmin(page)
  256. await page.goto('/knowledge/library')
  257. await page.getByRole('tab', { name: '用户自建' }).click()
  258. await expect(page.getByRole('button', { name: /新建知识库/ })).toBeVisible()
  259. await page.getByRole('button', { name: /新建知识库/ }).click()
  260. const createDialog = visibleDialog(page)
  261. await createDialog.locator('label').filter({ hasText: '知识库名称' }).locator('input').fill(baseName)
  262. await createDialog.locator('label').filter({ hasText: '知识库说明' }).locator('textarea').fill(`${runPrefix} 页面提交与持久化验证`)
  263. await createDialog.locator('label').filter({ hasText: '专业分类' }).locator('select').selectOption({ label: '液压气动' })
  264. await createDialog.locator('label').filter({ hasText: '标签' }).locator('input').fill('液压,安全,APE2E')
  265. const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/knowledge\/bases(?:\?|$)/.test(response.url()))
  266. await createDialog.getByRole('button', { name: '创建并进入' }).click()
  267. const createResponse = await createResponsePromise
  268. const createdBase = await responseData(createResponse, 201, '页面创建个人知识库')
  269. baseId = String(createdBase.id ?? '')
  270. expect(baseId).not.toBe('')
  271. await expect(page.getByRole('heading', { name: baseName })).toBeVisible()
  272. addPass(activities, '维修知识', '页面创建个人知识库并进入详情', createResponse, baseId)
  273. await captureScreenshot(page, testInfo, '01-personal-knowledge-base-created')
  274. const uploadResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/knowledge\/documents(?:\?|$)/.test(response.url()))
  275. await page.locator('input[type="file"]').setInputFiles({
  276. name: documentName,
  277. mimeType: 'text/plain',
  278. buffer: Buffer.from(`${uniqueRule}\n复核要求:作业完成后空载试运行并记录压力、温升和异常声响。`, 'utf8'),
  279. })
  280. const uploadResponse = await uploadResponsePromise
  281. const uploadPayload = await responseData(uploadResponse, 201, '页面上传知识资料')
  282. documentId = String(asRecord(uploadPayload.document).id ?? '')
  283. expect(documentId).not.toBe('')
  284. await waitForDocumentReady(request, headers, documentId)
  285. await page.reload()
  286. const uploadedRow = page.locator('.knowledge-document-list article').filter({ hasText: documentName })
  287. await expect(uploadedRow).toContainText(/个片段/)
  288. addPass(activities, '维修知识', 'TXT 上传、后台索引并刷新为可用', uploadResponse, documentId)
  289. await captureScreenshot(page, testInfo, '02-document-uploaded-ready')
  290. await uploadedRow.getByRole('button', { name: '编辑资料' }).click()
  291. let editor = visibleDialog(page)
  292. await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0)
  293. await editor.locator('label').filter({ hasText: '资料名称' }).locator('input').fill(editedDocumentName)
  294. await editor.locator('label').filter({ hasText: '资料状态' }).locator('select').selectOption({ label: '禁用' })
  295. await editor.locator('label').filter({ hasText: '参考资料正文' }).locator('textarea').fill(`${uniqueRule}\n编辑确认:禁用资料不参与问答,重新启用后使用最新正文。`)
  296. let updateResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`))
  297. await editor.getByRole('button', { name: '保存并重建索引' }).click()
  298. let updateResponse = await updateResponsePromise
  299. await responseData(updateResponse, 200, '页面编辑并禁用知识资料')
  300. await waitForDocumentReady(request, headers, documentId)
  301. await page.reload()
  302. let documentRow = page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName })
  303. await expect(documentRow).toContainText('已禁用')
  304. addPass(activities, '维修知识', '编辑正文、重建索引并禁用', updateResponse, documentId)
  305. await captureScreenshot(page, testInfo, '03-document-edited-disabled')
  306. await documentRow.getByRole('button', { name: '编辑资料' }).click()
  307. editor = visibleDialog(page)
  308. await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0)
  309. await editor.locator('label').filter({ hasText: '资料状态' }).locator('select').selectOption({ label: '启用并参与问答' })
  310. updateResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`))
  311. await editor.getByRole('button', { name: '保存并重建索引' }).click()
  312. updateResponse = await updateResponsePromise
  313. await responseData(updateResponse, 200, '页面重新启用知识资料')
  314. await waitForDocumentReady(request, headers, documentId)
  315. await page.reload()
  316. documentRow = page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName })
  317. await expect(documentRow).toContainText(/个片段/)
  318. await expect(documentRow).not.toContainText('已禁用')
  319. addPass(activities, '维修知识', '重新启用并刷新验证持久化', updateResponse, documentId)
  320. await captureScreenshot(page, testInfo, '04-document-enabled-persisted')
  321. const question = page.getByLabel('知识库问题')
  322. await question.fill(`${runPrefix}液压安全口令要求完成哪些步骤?`)
  323. const answerResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/api/v1/knowledge/answer'))
  324. await page.getByRole('button', { name: '生成有依据回答' }).click()
  325. const answerResponse = await answerResponsePromise
  326. const answer = await responseData(answerResponse, 200, '页面知识问答')
  327. expect(String(answer.answer ?? '')).toContain(runPrefix)
  328. const citations = page.locator('.knowledge-citations button:not(:disabled)')
  329. await expect(page.locator('.knowledge-answer')).toBeVisible()
  330. await expect(citations.first()).toBeVisible()
  331. addPass(activities, '维修知识', '真实问答命中最新正文并显示引用', answerResponse, documentId, `引用 ${asRecords(answer.results).length} 条`)
  332. await captureScreenshot(page, testInfo, '05-answer-with-citation')
  333. const downloadPromise = page.waitForEvent('download')
  334. await citations.first().click()
  335. const download = await downloadPromise
  336. expect(download.suggestedFilename()).toContain('.txt')
  337. addPass(activities, '维修知识', '从引用下载受保护原文', undefined, documentId)
  338. await captureScreenshot(page, testInfo, '06-citation-download-complete')
  339. await documentRow.getByRole('button', { name: '编辑资料' }).click()
  340. editor = visibleDialog(page)
  341. await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0)
  342. await editor.getByRole('button', { name: '删除资料' }).click()
  343. const deleteDocumentResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`))
  344. await confirmMessageBox(page, '删除')
  345. const deleteDocumentResponse = await deleteDocumentResponsePromise
  346. await responseData(deleteDocumentResponse, 200, '页面删除知识资料')
  347. const deletedDocumentId = documentId
  348. documentId = ''
  349. await expect(page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName })).toHaveCount(0)
  350. addPass(activities, '维修知识', '页面删除知识资料', deleteDocumentResponse, deletedDocumentId)
  351. await captureScreenshot(page, testInfo, '07-document-deleted')
  352. await page.getByRole('button', { name: /删除知识库/ }).click()
  353. const deleteBaseResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/knowledge/bases/${baseId}`))
  354. await confirmMessageBox(page, '确认删除')
  355. const deleteBaseResponse = await deleteBaseResponsePromise
  356. await responseData(deleteBaseResponse, 200, '页面删除个人知识库')
  357. const deletedBaseId = baseId
  358. baseId = ''
  359. await expect(page).toHaveURL(/\/knowledge\/library(?:\?|$)/)
  360. await page.getByRole('tab', { name: '用户自建' }).click()
  361. await expect(page.locator('.knowledge-base-card').filter({ hasText: baseName })).toHaveCount(0)
  362. addPass(activities, '维修知识', '页面删除个人知识库并从列表消失', deleteBaseResponse, deletedBaseId)
  363. await captureScreenshot(page, testInfo, '08-knowledge-base-deleted')
  364. } catch (error) {
  365. primaryError = error
  366. activities.push({ module: '维修知识', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  367. } finally {
  368. if (baseId || documentId) await cleanupKnowledgeByPrefix(request, headers, activities, cleanupErrors)
  369. await attachLifecycleReport(testInfo, '维修知识UI', activities, cleanupErrors)
  370. }
  371. if (primaryError) throw primaryError
  372. expect(cleanupErrors, '知识库测试资源必须清理干净').toEqual([])
  373. })
  374. test('ASR能力页面:词典保存刷新恢复与模型创建、编辑、启停、筛选、删除', async ({ page, request }, testInfo) => {
  375. test.setTimeout(120_000)
  376. const headers = await authHeaders(request)
  377. const activities: Activity[] = []
  378. const cleanupErrors: string[] = []
  379. const capabilityName = `${runPrefix}-本地维修ASR`.slice(0, 90)
  380. const testTerm = `${runPrefix}支腿阀组`.slice(0, 180)
  381. const wrongText = `${runPrefix}之腿阀组`.slice(0, 180)
  382. const rightText = `${runPrefix}支腿阀组`.slice(0, 180)
  383. const dictionarySnapshot = await getDictionary(request, headers)
  384. let capabilityId = ''
  385. let primaryError: unknown
  386. let dictionaryRestored = false
  387. await cleanupCapabilityByPrefix(request, headers, activities, cleanupErrors)
  388. cleanupErrors.length = 0
  389. activities.length = 0
  390. try {
  391. await loginAsAdmin(page)
  392. await page.goto('/assets/asr-models')
  393. const dictionary = page.locator('.asr-dictionary')
  394. await dictionary.locator('summary').click()
  395. await expect(dictionary.getByRole('button', { name: '保存词典' })).toBeEnabled()
  396. const termInput = dictionary.locator('label').filter({ hasText: '专有名词' }).locator('textarea')
  397. const homophoneInput = dictionary.locator('label').filter({ hasText: '同音词纠正' }).locator('textarea')
  398. const originalTerms = await termInput.inputValue()
  399. const originalHomophones = await homophoneInput.inputValue()
  400. await termInput.fill([originalTerms, testTerm].filter(Boolean).join('\n'))
  401. await homophoneInput.fill([originalHomophones, `${wrongText}=${rightText}`].filter(Boolean).join('\n'))
  402. const dictionarySaveResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && /\/api\/v1\/asr-dictionaries(?:\?|$)/.test(response.url()))
  403. await dictionary.getByRole('button', { name: '保存词典' }).click()
  404. const dictionarySaveResponse = await dictionarySaveResponsePromise
  405. await responseData(dictionarySaveResponse, 200, '页面保存 ASR 词典')
  406. await expect(page.getByText(/词典已保存/).last()).toBeVisible()
  407. addPass(activities, 'ASR词典', '页面追加专有名词与同音词', dictionarySaveResponse)
  408. await captureScreenshot(page, testInfo, '09-asr-dictionary-saved')
  409. await page.reload()
  410. await dictionary.locator('summary').click()
  411. await expect(termInput).toHaveValue(new RegExp(testTerm))
  412. await expect(homophoneInput).toHaveValue(new RegExp(wrongText))
  413. const persistedDictionary = await getDictionary(request, headers)
  414. expect(persistedDictionary.items.some((item) => item.kind === 'term' && item.term === testTerm)).toBeTruthy()
  415. expect(persistedDictionary.items.some((item) => item.kind === 'homophone' && item.wrongText === wrongText && item.rightText === rightText)).toBeTruthy()
  416. addPass(activities, 'ASR词典', '页面刷新与 API 均保留词典改动')
  417. await captureScreenshot(page, testInfo, '10-asr-dictionary-refresh-persisted')
  418. await page.getByRole('button', { name: /新增ASR模型/ }).click()
  419. let dialog = visibleDialog(page)
  420. await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill(capabilityName)
  421. await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(`${runPrefix} 页面创建的本地识别能力`)
  422. await dialog.locator('label.file-field').filter({ hasText: '识别热词' }).locator('textarea').fill(`${runPrefix}液压缸\n${runPrefix}连接销`)
  423. await dialog.locator('.capability-form-technical summary').click()
  424. await dialog.locator('.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-local-asr`)
  425. const enabledSwitch = dialog.locator('.switch-field .el-switch')
  426. if ((await enabledSwitch.getAttribute('class'))?.includes('is-checked')) await enabledSwitch.click()
  427. const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/capabilities(?:\?|$)/.test(response.url()))
  428. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  429. const createResponse = await createResponsePromise
  430. const created = await responseData(createResponse, 201, '页面创建 ASR 能力')
  431. capabilityId = String(created.id ?? '')
  432. expect(capabilityId).not.toBe('')
  433. let card = page.locator('.capability-card').filter({ hasText: capabilityName })
  434. await expect(card).toBeVisible()
  435. await expect(card.locator('.status-pill')).toHaveText('已停用')
  436. addPass(activities, 'ASR模型', '页面创建禁用状态的本地 ASR', createResponse, capabilityId)
  437. await captureScreenshot(page, testInfo, '11-asr-model-created-disabled')
  438. await card.getByRole('button', { name: '编辑', exact: true }).click()
  439. dialog = visibleDialog(page)
  440. await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(`${runPrefix} 已编辑、刷新后应保持`)
  441. await dialog.locator('label.file-field').filter({ hasText: '识别热词' }).locator('textarea').fill(`${runPrefix}液压缸\n${runPrefix}连接销\n${runPrefix}蓄能器`)
  442. const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${capabilityId}`) && !response.url().includes('/status'))
  443. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  444. const editResponse = await editResponsePromise
  445. const edited = await responseData(editResponse, 200, '页面编辑 ASR 能力')
  446. expect(asRecords(edited.hotWords).length || (Array.isArray(edited.hotWords) ? edited.hotWords.length : 0)).toBe(3)
  447. addPass(activities, 'ASR模型', '页面编辑说明与识别热词', editResponse, capabilityId)
  448. await page.reload()
  449. const search = page.getByPlaceholder(/搜索ASR模型名称/)
  450. await search.fill(capabilityName)
  451. card = page.locator('.capability-card').filter({ hasText: capabilityName })
  452. await expect(card).toContainText('已编辑、刷新后应保持')
  453. await expect(card).toContainText(`${runPrefix}蓄能器`)
  454. addPass(activities, 'ASR模型', '刷新后编辑内容保持', undefined, capabilityId)
  455. await captureScreenshot(page, testInfo, '12-asr-model-edit-persisted')
  456. const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${capabilityId}/status`))
  457. await card.getByRole('button', { name: '启用', exact: true }).click()
  458. const enableResponse = await enableResponsePromise
  459. await responseData(enableResponse, 200, '页面启用 ASR 能力')
  460. await expect(card.locator('.status-pill')).toHaveText('可用')
  461. addPass(activities, 'ASR模型', '页面启用能力', enableResponse, capabilityId)
  462. await page.reload()
  463. await search.fill(capabilityName)
  464. card = page.locator('.capability-card').filter({ hasText: capabilityName })
  465. await expect(card.locator('.status-pill')).toHaveText('可用')
  466. const statusFilter = page.getByLabel('状态筛选')
  467. await statusFilter.selectOption('enabled')
  468. await expect(card).toBeVisible()
  469. await statusFilter.selectOption('disabled')
  470. await expect(card).toHaveCount(0)
  471. await expect(page.getByText('没有匹配的配置')).toBeVisible()
  472. addPass(activities, 'ASR模型', '刷新验证启用状态并完成状态筛选', undefined, capabilityId)
  473. await captureScreenshot(page, testInfo, '13-asr-status-filter')
  474. await statusFilter.selectOption('all')
  475. card = page.locator('.capability-card').filter({ hasText: capabilityName })
  476. await card.getByRole('button', { name: '删除', exact: true }).click()
  477. const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/capabilities/${capabilityId}`))
  478. await confirmMessageBox(page, /确定/)
  479. const deleteResponse = await deleteResponsePromise
  480. await responseData(deleteResponse, 200, '页面删除 ASR 能力')
  481. const deletedCapabilityId = capabilityId
  482. capabilityId = ''
  483. await expect(page.locator('.capability-card').filter({ hasText: capabilityName })).toHaveCount(0)
  484. const absent = await request.get(`/api/v1/capabilities/${encodeURIComponent(deletedCapabilityId)}`, { headers })
  485. expect(absent.status()).toBe(404)
  486. addPass(activities, 'ASR模型', '页面删除并确认 API 不可读', deleteResponse, deletedCapabilityId)
  487. await captureScreenshot(page, testInfo, '14-asr-model-deleted')
  488. } catch (error) {
  489. primaryError = error
  490. activities.push({ module: 'ASR能力与词典', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  491. } finally {
  492. if (capabilityId) await cleanupCapabilityByPrefix(request, headers, activities, cleanupErrors)
  493. try {
  494. await restoreDictionary(request, headers, dictionarySnapshot.items)
  495. const restored = await getDictionary(request, headers)
  496. expect(dictionarySummary(restored.items)).toEqual(dictionarySummary(dictionarySnapshot.items))
  497. dictionaryRestored = true
  498. activities.push({ module: 'ASR词典', action: '精确恢复运行前语义快照', result: 'CLEANED', detail: `恢复 ${restored.items.length} 条词典数据` })
  499. } catch (error) {
  500. const message = `ASR 词典恢复异常:${error instanceof Error ? error.message : String(error)}`
  501. cleanupErrors.push(message)
  502. activities.push({ module: 'ASR词典', action: '恢复原快照', result: 'CLEANUP_FAILED', detail: message })
  503. }
  504. await attachLifecycleReport(testInfo, 'ASR能力与词典UI', activities, cleanupErrors)
  505. }
  506. if (primaryError) throw primaryError
  507. expect(dictionaryRestored, 'ASR 词典必须恢复为运行前快照').toBeTruthy()
  508. expect(cleanupErrors, 'ASR 测试能力与词典必须清理恢复').toEqual([])
  509. })