Merge branch 'main' into fix/tournament-icon-standard-20260813
This commit is contained in:
@@ -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$/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user