diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index b9059004..a94dac9a 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -34,12 +34,15 @@ storage, route guards, and image loading. ## Enforced contracts -| Screen | Ref entry point | Current automated contract | -| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | -| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | -| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | -| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | +| Screen | Ref entry point | Current automated contract | +| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | +| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | +| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | +| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | +| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | +| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | +| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is @@ -59,3 +62,7 @@ Adding or changing a frontend route requires: Pixel snapshots may be added after these structural assertions pass. Dynamic regions must not be hidden merely to make a pixel threshold pass. + +For a review run that also writes full-page screenshots, create an ignored +artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the +suite. The ordinary CI run does not write screenshots after successful tests. diff --git a/tools/frontend-legacy-parity/fixtures/canonical.ts b/tools/frontend-legacy-parity/fixtures/canonical.ts index 50d28d09..36706b0e 100644 --- a/tools/frontend-legacy-parity/fixtures/canonical.ts +++ b/tools/frontend-legacy-parity/fixtures/canonical.ts @@ -7,6 +7,17 @@ export const canonicalFrontendFixture = { roles: [], }, sessionToken: 'session_visual_fixture', + account: { + id: 'visual-user-1', + username: 'visual-user', + displayName: '시각검증', + roles: [], + oauthType: 'NONE', + createdAt: '2026-07-25T12:27:07.000Z', + iconUrl: null, + thirdPartyUse: true, + deleteAfter: null, + }, profile: { profileName: 'che', profile: 'che', @@ -119,5 +130,38 @@ export const canonicalFrontendFixture = { }, ], }, + yearbookRange: { + firstYearMonth: 197 * 12 + 5, + lastYearMonth: 197 * 12 + 6, + currentYearMonth: 197 * 12 + 6, + }, + yearbook: { + notModified: false, + hash: 'yearbook-visual-hash', + data: { + year: 197, + month: 7, + map: { + year: 197, + month: 7, + startYear: 190, + cityList: [ + [1, 7, 0, 1, 1, 1], + [2, 7, 0, 2, 1, 1], + [3, 6, 0, 1, 2, 1], + ], + nationList: [ + [1, '한', '#d32f2f', 1], + [2, '진', '#1976d2', 2], + ], + }, + nations: [ + { id: 1, name: '한', color: '#d32f2f', level: 5, power: 1250, cities: ['낙양', '업'] }, + { id: 2, name: '진', color: '#1976d2', level: 4, power: 980, cities: ['장안'] }, + ], + globalHistory: ['● 한이 낙양을 지키고 있습니다.'], + globalAction: ['● 유비가 내정을 수행했습니다.'], + }, + }, }, } as const; diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 823b5501..32154aab 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -7,6 +7,7 @@ import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const imageRoot = resolve(repositoryRoot, '../../image'); +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); @@ -66,6 +67,25 @@ const installGatewayFixture = async (page: Page): Promise => { if (operation === 'auth.kakaoStart') { return { mode: 'login', state: 'visual-state', authUrl: '/gateway/oauth-started' }; } + if (operation === 'auth.kakaoExchange') { + return { + status: 'join', + oauthSessionId: 'oauth-visual-session', + email: 'visual@example.test', + }; + } + if (operation === 'auth.register') { + loggedIn = true; + return { + user: fixture.gateway.user, + sessionToken: fixture.gateway.sessionToken, + issuedAt: '2026-07-25T00:00:00.000Z', + }; + } + if (operation === 'account.get') return fixture.gateway.account; + if (operation === 'account.changePassword') return { ok: true }; + if (operation === 'account.disallowThirdPartyUse') return { ok: true }; + if (operation === 'account.deleteIcon') return { ok: true, iconUrl: null }; throw new Error(`Unhandled gateway fixture operation: ${operation}`); }); }); @@ -116,6 +136,9 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { troops: [], }; } + if (operation === 'public.getMapLayout') return fixture.game.mapLayout; + if (operation === 'yearbook.getRange') return fixture.game.yearbookRange; + if (operation === 'yearbook.getHistory') return fixture.game.yearbook; throw new Error(`Unhandled authenticated game fixture operation: ${operation}`); }); }); @@ -197,6 +220,129 @@ test.describe('gateway legacy parity', () => { .poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token'))) .toBe(fixture.gateway.sessionToken); }); + + test('renders account management at the ref geometry and changes a password', async ({ page }) => { + await page.addInitScript((token) => { + window.localStorage.setItem('sammo-session-token', token); + }, fixture.gateway.sessionToken); + await page.goto('http://127.0.0.1:15100/gateway/account'); + await expect(page.getByText('시각검증')).toBeVisible(); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'account-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.locator('#account-container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + minHeight: style.minHeight, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + tableBackground: getComputedStyle(document.querySelector('#account-table')!) + .backgroundImage, + }; + }); + expect(geometry).toMatchObject({ width: 550, minHeight: '575px', fontSize: '14px' }); + expect(geometry.fontFamily).toContain('Pretendard'); + expect(geometry.tableBackground).toContain('back_walnut.jpg'); + + await page.locator('#current-password').fill('current-password'); + await page.locator('#new-password').fill('next-password'); + await page.locator('#confirm-password').fill('next-password'); + const changeButton = page.getByRole('button', { name: '비밀번호 변경' }); + await changeButton.hover(); + await changeButton.focus(); + await expect(changeButton).toBeFocused(); + await changeButton.click(); + await expect(page.getByRole('status')).toHaveText('비밀번호를 변경했습니다.'); + }); + + test('shows the account API error without losing the form', async ({ page }) => { + await page.addInitScript((token) => { + window.localStorage.setItem('sammo-session-token', token); + }, fixture.gateway.sessionToken); + await page.route('**/gateway/api/trpc/**', async (route) => { + if (operationNames(route).includes('account.changePassword')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '현재 비밀번호가 일치하지 않습니다.' } }), + }); + return; + } + await route.fallback(); + }); + await page.goto('http://127.0.0.1:15100/gateway/account'); + await page.locator('#current-password').fill('wrong-password'); + await page.locator('#new-password').fill('next-password'); + await page.locator('#confirm-password').fill('next-password'); + await page.getByRole('button', { name: '비밀번호 변경' }).click(); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.locator('#current-password')).toHaveValue('wrong-password'); + }); + + test('completes the OAuth registration page and stores the session', async ({ page }) => { + await page.goto('http://127.0.0.1:15100/gateway/oauth/callback?code=visual-code&state=visual-state'); + await expect(page.getByLabel('카카오 이메일')).toHaveValue('visual@example.test'); + const geometry = await page.locator('#oauth-container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, fontFamily: style.fontFamily }; + }); + expect(geometry.width).toBe(700); + expect(geometry.fontFamily).toContain('Pretendard'); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'oauth-join-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } + + await page.locator('#oauth-username').fill('visual-user'); + await page.locator('#oauth-password').fill('visual-password'); + await page.locator('#oauth-confirm').fill('visual-password'); + await page.locator('#oauth-display-name').fill('시각검증'); + await page.getByLabel('내용 확인 후 동의합니다.').first().check(); + await page.getByLabel('내용 확인 후 동의합니다.').last().check(); + const register = page.getByRole('button', { name: '가입' }); + await register.hover(); + await register.focus(); + await register.click(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + await expect + .poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token'))) + .toBe(fixture.gateway.sessionToken); + }); + + test('keeps OAuth registration input after an API error', async ({ page }) => { + await page.route('**/gateway/api/trpc/**', async (route) => { + if (operationNames(route).includes('auth.register')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '이미 사용 중인 계정명입니다.' } }), + }); + return; + } + await route.fallback(); + }); + await page.goto('http://127.0.0.1:15100/gateway/oauth/callback?code=visual-code&state=visual-state'); + await page.locator('#oauth-username').fill('duplicate-user'); + await page.locator('#oauth-password').fill('visual-password'); + await page.locator('#oauth-confirm').fill('visual-password'); + await page.locator('#oauth-display-name').fill('시각검증'); + await page.getByLabel('내용 확인 후 동의합니다.').first().check(); + await page.getByLabel('내용 확인 후 동의합니다.').last().check(); + await page.getByRole('button', { name: '가입' }).click(); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.locator('#oauth-username')).toHaveValue('duplicate-user'); + }); }); test.describe('hall of fame legacy parity', () => { @@ -269,3 +415,74 @@ test('canonical logged-in fixture passes the game route guard', async ({ page }) await expect(page).toHaveURL(/\/che\/troop$/); await expect(page.getByText('부대 편성')).toBeVisible(); }); + +test.describe('yearbook legacy parity', () => { + test.beforeEach(async ({ page }) => { + await installAuthenticatedGameFixture(page); + }); + + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768, containerWidth: 1000, mapWidth: 700 }, + { name: 'mobile', width: 390, height: 844, containerWidth: 500, mapWidth: 498 }, + ]) { + test(`renders the ref yearbook grid on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto('http://127.0.0.1:15102/che/yearbook'); + await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible(); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `yearbook-core-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => + document.querySelector(selector)!.getBoundingClientRect(); + const container = document.querySelector('#yearbook-container')!; + return { + containerWidth: rect('#yearbook-container').width, + mapWidth: rect('.map-position').width, + nationWidth: rect('.nation-position').width, + fontFamily: getComputedStyle(container).fontFamily, + fontSize: getComputedStyle(container).fontSize, + backgroundImage: getComputedStyle(container).backgroundImage, + }; + }); + expect(geometry.containerWidth).toBe(viewport.containerWidth); + expect(geometry.mapWidth).toBe(viewport.mapWidth); + expect(geometry.fontFamily).toContain('Pretendard'); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.backgroundImage).toContain('back_walnut.jpg'); + if (viewport.name === 'desktop') { + expect(geometry.nationWidth).toBe(300); + } else { + expect(geometry.nationWidth).toBe(498); + } + + const previous = page.getByRole('button', { name: '◀ 이전달' }); + await previous.hover(); + await previous.focus(); + await expect(previous).toBeFocused(); + await expect(page.getByRole('button', { name: '다음달 ▶' })).toBeDisabled(); + }); + } + + test('shows the history API error while keeping month navigation available', async ({ page }) => { + await page.route('**/che/api/trpc/**', async (route) => { + if (operationNames(route).includes('yearbook.getHistory')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '연감 데이터를 찾을 수 없습니다.' } }), + }); + return; + } + await route.fallback(); + }); + await page.goto('http://127.0.0.1:15102/che/yearbook'); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByLabel('연월 선택')).toBeVisible(); + }); +});