diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 66608a52..53013df5 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -5,6 +5,16 @@ import { describe, expect, it } from 'vitest'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; +type LoadedScenario = Awaited>; + +const readItemSlot = (scenario: LoadedScenario, slot: string): Record => { + const allItems = scenario.config.const.allItems as Record> | undefined; + return allItems?.[slot] ?? {}; +}; + +const readAvailableSpecialWar = (scenario: LoadedScenario): string[] => + (scenario.config.const.availableSpecialWar as string[] | undefined) ?? []; + describe('tracked scenario resources', () => { it('loads every scenario through its composed resource graph', async () => { const scenarioRoot = path.dirname(resolveScenarioDefaultsPath()); @@ -35,4 +45,36 @@ describe('tracked scenario resources', () => { ]); } }); + + it('keeps the buyable war-special pack scoped to each Ref scenario contract', async () => { + const [ordinaryBlank, legacySecretBlank, mirrorBlank, multiUnitBlank, moreEffectBlank, composedAddon] = + await Promise.all( + [0, 902, 910, 912, 913, 2141].map((scenarioId) => loadScenarioDefinitionById(scenarioId)) + ); + + expect( + Object.keys(readItemSlot(ordinaryBlank, 'item')).filter((key) => key.startsWith('event_전투특기_')) + ).toEqual([]); + + const legacySecretItems = readItemSlot(legacySecretBlank, 'item'); + expect(Object.keys(legacySecretItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(legacySecretItems).not.toHaveProperty('event_전투특기_견고'); + expect(readAvailableSpecialWar(legacySecretBlank)).not.toContain('che_견고'); + + const mirrorItems = readItemSlot(mirrorBlank, 'item'); + expect(Object.keys(mirrorItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(mirrorItems).not.toHaveProperty('event_전투특기_척사'); + expect(readAvailableSpecialWar(mirrorBlank)).not.toContain('che_척사'); + + const multiUnitItems = readItemSlot(multiUnitBlank, 'item'); + expect(Object.keys(multiUnitItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(multiUnitItems).not.toHaveProperty('event_전투특기_견고'); + + const moreEffectItems = readItemSlot(moreEffectBlank, 'item'); + const composedAddonItems = readItemSlot(composedAddon, 'item'); + expect(Object.keys(moreEffectItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(Object.keys(composedAddonItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4); + expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2); + }); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index c6d587c5..46b06dff 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -93,6 +93,49 @@ const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { + test('persists each blank-land scenario item contract without leaking the shared addon', async () => { + const readPersistedItemContract = async (targetScenarioId: number) => { + const { applied } = await seedScenarioToDatabase({ + scenarioId: targetScenarioId, + databaseUrl, + }); + + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + const config = worldState.config as Record; + const scenarioConst = (config.const ?? {}) as Record; + const allItems = (scenarioConst.allItems ?? {}) as Record>; + const items = allItems.item ?? {}; + const availableSpecialWar = (scenarioConst.availableSpecialWar ?? []) as string[]; + + return { + applied, + battleTraitItemCount: Object.keys(items).filter((key) => key.startsWith('event_전투특기_')).length, + availableSpecialWar, + items, + }; + } finally { + await connector.disconnect(); + } + }; + + const ordinaryBlank = await readPersistedItemContract(0); + const legacySecretBlank = await readPersistedItemContract(902); + + expect(ordinaryBlank).toMatchObject({ + applied: true, + battleTraitItemCount: 0, + availableSpecialWar: [], + }); + expect(legacySecretBlank.applied).toBe(true); + expect(legacySecretBlank.battleTraitItemCount).toBe(19); + expect(legacySecretBlank.availableSpecialWar).toHaveLength(19); + expect(legacySecretBlank.items).not.toHaveProperty('event_전투특기_견고'); + expect(legacySecretBlank.availableSpecialWar).not.toContain('che_견고'); + }); + test('snapshots the complete opening inheritance balance before game activity', async () => { const serverId = 'scenario-seeder-inheritance-baseline'; const userId = 'scenario-seeder-inheritance-user'; diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 4ca368ca..2d248099 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const imageRoots = [ @@ -2302,6 +2303,62 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await page.screenshot({ path: test.info().outputPath('advanced-command-editor.png'), fullPage: true }); }); +test('physical mobile touch drag selects general and nation turns in advanced mode', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile touch contract'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + try { + await install(mobilePage); + await mobilePage.goto(configuredBaseUrl); + + const editor = mobilePage.locator('[data-command-scope="general"]'); + await expect(editor).toBeVisible(); + await expect(editor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto'); + await editor.getByRole('button', { name: '고급 모드', exact: true }).click(); + await expect(editor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none'); + const cells = editor.locator('.index-column > button'); + await touchDrag(mobilePage, cells.nth(0), cells.nth(2), { targetYRatio: 0.9 }); + + await expect(editor.locator('.index-column > button.selected')).toHaveCount(3); + const dates = editor.locator('.date-column > div'); + await touchDrag(mobilePage, dates.nth(4), dates.nth(6), { targetYRatio: 0.9 }); + await expect + .poll(() => editor.locator('.index-column > button.selected').allTextContents()) + .toEqual(['5', '6', '7']); + await mobilePage.screenshot({ + path: testInfo.outputPath('advanced-general-command-editor-mobile-touch.png'), + fullPage: true, + }); + + await mobilePage.goto(new URL('chief-center', configuredBaseUrl).href); + const chiefEditor = mobilePage.locator('[data-command-scope="nation"]:visible'); + await expect(chiefEditor).toBeVisible(); + await expect(chiefEditor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto'); + await chiefEditor.getByRole('button', { name: '고급 모드', exact: true }).click(); + await expect(chiefEditor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none'); + const chiefCells = chiefEditor.locator('.index-column > button'); + await touchDrag(mobilePage, chiefCells.nth(0), chiefCells.nth(2), { targetYRatio: 0.9 }); + await expect(chiefEditor.locator('.index-column > button.selected')).toHaveCount(3); + await mobilePage.screenshot({ + path: testInfo.outputPath('advanced-nation-command-editor-mobile-touch.png'), + fullPage: true, + }); + } finally { + await context.close(); + } +}); + test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1000, height: 900 }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2d752e4d..81412299 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1102,7 +1102,9 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99'); }); -test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => { +test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ + page, +}, testInfo) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -1250,6 +1252,34 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); await expect(versionDialog).toBeVisible(); await expect(versionDialog).toContainText('메인 화면 검증 시나리오'); + await expect(versionDialog.getByText('빌드 커밋', { exact: true })).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + const dialogStyle = getComputedStyle(dialog); + const codeStyle = getComputedStyle(code); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + dialogBackground: dialogStyle.backgroundColor, + dialogColor: dialogStyle.color, + codeColor: codeStyle.color, + codeFontFamily: codeStyle.fontFamily, + viewportWidth: window.innerWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.dialogBackground).toBe('rgb(32, 32, 32)'); + expect(versionGeometry.dialogColor).toBe('rgb(255, 255, 255)'); + expect(versionGeometry.codeColor).toBe('rgb(215, 215, 215)'); + await writeFile( + testInfo.outputPath('desktop-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('desktop-game-version-dialog.png') }); await versionDialog.getByRole('button', { name: '닫기' }).click(); await expect(versionDialog).toBeHidden(); @@ -1479,6 +1509,30 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn expect(geometry.caretBorderTopWidth).toBe('0px'); expect(geometry.caretBorderBottomWidth).toBe('4px'); await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await bottomGlobal.locator('[data-navigation-id="version"]').click(); + const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); + await expect(versionDialog).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + viewportWidth: window.innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.documentScrollWidth).toBe(500); + await writeFile( + testInfo.outputPath('mobile-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('mobile-game-version-dialog.png') }); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 734b6cee..55766660 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -9,11 +9,12 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const baseURL = `http://127.0.0.1:${port}${basePath}/`; const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`; const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; +const buildCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567'; const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const frontendEnv = `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` + - 'VITE_GATEWAY_API_URL=/gateway/api/trpc'; + `VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_BUILD_COMMIT_SHA=${buildCommitSha}`; export default defineConfig({ testDir: '.', diff --git a/app/game-frontend/src/components/command/DragSelect.vue b/app/game-frontend/src/components/command/DragSelect.vue index 106fb386..686683f7 100644 --- a/app/game-frontend/src/components/command/DragSelect.vue +++ b/app/game-frontend/src/components/command/DragSelect.vue @@ -80,7 +80,9 @@ onBeforeUnmount(() => {