diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index aeca6dec..7be1b2c1 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -1,6 +1,33 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [ + resolve(repositoryRoot, '../image'), + resolve(repositoryRoot, '../../image'), + resolve(repositoryRoot, '../sam_rebuild/image'), + resolve(repositoryRoot, '../../sam_rebuild/image'), +]; +const readImage = async (relativePath: string): Promise => { + if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`); + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Product checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Fixture image not found: ${relativePath}`); +}; +const imageContentType = (relativePath: string): string => { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; + const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { @@ -184,7 +211,26 @@ const install = async (page: Page, rejectGeneral = false) => { localStorage.setItem('sammo-game-token', 'ga_commands'); localStorage.setItem('sammo-game-profile', profile); }, gameProfile); - await page.route('**/image/**', (route) => route.fulfill({ status: 404, body: '' })); + await page.route('**/image/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? ''); + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(relativePath), + }); + }); + await page.route('**/game/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? ''); + try { + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(`game/${relativePath}`), + }); + } catch { + await route.fulfill({ status: 404, body: '' }); + } + }); await page.route(gameTrpcRoute, async (route) => { const names = operations(route); const body = route.request().postDataJSON(); @@ -232,7 +278,7 @@ const install = async (page: Page, rejectGeneral = false) => { month: 1, cityList: [ [1, 8, 0, 1, 1, 1], - [2, 7, 40, 2, 2, 1], + [2, 7, 41, 2, 2, 1], ], nationList: [ [1, '아국', '#008000', 1], @@ -316,19 +362,52 @@ test('enters general and nation command arguments and sends exact values', async await expect(form.getByTestId('command-argument-map')).toBeVisible(); await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.'); await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸'); - await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click(); + const commandMap = form.getByTestId('command-argument-map'); + const mapCities = commandMap.locator('.city-base'); + await expect(mapCities).toHaveCount(2); + await expect(commandMap.locator('.city-bg')).toHaveCount(2); + await expect(commandMap.locator('.city-flag')).toHaveCount(2); + await expect(commandMap.locator('.city-state')).toHaveCount(1); + await expect + .poll(() => + commandMap + .locator('.city-icon') + .evaluateAll((images: HTMLImageElement[]) => + images.every((image) => image.complete && image.naturalWidth > 0 && image.naturalHeight > 0) + ) + ) + .toBe(true); + const castleGeometry = await commandMap.locator('.city-icon').evaluateAll((images: HTMLImageElement[]) => + images.map((image) => ({ + src: new URL(image.src).pathname, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + renderedWidth: image.getBoundingClientRect().width, + renderedHeight: image.getBoundingClientRect().height, + })) + ); + expect(castleGeometry).toEqual([ + expect.objectContaining({ src: '/game/cast_8.gif', naturalWidth: 32, naturalHeight: 24 }), + expect.objectContaining({ src: '/game/cast_7.gif', naturalWidth: 28, naturalHeight: 20 }), + ]); + for (const castle of castleGeometry) { + expect(castle.renderedWidth).toBeGreaterThan(castle.naturalWidth * 0.9); + expect(castle.renderedHeight).toBeGreaterThan(castle.naturalHeight * 0.9); + expect(castle.renderedWidth / castle.naturalWidth).toBeCloseTo(castle.renderedHeight / castle.naturalHeight, 2); + } + const layerStyles = await commandMap.evaluate((element) => ({ + background: getComputedStyle(element.querySelector('.map-bglayer1')!).backgroundImage, + road: getComputedStyle(element.querySelector('.map-bgroad')!).backgroundImage, + })); + expect(layerStyles.background).toContain('/game/map/che/bg_spring.jpg'); + expect(layerStyles.road).toContain('/game/map/che/che_road.png'); + await mapCities.nth(1).click(); await expect(form.locator('select')).toHaveValue('2'); await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸'); - await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover(); - expect( - await form - .getByTestId('command-argument-map') - .locator('.map-city') - .nth(1) - .evaluate((element) => getComputedStyle(element).cursor) - ).toBe('pointer'); - await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus(); - await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused(); + await mapCities.nth(1).hover(); + expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await mapCities.nth(1).focus(); + await expect(mapCities.nth(1)).toBeFocused(); await expect(page).toHaveURL(/\/$/); const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => { const area = element.querySelector('.map-area')!; @@ -385,7 +464,7 @@ test('uses the map to choose a nation target in the chief command window', async await picker.getByRole('button', { name: /선전포고/ }).click(); const form = picker.getByTestId('command-argument-form'); await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한'); - await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click(); + await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click(); await expect(form.locator('select')).toHaveValue('2'); await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개'); await expect(page).toHaveURL(/\/che\/chief-center$/); diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index aab614bd..8e17a473 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -258,7 +258,7 @@ watch( :map-layout="props.mapLayout ?? null" :loading="false" :selected-city-id="mapSelectedCityId" - :detail-mode="false" + :detail-mode="true" :fit-container="true" @select-city="selectMapCity" /> diff --git a/app/game-frontend/src/components/main/MapCityDetail.vue b/app/game-frontend/src/components/main/MapCityDetail.vue index b33e32cb..d1687119 100644 --- a/app/game-frontend/src/components/main/MapCityDetail.vue +++ b/app/game-frontend/src/components/main/MapCityDetail.vue @@ -158,7 +158,14 @@ const cityStateStyle = computed(() => ({ class="city-base" :type="props.selectOnly ? 'button' : undefined" :to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }" - :class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]" + :class="[ + { + mine: props.city.isMyCity, + selected: props.city.selected, + 'supply-off': !props.city.supply, + 'select-only': props.selectOnly, + }, + ]" :style="cityBaseStyle" @mouseenter="emit('hover', props.city.id)" @mouseleave="emit('leave')" @@ -195,6 +202,10 @@ const cityStateStyle = computed(() => ({ background: transparent; } +.city-base.select-only { + cursor: pointer; +} + .city-bg { position: absolute; background-position: center; diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index c04a5547..3061938e 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -63,14 +63,20 @@ interface CityView { selected: boolean; } -const props = defineProps<{ - mapData: MapSummary | null; - mapLayout: MapLayout | null; - loading: boolean; - selectedCityId?: number | null; - detailMode?: boolean; - fitContainer?: boolean; -}>(); +const props = withDefaults( + defineProps<{ + mapData: MapSummary | null; + mapLayout: MapLayout | null; + loading: boolean; + selectedCityId?: number | null; + detailMode?: boolean; + fitContainer?: boolean; + }>(), + { + // Vue casts an absent Boolean prop to false unless undefined is an explicit default. + detailMode: undefined, + } +); const emit = defineEmits<{ (event: 'select-city', cityId: number): void;