From ba7c4a9bbf6412216142609838c57c450f4adc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:36 +0800 Subject: [PATCH] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=AF=B9=E9=BD=90=E7=B1=BB=E5=88=AB=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E4=B8=8E=E7=B1=BB=E5=9E=8B=E5=8C=96reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 载体用例:authNetworkFailure 与 authResponseInvalid 改为内层 reason switch,覆盖全部 reason 与 default expectNever - 网关、上报、页面用例改用 authNetworkFailure 等新变体名与类型化 reason 载荷 - 新增可枚举细分不读服务端文案的分流断言 --- .../tests/appSurface/auth.suite.ts | 8 +- .../tests/authFailureReporting.test.tsx | 2 +- .../tests/clientAuthErrorCarrier.test.ts | 120 +++++++++++++----- .../tests/clientAuthHost.test.ts | 2 +- .../tests/errorReporting.test.ts | 2 +- 5 files changed, 95 insertions(+), 39 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 37dec4a95..0fad9ed16 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -288,7 +288,7 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw { type: 'authNetworkUnreachable' }; + throw { type: 'authNetworkFailure', reason: 'unreachable' }; } return null; }); @@ -300,7 +300,8 @@ export function registerAuthTests() { // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ - type: 'authNetworkUnreachable', + type: 'authNetworkFailure', + reason: 'unreachable', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); @@ -318,7 +319,8 @@ export function registerAuthTests() { reads += 1; return reads === 1 ? Promise.reject({ - type: 'serverAddressNotHttps', + type: 'serverAddressRejected', + reason: 'notHttps', }) : { status: 'unauthenticated' }; } diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 338a5ff09..c83a3a5fa 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -136,7 +136,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnreachable' }; + throw { type: 'authNetworkFailure', reason: 'unreachable' }; } return null; }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index 156c72079..ac048cd8f 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -5,13 +5,15 @@ * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 * `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 - * `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。 + * `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason, + * `expectNever(...)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; import { loginClientWithPassword } from '../src/services/clientAuth'; import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; -import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected'; +import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure'; +import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; @@ -19,29 +21,39 @@ import type { PasswordLoginRejected } from '../src/services/generated/PasswordLo import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; +import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; function expectNever(value: never): void { void value; } -/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */ +/** 编译期用例:顶层变体逐个列全;带载荷的分支先 `as`,reason 再按类型化枚举分流。 */ function variantMessage(error: ClientAuthError): string { switch (error.type) { - case 'serverAddressEmptyOrTooLong': - return '不能为空或超长'; - case 'serverAddressNotAUrl': - return '不是合法地址'; - case 'serverAddressHasCredentials': - return '不能包含凭据'; - case 'serverAddressHasPathOrQueryOrFragment': - return '不能带路径、查询或 fragment'; - case 'serverAddressNotHttps': - return '远程地址不是 https'; - case 'serverAddressUnsupportedScheme': - return '不是 http(s) 地址'; - case 'serverAddressOutsideChannel': - return '不在当前构建渠道范围内'; + case 'serverAddressRejected': { + const payload = error as ServerAddressRejected; + switch (payload.reason) { + case 'emptyOrTooLong': + return '不能为空或超长'; + case 'notAUrl': + return '不是合法地址'; + case 'hasCredentials': + return '不能包含凭据'; + case 'hasPathOrQueryOrFragment': + return '不能带路径、查询或 fragment'; + case 'notHttps': + return '远程地址不是 https'; + case 'unsupportedScheme': + return '不是 http(s) 地址'; + case 'outsideChannel': + return '不在当前构建渠道范围内'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'phoneNumberInvalid': return '手机号格式不正确'; case 'passwordMissing': @@ -66,26 +78,42 @@ function variantMessage(error: ClientAuthError): string { return '登录态失效'; case 'permissionDenied': return '权限不足'; - case 'authNetworkTimeout': - return '连接超时'; - case 'authNetworkUnreachable': - return '连接失败'; + case 'authNetworkFailure': { + const payload = error as AuthNetworkFailure; + switch (payload.reason) { + case 'timeout': + return '连接超时'; + case 'unreachable': + return '连接失败'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'authServiceUnavailable': return `HTTP ${(error as AuthServiceUnavailable).status}`; case 'unexpectedRejection': return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝'; - case 'authResponseNotJson': - return '响应不是 JSON'; - case 'authResponseInvalidBody': - return '响应结构不符'; - case 'authResponseMissingRefreshCookie': - return '缺续期凭据'; - case 'authResponseMissingUserIdentity': - return '缺用户主体'; - case 'authResponseServerRejected': - return ( - (error as AuthResponseServerRejected).serverMessage ?? '服务端显式拒绝' - ); + case 'authResponseInvalid': { + const payload = error as AuthResponseInvalid; + switch (payload.reason) { + case 'notJson': + return '响应不是 JSON'; + case 'invalidBody': + return '响应结构不符'; + case 'missingRefreshCookie': + return '缺续期凭据'; + case 'missingUserIdentity': + return '缺用户主体'; + case 'serverRejected': + return payload.serverMessage ?? '服务端显式拒绝'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'clientSessionPersistFailed': return (error as ClientSessionPersistFailed).detail; case 'runtimeSessionInstallFailed': @@ -170,4 +198,30 @@ describe('变体分流', () => { variantMessage({ type: 'authServiceUnavailable', status: 503 }), ).toBe('HTTP 503'); }); + + it('可枚举细分按类型化 reason 分流,不读服务端文案', () => { + expect( + variantMessage({ + type: 'serverAddressRejected', + reason: 'notHttps', + }), + ).toBe('远程地址不是 https'); + expect( + variantMessage({ type: 'authNetworkFailure', reason: 'timeout' }), + ).toBe('连接超时'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'serverRejected', + serverMessage: '登录服务请求失败', + }), + ).toBe('登录服务请求失败'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'missingRefreshCookie', + serverMessage: null, + }), + ).toBe('缺续期凭据'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 660f74f4f..6c6393676 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -159,7 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { }); test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { - const rejection = { type: 'authNetworkUnreachable' }; + const rejection = { type: 'authNetworkFailure', reason: 'unreachable' }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ error: rejection, diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 1265153f0..26f83c1bd 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -210,7 +210,7 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { - const original = { type: 'authNetworkUnreachable' }; + const original = { type: 'authNetworkFailure', reason: 'unreachable' }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection',