From e4fa234df157fb9a53cf26fb53904a4e3b3e4e27 Mon Sep 17 00:00:00 2001 From: Disy Date: Thu, 24 Sep 2026 14:15:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9A=AE=E8=82=A4?= =?UTF-8?q?=E7=AB=99=E7=99=BB=E5=BD=95=E5=90=8E=E4=BE=A7=E6=A0=8F=E7=8E=A9?= =?UTF-8?q?=E5=AE=B6=E9=80=89=E6=8B=A9=E5=99=A8=E6=9C=AA=E7=AB=8B=E5=8D=B3?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侧栏直接合并皮肤站角色与已保存账户并按 UUID 去重,无需先启动实例。 选择角色时才进行认证并保存默认账户,认证失败时保留原选择并允许重试。 移除逐个预认证角色的流程,修复无皮肤玩家的默认头像引用异常。 补充组件回归测试,覆盖首次登录、默认玩家切换、失败重试、退出登录和无皮肤角色。 验证:14 项相关测试及 ESLint 通过,前端生产构建通过。 全量检查仍有既有的设置搜索、升级结果组件测试失败及类型错误。 --- .../src/components/ui/AccountsCard.test.ts | 292 ++++++++++++++++++ .../src/components/ui/AccountsCard.vue | 102 ++++-- .../src/helpers/instance-player.ts | 50 +-- 3 files changed, 365 insertions(+), 79 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/AccountsCard.test.ts diff --git a/apps/app-frontend/src/components/ui/AccountsCard.test.ts b/apps/app-frontend/src/components/ui/AccountsCard.test.ts new file mode 100644 index 0000000..b3e8835 --- /dev/null +++ b/apps/app-frontend/src/components/ui/AccountsCard.test.ts @@ -0,0 +1,292 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module' +import test from 'node:test' + +import { createRenderer, h, nextTick, Suspense } from 'vue' + +import { + receiveSkinSiteMessage, + resetSkinSiteSession, + setSkinSiteFrame, + SKIN_SITE_ORIGIN, +} from '../../composables/skin-site-session.ts' +import { useNetworkStatus } from '../../composables/useNetworkStatus.ts' + +// Exercise the real component's setup and reactive account list. Only platform, +// navigation, and visual dependencies are replaced; auth/session logic stays real. +const require = createRequire(import.meta.url) +const { parse, compileScript } = require('vue/compiler-sfc') +const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href +const stubs: Record = { + '@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`, + '@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`, + '@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`, + '@tauri-apps/api/event': `export const listen=async()=>()=>{}`, + 'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`, + '@/assets/netherstar.png': `export default 'logo'`, + '@/components/ui/MinecraftLoginModal.vue': `export default {}`, + '@/helpers/events': `export const process_listener=async()=>()=>{}`, + '@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`, + '@/helpers/skins': `export const get_available_skins=async()=>[]`, + '@/store/error.js': `export const handleSevereError=()=>{}`, + '@/store/state': `export const useTheming=()=>({homeLayout:'standard'})`, +} +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier in stubs) { + return { + url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`, + shortCircuit: true, + } + } + if (specifier.startsWith('@/')) { + const file = new URL(`../../${specifier.slice(2)}`, import.meta.url) + if (!/\.(ts|js)$/.test(file.pathname)) + file.pathname += specifier === '@/helpers/auth' ? '.js' : '.ts' + return nextResolve(file.href, context) + } + return nextResolve(specifier, context) + }, + load(url, context, nextLoad) { + if (url !== componentUrl) return nextLoad(url, context) + const { descriptor } = parse(readFileSync(new URL(url), 'utf8')) + return { + format: 'module', + source: stripTypeScriptTypes(compileScript(descriptor, { id: 'accounts-card-test' }).content), + shortCircuit: true, + } + }, +}) +const { default: AccountsCard } = await import(componentUrl) +hooks.deregister() + +type TestNode = Record +type TestAccount = { + account_type: 'microsoft' | 'yggdrasil' + profile: { id: string; name: string } + yggdrasil?: { api_root: string; server_name: string; login: string } +} +type CardState = { + accounts: TestAccount[] + defaultUser: string | undefined + selectedAccount: TestAccount | undefined + setAccount: (account: TestAccount) => Promise + getAccountAvatarUrl: (account: TestAccount) => string | undefined +} +const renderer = createRenderer({ + createElement: () => ({}), + createText: () => ({}), + createComment: () => ({}), + insert() {}, + remove() {}, + setText() {}, + setElementText() {}, + patchProp() {}, + parentNode: () => null, + nextSibling: () => null, +}) +const flush = async () => { + await new Promise((resolve) => setImmediate(resolve)) + await nextTick() +} +const firstId = '12345678-1234-1234-1234-123456789abc' +const secondId = '22345678-1234-1234-1234-123456789abc' + +async function fixture(hasOfficial = true) { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + const stored: TestAccount[] = hasOfficial + ? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }] + : [] + let selected: string | undefined = hasOfficial ? 'official' : undefined + let rejectLogin = false + let loginCalls = 0 + const frame = { + postMessage(data: { type: string; requestId: string }) { + if (data.type === 'starlight-pack-token-request') + message({ + type: 'starlight-pack-token-result', + requestId: data.requestId, + token: 'fixture.jwt', + }) + if (data.type === 'starlight-skin-players-request') + message({ + type: 'starlight-skin-players-result', + requestId: data.requestId, + ok: true, + players: [ + { uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' }, + { uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' }, + ], + }) + }, + } as unknown as Window + function message(data: unknown) { + receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame) + } + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } }) + useNetworkStatus().refreshBrowserOffline() + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __TAURI_INTERNALS__: { + async invoke(command: string, args: Record) { + if (command === 'plugin:auth|get_default_user') return selected + if (command === 'plugin:auth|get_users') return stored + if (command === 'plugin:auth|login_skin_site_player') { + loginCalls++ + assert.equal(args.userId, 'owner') + assert.equal(args.token, 'fixture.jwt') + assert.ok(typeof args.playerId === 'string') + if (rejectLogin) throw new Error('Login failed') + stored.push({ + profile: { id: args.playerId, name: args.playerId === firstId ? 'First' : 'Second' }, + account_type: 'yggdrasil', + yggdrasil: { + api_root: `${SKIN_SITE_ORIGIN}/yggdrasil`, + server_name: 'StarLight', + login: 'owner', + }, + }) + return stored[stored.length - 1] + } + if (command === 'plugin:auth|set_default_user') { + assert.ok(typeof args.user === 'string') + assert.ok(stored.some((account) => account.profile.id === args.user)) + selected = args.user + return + } + throw new Error(`Unexpected command: ${command}`) + }, + }, + }, + }) + resetSkinSiteSession() + setSkinSiteFrame(frame) + const node = h({ ...AccountsCard, render: () => null }) + const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) }) + app.mount({}) + await flush() + return { + state: (node.component! as unknown as { setupState: CardState }).setupState, + login() { + message({ + type: 'starlight-skin-session', + status: 'signed-in', + user: { uuid: 'owner', username: 'Owner' }, + }) + }, + signOut() { + message({ type: 'starlight-skin-session', status: 'signed-out' }) + }, + failLogin(value: boolean) { + rejectLogin = value + }, + get selected() { + return selected + }, + get loginCalls() { + return loginCalls + }, + close() { + app.unmount() + resetSkinSiteSession() + setSkinSiteFrame(null) + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') + if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator) + else Reflect.deleteProperty(globalThis, 'navigator') + }, + } +} + +test('sidebar offers every skin-site player before any game authentication or launch', async () => { + const view = await fixture(false) + try { + view.failLogin(true) + view.login() + await flush() + assert.deepEqual( + view.state.accounts.map((account) => account.profile.id), + [firstId, secondId], + ) + assert.equal(view.loginCalls, 0) + assert.equal(view.selected, undefined) + } finally { + view.close() + } +}) + +test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => { + const view = await fixture() + try { + view.login() + await flush() + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === secondId)!, + ) + assert.equal(view.selected, secondId) + assert.equal(view.state.selectedAccount?.profile.id, secondId) + assert.equal(view.state.accounts.length, 3) + assert.equal(view.loginCalls, 1) + } finally { + view.close() + } +}) + +test('failed player authentication preserves the default and allows retry', async () => { + const view = await fixture() + try { + view.login() + await flush() + view.failLogin(true) + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === secondId)!, + ) + assert.equal(view.selected, 'official') + assert.equal(view.state.defaultUser, 'official') + view.failLogin(false) + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === secondId)!, + ) + assert.equal(view.selected, secondId) + } finally { + view.close() + } +}) + +test('signing out removes unregistered choices while keeping saved accounts', async () => { + const view = await fixture() + try { + view.login() + await flush() + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === firstId)!, + ) + view.signOut() + await flush() + assert.deepEqual( + view.state.accounts.map((account) => account.profile.id), + [firstId, 'official'], + ) + assert.equal(view.selected, firstId) + view.login() + await flush() + assert.equal(view.state.accounts.length, 3) + assert.equal(view.loginCalls, 1) + } finally { + view.close() + } +}) + +test('a new player without a skin can render the shared empty avatar', async () => { + const view = await fixture(false) + try { + view.login() + await flush() + assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined) + } finally { + view.close() + } +}) diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 82fe394..0656c99 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -164,6 +164,7 @@