diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts new file mode 100644 index 0000000..da9f6e5 --- /dev/null +++ b/app/game-api/test/inheritRouter.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 45, + intel: 85, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'che_선봉', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 4, + tickSeconds: 3600, + config: { + const: { + availableSpecialWar: ['che_선봉'], + allItems: { + weapon: { + che_무기_12_칠성검: 1, + che_무기_01_단도: 0, + }, + }, + }, + }, + meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 }, + updatedAt: new Date('2026-07-26T00:00:00Z'), +}; + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + target?: GeneralRow | null; + inheritancePoint?: number; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const target = + options.target === undefined + ? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } }) + : options.target; + const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({ + type: command.type, + ok: true, + generalId: command.generalId, + })); + const pointUpsert = vi.fn(async () => ({})); + const logCreate = vi.fn(async () => ({})); + const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); + const db = { + $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), + worldState: { + findFirst: vi.fn(async () => worldState), + }, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany, + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + target?.id === where.id ? target : null + ), + }, + inheritancePoint: { + upsert: pointUpsert, + }, + inheritanceLog: { + create: logCreate, + findMany: vi.fn(async () => []), + }, + inheritanceUserState: { + findUnique: vi.fn(async () => null), + upsert: vi.fn(async () => ({})), + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand, pointUpsert, logCreate, findMany }; +}; + +describe('inherit router actor and permission boundaries', () => { + it('rejects unauthenticated status and mutations', async () => { + const fixture = buildContext({ auth: null }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('builds status only from the authenticated user general and filters target generals like ref', async () => { + const fixture = buildContext({}); + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 }); + expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]); + expect(status.availableUnique).toEqual([ + expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }), + ]); + expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0); + expect(fixture.findMany).toHaveBeenCalledWith({ + where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }); + }); + + it('does not dispatch or charge when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '장수가 존재하지 않습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + }); + + it('mutates only the authenticated user general and inheritance balance', async () => { + const fixture = buildContext({ inheritancePoint: 1000 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).resolves.toEqual({ ok: true, remainPoint: 800 }); + + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'patchGeneral', + generalId: 7, + patch: expect.objectContaining({ + meta: expect.objectContaining({ + inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }), + }), + }), + }) + ); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 800 }, + }) + ); + }); + + it('reveals a target owner to the caller without using the caller general id from input', async () => { + const fixture = buildContext({ inheritancePoint: 1500 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) + ).resolves.toEqual({ + ok: true, + ownerName: '위유저', + targetName: '조조', + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 500 }, + }) + ); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + }, + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..42a4c80 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -43,22 +43,23 @@ 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 | -| 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 | -| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | -| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | -| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | -| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | -| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | -| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | -| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | +| 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 | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | +| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | +| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | +| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | +| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | +| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | +| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | +| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is diff --git a/packages/logic/test/inheritBuff.test.ts b/packages/logic/test/inheritBuff.test.ts new file mode 100644 index 0000000..ce6fb69 --- /dev/null +++ b/packages/logic/test/inheritBuff.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { General } from '../src/domain/entities.js'; +import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js'; +import { GeneralActionPipeline } from '../src/triggers/general-action.js'; + +const buildGeneral = (inheritBuff: Record): General => ({ + id: 1, + name: 'Tester', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 0, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) }, +}); + +describe('inheritance buff legacy keys', () => { + it('applies the canonical legacy domestic buff names', () => { + const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]); + const context = { + general: buildGeneral({ + domesticSuccessProb: 3, + domesticFailProb: 2, + }), + }; + + expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53); + expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18); + }); + + it('continues to read the earlier core success and fail aliases', () => { + const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]); + const context = { + general: buildGeneral({ + success: 2, + fail: 1, + }), + }; + + expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52); + expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19); + }); +}); diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts new file mode 100644 index 0000000..6907362 --- /dev/null +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -0,0 +1,237 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 } }); + +const operations = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installImages = async (page: Page): Promise => { + await page.route('**/image/**', async (route) => { + const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, ''); + for (const candidate of [ + resolve(imageRoot, relative), + resolve(imageRoot, 'game', relative), + resolve(imageRoot, 'icons', '22.jpg'), + ]) { + try { + const body = await readFile(candidate); + await route.fulfill({ + status: 200, + contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg', + body, + }); + return; + } catch { + // 다음 공개 image root 후보를 확인한다. + } + } + await route.abort('failed'); + }); +}; + +const statusFixture = { + items: { + previous: 12_000, + lived_month: 240, + max_domestic_critical: 80, + active_action: 35, + combat: 150, + sabotage: 60, + dex: 42, + unifier: 0, + tournament: 30, + betting: 20, + max_belong: 8, + }, + totalPoint: 12_665, + inheritConst: { + minMonthToAllowInheritItem: 4, + inheritBornSpecialPoint: 6000, + inheritBornTurntimePoint: 2500, + inheritBornCityPoint: 1000, + inheritBornStatPoint: 1000, + inheritItemUniqueMinPoint: 5000, + inheritItemRandomPoint: 3000, + inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000], + inheritSpecificSpecialPoint: 4000, + inheritResetAttrPointBase: [1000, 1000, 2000, 3000], + inheritCheckOwnerPoint: 1000, + }, + buffLevels: { + warAvoidRatio: 0, + warCriticalRatio: 1, + warMagicTrialProb: 0, + domesticSuccessProb: 0, + domesticFailProb: 0, + warAvoidRatioOppose: 0, + warCriticalRatioOppose: 0, + warMagicTrialProbOppose: 0, + }, + resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 }, + resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 }, + availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }], + availableUnique: [ + { + key: 'che_무기_12_칠성검', + name: '칠성검(+12)', + rawName: '칠성검', + info: '무력을 올려주는 유니크 무기입니다.', + }, + ], + availableTargetGenerals: [{ id: 8, name: '조조' }], + turnTimeZones: ['00:00'], + isUnited: false, + currentSpecialWar: 'che_선봉', + currentStat: { leadership: 70, strength: 45, intel: 85 }, +}; + +const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { + let buffMutationCount = 0; + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token'); + window.localStorage.setItem('sammo-game-profile', 'che'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const names = operations(route); + if (options.failBuff && names.includes('inherit.buyHiddenBuff')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }), + }); + return; + } + const result = names.map((name) => { + if (name === 'inherit.getStatus') return response(statusFixture); + if (name === 'lobby.info') { + return response({ + profile: { id: 'che', scenario: 'default', name: '체섭' }, + world: { year: 200, month: 4 }, + myGeneral: { id: 7, name: '유비', nationId: 1 }, + }); + } + if (name === 'inherit.getLogs') { + return response([ + { + id: 2, + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + createdAt: '2026-07-26T00:00:00.000Z', + }, + ]); + } + if (name === 'join.getConfig') { + return response({ rules: { stat: { total: 200, min: 10, max: 100 } } }); + } + if (name === 'inherit.buyHiddenBuff') { + buffMutationCount += 1; + return response({ ok: true, remainPoint: 11_800 }); + } + throw new Error(`Unhandled inheritance fixture operation: ${name}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(result), + }); + }); + return { buffMutationCount: () => buffMutationCount }; +}; + +test.describe('inheritance management legacy parity', () => { + test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => { + await installFixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await expect(page.locator('#container')).toBeVisible(); + await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); + + const desktop = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)!.getBoundingClientRect(); + return { x: box.x, width: box.width }; + }; + const container = getComputedStyle(document.querySelector('#container')!); + const title = getComputedStyle(document.querySelector('.section-title')!); + const button = getComputedStyle(document.querySelector('.buy-button')!); + return { + container: rect('#container'), + firstPoint: rect('#inherit_sum'), + fontFamily: container.fontFamily, + fontSize: container.fontSize, + backgroundImage: container.backgroundImage, + titleBackgroundImage: title.backgroundImage, + buttonBackground: button.backgroundColor, + }; + }); + + expect(desktop.container.width).toBe(1000); + expect(desktop.container.x).toBe(140); + expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0); + expect(desktop.fontFamily).toContain('Pretendard'); + expect(desktop.fontSize).toBe('14px'); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.titleBackgroundImage).toContain('back_green.jpg'); + + const buyButton = page.locator('.buy-button').first(); + const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor); + await buyButton.hover(); + const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor); + expect(afterHover).not.toBe(beforeHover); + await buyButton.focus(); + await expect(buyButton).toBeFocused(); + + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true }); + } + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + await expect(page.locator('#container')).toBeVisible(); + const mobile = await page.evaluate(() => { + const container = document.querySelector('#container')!.getBoundingClientRect(); + const first = document.querySelector('#inherit_sum')!.getBoundingClientRect(); + const second = document.querySelector('#inherit_previous')!.getBoundingClientRect(); + return { + containerWidth: container.width, + firstWidth: first.width, + stacked: second.y > first.y, + }; + }); + expect(mobile.containerWidth).toBe(500); + expect(mobile.firstWidth).toBeCloseTo(482, 0); + expect(mobile.stacked).toBe(true); + }); + + test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => { + const fixture = await installFixture(page); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.locator('#buff-warAvoidRatio').fill('1'); + await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); + await expect.poll(fixture.buffMutationCount).toBe(1); + await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); + }); + + test('keeps controls usable and renders an API mutation error', async ({ page }) => { + await installFixture(page, { failBuff: true }); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.locator('#buff-warAvoidRatio').fill('1'); + await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); + await expect(page.locator('[role="alert"]')).toBeVisible(); + await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1'); + await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled(); + }); +}); diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index eece2a1..fc57f96 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -13,6 +13,7 @@ export default defineConfig({ 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts', 'tournament-betting.spec.ts', + 'inheritance-management.spec.ts', ], fullyParallel: false, workers: 1,