Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/pages/GamepassForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ async function refresh(): Promise<void> {

async function goBack(): Promise<void> {
disposed = true
await router.push({ path: '/login', query: { pick: '1' } })
// "返回一般登入" — go back to the regular id-pass form within the
// same saved region, NOT to the region picker. Pushing
// `/login?pick=1` would force the user to re-pick TW/HK, which the
// button label does not promise. Mirrors WPF's
// `LoginMethod = Regular` + `loginMethodChanged()` flow.
await router.push('/login/id-pass')
}
</script>

Expand Down
12 changes: 1 addition & 11 deletions src/pages/LoginRegionSelection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* picker without triggering the redirect again.
*/

import { computed, onMounted, watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useConfigStore } from '../stores/config'
Expand All @@ -44,16 +44,6 @@ const route = useRoute()
const router = useRouter()
const config = useConfigStore()

/**
* Skip the region picker if a region is already saved — go
* straight to the login form. First-launch users see the picker.
*/
onMounted(() => {
if (config.get('loginRegion')) {
void router.replace('/login/id-pass')
}
})

/**
* Tile descriptors. Kept declarative so the template stays a flat
* `v-for` rather than two near-duplicate `<button>` blocks (DRY).
Expand Down
12 changes: 8 additions & 4 deletions src/pages/QrForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@
* QR challenge, overwriting any prior `pending_qr` slot (see
* `commands/auth.rs::login_qr_start` side-effect docs).
* - **Back button**: `qr_form.xaml.cs::btn_back_Click` flips
* `App.LoginMethod = Regular` and re-runs `loginMethodChanged()`.
* We push `/login`; the parent `LoginPage` re-renders
* `LoginRegionSelection` at the root child route.
* `App.LoginMethod = Regular` and re-runs `loginMethodChanged()`,
* which lands the user on the regular id-pass form (the QR mode is
* peer to id-pass within the same region, not above region picking).
* We mirror that with `router.push('/login/id-pass')` — staying in
* the saved region, just switching the login mode. We do NOT push
* `/login?pick=1`; the button is "返回一般登入" (back to regular
* login), not "重選區域" (re-pick region).
*
* # Polling strategy (Q2 + Q10)
*
Expand Down Expand Up @@ -300,7 +304,7 @@ async function refresh(): Promise<void> {
async function goBack(): Promise<void> {
disposed = true
clearPollTimer()
await router.push({ path: '/login', query: { pick: '1' } })
await router.push('/login/id-pass')
}

/**
Expand Down
22 changes: 19 additions & 3 deletions tests/unit/pages/GamepassForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
* `connection-lost` banner shows and the steps stay at `0`.
* 5. Refresh re-issues both commands and clears the banner on
* success.
* 6. Back button navigates to `/login` without further calls.
* 6. Back button ("返回一般登入") navigates to `/login/id-pass` (NOT
* `/login?pick=1` — the button switches login mode within the same
* region, it does not re-pick region) without further backend calls.
* 7. Locale switch re-renders the localized copy.
*
* CP4 event-wiring assertions (new):
Expand Down Expand Up @@ -231,6 +233,14 @@ function mountForm(opts: { region?: string } = {}) {
routes: [
{ path: '/login', name: 'login', component: LoginStub },
{ path: '/login/gamepass', name: 'login-gamepass', component: GamepassForm },
{
path: '/login/id-pass',
name: 'login-id-pass',
component: defineComponent({
name: 'IdPassStub',
render: () => h('div', { 'data-testid': 'id-pass-stub' }),
}),
},
{ path: '/accounts', name: 'accounts', component: AccountsStub },
],
})
Expand Down Expand Up @@ -368,7 +378,13 @@ describe('GamepassForm', () => {
expect(wrapper.find('[data-testid="gamepass-steps"]').attributes('data-active')).toBe('2')
})

it('Back button navigates to /login without further backend calls', async () => {
it('Back button ("返回一般登入") navigates to /login/id-pass without further backend calls', async () => {
/*
* Regression for the bug where goBack pushed `/login?pick=1`,
* which dumped the user back at the region picker even though
* the button label promised "back to regular (id-pass) login".
* The correct behaviour is mode-switch within the saved region.
*/
const ctx = mountForm()
const wrapper = await ctx.mountIt()
await flushPromises()
Expand All @@ -379,7 +395,7 @@ describe('GamepassForm', () => {
await wrapper.find('[data-testid="gamepass-back"]').trigger('click')
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login')
expect(ctx.router.currentRoute.value.path).toBe('/login/id-pass')
expect(mockLoginGamepassStart.mock.calls.length).toBe(startCallsAfterMount)
expect(mockOpenGamepassWindow.mock.calls.length).toBe(openCallsAfterMount)
})
Expand Down
94 changes: 92 additions & 2 deletions tests/unit/pages/LoginRegionSelection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { defineComponent, h, nextTick } from 'vue'
import type { I18n } from 'vue-i18n'

import type { CommandError, Result } from '../../../src/types/bindings'
import { useConfigStore } from '../../../src/stores/config'

vi.mock('element-plus', () => ({
ElIcon: defineComponent({
Expand Down Expand Up @@ -51,6 +52,7 @@ import LoginRegionSelection from '../../../src/pages/LoginRegionSelection.vue'
import { createAppI18n, i18nMessages, setLocale } from '../../../src/i18n'

const mockSetConfig = vi.mocked(commands.setConfig)
const mockGetAllConfig = vi.mocked(commands.getAllConfig)

const ok = <T>(data: T): Promise<Result<T, CommandError>> => Promise.resolve({ status: 'ok', data })

Expand All @@ -60,7 +62,7 @@ const ok = <T>(data: T): Promise<Result<T, CommandError>> => Promise.resolve({ s
* `router.push` resolves cleanly. Mirrors the test layout we'll reuse
* in D3-D8 to verify each form's nav target.
*/
function mountPicker(): {
function mountPicker(opts: { initialPath?: string } = {}): {
router: Router
i18n: I18n
mountIt: () => Promise<ReturnType<typeof mount>>
Expand All @@ -81,6 +83,14 @@ function mountPicker(): {
render: () => h('div', { 'data-testid': 'id-pass-stub' }),
}),
},
{
path: '/login/qr',
name: 'login-qr',
component: defineComponent({
name: 'QrStub',
render: () => h('div', { 'data-testid': 'qr-stub' }),
}),
},
],
})

Expand All @@ -90,7 +100,7 @@ function mountPicker(): {
router,
i18n,
async mountIt() {
await router.push('/login/region')
await router.push(opts.initialPath ?? '/login/region')
await router.isReady()
return mount(LoginRegionSelection, {
global: { plugins: [router, i18n] },
Expand All @@ -104,6 +114,8 @@ describe('LoginRegionSelection', () => {
setActivePinia(createPinia())
mockSetConfig.mockReset()
mockSetConfig.mockReturnValue(ok(null))
mockGetAllConfig.mockReset()
mockGetAllConfig.mockReturnValue(ok({}))
})

it('renders both region tiles with their localized labels', async () => {
Expand Down Expand Up @@ -172,6 +184,84 @@ describe('LoginRegionSelection', () => {
expect(ctx.router.currentRoute.value.name).toBe('login-id-pass')
})

/*
* Auto-redirect (WPF `loginMethodInit` parity, commit `24a07af`).
*
* Once `Config.xml` is loaded, a saved `loginRegion` should skip
* the picker and jump straight to the matching login form. The
* `?pick=1` query is the documented escape hatch — login-form back
* buttons append it so the user can return to the picker.
*
* This block locks down the bug fixed in
* `fix(login-region): remove duplicate onMounted redirect`: a stray
* `onMounted` block was racing the watcher with weaker logic
* (no `?pick` check, no `loginMethod` awareness), making the
* picker unreachable after first launch.
*/
describe('auto-redirect when Config.xml has saved preferences', () => {
it('jumps to /login/id-pass when loginRegion=TW is saved (default loginMethod)', async () => {
mockGetAllConfig.mockReturnValue(ok({ loginRegion: 'TW' }))
const ctx = mountPicker()
const config = useConfigStore()
await config.loadAll()
const wrapper = await ctx.mountIt()
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login/id-pass')
wrapper.unmount()
})

it('jumps to /login/qr when loginRegion=TW + loginMethod=1 are saved', async () => {
mockGetAllConfig.mockReturnValue(ok({ loginRegion: 'TW', loginMethod: '1' }))
const ctx = mountPicker()
const config = useConfigStore()
await config.loadAll()
const wrapper = await ctx.mountIt()
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login/qr')
wrapper.unmount()
})

it('falls back to /login/id-pass for HK even when loginMethod=1 (HK has no QR endpoint)', async () => {
mockGetAllConfig.mockReturnValue(ok({ loginRegion: 'HK', loginMethod: '1' }))
const ctx = mountPicker()
const config = useConfigStore()
await config.loadAll()
const wrapper = await ctx.mountIt()
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login/id-pass')
wrapper.unmount()
})

it('stays on the picker when ?pick=1 is in the route, even with a saved region', async () => {
mockGetAllConfig.mockReturnValue(ok({ loginRegion: 'TW' }))
const ctx = mountPicker({ initialPath: '/login/region?pick=1' })
const config = useConfigStore()
await config.loadAll()
const wrapper = await ctx.mountIt()
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login/region')
expect(wrapper.findAll('.region-tile')).toHaveLength(2)
wrapper.unmount()
})

it('stays on the picker on first launch when no region is saved', async () => {
mockGetAllConfig.mockReturnValue(ok({}))
const ctx = mountPicker()
const config = useConfigStore()
await config.loadAll()
const wrapper = await ctx.mountIt()
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login/region')
expect(wrapper.findAll('.region-tile')).toHaveLength(2)
wrapper.unmount()
})
})

it('re-renders heading + tile labels after a runtime locale switch', async () => {
const ctx = mountPicker()
const wrapper = await ctx.mountIt()
Expand Down
22 changes: 19 additions & 3 deletions tests/unit/pages/QrForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
* 7. `loginQrCheck` error result stops polling + shows inline
* "connection lost" banner (Q11 = B — no toast, inline fallback).
* 8. Refresh button re-mints the QR.
* 9. Back button navigates to `/login` and halts the polling loop.
* 9. Back button ("返回一般登入") navigates to `/login/id-pass` (NOT
* `/login?pick=1` — the button switches login mode within the same
* region, it does not re-pick region) and halts the polling loop.
* 10. Copy deeplink success path uses `navigator.clipboard.writeText`
* + success toast.
* 11. Copy deeplink button is disabled when the server returns no
Expand Down Expand Up @@ -179,6 +181,14 @@ function mountForm(opts: { region?: string } = {}) {
routes: [
{ path: '/login', name: 'login', component: LoginStub },
{ path: '/login/qr', name: 'login-qr', component: QrForm },
{
path: '/login/id-pass',
name: 'login-id-pass',
component: defineComponent({
name: 'IdPassStub',
render: () => h('div', { 'data-testid': 'id-pass-stub' }),
}),
},
{
path: '/accounts',
name: 'accounts',
Expand Down Expand Up @@ -409,7 +419,13 @@ describe('QrForm', () => {
)
})

it('Back button navigates to /login and halts polling', async () => {
it('Back button ("返回一般登入") navigates to /login/id-pass and halts polling', async () => {
/*
* Regression for the bug where goBack pushed `/login?pick=1`,
* which dumped the user back at the region picker even though
* the button label promised "back to regular (id-pass) login".
* The correct behaviour is mode-switch within the saved region.
*/
mockLoginQrStart.mockReturnValueOnce(ok(CHALLENGE))
mockLoginQrCheck.mockResolvedValue({ status: 'ok', data: STATUS_PENDING })

Expand All @@ -420,7 +436,7 @@ describe('QrForm', () => {
await wrapper.find('[data-testid="qr-back"]').trigger('click')
await flushPromises()

expect(ctx.router.currentRoute.value.path).toBe('/login')
expect(ctx.router.currentRoute.value.path).toBe('/login/id-pass')

const callsAfterBack = mockLoginQrCheck.mock.calls.length
await advancePoll(3)
Expand Down
Loading