Merge branch 'main' into fix/tournament-icon-standard-20260813

This commit is contained in:
2026-08-13 16:46:29 +00:00
5 changed files with 130 additions and 25 deletions
+93 -14
View File
@@ -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<Buffer> => {
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: {
@@ -278,7 +305,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();
@@ -343,7 +389,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],
@@ -427,19 +473,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<HTMLElement>('.map-bglayer1')!).backgroundImage,
road: getComputedStyle(element.querySelector<HTMLElement>('.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<HTMLElement>('.map-area')!;
@@ -604,7 +683,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$/);
+20 -1
View File
@@ -164,6 +164,25 @@ const install = async (
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'dashboard.getContextBundleDelta') {
return response({
context: {
kind: 'snapshot',
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
data: generalContext,
},
commandTable: {
kind: 'snapshot',
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
data: { general: [], nation: [] },
},
boardAccess: {
kind: 'snapshot',
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
data: { permission: 0, canMeeting: false, canSecret: false },
},
});
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(mapFixture);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
@@ -807,7 +826,7 @@ test('a Chromium map click opens the clicked city route and keeps the legacy poi
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, '');
const cityLink = page.locator('.map-city').first();
const cityLink = page.locator('.city-base').first();
await expect(cityLink).toBeVisible();
await cityLink.hover();
await expect(cityLink).toHaveCSS('cursor', 'pointer');
@@ -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"
/>
@@ -188,7 +188,7 @@ const cityStateStyle = computed(() => ({
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: #fff;
cursor: auto;
cursor: pointer;
text-decoration: none;
padding: 0;
border: 0;
@@ -63,14 +63,21 @@ 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,
selectedCityId: undefined,
}
);
const emit = defineEmits<{
(event: 'select-city', cityId: number): void;