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 { 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';
|
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 response = (data: unknown) => ({ result: { data } });
|
||||||
const errorResponse = (path: string, message: string) => ({
|
const errorResponse = (path: string, message: string) => ({
|
||||||
error: {
|
error: {
|
||||||
@@ -278,7 +305,26 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
localStorage.setItem('sammo-game-token', 'ga_commands');
|
localStorage.setItem('sammo-game-token', 'ga_commands');
|
||||||
localStorage.setItem('sammo-game-profile', profile);
|
localStorage.setItem('sammo-game-profile', profile);
|
||||||
}, gameProfile);
|
}, 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) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
const names = operations(route);
|
const names = operations(route);
|
||||||
const body = route.request().postDataJSON();
|
const body = route.request().postDataJSON();
|
||||||
@@ -343,7 +389,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
month: 1,
|
month: 1,
|
||||||
cityList: [
|
cityList: [
|
||||||
[1, 8, 0, 1, 1, 1],
|
[1, 8, 0, 1, 1, 1],
|
||||||
[2, 7, 40, 2, 2, 1],
|
[2, 7, 41, 2, 2, 1],
|
||||||
],
|
],
|
||||||
nationList: [
|
nationList: [
|
||||||
[1, '아국', '#008000', 1],
|
[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-map')).toBeVisible();
|
||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
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.locator('select')).toHaveValue('2');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
||||||
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover();
|
await mapCities.nth(1).hover();
|
||||||
expect(
|
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
await form
|
await mapCities.nth(1).focus();
|
||||||
.getByTestId('command-argument-map')
|
await expect(mapCities.nth(1)).toBeFocused();
|
||||||
.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 expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
||||||
const area = element.querySelector<HTMLElement>('.map-area')!;
|
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();
|
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||||||
const form = picker.getByTestId('command-argument-form');
|
const form = picker.getByTestId('command-argument-form');
|
||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
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.locator('select')).toHaveValue('2');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
||||||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||||
|
|||||||
@@ -164,6 +164,25 @@ const install = async (
|
|||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
||||||
if (operation === 'join.getConfig') return response({});
|
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 === 'general.me') return response(generalContext);
|
||||||
if (operation === 'world.getMap') return response(mapFixture);
|
if (operation === 'world.getMap') return response(mapFixture);
|
||||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
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 install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await go(page, '');
|
await go(page, '');
|
||||||
const cityLink = page.locator('.map-city').first();
|
const cityLink = page.locator('.city-base').first();
|
||||||
await expect(cityLink).toBeVisible();
|
await expect(cityLink).toBeVisible();
|
||||||
await cityLink.hover();
|
await cityLink.hover();
|
||||||
await expect(cityLink).toHaveCSS('cursor', 'pointer');
|
await expect(cityLink).toHaveCSS('cursor', 'pointer');
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ watch(
|
|||||||
:map-layout="props.mapLayout ?? null"
|
:map-layout="props.mapLayout ?? null"
|
||||||
:loading="false"
|
:loading="false"
|
||||||
:selected-city-id="mapSelectedCityId"
|
:selected-city-id="mapSelectedCityId"
|
||||||
:detail-mode="false"
|
:detail-mode="true"
|
||||||
:fit-container="true"
|
:fit-container="true"
|
||||||
@select-city="selectMapCity"
|
@select-city="selectMapCity"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const cityStateStyle = computed(() => ({
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: auto;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -63,14 +63,21 @@ interface CityView {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
mapData: MapSummary | null;
|
defineProps<{
|
||||||
mapLayout: MapLayout | null;
|
mapData: MapSummary | null;
|
||||||
loading: boolean;
|
mapLayout: MapLayout | null;
|
||||||
selectedCityId?: number | null;
|
loading: boolean;
|
||||||
detailMode?: boolean;
|
selectedCityId?: number | null;
|
||||||
fitContainer?: boolean;
|
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<{
|
const emit = defineEmits<{
|
||||||
(event: 'select-city', cityId: number): void;
|
(event: 'select-city', cityId: number): void;
|
||||||
|
|||||||
Reference in New Issue
Block a user