diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index c302f57..95d41ef 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' import { promisify } from 'node:util' +import * as vm from 'node:vm' import * as testBatchContext from '../src/utils/testBatchContext.js' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -778,6 +779,85 @@ function extractNamedFunction(source, functionName) { return source.slice(match.index, bodyEnd + 1) } +function extractScriptSetup(source) { + const match = /]*)?>([\s\S]*?)<\/script>/.exec(source) + assert.ok(match, '缺少 script setup') + return match[1].replace(/^import\s+.*$/gm, '') +} + +function createDeferred() { + let resolve + let reject + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +function createTestBatchPageHarness(pageSource, api = {}) { + const noopRequest = () => Promise.reject(new Error('未配置的测试请求')) + const ref = value => ({ value }) + const context = vm.createContext({ + Array, + Boolean, + Error, + JSON, + Number, + Object, + Promise, + Set, + String, + console, + ref, + reactive: value => value, + computed: getter => ({ get value() { return getter() } }), + getCurrentInstance: () => ({ proxy: { $modal: { confirm: () => Promise.resolve(), msgError: () => {}, msgSuccess: () => {} } } }), + onBeforeUnmount: () => {}, + onMounted: () => {}, + window: { innerWidth: 1280, addEventListener: () => {}, removeEventListener: () => {} }, + useUserStore: () => ({ admin: true }), + useTestBatchStore: () => ({ currentBatchId: undefined, leaveTestMode: () => {}, refreshOptions: () => Promise.resolve() }), + addTestBatch: api.addTestBatch || noopRequest, + assignTestBatchUsers: api.assignTestBatchUsers || noopRequest, + closeTestBatch: api.closeTestBatch || noopRequest, + getTestBatch: api.getTestBatch || noopRequest, + listTestBatches: api.listTestBatches || noopRequest, + previewTestBatchPurge: api.previewTestBatchPurge || noopRequest, + purgeTestBatch: api.purgeTestBatch || noopRequest, + reopenTestBatch: api.reopenTestBatch || noopRequest, + updateTestBatch: api.updateTestBatch || noopRequest + }) + const exposure = ` + globalThis.__testBatchHarness = { + openAssign, + submitAssign, + getList, + hydrateParticipantCounts, + state: { + assignBatch, + assignError, + assignLoading, + assignVisible, + assignedUserIds, + batchList, + detailLoaded, + loadError, + loading + } + } + ` + vm.runInContext(`${extractScriptSetup(pageSource)}\n${exposure}`, context, { filename: 'src/views/system/testBatch/index.vue' }) + return context.__testBatchHarness +} + function extractPromiseCallback(functionSource, methodName, occurrence = 0) { const sanitized = sanitizeSource(functionSource, { allowTemplateInterpolation: true }) const marker = `.${methodName}(` @@ -2543,3 +2623,141 @@ test('测试批次授权、清理和对话框异步状态拒绝错误目标与 assert.match(adoption, /max-height:\s*90vh/) assert.match(adoption, /overflow-y:\s*auto/) }) + +test('测试批次管理页真实授权详情 success、failure 与 finally 不得覆盖新会话', async () => { + const management = await source('src/views/system/testBatch/index.vue') + const detailRequests = [] + const page = createTestBatchPageHarness(management, { + getTestBatch: batchId => { + const request = createDeferred() + detailRequests.push({ batchId, request }) + return request.promise + } + }) + + page.openAssign({ batchId: 1 }) + page.openAssign({ batchId: 2 }) + detailRequests[0].request.resolve({ data: { batchId: 1, batchName: '批次 A', userIds: [101] } }) + await flushPromises() + + assert.equal(page.state.assignBatch.value, null, '过期 success 不得写入新批次详情') + assert.equal(page.state.assignedUserIds.value, '', '过期 success 不得写入新批次用户') + assert.equal(page.state.detailLoaded.value, false, '过期 success 不得解锁保存') + assert.equal(page.state.assignLoading.value, true, '过期 finally 不得结束新会话 loading') + + detailRequests[1].request.resolve({ data: { batchId: 2, batchName: '批次 B', userIds: [202] } }) + await flushPromises() + assert.deepEqual(page.state.assignBatch.value, { batchId: 2, batchName: '批次 B', userIds: [202] }) + assert.equal(page.state.assignedUserIds.value, '202') + assert.equal(page.state.detailLoaded.value, true) + assert.equal(page.state.assignLoading.value, false) + + const failedRequests = [] + const failedPage = createTestBatchPageHarness(management, { + getTestBatch: batchId => { + const request = createDeferred() + failedRequests.push({ batchId, request }) + return request.promise + } + }) + failedPage.openAssign({ batchId: 3 }) + failedPage.openAssign({ batchId: 4 }) + failedRequests[1].request.resolve({ data: { batchId: 4, batchName: '批次 B', userIds: [404] } }) + await flushPromises() + failedRequests[0].request.reject(new Error('A 详情失败')) + await flushPromises() + + assert.deepEqual(failedPage.state.assignBatch.value, { batchId: 4, batchName: '批次 B', userIds: [404] }, '过期 failure 不得清空已加载的新会话详情') + assert.equal(failedPage.state.assignedUserIds.value, '404', '过期 failure 不得改写新批次输入') + assert.equal(failedPage.state.detailLoaded.value, true) + assert.equal(failedPage.state.assignLoading.value, false) + assert.equal(failedPage.state.assignError.value, '', '过期 failure 不得展示旧批次错误') +}) + +test('测试批次管理页当前授权详情失败保持禁用并拒绝空数组提交', async () => { + const management = await source('src/views/system/testBatch/index.vue') + const detailRequest = createDeferred() + const assignments = [] + const page = createTestBatchPageHarness(management, { + getTestBatch: () => detailRequest.promise, + assignTestBatchUsers: (batchId, payload) => { + assignments.push({ batchId, payload }) + return Promise.resolve({ msg: '不应调用' }) + } + }) + + page.openAssign({ batchId: 5 }) + detailRequest.reject(new Error('详情失败')) + await flushPromises() + + assert.equal(page.state.assignBatch.value, null) + assert.equal(page.state.assignedUserIds.value, '') + assert.equal(page.state.detailLoaded.value, false, '详情失败后不得解锁保存') + assert.equal(page.state.assignLoading.value, false) + assert.match(page.state.assignError.value, /详情加载失败/) + page.submitAssign() + assert.deepEqual(assignments, [], '详情未成功加载时不得提交移除全部用户的空数组') +}) + +test('测试批次管理页真实 hydration 拒绝旧版本回写并保留当前失败状态', async () => { + const management = await source('src/views/system/testBatch/index.vue') + const listRequests = [] + const detailRequests = [] + const page = createTestBatchPageHarness(management, { + listTestBatches: () => { + const request = createDeferred() + listRequests.push(request) + return request.promise + }, + getTestBatch: batchId => { + const request = createDeferred() + detailRequests.push({ batchId, request }) + return request.promise + } + }) + + page.getList() + listRequests[0].resolve({ rows: [{ batchId: 7, batchName: '旧列表' }], total: 1 }) + await flushPromises() + page.getList() + listRequests[1].resolve({ rows: [{ batchId: 7, batchName: '新列表' }], total: 1 }) + await flushPromises() + detailRequests[0].request.resolve({ data: { batchId: 7, userIds: [1, 2] } }) + await flushPromises() + + assert.equal(page.state.batchList.value[0].batchName, '新列表') + assert.equal(page.state.batchList.value[0].participantState, 'loading', '旧 success 不得写入新列表同 ID 行') + assert.equal(page.state.batchList.value[0].participantCount, undefined) + + const failedListRequests = [] + const failedDetailRequests = [] + const failedPage = createTestBatchPageHarness(management, { + listTestBatches: () => { + const request = createDeferred() + failedListRequests.push(request) + return request.promise + }, + getTestBatch: batchId => { + const request = createDeferred() + failedDetailRequests.push({ batchId, request }) + return request.promise + } + }) + + failedPage.getList() + failedListRequests[0].resolve({ rows: [{ batchId: 8, batchName: '旧列表' }], total: 1 }) + await flushPromises() + failedPage.getList() + failedListRequests[1].resolve({ rows: [{ batchId: 8, batchName: '新列表' }], total: 1 }) + await flushPromises() + failedDetailRequests[0].request.reject(new Error('旧详情失败')) + await flushPromises() + + assert.equal(failedPage.state.batchList.value[0].participantState, 'loading', '旧 failure 不得把新列表同 ID 行标为失败') + failedDetailRequests[1].request.reject(new Error('当前详情失败')) + await flushPromises() + assert.equal(failedPage.state.batchList.value.length, 1, '当前详情失败不得清空整个列表') + assert.equal(failedPage.state.batchList.value[0].participantState, 'error') + assert.equal(failedPage.state.batchList.value[0].participantCount, undefined) + assert.equal(failedPage.state.loadError.value, '', '当前详情失败必须是单项状态,不得升级为列表失败') +})