- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과 임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다. - 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다. - 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인 전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다. - 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
4599 lines
230 KiB
TypeScript
4599 lines
230 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
||
import { readFile, writeFile } from 'node:fs/promises';
|
||
import {
|
||
buildRefGeneralTargetOptions,
|
||
buildRefNationTargetOptions,
|
||
type NationTargetSource,
|
||
} from '../../game-api/src/turns/commandTargets.js';
|
||
import { purifyNationHtml } from '../../game-api/src/security/nationHtml.js';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||
import { touchDrag } from './touchDrag.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: {
|
||
message,
|
||
code: -32000,
|
||
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||
},
|
||
});
|
||
const operations = (route: Route) =>
|
||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||
|
||
const inputOptions = {
|
||
cities: [
|
||
{ value: 1, label: '업 (아국)' },
|
||
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||
{ value: 3, label: '단양 (오)' },
|
||
],
|
||
nations: [
|
||
{ value: 1, label: '아국', color: '#008000' },
|
||
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
|
||
{ value: 3, label: '불가침국', color: '#000080', description: '수도 단양' },
|
||
],
|
||
nationTargets: {
|
||
che_물자원조: [
|
||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 원조 대상 · 교역' },
|
||
{
|
||
value: 3,
|
||
label: '불가침국',
|
||
color: '#000080',
|
||
availableNow: true,
|
||
description: '현재 원조 대상 · 불가침 12턴',
|
||
},
|
||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||
],
|
||
che_불가침제의: [
|
||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 제의 가능 · 교역' },
|
||
{
|
||
value: 3,
|
||
label: '불가침국',
|
||
color: '#000080',
|
||
availableNow: true,
|
||
description: '현재 제의 가능 · 불가침 12턴',
|
||
},
|
||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||
],
|
||
che_선전포고: [
|
||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 선전포고 가능 · 교역' },
|
||
{
|
||
value: 3,
|
||
label: '불가침국',
|
||
color: '#000080',
|
||
availableNow: false,
|
||
description: '현재 외교 관계에서는 선전포고 불가',
|
||
},
|
||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||
],
|
||
che_종전제의: [
|
||
{
|
||
value: 2,
|
||
label: '적국',
|
||
color: '#800000',
|
||
availableNow: true,
|
||
description: '현재 종전 제의 가능 · 전쟁 6턴',
|
||
},
|
||
{
|
||
value: 3,
|
||
label: '불가침국',
|
||
color: '#000080',
|
||
availableNow: false,
|
||
description: '전쟁·선포 중인 국가가 아닙니다.',
|
||
},
|
||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||
],
|
||
che_불가침파기제의: [
|
||
{
|
||
value: 3,
|
||
label: '불가침국',
|
||
color: '#000080',
|
||
availableNow: true,
|
||
description: '현재 불가침 파기 제의 가능 · 불가침 12턴',
|
||
},
|
||
{
|
||
value: 2,
|
||
label: '적국',
|
||
color: '#800000',
|
||
availableNow: false,
|
||
description: '불가침 중인 국가가 아닙니다.',
|
||
},
|
||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||
],
|
||
},
|
||
generals: [
|
||
{ value: 1, label: '장수 (아국 · 업)' },
|
||
{ value: 2, label: '관우 (아국 · 업)' },
|
||
],
|
||
generalTargets: {
|
||
che_포상: [
|
||
{
|
||
value: 1,
|
||
label: '장수 (업)',
|
||
gold: 5000,
|
||
rice: 400,
|
||
crew: 500,
|
||
description: '금 5,000 · 쌀 400 · 병력 500',
|
||
},
|
||
{
|
||
value: 2,
|
||
label: '관우 (업)',
|
||
gold: 100,
|
||
rice: 4000,
|
||
crew: 1200,
|
||
troopId: 2,
|
||
description: '금 100 · 쌀 4,000 · 병력 1,200',
|
||
},
|
||
{
|
||
value: 3,
|
||
label: '여포NPC (업)',
|
||
npcState: 2,
|
||
gold: 3000,
|
||
rice: 500,
|
||
crew: 1500,
|
||
troopId: 2,
|
||
description: '금 3,000 · 쌀 500 · 병력 1,500',
|
||
},
|
||
],
|
||
che_몰수: [
|
||
{
|
||
value: 1,
|
||
label: '장수 (업)',
|
||
gold: 5000,
|
||
rice: 400,
|
||
crew: 500,
|
||
description: '금 5,000 · 쌀 400 · 병력 500',
|
||
},
|
||
{
|
||
value: 2,
|
||
label: '관우 (업)',
|
||
gold: 100,
|
||
rice: 4000,
|
||
crew: 1200,
|
||
troopId: 2,
|
||
description: '금 100 · 쌀 4,000 · 병력 1,200',
|
||
},
|
||
{
|
||
value: 3,
|
||
label: '여포NPC (업)',
|
||
npcState: 2,
|
||
gold: 3000,
|
||
rice: 500,
|
||
crew: 1500,
|
||
troopId: 2,
|
||
description: '금 3,000 · 쌀 500 · 병력 1,500',
|
||
},
|
||
],
|
||
che_발령:
|
||
buildRefGeneralTargetOptions({
|
||
actorId: 1,
|
||
actorNationId: 1,
|
||
generals: [
|
||
{
|
||
id: 1,
|
||
name: '장수',
|
||
nationId: 1,
|
||
cityId: 1,
|
||
npcState: 0,
|
||
officerLevel: 12,
|
||
leadership: 70,
|
||
strength: 80,
|
||
intel: 90,
|
||
crew: 500,
|
||
train: 0,
|
||
atmos: 0,
|
||
gold: 5000,
|
||
rice: 400,
|
||
troopId: 0,
|
||
},
|
||
{
|
||
id: 2,
|
||
name: '관우',
|
||
nationId: 1,
|
||
cityId: 1,
|
||
npcState: 0,
|
||
officerLevel: 5,
|
||
leadership: 100,
|
||
strength: 95,
|
||
intel: 70,
|
||
crew: 1200,
|
||
train: 80,
|
||
atmos: 70,
|
||
gold: 100,
|
||
rice: 4000,
|
||
troopId: 2,
|
||
},
|
||
{
|
||
id: 3,
|
||
name: '여포NPC',
|
||
nationId: 1,
|
||
cityId: 1,
|
||
npcState: 2,
|
||
officerLevel: 0,
|
||
leadership: 90,
|
||
strength: 100,
|
||
intel: 0,
|
||
crew: 1500,
|
||
train: 100,
|
||
atmos: 100,
|
||
gold: 3000,
|
||
rice: 500,
|
||
troopId: 2,
|
||
},
|
||
],
|
||
nationNames: new Map([[1, '아국']]),
|
||
cityNames: new Map([[1, '업']]),
|
||
troopNames: new Map([[2, '청룡대']]),
|
||
}).generalTargets.che_발령 ?? [],
|
||
che_부대탈퇴지시: [
|
||
{
|
||
value: 3,
|
||
label: '여포NPC (아국 · 업)',
|
||
npcState: 2,
|
||
availableNow: true,
|
||
crew: 1500,
|
||
troopId: 2,
|
||
description: '현재 탈퇴 지시 가능 · 병력 1,500 · 탑승 부대 청룡대',
|
||
},
|
||
{
|
||
value: 1,
|
||
label: '장수 (아국 · 업)',
|
||
availableNow: false,
|
||
crew: 500,
|
||
description: '현재 탈퇴 지시 불가 · 탑승 부대 없음',
|
||
},
|
||
{
|
||
value: 2,
|
||
label: '관우 (아국 · 업)',
|
||
availableNow: false,
|
||
crew: 1200,
|
||
troopId: 2,
|
||
description: '현재 탈퇴 지시 불가 · 탑승 부대 청룡대 (부대장)',
|
||
},
|
||
],
|
||
},
|
||
crewTypes: [{ value: 1100, label: '보병' }],
|
||
armTypes: [{ value: 1, label: '보병' }],
|
||
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
|
||
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
|
||
items: { horse: [{ value: 'None', label: '판매/해제' }] },
|
||
recruitment: {
|
||
techLevel: 1,
|
||
leadership: 68,
|
||
fullLeadership: 70,
|
||
currentCrewTypeId: 1100,
|
||
currentCrewTypeName: '보병',
|
||
crew: 500,
|
||
gold: 12_345,
|
||
groups: [
|
||
{
|
||
armType: 1,
|
||
armName: '보병',
|
||
values: [
|
||
{
|
||
id: 1100,
|
||
armType: 1,
|
||
name: '보병',
|
||
available: true,
|
||
special: false,
|
||
attack: 125,
|
||
defence: 175,
|
||
speed: 7,
|
||
avoid: 10,
|
||
baseCost: 10.35,
|
||
baseRice: 10.35,
|
||
info: ['표준적인 보병입니다.', '보병은 방어특화입니다.'],
|
||
},
|
||
{
|
||
id: 1101,
|
||
armType: 1,
|
||
name: '정예병',
|
||
available: false,
|
||
special: true,
|
||
attack: 175,
|
||
defence: 225,
|
||
speed: 8,
|
||
avoid: 20,
|
||
baseCost: 13.8,
|
||
baseRice: 11.5,
|
||
info: [
|
||
'강력하지만 기술이 필요합니다.',
|
||
'기술력 2000 이상 필요',
|
||
'중원 지역 소유시 가능',
|
||
'저 소유시 가능',
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
amountPresets: {
|
||
che_포상: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
||
che_몰수: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
||
che_물자원조: { values: [10000, 20000, 30000], defaultValue: 1000, min: 1000, max: 30000, step: 10 },
|
||
},
|
||
context: {
|
||
actorGold: 1000,
|
||
actorRice: 1000,
|
||
citySecurity: 500,
|
||
nationGold: 5000,
|
||
nationRice: 6000,
|
||
nationLevel: 1,
|
||
},
|
||
};
|
||
const buildCityCommand = (key: string, name: string) => ({
|
||
key,
|
||
name,
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [{ key: 'destCityId', label: '대상 도시', kind: 'select', required: true, optionSource: 'cities' }],
|
||
});
|
||
const buildNationCommand = (key: string, name: string) => ({
|
||
key,
|
||
name,
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [{ key: 'destNationId', label: '대상 국가', kind: 'select', required: true, optionSource: 'nations' }],
|
||
});
|
||
const buildGeneralCommand = (key: string, name: string) => ({
|
||
key,
|
||
name,
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
|
||
],
|
||
});
|
||
const buildSimpleCommand = (key: string, name: string, turnDurationText?: string, costText?: string) => ({
|
||
key,
|
||
name,
|
||
...(turnDurationText ? { turnDurationText } : {}),
|
||
...(costText ? { costText } : {}),
|
||
reqArg: false,
|
||
possible: true,
|
||
status: 'available',
|
||
inputFields: [],
|
||
});
|
||
const commandTable = {
|
||
general: [
|
||
{
|
||
category: '계략',
|
||
values: [
|
||
{
|
||
key: 'che_화계',
|
||
name: '화계',
|
||
reqArg: true,
|
||
possible: false,
|
||
status: 'blocked',
|
||
reason: '현재 조건에서는 실행할 수 없습니다.',
|
||
inputFields: [
|
||
{
|
||
key: 'destCityId',
|
||
label: '대상 도시',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'cities',
|
||
},
|
||
],
|
||
},
|
||
...[
|
||
{ key: 'che_선동', name: '선동' },
|
||
{ key: 'che_탈취', name: '탈취' },
|
||
{ key: 'che_파괴', name: '파괴' },
|
||
].map(({ key, name }) => ({
|
||
key,
|
||
name,
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{
|
||
key: 'destCityId',
|
||
label: '대상 도시',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'cities',
|
||
},
|
||
],
|
||
})),
|
||
],
|
||
},
|
||
{
|
||
category: '내정',
|
||
values: [
|
||
{
|
||
key: 'che_징병',
|
||
name: '징병',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
|
||
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||
],
|
||
},
|
||
{
|
||
key: 'che_모병',
|
||
name: '모병',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
|
||
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
category: '군사',
|
||
values: [
|
||
{
|
||
key: 'che_출병',
|
||
name: '출병',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{
|
||
key: 'destCityId',
|
||
label: '대상 도시',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'cities',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
key: 'che_첩보',
|
||
name: '첩보',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{
|
||
key: 'destCityId',
|
||
label: '대상 도시',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'cities',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
nation: [
|
||
{
|
||
category: '인사',
|
||
values: [
|
||
{
|
||
...buildGeneralCommand('che_발령', '발령'),
|
||
inputFields: [
|
||
...buildGeneralCommand('che_발령', '발령').inputFields,
|
||
...buildCityCommand('che_발령', '발령').inputFields,
|
||
],
|
||
},
|
||
{
|
||
key: 'che_포상',
|
||
name: '포상',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
|
||
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||
{
|
||
key: 'destGeneralId',
|
||
label: '대상 장수',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'generals',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
key: 'che_몰수',
|
||
name: '몰수',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
|
||
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||
...buildGeneralCommand('che_몰수', '몰수').inputFields,
|
||
],
|
||
},
|
||
buildGeneralCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
|
||
],
|
||
},
|
||
{
|
||
category: '외교',
|
||
values: [
|
||
{
|
||
key: 'che_물자원조',
|
||
name: '원조',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
...buildNationCommand('che_물자원조', '원조').inputFields,
|
||
{
|
||
key: 'amountList',
|
||
label: '지원 물자',
|
||
kind: 'numberTuple',
|
||
required: true,
|
||
min: 0,
|
||
step: 1,
|
||
tupleLabels: ['금', '쌀'],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
...buildNationCommand('che_불가침제의', '불가침 제의'),
|
||
inputFields: [
|
||
...buildNationCommand('che_불가침제의', '불가침 제의').inputFields,
|
||
{
|
||
key: 'year',
|
||
label: '기간(년)',
|
||
kind: 'number',
|
||
required: true,
|
||
min: 191,
|
||
max: 210,
|
||
step: 1,
|
||
defaultValue: 191,
|
||
},
|
||
{
|
||
key: 'month',
|
||
label: '기간(월)',
|
||
kind: 'number',
|
||
required: true,
|
||
min: 1,
|
||
max: 12,
|
||
step: 1,
|
||
defaultValue: 1,
|
||
},
|
||
],
|
||
},
|
||
buildNationCommand('che_선전포고', '선전포고'),
|
||
buildNationCommand('che_종전제의', '종전 제의'),
|
||
buildNationCommand('che_불가침파기제의', '불가침 파기 제의'),
|
||
],
|
||
},
|
||
{
|
||
category: '특수',
|
||
values: [
|
||
buildCityCommand('che_초토화', '초토화'),
|
||
buildCityCommand('che_천도', '천도'),
|
||
buildSimpleCommand('che_증축', '증축'),
|
||
buildSimpleCommand('che_감축', '감축'),
|
||
],
|
||
},
|
||
{
|
||
category: '전략',
|
||
values: [
|
||
buildCityCommand('che_백성동원', '백성동원'),
|
||
buildCityCommand('che_수몰', '수몰'),
|
||
buildCityCommand('che_허보', '허보'),
|
||
buildNationCommand('che_이호경식', '이호경식'),
|
||
buildNationCommand('che_급습', '급습'),
|
||
{
|
||
...buildNationCommand('che_피장파장', '피장파장'),
|
||
inputFields: [
|
||
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||
{
|
||
key: 'commandType',
|
||
label: '대응 명령',
|
||
kind: 'select',
|
||
required: true,
|
||
options: [{ value: 'che_수몰', label: '수몰' }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
inputOptions,
|
||
};
|
||
const basicRecruitmentCrewTypes = [
|
||
{
|
||
id: 1100,
|
||
armType: 1,
|
||
name: '보병',
|
||
attack: 100,
|
||
defence: 150,
|
||
speed: 7,
|
||
avoid: 10,
|
||
baseCost: 9,
|
||
baseRice: 9,
|
||
info: ['표준적인 보병입니다.'],
|
||
},
|
||
{
|
||
id: 1200,
|
||
armType: 2,
|
||
name: '궁병',
|
||
attack: 100,
|
||
defence: 100,
|
||
speed: 7,
|
||
avoid: 20,
|
||
baseCost: 10,
|
||
baseRice: 10,
|
||
info: ['표준적인 궁병입니다.'],
|
||
},
|
||
{
|
||
id: 1300,
|
||
armType: 3,
|
||
name: '기병',
|
||
attack: 150,
|
||
defence: 100,
|
||
speed: 7,
|
||
avoid: 5,
|
||
baseCost: 11,
|
||
baseRice: 11,
|
||
info: ['표준적인 기병입니다.'],
|
||
},
|
||
{
|
||
id: 1400,
|
||
armType: 4,
|
||
name: '귀병',
|
||
attack: 80,
|
||
defence: 80,
|
||
speed: 7,
|
||
avoid: 5,
|
||
baseCost: 9,
|
||
baseRice: 9,
|
||
info: ['계략을 사용하는 병종입니다.'],
|
||
},
|
||
].map((crewType) => ({ ...crewType, available: true, special: false }));
|
||
const fourArmRecruitmentCommandTable = {
|
||
...commandTable,
|
||
inputOptions: {
|
||
...inputOptions,
|
||
crewTypes: basicRecruitmentCrewTypes.map((crewType) => ({ value: crewType.id, label: crewType.name })),
|
||
recruitment: {
|
||
...inputOptions.recruitment,
|
||
groups: basicRecruitmentCrewTypes.map((crewType) => ({
|
||
armType: crewType.armType,
|
||
armName: crewType.name,
|
||
values: [crewType],
|
||
})),
|
||
},
|
||
},
|
||
};
|
||
const refChiefCommandTable = {
|
||
general: [],
|
||
nation: [
|
||
{ category: '휴식', values: [buildSimpleCommand('휴식', '휴식')] },
|
||
{
|
||
category: '인사',
|
||
values: [
|
||
buildSimpleCommand('che_발령', '발령'),
|
||
buildSimpleCommand('che_포상', '포상'),
|
||
buildSimpleCommand('che_몰수', '몰수'),
|
||
buildSimpleCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
|
||
],
|
||
},
|
||
{
|
||
category: '외교',
|
||
values: [
|
||
buildSimpleCommand('che_물자원조', '원조'),
|
||
buildSimpleCommand('che_불가침제의', '불가침 제의'),
|
||
buildSimpleCommand('che_선전포고', '선전포고'),
|
||
buildSimpleCommand('che_종전제의', '종전 제의'),
|
||
buildSimpleCommand('che_불가침파기제의', '불가침 파기 제의'),
|
||
],
|
||
},
|
||
{
|
||
category: '특수',
|
||
values: [
|
||
buildSimpleCommand('che_초토화', '초토화', '3턴'),
|
||
buildSimpleCommand('che_천도', '천도', '1+거리×2턴', '금·쌀 500 × 2^거리'),
|
||
buildSimpleCommand('che_증축', '증축', '6턴', '금 110,000 · 쌀 110,000'),
|
||
buildSimpleCommand('che_감축', '감축', '6턴', '금 80,000 · 쌀 80,000 회수'),
|
||
],
|
||
},
|
||
{
|
||
category: '전략',
|
||
values: [
|
||
buildSimpleCommand('che_필사즉생', '필사즉생', '3턴'),
|
||
buildSimpleCommand('che_백성동원', '백성동원'),
|
||
buildSimpleCommand('che_수몰', '수몰', '3턴'),
|
||
buildSimpleCommand('che_허보', '허보', '2턴'),
|
||
buildSimpleCommand('che_의병모집', '의병모집', '3턴'),
|
||
buildSimpleCommand('che_이호경식', '이호경식'),
|
||
buildSimpleCommand('che_급습', '급습'),
|
||
buildSimpleCommand('che_피장파장', '피장파장', '2턴'),
|
||
],
|
||
},
|
||
{
|
||
category: '기타',
|
||
values: [buildSimpleCommand('che_국기변경', '국기변경'), buildSimpleCommand('che_국호변경', '국호변경')],
|
||
},
|
||
],
|
||
inputOptions,
|
||
};
|
||
const generalContext = {
|
||
general: {
|
||
id: 1,
|
||
name: '장수',
|
||
nationId: 1,
|
||
cityId: 1,
|
||
officerLevel: 5,
|
||
npcState: 0,
|
||
troopId: 0,
|
||
picture: null,
|
||
imageServer: 0,
|
||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||
gold: 1000,
|
||
rice: 1000,
|
||
crew: 500,
|
||
train: 90,
|
||
atmos: 90,
|
||
injury: 0,
|
||
experience: 0,
|
||
dedication: 0,
|
||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||
turnTime: '2026-08-17T12:00:00.000Z',
|
||
},
|
||
city: {
|
||
id: 1,
|
||
name: '업',
|
||
level: 8,
|
||
levelName: '특',
|
||
region: 1,
|
||
regionName: '하북',
|
||
nationId: 1,
|
||
nationName: '아국',
|
||
population: 1000,
|
||
populationMax: 2000,
|
||
agriculture: 100,
|
||
agricultureMax: 200,
|
||
commerce: 100,
|
||
commerceMax: 200,
|
||
security: 100,
|
||
securityMax: 200,
|
||
trust: 70,
|
||
trade: 100,
|
||
defence: 100,
|
||
defenceMax: 200,
|
||
wall: 100,
|
||
wallMax: 200,
|
||
supplyState: 1,
|
||
frontState: 0,
|
||
},
|
||
nation: {
|
||
id: 1,
|
||
name: '아국',
|
||
color: '#008000',
|
||
level: 1,
|
||
gold: 5000,
|
||
rice: 6000,
|
||
tech: 100,
|
||
typeName: '중립',
|
||
typePros: '',
|
||
typeCons: '',
|
||
population: { cityCount: 1, current: 1000, max: 2000 },
|
||
crew: { generalCount: 2, current: 500, max: 7000 },
|
||
power: 1234,
|
||
bill: 100,
|
||
taxRate: 20,
|
||
strategicCommandLimit: 0,
|
||
diplomaticLimit: 0,
|
||
prohibitScout: false,
|
||
prohibitWar: false,
|
||
techLevel: 1,
|
||
techLimited: false,
|
||
topChiefs: {},
|
||
impossibleStrategicCommands: [],
|
||
},
|
||
settings: {},
|
||
penalties: {},
|
||
};
|
||
const turns = (count: number) => Array.from({ length: count }, (_, index) => ({ index, action: '휴식', args: {} }));
|
||
const chiefCenter = {
|
||
me: { id: 1, officerLevel: 5, nationId: 1 },
|
||
nation: { id: 1, name: '아국', level: 1 },
|
||
currentYear: 200,
|
||
currentMonth: 1,
|
||
turnTermMinutes: 10,
|
||
maxTurns: 12,
|
||
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
|
||
officerLevel,
|
||
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
|
||
npcState: officerLevel === 8 ? 2 : 0,
|
||
turnTime: '2026-08-30T01:46:47.590Z',
|
||
revision: 0,
|
||
turns:
|
||
officerLevel === 12
|
||
? [
|
||
{ index: 0, action: 'che_포상', args: { destGeneralId: 2, isGold: false, amount: 300 } },
|
||
...turns(11).map((turn) => ({ ...turn, index: turn.index + 1 })),
|
||
]
|
||
: turns(12),
|
||
})),
|
||
};
|
||
|
||
const install = async (
|
||
page: Page,
|
||
rejectGeneral = false,
|
||
commandTableResponse: unknown = commandTable,
|
||
generalId = 1,
|
||
recoveryClock?: {
|
||
serverTime: string;
|
||
serverWallTime: string;
|
||
clockRunning: boolean;
|
||
clockRecovery: { startsAt: string; endsAt: string } | null;
|
||
turnEngineRunning: boolean;
|
||
},
|
||
decoratedNpcChiefs = false
|
||
) => {
|
||
const requests: unknown[] = [];
|
||
const currentGeneralContext = {
|
||
...generalContext,
|
||
general: {
|
||
...generalContext.general,
|
||
id: generalId,
|
||
...(recoveryClock ? { turnTime: '2026-09-10T01:20:00Z' } : {}),
|
||
},
|
||
};
|
||
const generalTurns = turns(30);
|
||
const nationTurns = turns(12);
|
||
let generalRevision = 0;
|
||
let nationRevision = 0;
|
||
let dashboardLoaded = false;
|
||
page.on('framenavigated', (frame) => {
|
||
if (frame === page.mainFrame()) {
|
||
dashboardLoaded = false;
|
||
}
|
||
});
|
||
await page.addInitScript((profile) => {
|
||
localStorage.setItem('sammo-game-token', 'ga_commands');
|
||
localStorage.setItem('sammo-game-profile', profile);
|
||
}, gameProfile);
|
||
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();
|
||
const results = names.map((name) => {
|
||
if (name === 'dashboard.getContextBundleDelta') {
|
||
const initial = !dashboardLoaded;
|
||
dashboardLoaded = true;
|
||
return response({
|
||
context: initial
|
||
? {
|
||
kind: 'snapshot',
|
||
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||
data: currentGeneralContext,
|
||
}
|
||
: { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' },
|
||
commandTable: initial
|
||
? {
|
||
kind: 'snapshot',
|
||
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||
data: commandTableResponse,
|
||
}
|
||
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
|
||
boardAccess: initial
|
||
? {
|
||
kind: 'snapshot',
|
||
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
||
data: { permission: 4, canMeeting: true, canSecret: true },
|
||
}
|
||
: { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' },
|
||
});
|
||
}
|
||
if (name === 'general.me') return response(currentGeneralContext);
|
||
if (name === 'world.getMapLayout')
|
||
return response({
|
||
mapName: 'che',
|
||
cityList: [
|
||
{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
|
||
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||
],
|
||
regionMap: { 1: '하북', 2: '예주' },
|
||
levelMap: { 8: '특', 7: '대' },
|
||
});
|
||
if (name === 'auth.status') return response({ ok: true });
|
||
if (name === 'lobby.info')
|
||
return response({
|
||
myGeneral: { id: generalId, name: '장수' },
|
||
year: 200,
|
||
month: 1,
|
||
turnTerm: recoveryClock ? 60 : 10,
|
||
...recoveryClock,
|
||
userCnt: 1,
|
||
maxUserCnt: 100,
|
||
npcCnt: 0,
|
||
nationCnt: 2,
|
||
});
|
||
if (name === 'join.getConfig') return response({});
|
||
if (name === 'world.getMap') {
|
||
requests.push({ operation: name, body });
|
||
return response({
|
||
result: true,
|
||
version: 0,
|
||
startYear: 180,
|
||
year: 200,
|
||
month: 1,
|
||
cityList: [
|
||
[1, 8, 0, 1, 1, 1],
|
||
[2, 7, 41, 2, 2, 1],
|
||
],
|
||
nationList: [
|
||
[1, '아국', '#008000', 1],
|
||
[2, '적국', '#800000', 2],
|
||
],
|
||
spyList: {},
|
||
shownByGeneralList: [],
|
||
myCity: 1,
|
||
myNation: 1,
|
||
});
|
||
}
|
||
if (name === 'turns.getCommandTable') return response(commandTableResponse);
|
||
if (name === 'nation.getChiefCenter')
|
||
return response(
|
||
recoveryClock
|
||
? {
|
||
...chiefCenter,
|
||
turnTermMinutes: 60,
|
||
chiefs: chiefCenter.chiefs.map((chief) => ({
|
||
...chief,
|
||
turnTime: '2026-09-10T01:20:00Z',
|
||
})),
|
||
}
|
||
: decoratedNpcChiefs
|
||
? {
|
||
...chiefCenter,
|
||
chiefs: chiefCenter.chiefs.map((chief, index) => ({
|
||
...chief,
|
||
name: ['ⓖ의병', 'ⓤ중립', 'ⓞ특수', 'ⓧ기타', '㉥부대장', 'ⓜ인재', 'ⓝ장수', '사용자'][
|
||
index
|
||
]!,
|
||
npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!,
|
||
})),
|
||
}
|
||
: chiefCenter
|
||
);
|
||
if (name === 'turns.reserved.getGeneral')
|
||
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
|
||
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
|
||
if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
||
if (name === 'general.getFrontStatus')
|
||
return response({
|
||
onlineUserCount: 1,
|
||
onlineNations: '아국(1)',
|
||
onlineGenerals: '장수',
|
||
nationNotice: '',
|
||
lastExecuted: null,
|
||
latestVote: null,
|
||
});
|
||
if (name === 'messages.getRecent')
|
||
return response({
|
||
private: [],
|
||
national: [],
|
||
public: [],
|
||
diplomacy: [],
|
||
sequence: -1,
|
||
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
||
latestRead: { private: 0, diplomacy: 0 },
|
||
canRespondDiplomacy: false,
|
||
});
|
||
if (name === 'messages.getContacts') return response({ nation: [] });
|
||
if (name === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
||
if (name === 'tournament.getState') return response({ stage: 0 });
|
||
if (name === 'turns.reserved.shiftGeneral') {
|
||
const input = (body as Record<string, { generalId: number; amount: number; expectedRevision: number }>)[
|
||
String(names.indexOf(name))
|
||
];
|
||
expect(route.request().method()).toBe('POST');
|
||
expect(input.generalId).toBe(generalId);
|
||
expect(input.expectedRevision).toBe(generalRevision);
|
||
expect(
|
||
Number.isInteger(input.amount) && Math.abs(input.amount) >= 1 && Math.abs(input.amount) <= 6
|
||
).toBe(true);
|
||
requests.push(input);
|
||
generalRevision += 1;
|
||
return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
|
||
}
|
||
if (name === 'turns.reserved.setGeneralBulk') {
|
||
requests.push(body);
|
||
if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.');
|
||
const input = (
|
||
body as Record<string, { entries: Array<{ turnList: number[]; action: string; args?: unknown }> }>
|
||
)[String(names.indexOf(name))];
|
||
for (const entry of input?.entries ?? []) {
|
||
for (const index of entry.turnList)
|
||
generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
|
||
}
|
||
generalRevision += 1;
|
||
return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
|
||
}
|
||
if (name === 'turns.reserved.setNationBulk') {
|
||
requests.push(body);
|
||
const input = (
|
||
body as Record<string, { entries: Array<{ turnList: number[]; action: string; args?: unknown }> }>
|
||
)[String(names.indexOf(name))];
|
||
for (const entry of input?.entries ?? []) {
|
||
for (const index of entry.turnList)
|
||
nationTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
|
||
}
|
||
nationRevision += 1;
|
||
return response({ ok: true, revision: nationRevision, turns: nationTurns });
|
||
}
|
||
return errorResponse(name, `unhandled ${name}`);
|
||
});
|
||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||
});
|
||
return requests;
|
||
};
|
||
|
||
for (const width of [1200, 500]) {
|
||
test(`split turn shift applies one turn or the chosen count in both modes at ${width}px`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page);
|
||
const shifts = () =>
|
||
requests.filter((entry) => typeof entry === 'object' && entry !== null && 'amount' in entry);
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto('/');
|
||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||
const evidence: unknown[] = [];
|
||
for (const advanced of [false, true]) {
|
||
if (advanced) await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
for (const [index, label] of ['당기기', '미루기'].entries()) {
|
||
const direction = index === 0 ? -1 : 1;
|
||
const control = editor.locator('.bottom-shift-control').nth(index);
|
||
const main = control.getByRole('button', { name: label, exact: true });
|
||
const menu = control.locator('details');
|
||
const toggle = menu.locator('summary');
|
||
const before = shifts().length;
|
||
await main.click();
|
||
await expect.poll(() => shifts().length).toBe(before + 1);
|
||
expect(shifts().at(-1)).toMatchObject({ amount: direction });
|
||
await expect(menu).not.toHaveAttribute('open');
|
||
await toggle.focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(menu).toHaveAttribute('open');
|
||
expect(shifts().length).toBe(before + 1);
|
||
await expect(menu.locator('.menu-items > button')).toHaveText([
|
||
'1턴',
|
||
'2턴',
|
||
'3턴',
|
||
'4턴',
|
||
'5턴',
|
||
'6턴',
|
||
]);
|
||
const geometry = await control.evaluate((element) => {
|
||
const main = element.querySelector('button')!;
|
||
const toggle = element.querySelector('summary')!;
|
||
const menu = element.querySelector('.menu-items')!;
|
||
return {
|
||
main: main.getBoundingClientRect().toJSON(),
|
||
toggle: toggle.getBoundingClientRect().toJSON(),
|
||
menu: menu.getBoundingClientRect().toJSON(),
|
||
style: { radius: getComputedStyle(main).borderRadius, font: getComputedStyle(main).fontFamily },
|
||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
};
|
||
});
|
||
expect(geometry.main.right).toBeCloseTo(geometry.toggle.left, 1);
|
||
expect(geometry.menu.right).toBeLessThanOrEqual(width);
|
||
expect(geometry.menu.left).toBeGreaterThanOrEqual(0);
|
||
expect(geometry.overflow).toBeLessThanOrEqual(0);
|
||
evidence.push({ advanced, label, geometry });
|
||
await page.screenshot({
|
||
path: testInfo.outputPath(`split-${advanced}-${index}-${width}.png`),
|
||
fullPage: true,
|
||
});
|
||
await menu.getByRole('button', { name: '6턴', exact: true }).click();
|
||
await expect.poll(() => shifts().length).toBe(before + 2);
|
||
expect(shifts().at(-1)).toMatchObject({ amount: direction * 6 });
|
||
await expect(menu).not.toHaveAttribute('open');
|
||
await main.click();
|
||
await expect.poll(() => shifts().length).toBe(before + 3);
|
||
expect(shifts().at(-1)).toMatchObject({ amount: direction });
|
||
}
|
||
}
|
||
await writeFile(testInfo.outputPath('split-evidence.json'), JSON.stringify(evidence, null, 2));
|
||
await writeFile(
|
||
testInfo.outputPath('split-editor.html'),
|
||
await editor.evaluate((element) => element.outerHTML)
|
||
);
|
||
});
|
||
}
|
||
|
||
test('offers 12 repeat turns and six shift turns for general turns while keeping the chief range', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const generalEditor = page.locator('[data-command-scope="general"]:visible');
|
||
const generalRepeat = generalEditor.locator('.control-pad > details').filter({ hasText: '반복' });
|
||
await generalRepeat.locator('summary').click();
|
||
await expect(generalRepeat.locator('.menu-items > button')).toHaveText(
|
||
Array.from({ length: 12 }, (_, index) => `${index + 1}턴`)
|
||
);
|
||
const generalPull = generalEditor.locator('.bottom-shift-menu').first();
|
||
await generalPull.locator('summary').click();
|
||
await expect(generalPull.locator('.menu-items > button')).toHaveText(
|
||
Array.from({ length: 6 }, (_, index) => `${index + 1}턴`)
|
||
);
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobileGeneralEditor = page.locator('[data-command-scope="general"]:visible');
|
||
const mobileGeneralRepeat = mobileGeneralEditor.locator('.control-pad > details').filter({ hasText: '반복' });
|
||
await mobileGeneralRepeat.locator('summary').click();
|
||
const mobileRepeatItems = mobileGeneralRepeat.locator('.menu-items');
|
||
await expect(mobileRepeatItems).toBeVisible();
|
||
await expect(mobileRepeatItems.locator(':scope > button')).toHaveText(
|
||
Array.from({ length: 12 }, (_, index) => `${index + 1}턴`)
|
||
);
|
||
const mobileGeometry = await mobileRepeatItems.evaluate((element) => ({
|
||
right: element.getBoundingClientRect().right,
|
||
height: element.getBoundingClientRect().height,
|
||
viewportWidth: document.documentElement.clientWidth,
|
||
overflow: element.scrollWidth - element.clientWidth,
|
||
}));
|
||
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||
expect(mobileGeometry.height).toBeGreaterThan(0);
|
||
expect(mobileGeometry.overflow).toBeLessThanOrEqual(0);
|
||
await page.screenshot({ path: test.info().outputPath('general-repeat-12-mobile-500.png'), fullPage: true });
|
||
|
||
await page.goto('/che/chief-center');
|
||
const chiefEditor = page.locator('[data-command-scope="nation"]:visible').first();
|
||
const chiefRepeat = chiefEditor.locator('.control-pad > details').filter({ hasText: '반복' });
|
||
await chiefRepeat.locator('summary').click();
|
||
await expect(chiefRepeat.locator('.menu-items > button')).toHaveText(
|
||
Array.from({ length: 6 }, (_, index) => `${index + 1}턴`)
|
||
);
|
||
});
|
||
|
||
test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '계략', exact: true }).click();
|
||
const strategies = [
|
||
{ name: '선동', guidance: '선택한 도시에 선동을 실행합니다.' },
|
||
{ name: '탈취', guidance: '선택한 도시에 탈취를 실행합니다.' },
|
||
{ name: '파괴', guidance: '선택한 도시에 파괴를 실행합니다.' },
|
||
{ name: '화계', guidance: '선택한 도시에 화계를 실행합니다.' },
|
||
];
|
||
for (const strategy of strategies) {
|
||
const button = picker.getByRole('button', { name: strategy.name, exact: true });
|
||
await expect(button).toBeVisible();
|
||
await button.click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
|
||
await expect(form.locator('select option')).toHaveCount(3);
|
||
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||
}
|
||
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
|
||
});
|
||
|
||
test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
const editor = page.locator('[data-command-scope="general"]');
|
||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
|
||
let picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||
const spy = picker.getByRole('button', { name: '첩보', exact: true });
|
||
await expect(spy).toBeVisible();
|
||
await spy.hover();
|
||
await spy.focus();
|
||
await expect(spy).toBeFocused();
|
||
await spy.click();
|
||
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.locator('select').selectOption('2');
|
||
await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') });
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행');
|
||
expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}');
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||
await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible();
|
||
const geometry = await picker.evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||
}));
|
||
expect(geometry.width).toBeLessThanOrEqual(500);
|
||
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
|
||
});
|
||
|
||
test('defaults founding to a Ref-selectable nation trait and opens colored options inside the app', async ({
|
||
page,
|
||
}) => {
|
||
const foundingColors = [
|
||
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||
{ value: 15, label: '색상 16', color: '#6495ED' },
|
||
{ value: 16, label: '색상 17', color: '#7FFFD4' },
|
||
];
|
||
const foundingCommandTable = {
|
||
general: [
|
||
{
|
||
category: '국가',
|
||
values: [
|
||
{
|
||
key: 'che_건국',
|
||
name: '건국',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'needsInput',
|
||
inputFields: [
|
||
{
|
||
key: 'nationName',
|
||
label: '국가명',
|
||
kind: 'text',
|
||
required: true,
|
||
min: 1,
|
||
max: 18,
|
||
legacyWidthMax: 18,
|
||
},
|
||
{
|
||
key: 'nationType',
|
||
label: '국가 성향',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'nationTypes',
|
||
},
|
||
{
|
||
key: 'colorType',
|
||
label: '국기 색상',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'colors',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
nation: [],
|
||
inputOptions: { ...inputOptions, colors: foundingColors },
|
||
};
|
||
const requests = await install(page, false, foundingCommandTable);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||
await picker.getByRole('button', { name: '건국', exact: true }).click();
|
||
const nationType = picker.getByLabel('국가 성향');
|
||
await expect(nationType).toHaveValue('che_도적');
|
||
await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0);
|
||
await expect(nationType.locator('option')).toHaveText(['도적']);
|
||
await nationType.focus();
|
||
await expect(nationType).toBeFocused();
|
||
|
||
const colorType = picker.getByLabel('국기 색상');
|
||
await expect(colorType).toHaveRole('button');
|
||
await colorType.click();
|
||
const colorList = picker.getByRole('listbox');
|
||
const color16 = colorList.getByRole('option', { name: '색상 16' });
|
||
await expect(color16).toHaveText('색상 16');
|
||
await expect(color16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||
await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||
await color16.hover();
|
||
await color16.focus();
|
||
await expect(color16).toBeFocused();
|
||
await color16.press('Escape');
|
||
await expect(colorList).toBeHidden();
|
||
await expect(colorType).toBeFocused();
|
||
await colorType.click();
|
||
await color16.click();
|
||
await expect(colorType).toContainText('색상 16');
|
||
await expect(colorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||
await expect(colorType).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||
await colorType.press('ArrowDown');
|
||
await page.getByRole('option', { name: '색상 17' }).press('Enter');
|
||
await expect(colorType).toHaveCSS('background-color', 'rgb(127, 255, 212)');
|
||
await expect(colorType).toHaveCSS('color', 'rgb(0, 0, 0)');
|
||
await colorType.press('ArrowUp');
|
||
await page.getByRole('option', { name: '색상 16' }).press('Enter');
|
||
await picker.screenshot({ path: test.info().outputPath('founding-colored-option-desktop-1200.png') });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const mobilePicker = page.getByTestId('command-picker');
|
||
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
|
||
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
|
||
await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차');
|
||
await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible();
|
||
await expect(mobilePicker.getByRole('button', { name: / 입력$/ })).toBeDisabled();
|
||
await mobilePicker.getByLabel('국가명').fill('신국');
|
||
const mobileColorType = mobilePicker.getByLabel('국기 색상');
|
||
await mobileColorType.click();
|
||
const mobileColorList = mobilePicker.getByRole('listbox');
|
||
const mobileColor16 = mobileColorList.getByRole('option', { name: '색상 16' });
|
||
await expect(mobileColor16).toBeVisible();
|
||
await expect(mobileColor16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||
await expect(mobileColor16).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||
const mobileOptionRect = await mobileColor16.evaluate((element) => element.getBoundingClientRect().toJSON());
|
||
expect(mobileOptionRect.height).toBeGreaterThanOrEqual(44);
|
||
const mobileListGeometry = await mobileColorList.evaluate((element) => ({
|
||
bottom: element.getBoundingClientRect().bottom,
|
||
scrollHeight: element.scrollHeight,
|
||
clientHeight: element.clientHeight,
|
||
}));
|
||
const mobilePickerBottom = await mobilePicker.evaluate((element) => element.getBoundingClientRect().bottom);
|
||
expect(mobileListGeometry.bottom).toBeLessThanOrEqual(mobilePickerBottom);
|
||
expect(mobileListGeometry.scrollHeight).toBeGreaterThanOrEqual(mobileListGeometry.clientHeight);
|
||
await mobilePicker.screenshot({ path: test.info().outputPath('founding-color-options-open-mobile-500.png') });
|
||
await mobileColor16.click();
|
||
await expect(mobileColorType).toContainText('색상 16');
|
||
await expect(mobileColorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||
await expect(mobileColorType).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||
const colorSelectRect = await mobileColorType.evaluate((element) => element.getBoundingClientRect().toJSON());
|
||
expect(colorSelectRect.x).toBeGreaterThanOrEqual(0);
|
||
expect(colorSelectRect.right).toBeLessThanOrEqual(500);
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
||
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
|
||
await mobilePicker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국');
|
||
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
|
||
});
|
||
|
||
test('shows speciality reset cooldowns and reserves special user commands from the picker', async ({ page }) => {
|
||
const specialCommandTable = {
|
||
general: [
|
||
{
|
||
category: '개인',
|
||
values: [
|
||
{
|
||
key: 'che_은퇴',
|
||
name: '은퇴',
|
||
reqArg: false,
|
||
possible: false,
|
||
status: 'blocked',
|
||
reason: '나이가 60세 이상이어야 합니다.',
|
||
inputFields: [],
|
||
},
|
||
{
|
||
key: 'che_내정특기초기화',
|
||
name: '내정 특기 초기화',
|
||
reqArg: false,
|
||
possible: false,
|
||
status: 'blocked',
|
||
reason: '12턴 더 기다려야 합니다',
|
||
inputFields: [],
|
||
},
|
||
{
|
||
key: 'che_전투특기초기화',
|
||
name: '전투 특기 초기화',
|
||
reqArg: false,
|
||
possible: false,
|
||
status: 'blocked',
|
||
reason: '24턴 더 기다려야 합니다',
|
||
inputFields: [],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
category: '인사',
|
||
values: [
|
||
{
|
||
key: 'che_강행',
|
||
name: '강행',
|
||
reqArg: true,
|
||
possible: true,
|
||
status: 'available',
|
||
inputFields: [
|
||
{
|
||
key: 'destCityId',
|
||
label: '대상 도시',
|
||
kind: 'select',
|
||
required: true,
|
||
optionSource: 'cities',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
category: '국가',
|
||
values: [
|
||
{
|
||
key: 'che_하야',
|
||
name: '하야',
|
||
reqArg: false,
|
||
possible: true,
|
||
status: 'available',
|
||
inputFields: [],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
nation: [],
|
||
inputOptions,
|
||
};
|
||
const requests = await install(page, false, specialCommandTable);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const editor = page.locator('[data-command-scope="general"]');
|
||
|
||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
let picker = page.getByTestId('command-picker');
|
||
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
|
||
await expect(retirement).toHaveClass(/blocked/);
|
||
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
|
||
const domesticReset = picker.getByRole('button', { name: '내정 특기 초기화', exact: true });
|
||
const warReset = picker.getByRole('button', { name: '전투 특기 초기화', exact: true });
|
||
await expect(domesticReset).toHaveClass(/blocked/);
|
||
await expect(domesticReset).toHaveAttribute('title', '12턴 더 기다려야 합니다');
|
||
await expect(warReset).toHaveClass(/blocked/);
|
||
await expect(warReset).toHaveAttribute('title', '24턴 더 기다려야 합니다');
|
||
await retirement.hover();
|
||
await retirement.focus();
|
||
await expect(retirement).toBeFocused();
|
||
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
|
||
await retirement.click();
|
||
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
|
||
|
||
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||
await picker.getByRole('button', { name: '하야', exact: true }).click();
|
||
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
|
||
|
||
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||
await picker.getByRole('button', { name: '강행', exact: true }).click();
|
||
const forceMoveForm = picker.getByTestId('command-argument-form');
|
||
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
||
await forceMoveForm.locator('select').selectOption('2');
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
|
||
|
||
const serialized = JSON.stringify(requests);
|
||
expect(serialized).toContain('"action":"che_은퇴","args":{}');
|
||
expect(serialized).toContain('"action":"che_하야","args":{}');
|
||
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
|
||
const mobileGeometry = await picker.evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
||
}));
|
||
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
||
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
||
await picker.getByRole('button', { name: '개인', exact: true }).click();
|
||
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
|
||
await expect(picker.getByRole('button', { name: '내정 특기 초기화', exact: true })).toHaveClass(/blocked/);
|
||
await expect(picker.getByRole('button', { name: '전투 특기 초기화', exact: true })).toHaveClass(/blocked/);
|
||
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
|
||
});
|
||
|
||
test('shows general command durations from metadata on desktop and mobile', async ({ page }) => {
|
||
const requests = await install(page, false, {
|
||
general: [
|
||
{
|
||
category: '개인',
|
||
values: [
|
||
buildSimpleCommand('che_휴식', '휴식'),
|
||
buildSimpleCommand('che_내정특기초기화', '내정 특기 초기화', '2턴'),
|
||
buildSimpleCommand('che_전투특기초기화', '전투 특기 초기화', '2턴'),
|
||
buildSimpleCommand('che_전투태세', '전투태세', '4턴'),
|
||
buildSimpleCommand('fixture_future', '미래 명령', '7턴'),
|
||
buildSimpleCommand('fixture_dynamic', '대상별 명령', '1+거리×2턴'),
|
||
],
|
||
},
|
||
],
|
||
nation: [],
|
||
inputOptions,
|
||
});
|
||
for (const width of [1200, 500]) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto(gamePath('/'));
|
||
await page.reload();
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await expect(picker.locator('.command-duration')).toHaveText(['/2턴', '/2턴', '/4턴', '/7턴', '/1+거리×2턴']);
|
||
await expect(
|
||
picker.getByRole('button', { name: '휴식', exact: true }).locator('.command-duration')
|
||
).toHaveCount(0);
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const reset = picker.getByRole('button', { name: '내정 특기 초기화 /2턴', exact: true });
|
||
await reset.hover();
|
||
await reset.focus();
|
||
await expect(reset).toBeFocused();
|
||
await page.mouse.down();
|
||
await expect(reset.locator('.command-duration')).toBeVisible();
|
||
const geometry = await picker.locator('.command-item').evaluateAll((buttons) =>
|
||
buttons.map((button) => ({
|
||
text: button.textContent,
|
||
rect: button.getBoundingClientRect().toJSON(),
|
||
overflow: button.scrollWidth - button.clientWidth,
|
||
fontSize: getComputedStyle(button).fontSize,
|
||
}))
|
||
);
|
||
expect(geometry.every(({ overflow, rect }) => overflow <= 0 && rect.height >= 35)).toBe(true);
|
||
expect(await picker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||
await writeFile(test.info().outputPath(`general-duration-${width}.json`), JSON.stringify(geometry, null, 2));
|
||
await writeFile(
|
||
test.info().outputPath(`general-duration-${width}.html`),
|
||
await picker.evaluate((element) => element.outerHTML)
|
||
);
|
||
await picker.screenshot({ path: test.info().outputPath(`general-duration-${width}.png`) });
|
||
await page.mouse.up();
|
||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||
'내정 특기 초기화'
|
||
);
|
||
}
|
||
expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}');
|
||
});
|
||
|
||
test('shows every Ref chief command in the exact category and command order', async ({ page }) => {
|
||
await install(page, false, refChiefCommandTable);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
|
||
const editor = page.locator('[data-command-scope="nation"]');
|
||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
const expected = {
|
||
휴식: ['휴식'],
|
||
인사: ['발령', '포상', '몰수', '부대 탈퇴 지시'],
|
||
외교: ['원조', '불가침 제의', '선전포고', '종전 제의', '불가침 파기 제의'],
|
||
특수: ['초토화', '천도', '증축', '감축'],
|
||
전략: ['필사즉생', '백성동원', '수몰', '허보', '의병모집', '이호경식', '급습', '피장파장'],
|
||
기타: ['국기변경', '국호변경'],
|
||
} as const;
|
||
|
||
await expect(picker.locator('.category-btn')).toHaveText(Object.keys(expected));
|
||
for (const [category, commands] of Object.entries(expected)) {
|
||
await picker.locator('.category-btn').filter({ hasText: category }).click();
|
||
await expect(picker.locator('.command-grid .command-name')).toHaveText([...commands]);
|
||
}
|
||
await picker.getByRole('button', { name: '특수', exact: true }).click();
|
||
await expect(picker.locator('.command-grid .command-item')).toHaveText([
|
||
'초토화 /3턴',
|
||
'천도 /1+거리×2턴금·쌀 500 × 2^거리',
|
||
'증축 /6턴금 110,000 · 쌀 110,000',
|
||
'감축 /6턴금 80,000 · 쌀 80,000 회수',
|
||
]);
|
||
const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) =>
|
||
buttons.map((button) => ({
|
||
text: button.textContent?.replace(/\s/g, ''),
|
||
height: button.getBoundingClientRect().height,
|
||
overflow: button.scrollWidth - button.clientWidth,
|
||
}))
|
||
);
|
||
expect(durationGeometry.every(({ height, overflow }) => height >= 35 && overflow <= 0)).toBe(true);
|
||
await picker.screenshot({ path: test.info().outputPath('chief-command-costs-desktop-1200.png') });
|
||
await picker.getByRole('button', { name: '기타', exact: true }).click();
|
||
const rename = picker.getByRole('button', { name: '국호변경', exact: true });
|
||
await rename.hover();
|
||
await rename.focus();
|
||
await expect(rename).toBeFocused();
|
||
await picker.screenshot({ path: test.info().outputPath('ref-chief-command-list-desktop-1200.png') });
|
||
|
||
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobileEditor = page.locator('[data-command-scope="nation"]:visible');
|
||
await mobileEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const mobilePicker = page.locator('[data-testid="command-picker"]:visible');
|
||
await expect(mobilePicker.locator('.category-btn')).toHaveText(Object.keys(expected));
|
||
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
||
}));
|
||
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
||
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
||
await mobilePicker.getByRole('button', { name: '특수', exact: true }).click();
|
||
const mobileExpand = mobilePicker.getByRole('button', { name: '증축 /6턴 금 110,000 · 쌀 110,000', exact: true });
|
||
await expect(mobileExpand).toBeVisible();
|
||
await mobileExpand.hover();
|
||
await mobileExpand.focus();
|
||
await expect(mobileExpand).toBeFocused();
|
||
await mobileExpand.dispatchEvent('pointerdown');
|
||
await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴');
|
||
await expect(mobileExpand.locator('.command-cost')).toHaveText('금 110,000 · 쌀 110,000');
|
||
await mobileExpand.dispatchEvent('pointerup');
|
||
expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
|
||
});
|
||
|
||
test('keeps general and chief command categories after input and across page reloads', async ({ page, context }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const generalEditor = page.locator('[data-command-scope="general"]');
|
||
await generalEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
let picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
||
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병');
|
||
|
||
await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await expect(picker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
|
||
await expect(picker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
|
||
await picker.screenshot({ path: test.info().outputPath('general-category-after-input-desktop-1200.png') });
|
||
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
||
await expect
|
||
.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:category')))
|
||
.toBe(JSON.stringify('general:군사'));
|
||
|
||
await page.reload();
|
||
const reloadedGeneralEditor = page.locator('[data-command-scope="general"]');
|
||
await reloadedGeneralEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const reloadedGeneralPicker = page.getByTestId('command-picker');
|
||
await expect(reloadedGeneralPicker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
|
||
await expect(reloadedGeneralPicker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
|
||
await reloadedGeneralPicker.screenshot({
|
||
path: test.info().outputPath('general-category-after-reload-desktop-1200.png'),
|
||
});
|
||
|
||
const chiefPage = await context.newPage();
|
||
await install(chiefPage, false, refChiefCommandTable);
|
||
await chiefPage.setViewportSize({ width: 500, height: 900 });
|
||
await chiefPage.goto('/che/chief-center');
|
||
const chiefEditor = chiefPage.locator('[data-command-scope="nation"]');
|
||
await chiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
picker = chiefPage.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '전략', exact: true }).click();
|
||
await picker.getByRole('button', { name: '필사즉생', exact: true }).click();
|
||
await expect(chiefEditor.locator('.action-column > div').first()).toHaveText('필사즉생');
|
||
|
||
await chiefEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = chiefPage.getByTestId('command-picker');
|
||
await expect(picker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
||
await expect(picker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
||
await picker.screenshot({ path: test.info().outputPath('chief-category-after-input-mobile-500.png') });
|
||
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
||
await expect
|
||
.poll(() => chiefPage.evaluate(() => localStorage.getItem('core2026:nation:1:5:category')))
|
||
.toBe(JSON.stringify('nation:전략'));
|
||
await chiefPage.reload();
|
||
const reloadedChiefEditor = chiefPage.locator('[data-command-scope="nation"]');
|
||
await reloadedChiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
||
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
||
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
||
await expect
|
||
.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height))
|
||
.toBeGreaterThan(200);
|
||
await reloadedChiefPicker.screenshot({
|
||
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
||
});
|
||
});
|
||
|
||
test('keeps the general turn editor mode across general recreation within one server profile', async ({
|
||
page,
|
||
context,
|
||
}) => {
|
||
await install(page, false, commandTable, 1);
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('core2026:general:1:editMode', '1');
|
||
});
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const profileModeKey = 'core2026:profile:che:general-turn-editor:editMode';
|
||
const firstEditor = page.locator('[data-command-scope="general"]');
|
||
await expect(firstEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible();
|
||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('1');
|
||
await page.evaluate(() => localStorage.removeItem('core2026:general:1:editMode'));
|
||
|
||
const recreatedPage = await context.newPage();
|
||
await install(recreatedPage, false, commandTable, 2);
|
||
await recreatedPage.setViewportSize({ width: 500, height: 900 });
|
||
await recreatedPage.goto('/');
|
||
const recreatedEditor = recreatedPage.locator('[data-command-scope="general"]');
|
||
await expect(recreatedEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible();
|
||
await expect
|
||
.poll(() => recreatedPage.evaluate(() => localStorage.getItem('core2026:general:2:editMode')))
|
||
.toBeNull();
|
||
await recreatedEditor.screenshot({
|
||
path: test.info().outputPath('general-advanced-mode-after-recreation-mobile-500.png'),
|
||
});
|
||
|
||
await recreatedEditor.getByRole('button', { name: '일반 모드', exact: true }).click();
|
||
await expect(recreatedEditor.getByRole('button', { name: '고급 모드', exact: true })).toBeVisible();
|
||
await expect.poll(() => recreatedPage.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('0');
|
||
|
||
const nextGeneralPage = await context.newPage();
|
||
await install(nextGeneralPage, false, commandTable, 3);
|
||
await nextGeneralPage.goto('/');
|
||
await expect(
|
||
nextGeneralPage.locator('[data-command-scope="general"]').getByRole('button', {
|
||
name: '고급 모드',
|
||
exact: true,
|
||
})
|
||
).toBeVisible();
|
||
await nextGeneralPage.close();
|
||
await recreatedPage.close();
|
||
});
|
||
|
||
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
|
||
const editor = page.locator('[data-command-scope="nation"]:visible');
|
||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
await expect(editor.locator('.index-column > button')).toHaveCount(12);
|
||
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
|
||
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
|
||
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
|
||
|
||
const frame = page.locator('.chief-overview-frame');
|
||
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
|
||
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
|
||
for (const gutter of await frame.locator('.overview-turn-index').all()) {
|
||
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
|
||
Array.from({ length: 12 }, (_, index) => String(index + 1))
|
||
);
|
||
}
|
||
await expect(frame.locator('.compact-name')).toHaveCount(8);
|
||
|
||
const geometry = await page.locator('.chief-page').evaluate((element) => {
|
||
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
|
||
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
|
||
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
|
||
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
|
||
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
|
||
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
|
||
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
|
||
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
|
||
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
|
||
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
|
||
const frameRect = overviewFrame.getBoundingClientRect();
|
||
const editorRect = editorElement.getBoundingClientRect();
|
||
const actionsRect = actions.getBoundingClientRect();
|
||
return {
|
||
editorBottom: editorRect.bottom,
|
||
editorHeight: editorRect.height,
|
||
queueBottom: queue.getBoundingClientRect().bottom,
|
||
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
|
||
actionsTop: actionsRect.top,
|
||
actionsBottom: actionsRect.bottom,
|
||
frameTop: frameRect.top,
|
||
frameWidth: frameRect.width,
|
||
rowWidth: firstOverviewRow.getBoundingClientRect().width,
|
||
rowEdges: overviewRows.map((item) => ({
|
||
top: item.getBoundingClientRect().top - frameRect.top,
|
||
bottom: item.getBoundingClientRect().bottom - frameRect.top,
|
||
})),
|
||
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
|
||
gutterEdges: gutters.map((item) => ({
|
||
left: item.getBoundingClientRect().left - frameRect.left,
|
||
right: item.getBoundingClientRect().right - frameRect.left,
|
||
})),
|
||
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
|
||
namesInsideFrame: names.every((item) => {
|
||
const rect = item.getBoundingClientRect();
|
||
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
|
||
}),
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
};
|
||
});
|
||
|
||
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
|
||
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
|
||
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
|
||
expect(geometry.frameWidth).toBe(500);
|
||
expect(geometry.rowWidth).toBe(500);
|
||
expect(geometry.rowEdges).toEqual([
|
||
{ top: 0, bottom: 155 },
|
||
{ top: 155, bottom: 310 },
|
||
]);
|
||
expect(geometry.gutterWidths).toEqual([12, 12]);
|
||
expect(geometry.gutterEdges).toEqual([
|
||
{ left: 0, right: 12 },
|
||
{ left: 488, right: 500 },
|
||
]);
|
||
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
|
||
expect(geometry.namesInsideFrame).toBe(true);
|
||
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
|
||
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
|
||
});
|
||
|
||
test('keeps the advanced chief return control visible and fills the two-cell bottom row', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
|
||
const editor = page.locator('[data-command-scope="nation"]:visible');
|
||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
const modeButton = editor.getByRole('button', { name: '일반 모드', exact: true });
|
||
await expect(modeButton).toBeVisible();
|
||
|
||
const geometry = await editor.evaluate((element) => {
|
||
const queue = element.querySelector<HTMLElement>('.queue-area')!;
|
||
const actions = element.querySelector<HTMLElement>('.advanced-actions')!;
|
||
const controls = element.querySelector<HTMLElement>('.control-pad')!;
|
||
const mode = [...controls.querySelectorAll<HTMLButtonElement>(':scope > button')].find(
|
||
(item) => item.textContent?.trim() === '일반 모드'
|
||
)!;
|
||
const clock = controls.querySelector<HTMLElement>(':scope > .clock')!;
|
||
const summary = (label: string) =>
|
||
[...controls.querySelectorAll<HTMLElement>(':scope > details > summary')].find(
|
||
(item) => item.textContent?.trim() === label
|
||
)!;
|
||
const repeat = summary('반복');
|
||
const range = summary('범위');
|
||
const storage = summary('보관함');
|
||
const recent = summary('최근');
|
||
const pull = summary('당기기').closest<HTMLElement>('details')!;
|
||
const push = summary('미루기').closest<HTMLElement>('details')!;
|
||
const rect = (item: HTMLElement) => {
|
||
const box = item.getBoundingClientRect();
|
||
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width };
|
||
};
|
||
const modeRect = mode.getBoundingClientRect();
|
||
const hit = document.elementFromPoint(modeRect.left + modeRect.width / 2, modeRect.top + modeRect.height / 2);
|
||
return {
|
||
queue: rect(queue),
|
||
actions: rect(actions),
|
||
controls: rect(controls),
|
||
mode: rect(mode),
|
||
firstRow: [rect(mode), rect(clock), rect(repeat)],
|
||
secondRow: [rect(range), rect(storage), rect(recent)],
|
||
bottomRow: [rect(pull), rect(push)],
|
||
modeReceivesPointer: Boolean(hit && mode.contains(hit)),
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
};
|
||
});
|
||
|
||
expect(geometry.queue.bottom).toBeLessThanOrEqual(geometry.actions.top);
|
||
expect(geometry.actions.bottom).toBeLessThanOrEqual(geometry.controls.top);
|
||
expect(geometry.modeReceivesPointer).toBe(true);
|
||
expect(new Set(geometry.firstRow.map(({ top }) => top)).size).toBe(1);
|
||
expect(new Set(geometry.secondRow.map(({ top }) => top)).size).toBe(1);
|
||
expect(geometry.bottomRow[0]?.top).toBe(geometry.bottomRow[1]?.top);
|
||
expect(geometry.bottomRow[0]?.left).toBeCloseTo(geometry.controls.left, 1);
|
||
expect(geometry.bottomRow[1]?.right).toBeCloseTo(geometry.controls.right, 1);
|
||
expect(geometry.bottomRow[0]?.width).toBeCloseTo(geometry.bottomRow[1]?.width ?? 0, 1);
|
||
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
|
||
|
||
await page.screenshot({ path: test.info().outputPath('chief-advanced-controls-desktop-1200.png'), fullPage: true });
|
||
await modeButton.click();
|
||
await expect(editor.locator('.advanced-actions')).toHaveCount(0);
|
||
await expect(editor.getByRole('button', { name: '고급 모드', exact: true })).toBeVisible();
|
||
});
|
||
|
||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
await expect(page.getByTestId('current-city-marker')).toHaveCount(0);
|
||
const mainMap = page.locator('[data-main-target="map"]');
|
||
const currentMainCity = mainMap.locator('.city-base.mine');
|
||
await expect(currentMainCity).toHaveAttribute('aria-label', '업, 현재 도시');
|
||
const currentCityHighlight = await currentMainCity.locator('.city-filler.my-city').evaluate((element) => {
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
outlineColor: style.outlineColor,
|
||
outlineStyle: style.outlineStyle,
|
||
outlineWidth: style.outlineWidth,
|
||
animationName: style.animationName,
|
||
boxShadow: style.boxShadow,
|
||
};
|
||
});
|
||
expect(currentCityHighlight).toMatchObject({
|
||
outlineColor: 'rgb(211, 47, 47)',
|
||
outlineStyle: 'solid',
|
||
outlineWidth: '2px',
|
||
animationName: 'none',
|
||
});
|
||
expect(currentCityHighlight.boxShadow).toContain('211, 47, 47');
|
||
await mainMap.screenshot({ path: test.info().outputPath('main-map-current-city-static-highlight-desktop.png') });
|
||
await mainMap.locator('.city-base').nth(1).click();
|
||
await expect(page).toHaveURL(/\/current-city\?cityId=2$/u);
|
||
await page.goBack();
|
||
await expect(page).toHaveURL(/\/$/u);
|
||
await expect(page.locator('[data-main-target="map"] .city-base.selected')).toHaveCount(0);
|
||
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||
const form = page.getByTestId('command-argument-form');
|
||
await expect(form).toBeVisible();
|
||
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칸');
|
||
const commandMap = form.getByTestId('command-argument-map');
|
||
const currentCityMarker = commandMap.getByTestId('current-city-marker');
|
||
const selectionStatus = form.getByTestId('command-map-selection-status');
|
||
await expect(currentCityMarker).toHaveText('현재');
|
||
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
||
await expect(selectionStatus).toContainText('현재 도시업');
|
||
await expect(selectionStatus).toContainText('선택 도시업');
|
||
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: element.querySelector<HTMLImageElement>('.map-bglayer1 .map-background-image')?.src ?? '',
|
||
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 expect(selectionStatus).toContainText('현재 도시업');
|
||
await expect(selectionStatus).toContainText('선택 도시허창');
|
||
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
||
await expect(mapCities.nth(0)).toHaveClass(/mine/);
|
||
await expect(mapCities.nth(1)).toHaveClass(/selected/);
|
||
expect(
|
||
await mapCities
|
||
.nth(1)
|
||
.locator('.city-icon')
|
||
.evaluate((element) => getComputedStyle(element).boxShadow)
|
||
).toContain('255, 235, 150');
|
||
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(/\/$/);
|
||
await commandMap.screenshot({ path: test.info().outputPath('main-city-current-marker-desktop.png') });
|
||
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
||
const area = element.querySelector<HTMLElement>('.map-area')!;
|
||
const rect = area.getBoundingClientRect();
|
||
return { width: rect.width, height: rect.height };
|
||
});
|
||
await page
|
||
.getByTestId('command-picker')
|
||
.getByRole('button', { name: / 입력$/ })
|
||
.click();
|
||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||
'【허창】에 화계실행'
|
||
);
|
||
|
||
await page.goto(gamePath('/chief-center'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const chiefPicker = page.getByTestId('command-picker');
|
||
await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await chiefPicker.getByRole('button', { name: /포상/ }).click();
|
||
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
||
await chiefForm.getByRole('button', { name: '쌀', exact: true }).click();
|
||
await chiefForm.locator('input[type=number]').fill('300');
|
||
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
|
||
await expect(chiefTarget.locator('option')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||
await chiefTarget.selectOption('3');
|
||
const geometry = await chiefForm.evaluate((element) => {
|
||
const row = element.querySelector('.argument-row');
|
||
const rect = element.getBoundingClientRect();
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
width: rect.width,
|
||
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
||
borderStyle: style.borderStyle,
|
||
fontSize: style.fontSize,
|
||
};
|
||
});
|
||
await chiefPicker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||
'【여포NPC】 쌀 300 포상'
|
||
);
|
||
|
||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
||
expect(JSON.stringify(requests)).toContain('"amount":300');
|
||
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
||
|
||
expect(mapGeometry.width).toBeGreaterThan(650);
|
||
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
||
|
||
expect(geometry.width).toBeGreaterThan(200);
|
||
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
||
expect(geometry.borderStyle).toBe('solid');
|
||
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
||
});
|
||
|
||
test('shows full nation command briefs in every chief card', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
|
||
const desktopSummary = page.locator('.layout-desktop .chief-card').first().locator('.row-action').first();
|
||
await expect(desktopSummary).toHaveText('【관우】 쌀 300 포상');
|
||
await expect(desktopSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
|
||
await page.screenshot({
|
||
path: test.info().outputPath('chief-card-command-brief-desktop-1200.png'),
|
||
fullPage: true,
|
||
});
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobileSummary = page.locator('.chief-overview .chief-card').first().locator('.row-action').first();
|
||
await expect(mobileSummary).toHaveText('【관우】 쌀 300 포상');
|
||
await expect(mobileSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
|
||
|
||
const geometry = await mobileSummary.evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const card = element.closest<HTMLElement>('.chief-card');
|
||
if (!card) throw new Error('chief card is missing');
|
||
return {
|
||
width: rect.width,
|
||
cardWidth: card.getBoundingClientRect().width,
|
||
horizontalOverflow: card.scrollWidth - card.clientWidth,
|
||
};
|
||
});
|
||
expect(geometry.width).toBeLessThanOrEqual(geometry.cardWidth);
|
||
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-mobile-500.png'), fullPage: true });
|
||
});
|
||
|
||
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
let picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
||
await expect(picker).toHaveAttribute('role', 'dialog');
|
||
await expect(picker).toHaveAttribute('aria-modal', 'true');
|
||
await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||
const form = picker.getByTestId('recruitment-command-form');
|
||
await expect(form).toContainText('현재 기술력 : 1등급');
|
||
await expect(form).toContainText('공격');
|
||
await expect(form).toContainText('방어');
|
||
await expect(form).toContainText('기동');
|
||
await expect(form).toContainText('회피');
|
||
await expect(form).toContainText('가격');
|
||
await expect(form).toContainText('군량');
|
||
await expect(form).toContainText('표준적인 보병입니다.');
|
||
await expect(form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true })).toHaveCount(0);
|
||
await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
|
||
const unavailable = form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true });
|
||
await expect(unavailable).toBeVisible();
|
||
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
|
||
await expect(unavailable.locator('.crew-info')).toHaveText(
|
||
'강력하지만 기술이 필요합니다.기술력 2000 이상 필요중원 지역 소유시 가능저 소유시 가능'
|
||
);
|
||
|
||
const desktopGeometry = await form.evaluate(async (element) => {
|
||
const row = element.querySelector('.crew-row');
|
||
const image = row?.querySelector('.crew-image');
|
||
const info = row?.querySelector('.crew-info');
|
||
const backgroundImage = image ? getComputedStyle(image).backgroundImage : '';
|
||
const imageUrl = backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1];
|
||
const naturalSize = imageUrl
|
||
? await new Promise<{ width: number; height: number } | null>((resolve) => {
|
||
const probe = new Image();
|
||
probe.onload = () => resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
|
||
probe.onerror = () => resolve(null);
|
||
probe.src = imageUrl;
|
||
})
|
||
: null;
|
||
return {
|
||
overlay: element.closest('[data-testid="command-picker"]')?.getBoundingClientRect().toJSON(),
|
||
formWidth: element.getBoundingClientRect().width,
|
||
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
||
infoWidth: info?.getBoundingClientRect().width ?? 0,
|
||
imageNaturalSize: naturalSize,
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
bodyOverflow: getComputedStyle(document.body).overflow,
|
||
};
|
||
});
|
||
expect(desktopGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
|
||
expect(desktopGeometry.formWidth).toBe(1000);
|
||
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(64);
|
||
expect(desktopGeometry.infoWidth).toBeCloseTo(250, 0);
|
||
expect(desktopGeometry.imageNaturalSize).toEqual({ width: 128, height: 128 });
|
||
expect(desktopGeometry.scrollWidth).toBe(desktopGeometry.clientWidth);
|
||
expect(desktopGeometry.bodyOverflow).toBe('hidden');
|
||
|
||
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
|
||
const estimatedGold = infantry.getByRole('status', { name: '보병 예상 자금' });
|
||
await expect(estimatedGold).toHaveText('673금');
|
||
await infantry.locator('input[type=number]').fill('100');
|
||
await expect(estimatedGold).toHaveText('1,035금');
|
||
await infantry.getByRole('button', { name: '절반', exact: true }).click();
|
||
await expect(estimatedGold).toHaveText('363금');
|
||
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||
'【보병】 3500명 징병'
|
||
);
|
||
expect(JSON.stringify(requests)).toContain('"crewType":1100');
|
||
expect(JSON.stringify(requests)).toContain('"amount":3500');
|
||
|
||
await page.setViewportSize({ width: 500, height: 844 });
|
||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
||
await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||
const referenceWidthGeometry = await picker.evaluate((element) => {
|
||
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
||
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
|
||
const selectedPanel = formElement.querySelector<HTMLElement>('.mobile-selected-panel')!;
|
||
return {
|
||
overlayWidth: element.getBoundingClientRect().width,
|
||
rowWidth: row.getBoundingClientRect().width,
|
||
rowGridColumns: getComputedStyle(row).gridTemplateColumns,
|
||
selectedWidth: selectedPanel.getBoundingClientRect().width,
|
||
selectedGridColumns: getComputedStyle(selectedPanel).gridTemplateColumns,
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
};
|
||
});
|
||
expect(referenceWidthGeometry).toMatchObject({
|
||
overlayWidth: 500,
|
||
rowWidth: 500,
|
||
rowGridColumns: '64px 76px 30px 30px 30px 270px',
|
||
selectedWidth: 500,
|
||
selectedGridColumns: '64px 76px 270px 90px',
|
||
scrollWidth: 500,
|
||
clientWidth: 500,
|
||
});
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const mobileGeometry = await picker.evaluate((element) => {
|
||
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
||
const row = formElement.querySelector('.crew-row');
|
||
const image = row?.querySelector('.crew-image');
|
||
const info = row?.querySelector('.crew-info');
|
||
const selectedPanel = formElement.querySelector('.mobile-selected-panel');
|
||
const rect = element.getBoundingClientRect();
|
||
const bottomElement = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1);
|
||
return {
|
||
overlay: rect.toJSON(),
|
||
overlayCoversBottom: bottomElement instanceof Element && element.contains(bottomElement),
|
||
formLeft: formElement.getBoundingClientRect().left,
|
||
formWidth: formElement.getBoundingClientRect().width,
|
||
rowWidth: row?.getBoundingClientRect().width ?? 0,
|
||
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
||
rowGridColumns: row ? getComputedStyle(row).gridTemplateColumns : '',
|
||
rowGridRows: row ? getComputedStyle(row).gridTemplateRows : '',
|
||
imageWidth: image?.getBoundingClientRect().width ?? 0,
|
||
infoWidth: info?.getBoundingClientRect().width ?? 0,
|
||
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
|
||
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
|
||
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0,
|
||
viewportHeight: window.innerHeight,
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
bodyOverflow: getComputedStyle(document.body).overflow,
|
||
};
|
||
});
|
||
expect(mobileGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 390, height: 844 });
|
||
expect(mobileGeometry.overlayCoversBottom).toBe(true);
|
||
expect(mobileGeometry.rowGridColumns).toBe('64px 76px 30px 30px 30px 160px');
|
||
expect(mobileGeometry).toMatchObject({
|
||
formLeft: 0,
|
||
formWidth: 390,
|
||
rowWidth: 390,
|
||
imageWidth: 64,
|
||
selectedWidth: 390,
|
||
selectedDisplay: 'grid',
|
||
actionsBottom: 844,
|
||
viewportHeight: 844,
|
||
bodyOverflow: 'hidden',
|
||
});
|
||
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
|
||
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
|
||
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
|
||
|
||
await page.setViewportSize({ width: 390, height: 360 });
|
||
await picker.evaluate((element) => (element.scrollTop = 160));
|
||
const stickyGeometry = await picker.evaluate((element) => {
|
||
const header = element.querySelector<HTMLElement>(':scope > header')!;
|
||
const listFront = element.querySelector<HTMLElement>('.recruitment-list-front')!;
|
||
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
|
||
return {
|
||
scrollTop: element.scrollTop,
|
||
headerTop: header.getBoundingClientRect().top,
|
||
headerBottom: header.getBoundingClientRect().bottom,
|
||
listFrontTop: listFront.getBoundingClientRect().top,
|
||
actionsBottom: actions.getBoundingClientRect().bottom,
|
||
viewportHeight: window.innerHeight,
|
||
};
|
||
});
|
||
expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
|
||
expect(stickyGeometry.headerTop).toBe(0);
|
||
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(stickyGeometry.headerBottom);
|
||
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await picker.evaluate((element) => (element.scrollTop = 0));
|
||
await page.screenshot({ path: testInfo.outputPath('recruitment-mobile.png') });
|
||
|
||
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||
await picker.getByRole('button', { name: '모병', exact: true }).click();
|
||
const mercenaryForm = picker.getByTestId('recruitment-command-form');
|
||
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
|
||
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
|
||
await mercenaryForm.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
|
||
const unavailableMercenary = mercenaryForm.getByRole('button', {
|
||
name: '정예병 현재 실행 불가, 예약 가능',
|
||
exact: true,
|
||
});
|
||
await unavailableMercenary.click();
|
||
await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25');
|
||
await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled();
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText(
|
||
'【정예병】 2500명 모병'
|
||
);
|
||
expect(JSON.stringify(requests)).toContain('"crewType":1101');
|
||
await expect(picker).toHaveCount(0);
|
||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||
});
|
||
|
||
test('keeps arbitrary direct recruitment and mercenary amounts for all four arms after turn refresh', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page, false, fourArmRecruitmentCommandTable);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const entries = [
|
||
{ turn: 1, command: '징병', crewTypeId: 1100, name: '보병', inputAmount: 13, savedAmount: 1300 },
|
||
{ turn: 2, command: '징병', crewTypeId: 1200, name: '궁병', inputAmount: 27, savedAmount: 2700 },
|
||
{ turn: 3, command: '징병', crewTypeId: 1300, name: '기병', inputAmount: 41, savedAmount: 4100 },
|
||
{ turn: 4, command: '징병', crewTypeId: 1400, name: '귀병', inputAmount: 59, savedAmount: 5900 },
|
||
{ turn: 5, command: '모병', crewTypeId: 1100, name: '보병', inputAmount: 17, savedAmount: 1700 },
|
||
{ turn: 6, command: '모병', crewTypeId: 1200, name: '궁병', inputAmount: 31, savedAmount: 3100 },
|
||
{ turn: 7, command: '모병', crewTypeId: 1300, name: '기병', inputAmount: 43, savedAmount: 4300 },
|
||
{ turn: 8, command: '모병', crewTypeId: 1400, name: '귀병', inputAmount: 61, savedAmount: 6100 },
|
||
];
|
||
|
||
for (const entry of entries) {
|
||
await page.getByRole('button', { name: `${entry.turn}턴 명령 입력`, exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||
await picker.getByRole('button', { name: entry.command, exact: true }).click();
|
||
|
||
const row = picker.getByRole('button', { name: `${entry.name} 선택 가능`, exact: true });
|
||
const amountInput = row.locator('input[type=number]');
|
||
await amountInput.fill(String(entry.inputAmount));
|
||
await expect(amountInput).toHaveValue(String(entry.inputAmount));
|
||
if (entry.turn === 1) {
|
||
const inputGeometry = await amountInput.evaluate((element) => {
|
||
if (!(element instanceof HTMLInputElement)) throw new Error('Expected recruitment amount input');
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
width: rect.width,
|
||
height: rect.height,
|
||
textAlign: getComputedStyle(element).textAlign,
|
||
value: element.value,
|
||
};
|
||
});
|
||
expect(inputGeometry).toMatchObject({ height: 28, textAlign: 'right', value: '13' });
|
||
expect(inputGeometry.width).toBeGreaterThan(0);
|
||
await picker.screenshot({ path: testInfo.outputPath('recruitment-direct-amount-desktop.png') });
|
||
}
|
||
await row.getByRole('button', { name: entry.command, exact: true }).click();
|
||
|
||
await expect(picker).toHaveCount(0);
|
||
await expect(
|
||
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
||
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
||
}
|
||
|
||
const refreshResponse = page.waitForResponse((apiResponse) =>
|
||
decodeURIComponent(apiResponse.url()).includes('turns.reserved.getGeneral')
|
||
);
|
||
await page.getByRole('button', { name: '갱 신', exact: true }).click();
|
||
await refreshResponse;
|
||
|
||
for (const entry of entries) {
|
||
await expect(
|
||
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
||
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
||
}
|
||
await page
|
||
.locator('[data-command-scope="general"]')
|
||
.screenshot({ path: testInfo.outputPath('recruitment-arbitrary-amounts-after-refresh.png') });
|
||
|
||
const serializedRequests = JSON.stringify(requests);
|
||
for (const entry of entries) {
|
||
expect(serializedRequests).toContain(`"crewType":${entry.crewTypeId}`);
|
||
expect(serializedRequests).toContain(`"amount":${entry.savedAmount}`);
|
||
}
|
||
});
|
||
|
||
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
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('.city-base').nth(1).click();
|
||
await expect(form.locator('select')).toHaveValue('2');
|
||
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||
expect(JSON.stringify(requests)).toContain('"generalId":1');
|
||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||
});
|
||
|
||
test('touch command maps select city and nation on the first tap without changing navigation mode', async ({
|
||
browser,
|
||
}, testInfo) => {
|
||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||
if (typeof configuredBaseUrl !== 'string') {
|
||
throw new Error('Playwright baseURL is required for the mobile command map 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.addInitScript(() => localStorage.setItem('sam.toggleSingleTap', 'no'));
|
||
await mobilePage.goto('/');
|
||
|
||
const mainCurrentCity = mobilePage.locator('[data-main-target="map"] .city-base.mine');
|
||
await expect(mainCurrentCity).toHaveAttribute('aria-label', '업, 현재 도시');
|
||
await expect(mobilePage.locator('[data-main-target="map"] .city-base.selected')).toHaveCount(0);
|
||
await mobilePage.locator('[data-main-target="map"]').screenshot({
|
||
path: testInfo.outputPath('main-map-current-city-static-highlight-mobile.png'),
|
||
});
|
||
|
||
const mainMap = mobilePage.locator('[data-main-target="map"]');
|
||
const city = mainMap.locator('.city-base').nth(1);
|
||
await city.scrollIntoViewIfNeeded();
|
||
const beforeTap = await city.boundingBox();
|
||
await city.tap();
|
||
await expect(mainMap.locator('.map-tooltip')).toBeVisible();
|
||
await mobilePage.waitForTimeout(700);
|
||
expect(await city.boundingBox()).toEqual(beforeTap);
|
||
// 실제 Chromium touchMove를 포함한 손떨림도 두 번째 탭으로 처리한다.
|
||
const box = await city.boundingBox();
|
||
if (!box) throw new Error('Missing city geometry');
|
||
const cdp = await context.newCDPSession(mobilePage);
|
||
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2, id: 0 };
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] });
|
||
await cdp.send('Input.dispatchTouchEvent', {
|
||
type: 'touchMove',
|
||
touchPoints: [{ ...point, x: point.x + 4, y: point.y + 2 }],
|
||
});
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await expect(mobilePage).toHaveURL(/current-city\?cityId=2$/u);
|
||
await mobilePage.goto('/');
|
||
await city.tap();
|
||
const dragBox = await city.boundingBox();
|
||
if (!dragBox) throw new Error('Missing drag geometry');
|
||
const dragStart = { x: dragBox.x + dragBox.width / 2, y: dragBox.y + dragBox.height / 2, id: 0 };
|
||
const scrollBeforeDrag = await mobilePage.evaluate(() => window.scrollY);
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [dragStart] });
|
||
for (let step = 1; step <= 5; step += 1) {
|
||
await cdp.send('Input.dispatchTouchEvent', {
|
||
type: 'touchMove',
|
||
touchPoints: [{ ...dragStart, y: dragStart.y - step * 20 }],
|
||
});
|
||
}
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await expect(mainMap.locator('.map-tooltip')).toHaveCount(0);
|
||
await expect(mobilePage).toHaveURL(/\/$/u);
|
||
await expect.poll(() => mobilePage.evaluate(() => window.scrollY)).toBeGreaterThan(scrollBeforeDrag);
|
||
await expect
|
||
.poll(async () => {
|
||
const y = await mobilePage.evaluate(() => window.scrollY);
|
||
await mobilePage.waitForTimeout(100);
|
||
return (await mobilePage.evaluate(() => window.scrollY)) - y;
|
||
})
|
||
.toBe(0);
|
||
await city.tap();
|
||
await expect(mobilePage).toHaveURL(/\/$/u);
|
||
await expect(mainMap.locator('.map-tooltip')).toBeVisible();
|
||
await mainMap.screenshot({ path: testInfo.outputPath('main-map-after-scroll-first-tap.png') });
|
||
await writeFile(
|
||
testInfo.outputPath('main-touch-geometry.json'),
|
||
JSON.stringify(
|
||
{
|
||
beforeTap,
|
||
scrollBeforeDrag,
|
||
afterDrag: await mobilePage.evaluate(() => window.scrollY),
|
||
map: await mainMap.evaluate((element) => ({
|
||
rect: element.getBoundingClientRect().toJSON(),
|
||
tooltip: element.querySelector('.map-tooltip')?.getBoundingClientRect().toJSON(),
|
||
touchAction: getComputedStyle(element.querySelector('.city-base')!).touchAction,
|
||
})),
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
await cdp.detach();
|
||
await mobilePage.goto('/');
|
||
|
||
await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
await mobilePage.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||
let form = mobilePage.getByTestId('command-argument-form');
|
||
let commandMap = form.getByTestId('command-argument-map');
|
||
await expect(commandMap.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||
await commandMap.locator('.city-base').nth(1).tap();
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/);
|
||
await expect(commandMap.locator('.map-tooltip .tooltip-title')).toContainText('허창');
|
||
await expect(commandMap.locator('.map-tooltip .tooltip-body')).toHaveText('적국');
|
||
await commandMap.locator('.city-base').nth(1).dispatchEvent('mouseleave');
|
||
await mobilePage.waitForTimeout(700);
|
||
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
|
||
await expect(mobilePage.locator('[data-main-target="map"] .map-tooltip')).toHaveCount(0);
|
||
await commandMap.screenshot({
|
||
path: testInfo.outputPath(
|
||
`persistent-panel-${(await form.locator('#command-arg-destCityId').count()) ? 'personal' : 'chief'}.png`
|
||
),
|
||
});
|
||
await commandMap.locator('.city-base').nth(1).tap();
|
||
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
|
||
await commandMap.getByRole('button', { name: '지도 옵션', exact: true }).tap();
|
||
await expect(commandMap.locator('.map-tooltip')).toHaveCount(0);
|
||
|
||
await expect(mobilePage).toHaveURL(/\/$/u);
|
||
|
||
await mobilePage.goto(gamePath('/chief-center'));
|
||
await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = mobilePage.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
commandMap = form.getByTestId('command-argument-map');
|
||
await expect(commandMap.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||
await commandMap.locator('.city-base').nth(1).tap();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/);
|
||
await expect(commandMap.locator('.map-tooltip .tooltip-title')).toContainText('허창');
|
||
await expect(commandMap.locator('.map-tooltip .tooltip-body')).toHaveText('적국');
|
||
await commandMap.locator('.city-base').nth(1).dispatchEvent('mouseleave');
|
||
await mobilePage.waitForTimeout(700);
|
||
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
|
||
await expect(mobilePage.locator('[data-main-target="map"] .map-tooltip')).toHaveCount(0);
|
||
await commandMap.screenshot({
|
||
path: testInfo.outputPath(
|
||
`persistent-panel-${(await form.locator('#command-arg-destCityId').count()) ? 'personal' : 'chief'}.png`
|
||
),
|
||
});
|
||
await commandMap.locator('.city-base').nth(1).tap();
|
||
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
|
||
await commandMap.getByRole('button', { name: '지도 옵션', exact: true }).tap();
|
||
await expect(commandMap.locator('.map-tooltip')).toHaveCount(0);
|
||
|
||
await expect(mobilePage).toHaveURL(new RegExp(`${gamePath('/chief-center')}$`, 'u'));
|
||
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
|
||
|
||
await mobilePage.screenshot({ path: testInfo.outputPath('command-map-first-tap-selection-mobile.png') });
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
});
|
||
|
||
test('limits non-aggression end years to the Ref twenty-year window on desktop and mobile', async ({
|
||
browser,
|
||
}, testInfo) => {
|
||
for (const viewport of [
|
||
{ name: 'desktop', width: 1200, height: 900 },
|
||
{ name: 'mobile', width: 500, height: 900 },
|
||
]) {
|
||
const context = await browser.newContext({ viewport });
|
||
const page = await context.newPage();
|
||
try {
|
||
await install(page);
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /불가침 제의/ }).click();
|
||
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const yearInput = form.locator('#command-arg-year');
|
||
await expect(yearInput).toHaveAttribute('min', '191');
|
||
await expect(yearInput).toHaveAttribute('max', '210');
|
||
await expect(yearInput).toHaveValue('191');
|
||
|
||
const submit = picker.getByRole('button', { name: / 입력$/ });
|
||
await yearInput.fill('211');
|
||
await expect(submit).toBeDisabled();
|
||
await yearInput.fill('210');
|
||
await expect(submit).toBeEnabled();
|
||
|
||
await picker.screenshot({ path: testInfo.outputPath(`non-aggression-year-limit-${viewport.name}.png`) });
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('reserves argument-free expansion and reduction immediately on desktop and mobile', async ({
|
||
browser,
|
||
}, testInfo) => {
|
||
for (const viewport of [
|
||
{ name: 'desktop', width: 1200, height: 900 },
|
||
{ name: 'mobile', width: 500, height: 900 },
|
||
]) {
|
||
const context = await browser.newContext({ viewport });
|
||
const page = await context.newPage();
|
||
try {
|
||
const requests = await install(page);
|
||
await page.goto('/che/chief-center');
|
||
const editor = page.locator('[data-command-scope="nation"]');
|
||
|
||
for (const [turn, action] of ['증축', '감축'].entries()) {
|
||
await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click();
|
||
const picker = editor.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?특수$/ }).click();
|
||
await picker.getByRole('button', { name: new RegExp(action) }).click();
|
||
await expect(picker).toBeHidden();
|
||
await expect(editor.locator('.action-column > div').nth(turn)).toContainText(action);
|
||
}
|
||
|
||
const serialized = JSON.stringify(requests);
|
||
expect(serialized).toContain('"action":"che_증축","args":{}');
|
||
expect(serialized).toContain('"action":"che_감축","args":{}');
|
||
await page.screenshot({ path: testInfo.outputPath(`chief-capital-commands-${viewport.name}.png`) });
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('shows a map and target details for every city or nation argument chief command except assignment', async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
const cases = [
|
||
{ category: '특수', action: '초토화', mode: 'city' },
|
||
{ category: '특수', action: '천도', mode: 'city' },
|
||
{ category: '전략', action: '수몰', mode: 'city' },
|
||
{ category: '전략', action: '허보', mode: 'city' },
|
||
{ category: '전략', action: '백성동원', mode: 'city' },
|
||
{ category: '외교', action: '원조', mode: 'nation' },
|
||
{ category: '외교', action: '불가침 제의', mode: 'nation' },
|
||
{ category: '외교', action: '선전포고', mode: 'nation' },
|
||
{ category: '외교', action: '종전 제의', mode: 'nation' },
|
||
{ category: '외교', action: '불가침 파기 제의', mode: 'nation' },
|
||
{ category: '전략', action: '이호경식', mode: 'nation' },
|
||
{ category: '전략', action: '급습', mode: 'nation' },
|
||
{ category: '전략', action: '피장파장', mode: 'nation' },
|
||
];
|
||
|
||
for (const entry of cases) {
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${entry.category}$`) }).click();
|
||
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const map = form.getByTestId('command-argument-map');
|
||
await expect(map, `${entry.action} 지도`).toBeVisible();
|
||
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
||
|
||
await map.locator('.city-base').nth(1).click();
|
||
if (entry.mode === 'city') {
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||
await expect(form.getByTestId('command-map-target-summary')).toContainText(
|
||
'허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸'
|
||
);
|
||
} else {
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||
await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개');
|
||
}
|
||
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||
}
|
||
|
||
await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /피장파장/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const map = form.getByTestId('command-argument-map');
|
||
const targetCity = map.locator('.city-base').nth(1);
|
||
await targetCity.hover();
|
||
expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer');
|
||
await targetCity.focus();
|
||
await expect(targetCity).toBeFocused();
|
||
await targetCity.click();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
const geometry = await picker.evaluate((element) => ({
|
||
pickerWidth: element.getBoundingClientRect().width,
|
||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
mapInsidePicker:
|
||
element.querySelector<HTMLElement>('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <=
|
||
element.getBoundingClientRect().right,
|
||
}));
|
||
expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true });
|
||
await page.screenshot({
|
||
path: test.info().outputPath('chief-command-target-map-details-mobile.png'),
|
||
fullPage: true,
|
||
});
|
||
});
|
||
|
||
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
|
||
const assignmentTable = structuredClone(commandTable);
|
||
assignmentTable.inputOptions.cities = [
|
||
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||
{ value: 3, label: '단양 (무주)' },
|
||
{ value: 1, label: '업 (아국)' },
|
||
];
|
||
|
||
await install(page, false, assignmentTable);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /발령/ }).click();
|
||
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const citySelect = form.locator('#command-arg-destCityId');
|
||
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||
await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||
await expect(citySelect).toHaveValue('1');
|
||
|
||
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
||
await expect(citySelect).toHaveValue('2');
|
||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||
await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const mobilePicker = page.getByTestId('command-picker');
|
||
await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await mobilePicker.getByRole('button', { name: /발령/ }).click();
|
||
|
||
const mobileForm = mobilePicker.getByTestId('command-argument-form');
|
||
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
||
const mobileCitySelect = mobileForm.locator('#command-arg-destCityId');
|
||
await expect(mobileMap).toBeVisible();
|
||
await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
overflow: element.scrollWidth - element.clientWidth,
|
||
}));
|
||
expect(mobileGeometry).toEqual({ width: 500, overflow: 0 });
|
||
const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
return { width: rect.width, height: rect.height };
|
||
});
|
||
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
|
||
await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') });
|
||
await mobileCitySelect.scrollIntoViewIfNeeded();
|
||
await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') });
|
||
});
|
||
|
||
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
const cases = [
|
||
{ action: '원조', first: '적국' },
|
||
{ action: '불가침 제의', first: '적국' },
|
||
{ action: '선전포고', first: '적국' },
|
||
{ action: '종전 제의', first: '적국' },
|
||
{ action: '불가침 파기 제의', first: '불가침국' },
|
||
];
|
||
|
||
for (const entry of cases) {
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const targetList = form.getByTestId('nation-target-list');
|
||
const targets = targetList.locator('.target-option');
|
||
await expect(targets).toHaveCount(3);
|
||
await expect(targets.first().locator('strong')).toHaveText(entry.first);
|
||
await expect(targets.first().locator('.target-state')).toHaveText('우선 대상');
|
||
await expect(targets.last().locator('.target-state')).toHaveText('현재 불가');
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue(entry.first === '불가침국' ? '3' : '2');
|
||
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||
}
|
||
|
||
const form = page.getByTestId('command-picker').getByTestId('command-argument-form');
|
||
const targets = form.getByTestId('nation-target-list').locator('.target-option');
|
||
await targets.filter({ hasText: '적국' }).click();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
await expect(targets.filter({ hasText: '적국' })).toHaveClass(/selected/);
|
||
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창');
|
||
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority.png'), fullPage: true });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const mobilePicker = page.getByTestId('command-picker');
|
||
await mobilePicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await mobilePicker.getByRole('button', { name: /불가침 파기 제의/ }).click();
|
||
const mobileTargets = mobilePicker.getByTestId('nation-target-list').locator('.target-option');
|
||
await mobileTargets.nth(1).hover();
|
||
expect(await mobileTargets.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||
await mobileTargets.nth(1).focus();
|
||
await expect(mobileTargets.nth(1)).toBeFocused();
|
||
const mobileGeometry = await mobilePicker.evaluate((element) => {
|
||
const list = element.querySelector<HTMLElement>('[data-testid="nation-target-list"]')!;
|
||
const cards = Array.from(list.querySelectorAll<HTMLElement>('.target-option'));
|
||
const listRect = list.getBoundingClientRect();
|
||
return {
|
||
pickerWidth: element.getBoundingClientRect().width,
|
||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
cardsInsideList: cards.every((card) => {
|
||
const rect = card.getBoundingClientRect();
|
||
return rect.left >= listRect.left && rect.right <= listRect.right;
|
||
}),
|
||
};
|
||
});
|
||
expect(mobileGeometry).toEqual({
|
||
pickerWidth: 500,
|
||
pickerOverflow: 0,
|
||
documentOverflow: 0,
|
||
cardsInsideList: true,
|
||
});
|
||
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority-mobile.png'), fullPage: true });
|
||
});
|
||
|
||
test('offers Ref amount presets and rich, command-specific general lists', async ({ page }) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
let picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /포상/ }).click();
|
||
let form = picker.getByTestId('command-argument-form');
|
||
const amount = form.locator('#command-arg-amount');
|
||
const amountPreset = form.getByRole('combobox', { name: '금액 프리셋' });
|
||
await expect(amount).toHaveValue('1000');
|
||
await expect(amountPreset.locator('option')).toHaveText(['프리셋', '100', '500', '1,000', '5,000', '10,000']);
|
||
await amountPreset.selectOption('5000');
|
||
await expect(amount).toHaveValue('5000');
|
||
await amount.fill('1375');
|
||
await expect(amount).toHaveValue('1375');
|
||
|
||
let generalList = form.getByTestId('general-target-list');
|
||
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
|
||
'관우 (업)',
|
||
'장수 (업)',
|
||
'여포NPC (업)',
|
||
]);
|
||
await expect(generalList.locator('.target-option strong')).toHaveText(['관우 (업)', '장수 (업)', '여포NPC (업)']);
|
||
await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS(
|
||
'color',
|
||
'rgb(0, 255, 255)'
|
||
);
|
||
await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200');
|
||
await expect(generalList).not.toContainText('아국');
|
||
await expect(generalList).not.toContainText('탑승 부대');
|
||
await form.getByRole('button', { name: '쌀', exact: true }).click();
|
||
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
|
||
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await awardResponse;
|
||
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /몰수/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
generalList = form.getByTestId('general-target-list');
|
||
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
|
||
'장수 (업)',
|
||
'관우 (업)',
|
||
'여포NPC (업)',
|
||
]);
|
||
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||
await expect(generalList).not.toContainText('아국');
|
||
await expect(generalList).not.toContainText('탑승 부대');
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /발령/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
generalList = form.getByTestId('general-target-list');
|
||
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
|
||
'장수 (업)',
|
||
'관우 (업)',
|
||
'여포NPC (업)',
|
||
]);
|
||
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||
await expect(generalList).not.toContainText('아국');
|
||
await expect(generalList).toContainText('탑승 부대 청룡대 (부대장)');
|
||
await expect(generalList).toContainText('통솔 100 · 무력 95 · 지력 70');
|
||
await picker.screenshot({ path: test.info().outputPath('chief-assignment-option-format-desktop.png') });
|
||
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
generalList = form.getByTestId('general-target-list');
|
||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||
'장수 (아국 · 업)',
|
||
'관우 (아국 · 업)',
|
||
'여포NPC (아국 · 업)',
|
||
]);
|
||
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('현재 불가');
|
||
await expect(generalList.locator('.target-option').nth(2).locator('.target-state')).toHaveText('우선 대상');
|
||
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /원조/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
const tupleInputs = form.locator('.tuple-options input[type=number]');
|
||
await expect(tupleInputs.nth(0)).toHaveValue('1000');
|
||
await expect(tupleInputs.nth(1)).toHaveValue('1000');
|
||
await form.getByRole('combobox', { name: '금 금액 프리셋' }).selectOption('20000');
|
||
await tupleInputs.nth(1).fill('1370');
|
||
await expect(tupleInputs.nth(0)).toHaveValue('20000');
|
||
await expect(tupleInputs.nth(1)).toHaveValue('1370');
|
||
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await aidResponse;
|
||
|
||
const serialized = JSON.stringify(requests);
|
||
expect(serialized).toContain('"amount":1375');
|
||
expect(serialized).toContain('"destGeneralId":3');
|
||
expect(serialized).toContain('"amountList":[20000,1370]');
|
||
await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobilePersonnelCases = [
|
||
{ turn: 5, command: '포상', labels: ['관우 (업)', '장수 (업)', '여포NPC (업)'] },
|
||
{ turn: 6, command: '몰수', labels: ['장수 (업)', '관우 (업)', '여포NPC (업)'] },
|
||
{
|
||
turn: 7,
|
||
command: '발령',
|
||
labels: ['장수 (업)', '관우 (업)', '여포NPC (업)'],
|
||
},
|
||
];
|
||
for (const entry of mobilePersonnelCases) {
|
||
await page.goto('/che/chief-center');
|
||
await page.getByRole('button', { name: `${entry.turn + 1}턴 명령 입력`, exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: new RegExp(entry.command) }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText(entry.labels);
|
||
await expect(form.getByTestId('general-target-list')).not.toContainText('아국');
|
||
}
|
||
const mobileGeometry = await picker.evaluate((element) => ({
|
||
pickerWidth: element.getBoundingClientRect().width,
|
||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
}));
|
||
expect(mobileGeometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0 });
|
||
await page.screenshot({ path: test.info().outputPath('chief-personnel-option-format-mobile.png'), fullPage: true });
|
||
});
|
||
|
||
for (const width of [1200, 500]) {
|
||
test(`shows complete assignment candidate details with wrapping at ${width}px`, async ({ page }, testInfo) => {
|
||
const longName = '아주긴이름의발령후보장수';
|
||
const longTroop = '먼도시로함께이동하는아주긴이름의원정부대'.repeat(3);
|
||
const assignmentOptions = inputOptions.generalTargets.che_발령.map((option) =>
|
||
option.value === 3
|
||
? {
|
||
...option,
|
||
label: `${longName} (업)`,
|
||
description: option.description,
|
||
targetNames: { ...option.targetNames!, name: longName, troopName: longTroop },
|
||
}
|
||
: option
|
||
);
|
||
const table = {
|
||
...commandTable,
|
||
inputOptions: {
|
||
...inputOptions,
|
||
generalTargets: { ...inputOptions.generalTargets, che_발령: assignmentOptions },
|
||
},
|
||
};
|
||
const requests = await install(page, false, table);
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto(gamePath('chief-center'));
|
||
await page.reload();
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /발령/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const list = form.getByTestId('general-target-list');
|
||
const cards = list.locator('.target-option');
|
||
await expect(cards).toHaveCount(3);
|
||
await expect(cards.nth(0)).toContainText('탑승 부대 없음');
|
||
await expect(cards.nth(0)).toContainText('훈련 0 · 사기 0');
|
||
await expect(cards.nth(1)).toContainText('탑승 부대 청룡대 (부대장)');
|
||
await expect(cards.nth(1)).toContainText('통솔 100 · 무력 95 · 지력 70');
|
||
await expect(cards.nth(1)).toContainText('병력 1,200 · 훈련 80 · 사기 70');
|
||
await expect(cards.nth(1)).toContainText('금 100 · 쌀 4,000');
|
||
await expect(list).not.toContainText('아국');
|
||
await form.locator('#command-arg-destGeneralId').selectOption('2');
|
||
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||
await cards.nth(2).hover();
|
||
await cards.nth(2).focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('3');
|
||
await expect(form.locator('.assignment-detail')).toContainText(longTroop);
|
||
await expect(cards.nth(2).locator('strong')).toHaveCSS('color', 'rgb(0, 255, 255)');
|
||
await cards.nth(1).click();
|
||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('2');
|
||
await cards.nth(2).click();
|
||
await form.locator('#command-arg-destCityId').selectOption('2');
|
||
await page.evaluate(async () => {
|
||
await document.fonts.ready;
|
||
});
|
||
const geometry = await picker.evaluate((element) => {
|
||
const measure = (node: Element) => {
|
||
const html = node as HTMLElement;
|
||
const style = getComputedStyle(node);
|
||
return {
|
||
text: html.innerText,
|
||
rect: node.getBoundingClientRect().toJSON(),
|
||
overflow: html.scrollWidth - html.clientWidth,
|
||
whiteSpace: style.whiteSpace,
|
||
overflowWrap: style.overflowWrap,
|
||
color: style.color,
|
||
background: style.backgroundColor,
|
||
font: style.font,
|
||
lineHeight: style.lineHeight,
|
||
};
|
||
};
|
||
return {
|
||
url: location.href,
|
||
dpr: devicePixelRatio,
|
||
viewport: innerWidth,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
picker: measure(element),
|
||
cards: [...element.querySelectorAll('.assignment-target-list .target-option')].map(measure),
|
||
details: [...element.querySelectorAll('.assignment-target-list small')].map(measure),
|
||
images: [...element.querySelectorAll('img')].map((img) => ({
|
||
src: img.getAttribute('src'),
|
||
complete: img.complete,
|
||
naturalWidth: img.naturalWidth,
|
||
naturalHeight: img.naturalHeight,
|
||
objectFit: getComputedStyle(img).objectFit,
|
||
})),
|
||
};
|
||
});
|
||
expect(geometry.documentOverflow).toBe(0);
|
||
expect(geometry.picker.overflow).toBe(0);
|
||
for (const card of geometry.cards) expect(card.overflow).toBe(0);
|
||
for (const detail of geometry.details) {
|
||
expect(detail.overflow).toBe(0);
|
||
expect(detail.whiteSpace).toBe('pre-line');
|
||
expect(detail.rect.height).toBeGreaterThanOrEqual(parseFloat(detail.lineHeight) * 4 - 1);
|
||
}
|
||
await writeFile(testInfo.outputPath('assignment-geometry.json'), JSON.stringify(geometry, null, 2));
|
||
await writeFile(
|
||
testInfo.outputPath('assignment-dom.html'),
|
||
await picker.evaluate((element) => element.outerHTML)
|
||
);
|
||
await picker.screenshot({ path: testInfo.outputPath('assignment-details.png') });
|
||
const saved = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await saved;
|
||
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||
expect(JSON.stringify(requests)).not.toContain('description');
|
||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toContainText(
|
||
`【${longName}】【허창】으로 발령`
|
||
);
|
||
});
|
||
}
|
||
|
||
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await page.goto('/');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /화계/ }).click();
|
||
const mobileForm = picker.getByTestId('command-argument-form');
|
||
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
||
const mobileCities = mobileMap.locator('.city-base');
|
||
await mobileCities.nth(1).click();
|
||
await expect(mobileForm.locator('select')).toHaveValue('2');
|
||
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
||
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||
await expect(mobileMap.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||
await expect(mobileCities.nth(0)).toHaveClass(/mine/);
|
||
await expect(mobileCities.nth(1)).toHaveClass(/selected/);
|
||
const geometry = await picker.evaluate((element) => {
|
||
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
||
const pickerRect = element.getBoundingClientRect();
|
||
const mapRect = map.getBoundingClientRect();
|
||
return {
|
||
pickerX: pickerRect.x,
|
||
pickerRight: pickerRect.right,
|
||
pickerWidth: pickerRect.width,
|
||
pickerScrollWidth: element.scrollWidth,
|
||
mapWidth: mapRect.width,
|
||
mapHeight: mapRect.height,
|
||
marker: (() => {
|
||
const marker = element.querySelector<HTMLElement>('[data-testid="current-city-marker"]')!;
|
||
const rect = marker.getBoundingClientRect();
|
||
const style = getComputedStyle(marker);
|
||
return {
|
||
left: rect.left,
|
||
right: rect.right,
|
||
top: rect.top,
|
||
bottom: rect.bottom,
|
||
pointerEvents: style.pointerEvents,
|
||
borderColor: style.borderColor,
|
||
};
|
||
})(),
|
||
};
|
||
});
|
||
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
||
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
|
||
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
|
||
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
||
expect(geometry.mapWidth).toBeGreaterThan(470);
|
||
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
||
expect(geometry.marker.left).toBeGreaterThanOrEqual(0);
|
||
expect(geometry.marker.right).toBeLessThanOrEqual(500);
|
||
expect(geometry.marker.top).toBeGreaterThanOrEqual(0);
|
||
expect(geometry.marker.bottom).toBeLessThanOrEqual(900);
|
||
expect(geometry.marker.pointerEvents).toBe('none');
|
||
expect(geometry.marker.borderColor).toBe('rgb(130, 207, 255)');
|
||
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
||
});
|
||
|
||
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
||
await install(page, true);
|
||
await page.goto('/');
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||
await page.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||
const submit = page.getByTestId('command-picker').getByRole('button', { name: / 입력$/ });
|
||
await submit.click();
|
||
|
||
await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.');
|
||
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
||
await expect(submit).toBeEnabled();
|
||
await expect(page.getByTestId('command-picker').getByRole('button', { name: '저장 중', exact: true })).toHaveCount(
|
||
0
|
||
);
|
||
});
|
||
|
||
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page, context }) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const editor = page.locator('[data-command-scope="general"]');
|
||
const firstRow = editor.locator('.action-column > div').first();
|
||
await expect(editor.locator('[data-command-autorun-status]')).toHaveCount(0);
|
||
await expect(firstRow).toContainText('휴식(자율 행동)');
|
||
await expect(firstRow).toHaveAttribute(
|
||
'data-autorun-tooltip',
|
||
/자율 행동: 200年 3月 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}까지/u
|
||
);
|
||
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
||
|
||
const tooltipStyle = () =>
|
||
firstRow.evaluate((element) => {
|
||
const style = getComputedStyle(element, '::after');
|
||
return { content: style.content, opacity: style.opacity, visibility: style.visibility };
|
||
});
|
||
expect((await tooltipStyle()).visibility).toBe('hidden');
|
||
const heightBeforeHover = await editor.evaluate((element) => element.getBoundingClientRect().height);
|
||
await firstRow.hover();
|
||
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
|
||
expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月');
|
||
expect(await editor.evaluate((element) => element.getBoundingClientRect().height)).toBe(heightBeforeHover);
|
||
await page.screenshot({
|
||
path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'),
|
||
fullPage: true,
|
||
});
|
||
await page.mouse.move(0, 0);
|
||
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('hidden');
|
||
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
||
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
||
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
|
||
await expect(firstRow).toHaveText('【단양】으로 출병');
|
||
await expect(firstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
|
||
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
||
await firstRow.focus();
|
||
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
|
||
await expect(firstRow).toBeFocused();
|
||
|
||
const desktopGeometry = await editor.evaluate((element) => {
|
||
const layout = element.querySelector<HTMLElement>('.editor-layout');
|
||
const controlPad = element.querySelector<HTMLElement>('.control-pad');
|
||
const row = element.querySelector<HTMLElement>('.action-column > div');
|
||
if (!layout || !controlPad || !row) throw new Error('autonomous command geometry is missing');
|
||
return {
|
||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||
controlPadOffset: controlPad.getBoundingClientRect().top - layout.getBoundingClientRect().top,
|
||
rowHeight: row.getBoundingClientRect().height,
|
||
};
|
||
});
|
||
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
expect(desktopGeometry.controlPadOffset).toBe(0);
|
||
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
|
||
await page.screenshot({
|
||
path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'),
|
||
fullPage: true,
|
||
});
|
||
|
||
const mobilePage = await context.newPage();
|
||
await install(mobilePage);
|
||
await mobilePage.setViewportSize({ width: 500, height: 900 });
|
||
await mobilePage.goto('/');
|
||
const mobileEditor = mobilePage.locator('[data-command-scope="general"]');
|
||
const mobileFirstRow = mobileEditor.locator('.action-column > div').first();
|
||
await expect(mobileFirstRow).toContainText('휴식(자율 행동)');
|
||
const mobileRestGeometry = await mobileFirstRow.evaluate((element) => {
|
||
const label = element.querySelector<HTMLElement>('span');
|
||
const autonomous = element.querySelector<HTMLElement>('small');
|
||
if (!label || !autonomous) throw new Error('mobile autonomous rest labels are missing');
|
||
const labelRect = label.getBoundingClientRect();
|
||
const autonomousRect = autonomous.getBoundingClientRect();
|
||
return {
|
||
display: getComputedStyle(element).display,
|
||
labelCenterY: labelRect.top + labelRect.height / 2,
|
||
autonomousCenterY: autonomousRect.top + autonomousRect.height / 2,
|
||
inlineGap: autonomousRect.left - labelRect.right,
|
||
rowOverflow: element.scrollWidth - element.clientWidth,
|
||
};
|
||
});
|
||
expect(mobileRestGeometry.display).toBe('flex');
|
||
expect(mobileRestGeometry.labelCenterY).toBeCloseTo(mobileRestGeometry.autonomousCenterY, 0);
|
||
expect(Math.abs(mobileRestGeometry.inlineGap)).toBeLessThanOrEqual(1);
|
||
expect(mobileRestGeometry.rowOverflow).toBeLessThanOrEqual(0);
|
||
await expect(mobileFirstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
|
||
await mobileFirstRow.focus();
|
||
await expect
|
||
.poll(() => mobileFirstRow.evaluate((element) => getComputedStyle(element, '::after').visibility))
|
||
.toBe('visible');
|
||
expect(await mobileEditor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||
await mobilePage.screenshot({
|
||
path: test.info().outputPath('command-brief-autorun-focus-mobile-500.png'),
|
||
fullPage: true,
|
||
});
|
||
await mobilePage.close();
|
||
});
|
||
|
||
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/');
|
||
|
||
const editor = page.locator('[data-command-scope="general"]');
|
||
await expect(editor).toBeVisible();
|
||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
const drag = async (first: number, last: number, selector = '.index-column > button') => {
|
||
const cells = editor.locator(selector);
|
||
const from = await cells.nth(first).boundingBox();
|
||
const to = await cells.nth(last).boundingBox();
|
||
if (!from || !to) throw new Error('turn buttons are not measurable');
|
||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 8 });
|
||
await page.mouse.up();
|
||
};
|
||
|
||
await drag(0, 2);
|
||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
||
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
const blockedFire = picker.getByRole('button', { name: '화계', exact: true });
|
||
await expect(blockedFire).toBeEnabled();
|
||
await blockedFire.click();
|
||
await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
||
|
||
const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) });
|
||
await recentMenu.locator('summary').click();
|
||
const recentBriefButton = recentMenu.getByRole('button', { name: '【허창】에 화계실행', exact: true });
|
||
await expect(recentBriefButton).toBeVisible();
|
||
await recentBriefButton.hover();
|
||
await page.screenshot({
|
||
path: test.info().outputPath('advanced-recent-command-brief-desktop-1200.png'),
|
||
fullPage: true,
|
||
});
|
||
await recentMenu.locator('summary').click();
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
await recentMenu.locator('summary').click();
|
||
await expect(recentBriefButton).toBeVisible();
|
||
await recentBriefButton.focus();
|
||
await expect(recentBriefButton).toBeFocused();
|
||
const mobileRecentGeometry = await editor.evaluate((element) => {
|
||
const menu = element.querySelector<HTMLElement>('details[open] .menu-items');
|
||
const recentButton = menu?.querySelector<HTMLElement>('button');
|
||
if (!menu || !recentButton) throw new Error('advanced recent command menu is missing');
|
||
return {
|
||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||
menuRight: menu.getBoundingClientRect().right,
|
||
buttonRight: recentButton.getBoundingClientRect().right,
|
||
};
|
||
});
|
||
expect(mobileRecentGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||
expect(mobileRecentGeometry.menuRight).toBeLessThanOrEqual(500);
|
||
expect(mobileRecentGeometry.buttonRight).toBeLessThanOrEqual(500);
|
||
await page.screenshot({
|
||
path: test.info().outputPath('advanced-recent-command-brief-mobile-500.png'),
|
||
fullPage: true,
|
||
});
|
||
await recentMenu.locator('summary').click();
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
|
||
await drag(0, 2);
|
||
await editor.locator('details.selected-menu > summary').click();
|
||
await editor.getByRole('button', { name: '복사하기', exact: true }).click();
|
||
await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull();
|
||
await editor.locator('details.range-menu > summary').click();
|
||
await editor.getByRole('button', { name: '모든턴', exact: true }).click();
|
||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
|
||
await editor.locator('details.selected-menu > summary').click();
|
||
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
|
||
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행');
|
||
|
||
await drag(0, 2);
|
||
page.once('dialog', (dialog) => dialog.accept('화계 세트'));
|
||
await editor.locator('details.selected-menu > summary').click();
|
||
await editor.getByRole('button', { name: '보관하기', exact: true }).click();
|
||
await editor
|
||
.locator('details')
|
||
.filter({ has: page.getByText('보관함', { exact: true }) })
|
||
.locator('summary')
|
||
.click();
|
||
await expect(editor.getByRole('button', { name: '화계 세트', exact: true })).toBeVisible();
|
||
|
||
expect(JSON.stringify(requests)).toContain('"turnList":[0,1,2]');
|
||
expect(JSON.stringify(requests)).toContain('"turnList":[0,3,6,9,12');
|
||
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('renders chief turn rows in the fixed UTC+9 server timezone', async ({ page }) => {
|
||
await install(page);
|
||
await page.goto('chief-center');
|
||
|
||
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
||
await expect(page.locator('.chief-grid-row').first().locator('.row-time').nth(0)).toHaveText('10:46');
|
||
await expect(page.locator('.chief-grid-row').first().locator('.row-time').nth(1)).toHaveText('10:56');
|
||
});
|
||
|
||
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 });
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||
|
||
const mainGeometry = await page.locator('.main-page').evaluate((element) => {
|
||
const header = element.querySelector<HTMLElement>('.game-shell__header')!;
|
||
const title = element.querySelector<HTMLElement>('.game-shell__title')!;
|
||
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle')!;
|
||
const action = element.querySelector<HTMLElement>('.game-shell__action')!;
|
||
return {
|
||
width: element.getBoundingClientRect().width,
|
||
padding: getComputedStyle(element).padding,
|
||
gap: getComputedStyle(element).gap,
|
||
headerWidth: header.getBoundingClientRect().width,
|
||
headerGap: getComputedStyle(header).gap,
|
||
headerBorder: getComputedStyle(header).borderBottomWidth,
|
||
headerPadding: getComputedStyle(header).paddingBottom,
|
||
titleFontSize: getComputedStyle(title).fontSize,
|
||
subtitleFontSize: getComputedStyle(subtitle).fontSize,
|
||
actionPadding: getComputedStyle(action).padding,
|
||
actionFontSize: getComputedStyle(action).fontSize,
|
||
};
|
||
});
|
||
expect(mainGeometry).toMatchObject({
|
||
width: 1000,
|
||
padding: '0px',
|
||
gap: '10px',
|
||
headerWidth: 1000,
|
||
headerGap: '12px',
|
||
headerBorder: '1px',
|
||
headerPadding: '12px',
|
||
actionPadding: '6px 12px',
|
||
});
|
||
expect(Number.parseFloat(mainGeometry.titleFontSize)).toBeGreaterThan(20);
|
||
expect(Number.parseFloat(mainGeometry.subtitleFontSize)).toBeGreaterThan(10);
|
||
expect(Number.parseFloat(mainGeometry.actionFontSize)).toBeGreaterThan(10);
|
||
|
||
const mainAction = page.getByRole('link', { name: '세력 정보' });
|
||
await mainAction.hover();
|
||
expect(await mainAction.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||
await mainAction.focus();
|
||
expect(await mainAction.evaluate((element) => document.activeElement === element)).toBe(true);
|
||
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click();
|
||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
await expect(page.getByTestId('command-picker')).toBeVisible();
|
||
await page
|
||
.getByTestId('command-picker')
|
||
.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true })
|
||
.click();
|
||
await page.getByTestId('command-picker').getByRole('button', { name: /포상/ }).click();
|
||
const chiefArgumentForm = page.getByTestId('command-picker').getByTestId('command-argument-form');
|
||
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
|
||
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
||
await chiefArgumentForm.locator('select').selectOption('2');
|
||
await page
|
||
.getByTestId('command-picker')
|
||
.getByRole('button', { name: / 입력$/ })
|
||
.click();
|
||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||
'【관우】 쌀 300 포상'
|
||
);
|
||
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
|
||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
||
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
padding: getComputedStyle(element).padding,
|
||
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
||
}));
|
||
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
|
||
width: element.getBoundingClientRect().width,
|
||
padding: getComputedStyle(element).padding,
|
||
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
||
}));
|
||
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
|
||
});
|
||
|
||
test('keeps the chief footer return button the same size as the top return button', async ({ page }) => {
|
||
await install(page);
|
||
const measure = () =>
|
||
page.locator('.chief-page').evaluate((element) => {
|
||
const top = element.querySelector<HTMLElement>('.chief-top .chief-nav')!.getBoundingClientRect();
|
||
const bottom = element.querySelector<HTMLElement>('.chief-footer .chief-nav')!.getBoundingClientRect();
|
||
return {
|
||
top: { width: top.width, height: top.height },
|
||
bottom: { width: bottom.width, height: bottom.height },
|
||
};
|
||
});
|
||
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto('/che/chief-center');
|
||
const desktop = await measure();
|
||
expect(desktop.bottom).toEqual(desktop.top);
|
||
|
||
await page.setViewportSize({ width: 500, height: 900 });
|
||
const mobile = await measure();
|
||
expect(mobile.bottom).toEqual(mobile.top);
|
||
});
|
||
|
||
const captureRecoveryControl = async (page: Page, selector: string, name: string) => {
|
||
const control = page.locator(selector).first();
|
||
await control.scrollIntoViewIfNeeded();
|
||
const read = () =>
|
||
control.evaluate((el) => {
|
||
const rect = el.getBoundingClientRect();
|
||
const style = getComputedStyle(el);
|
||
return {
|
||
rect: rect.toJSON(),
|
||
color: style.color,
|
||
background: style.backgroundColor,
|
||
font: style.font,
|
||
outline: style.outline,
|
||
cursor: style.cursor,
|
||
html: el.outerHTML,
|
||
overflow: el.scrollWidth > el.clientWidth,
|
||
};
|
||
});
|
||
const normal = await read();
|
||
expect(normal.rect.width).toBeGreaterThan(0);
|
||
expect(normal.rect.x).toBeGreaterThanOrEqual(0);
|
||
expect(normal.rect.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1);
|
||
await control.hover();
|
||
const hover = await read();
|
||
await control.focus();
|
||
const focus = await read();
|
||
await page.mouse.down();
|
||
const active = await read();
|
||
await page.mouse.move(0, 0);
|
||
await page.mouse.up();
|
||
await writeFile(test.info().outputPath(`${name}.json`), JSON.stringify({ normal, hover, focus, active }, null, 2));
|
||
};
|
||
|
||
for (const width of [1200, 500]) {
|
||
test(`recovery clock preference and quick switches at ${width}px`, async ({ page }) => {
|
||
await page.setViewportSize({ width, height: 1000 });
|
||
await page.clock.setFixedTime(new Date('2026-09-10T02:00:00Z'));
|
||
const recoveryClock: NonNullable<Parameters<typeof install>[4]> = {
|
||
serverTime: '2026-09-10T01:00:00Z',
|
||
serverWallTime: '2026-09-10T02:00:00Z',
|
||
clockRunning: true,
|
||
turnEngineRunning: true,
|
||
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
|
||
};
|
||
await install(page, false, commandTable, 1, recoveryClock);
|
||
await page.goto(gamePath('/'));
|
||
const top = page.locator('.execution-status');
|
||
const clock = page.locator('[data-command-current-time]:visible').first();
|
||
await expect(top).toContainText('게임 시간');
|
||
await expect(top).toHaveCSS('color', 'rgb(255, 209, 128)');
|
||
const gameText = await clock.textContent();
|
||
await top.click();
|
||
await expect(top).toContainText('실제 시간');
|
||
await expect(top).toHaveCSS('color', 'rgb(165, 214, 167)');
|
||
await expect(clock).not.toHaveText(gameText!);
|
||
await expect(page.locator('[data-general-turn-time]:visible').first()).toContainText('02:10:00');
|
||
const editor = page.locator('[data-command-scope="general"]:visible').first();
|
||
await expect(editor).toContainText('02:40');
|
||
await expect(editor).toContainText('03:20');
|
||
await clock.focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(top).toContainText('게임 시간');
|
||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
await clock.click();
|
||
await expect(top).toContainText('실제 시간');
|
||
await page.reload();
|
||
await expect(top).toContainText('실제 시간');
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await page.screenshot({ path: test.info().outputPath(`recovery-main-${width}.png`), fullPage: true });
|
||
await writeFile(
|
||
test.info().outputPath(`recovery-main-${width}.json`),
|
||
JSON.stringify(
|
||
await top.evaluate((el) => {
|
||
const rect = el.getBoundingClientRect();
|
||
const style = getComputedStyle(el);
|
||
return {
|
||
text: el.textContent,
|
||
rect: rect.toJSON(),
|
||
color: style.color,
|
||
font: style.font,
|
||
html: el.outerHTML,
|
||
};
|
||
}),
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
await captureRecoveryControl(page, '.execution-status', `recovery-main-states-${width}`);
|
||
await page.goto(gamePath('/chief-center'));
|
||
const chiefClock = page.locator('[data-command-current-time]:visible').first();
|
||
await expect(chiefClock).toHaveText('11:00:00');
|
||
await chiefClock.click();
|
||
await expect(chiefClock).toHaveText('10:00:00');
|
||
const chief = page.locator('[data-command-scope="nation"]:visible').first();
|
||
await chief.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||
await chiefClock.click();
|
||
await expect(chiefClock).toHaveText('11:00:00');
|
||
await page.screenshot({ path: test.info().outputPath(`recovery-chief-${width}.png`), fullPage: true });
|
||
await captureRecoveryControl(page, '[data-command-current-time]:visible', `recovery-chief-states-${width}`);
|
||
// At the end boundary, 1x clicks cannot change the preference.
|
||
recoveryClock.clockRecovery = null;
|
||
recoveryClock.serverTime = '2026-09-10T03:00:00Z';
|
||
recoveryClock.serverWallTime = '2026-09-10T03:00:00Z';
|
||
await page.clock.setFixedTime(new Date('2026-09-10T03:00:00Z'));
|
||
await expect(chiefClock).toBeDisabled();
|
||
await expect(chiefClock).toHaveText('12:00:00');
|
||
await chiefClock.evaluate((element: HTMLButtonElement) => element.click());
|
||
await expect(chiefClock).toHaveAttribute('title', '실제 시간 기준');
|
||
await page.goto(gamePath('/my-settings'));
|
||
const setting = page.getByRole('radiogroup', { name: '가속 시 시간 표시 기준' });
|
||
await expect(setting.getByRole('radio', { name: '실제 시간 기준', exact: true })).toBeChecked();
|
||
await setting.getByRole('radio', { name: '게임 시간 기준', exact: true }).check();
|
||
await page.screenshot({ path: test.info().outputPath(`recovery-settings-${width}.png`), fullPage: true });
|
||
await captureRecoveryControl(
|
||
page,
|
||
'[aria-label="가속 시 시간 표시 기준"]',
|
||
`recovery-settings-states-${width}`
|
||
);
|
||
await page.reload();
|
||
await expect(setting.getByRole('radio', { name: '게임 시간 기준', exact: true })).toBeChecked();
|
||
await page.goto(gamePath('/'));
|
||
await expect(top).toHaveCSS('color', 'rgb(0, 255, 255)');
|
||
await expect(top).toBeDisabled();
|
||
recoveryClock.turnEngineRunning = false;
|
||
await page.reload();
|
||
await expect(top).toHaveCSS('color', 'rgb(255, 0, 255)');
|
||
await expect(clock).toBeDisabled();
|
||
});
|
||
}
|
||
|
||
test('NPC prefixes stay single in chief command cards', async ({ page }, testInfo) => {
|
||
await install(page, false, commandTable, 1, undefined, true);
|
||
for (const width of [1200, 500]) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto(gamePath('/chief-center'));
|
||
const names = page.locator('.chief-card .chief-name, .chief-card .compact-name');
|
||
// Desktop uses the editor for the current officer; mobile includes that officer in the overview.
|
||
await expect(names).toHaveText([
|
||
'ⓖ의병',
|
||
'ⓤ중립',
|
||
'ⓞ특수',
|
||
'ⓧ기타',
|
||
'㉥부대장',
|
||
'ⓜ인재',
|
||
'ⓝ장수',
|
||
...(width === 500 ? ['사용자'] : []),
|
||
]);
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await testInfo.attach(`npc-prefix-dom-${width}`, {
|
||
contentType: 'application/json',
|
||
body: JSON.stringify(
|
||
await page.locator('.chief-card').evaluateAll((elements) =>
|
||
elements.map((element) => ({
|
||
html: element.outerHTML,
|
||
rect: element.getBoundingClientRect().toJSON(),
|
||
fontSize: getComputedStyle(element).fontSize,
|
||
color: getComputedStyle(element).color,
|
||
}))
|
||
)
|
||
),
|
||
});
|
||
await page.screenshot({ path: testInfo.outputPath(`npc-chief-prefix-${width}.png`), fullPage: true });
|
||
}
|
||
});
|
||
|
||
for (const width of [1200, 390]) {
|
||
test.describe(`target search viewport ${width}`, () => {
|
||
test.use({ isMobile: width < 500, hasTouch: width < 500 });
|
||
test(`target search preserves selection and searches Korean initials in both editors at ${width}px`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto(gamePath('/'));
|
||
const initialScale = await page.evaluate(() => visualViewport?.scale);
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
let picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /화계/ }).click();
|
||
let form = picker.getByTestId('command-argument-form');
|
||
const toggle = picker.getByRole('button', { name: '검색 꺼짐', exact: true });
|
||
await expect(form.locator('input[type=search]')).toHaveCount(0);
|
||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
||
await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click();
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await form.screenshot({ path: testInfo.outputPath(`search-off-${width}.png`) });
|
||
await toggle.click();
|
||
let input = form.locator('input[type=search]');
|
||
await expect(input).not.toBeFocused();
|
||
await expect(input).toHaveCSS('font-size', '16px');
|
||
await input.fill('ㅎㅊ');
|
||
let results = form.getByTestId('city-target-list');
|
||
await expect(results.locator('button')).toHaveCount(1);
|
||
await expect(results).toContainText('허창');
|
||
await results.getByRole('button').click();
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await input.fill('없는도시');
|
||
await expect(results).toContainText('검색 결과가 없습니다.');
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await form.getByRole('button', { name: '지우기', exact: true }).click();
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await input.fill('GC');
|
||
await expect(results.locator('button')).toHaveCount(1);
|
||
await input.press('Enter');
|
||
await expect(input).not.toBeFocused();
|
||
await expect(picker).toBeVisible();
|
||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||
await expect(input).toHaveCount(0);
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect.poll(() => JSON.stringify(requests)).toContain('"destCityId":2');
|
||
|
||
await page.goto(gamePath('/chief-center'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /발령/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3);
|
||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
const generalSearch = form.locator('#command-search-destGeneralId');
|
||
const citySearch = form.locator('#command-search-destCityId');
|
||
for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) {
|
||
await generalSearch.fill(query);
|
||
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(0);
|
||
}
|
||
await generalSearch.fill('ㅊㄹㄷ');
|
||
await expect(form.getByTestId('general-target-list')).toContainText('청룡대');
|
||
await generalSearch.fill('ㄱㅇ');
|
||
await citySearch.fill('ㅎㅊ');
|
||
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(1);
|
||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(1);
|
||
await form.getByTestId('general-target-list').getByRole('button').click();
|
||
await form.getByTestId('city-target-list').getByRole('button').click();
|
||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('2');
|
||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const geometry = await form.evaluate((element) => ({
|
||
html: element.outerHTML,
|
||
inputs: [...element.querySelectorAll('input[type=search]')].map((input) => {
|
||
const rect = input.getBoundingClientRect();
|
||
return {
|
||
x: rect.x,
|
||
right: rect.right,
|
||
width: rect.width,
|
||
fontSize: getComputedStyle(input).fontSize,
|
||
};
|
||
}),
|
||
viewport: { width: innerWidth, scale: visualViewport?.scale },
|
||
}));
|
||
expect(geometry.inputs.every((input) => input.x >= 0 && input.right <= geometry.viewport.width)).toBe(true);
|
||
expect(geometry.viewport.scale).toBe(initialScale);
|
||
await writeFile(testInfo.outputPath(`search-${width}.json`), JSON.stringify(geometry, null, 2));
|
||
await form.screenshot({ path: testInfo.outputPath(`search-${width}.png`) });
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect.poll(() => JSON.stringify(requests)).toContain('"destGeneralId":2,"destCityId":2');
|
||
await page.reload();
|
||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||
form = picker.getByTestId('command-argument-form');
|
||
await expect(picker.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true'
|
||
);
|
||
input = form.locator('input[type=search]');
|
||
await expect(input).toHaveValue('');
|
||
await expect(input).not.toBeFocused();
|
||
await input.fill('ㅂㄱㅊㄱ');
|
||
results = form.getByTestId('nation-target-list');
|
||
await expect(results.locator('button')).toHaveCount(1);
|
||
await expect(results).toContainText('현재 불가');
|
||
await results.getByRole('button').click();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('3');
|
||
await input.fill('적국');
|
||
await expect(results.locator('button strong')).toHaveText(['적국']);
|
||
await results.getByRole('button').click();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
await input.press('Escape');
|
||
await expect(input).toHaveValue('');
|
||
await expect(picker).toBeVisible();
|
||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||
await expect(input).toHaveCount(0);
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
});
|
||
});
|
||
}
|
||
|
||
test('gift search indexes nullable names without matching the displayed no-troop fallback', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const targets = buildRefGeneralTargetOptions({
|
||
actorId: 1,
|
||
actorNationId: 1,
|
||
generals: [
|
||
{ id: 1, name: '관우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
|
||
{ id: 2, name: '원우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
|
||
{ id: 3, name: '조조', nationId: 1, cityId: 1, troopId: 3, npcState: 0, officerLevel: 5 },
|
||
],
|
||
nationNames: new Map([[1, '피곤']]),
|
||
cityNames: new Map([[1, '업']]),
|
||
troopNames: new Map([[3, '원위대']]),
|
||
});
|
||
await install(page, false, {
|
||
...commandTable,
|
||
general: [{ category: '인사', values: [buildGeneralCommand('che_증여', '증여')] }],
|
||
inputOptions: { ...inputOptions, ...targets },
|
||
});
|
||
await page.goto(gamePath('/'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /증여/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const results = form.getByTestId('general-target-list');
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음');
|
||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
const search = form.locator('input[type=search]');
|
||
await search.fill('ㅇㅇ');
|
||
await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']);
|
||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('1');
|
||
await form.screenshot({ path: testInfo.outputPath('gift-real-name-search.png') });
|
||
await writeFile(
|
||
testInfo.outputPath('gift-real-name-search.html'),
|
||
await form.evaluate((element) => element.outerHTML)
|
||
);
|
||
await search.fill('없음');
|
||
await expect(results.locator('button')).toHaveCount(0);
|
||
await search.fill('피곤');
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
});
|
||
|
||
test('search reflects an active Korean IME composition after debounce without committing it', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const targets = buildRefGeneralTargetOptions({
|
||
actorId: 1,
|
||
actorNationId: 1,
|
||
generals: [
|
||
{ id: 1, name: '선녀', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
|
||
{ id: 2, name: '손권', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
|
||
{ id: 3, name: '관우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
|
||
],
|
||
nationNames: new Map([[1, '피곤']]),
|
||
cityNames: new Map([[1, '업']]),
|
||
});
|
||
await install(page, false, {
|
||
...commandTable,
|
||
general: [{ category: '인사', values: [buildGeneralCommand('che_증여', '증여')] }],
|
||
inputOptions: { ...inputOptions, ...targets },
|
||
});
|
||
await page.goto(gamePath('/'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /증여/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
const input = form.locator('input[type=search]');
|
||
const results = form.getByTestId('general-target-list');
|
||
await input.focus();
|
||
await input.evaluate((element) => {
|
||
element.setAttribute('data-composition-ends', '0');
|
||
element.addEventListener('compositionend', () =>
|
||
element.setAttribute(
|
||
'data-composition-ends',
|
||
String(Number(element.getAttribute('data-composition-ends')) + 1)
|
||
)
|
||
);
|
||
});
|
||
const cdp = await page.context().newCDPSession(page);
|
||
await cdp.send('Input.imeSetComposition', { text: 'ㅅ', selectionStart: 1, selectionEnd: 1 });
|
||
await expect(results.locator('button strong')).toHaveText(['선녀 (피곤 · 업)', '손권 (피곤 · 업)']);
|
||
await cdp.send('Input.imeSetComposition', { text: 'ㅅㄴ', selectionStart: 2, selectionEnd: 2 });
|
||
await expect(input).toHaveValue('ㅅㄴ');
|
||
await expect(results.locator('button strong')).toHaveText(['선녀 (피곤 · 업)']);
|
||
await expect(input).toBeFocused();
|
||
await expect(input).toHaveAttribute('data-composition-ends', '0');
|
||
// IME 확정 Enter를 검색창 닫기로 가로채지 않는다.
|
||
await input.dispatchEvent('keydown', { key: 'Enter', isComposing: true, keyCode: 229 });
|
||
await expect(input).toBeFocused();
|
||
await expect(input).toHaveAttribute('data-composition-ends', '0');
|
||
await form.screenshot({ path: testInfo.outputPath('active-ime-search.png') });
|
||
await writeFile(testInfo.outputPath('active-ime-search.html'), await form.evaluate((element) => element.outerHTML));
|
||
await cdp.send('Input.insertText', { text: 'ㅅㄴ' });
|
||
await expect(input).toHaveAttribute('data-composition-ends', '1');
|
||
await form.getByRole('button', { name: '지우기', exact: true }).click();
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await input.fill('ㄱㅇ');
|
||
await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||
await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다.
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
await expect(input).toHaveValue('');
|
||
await expect(results.locator('button')).toHaveCount(3);
|
||
await cdp.detach();
|
||
});
|
||
|
||
// Ref v_processing처럼 인자 입력은 화면 전체를 차지하되, Core는 URL을 바꾸지 않고
|
||
// Back·명령 취소·명령 다시 선택·Esc로 같은 문서의 overlay만 닫는다.
|
||
for (const viewport of [
|
||
{ width: 1200, height: 900 },
|
||
{ width: 500, height: 900 },
|
||
{ width: 390, height: 844 },
|
||
] as const) {
|
||
test(`opens every argument input as a full-screen overlay with a top control bar at ${viewport.width}px`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize(viewport);
|
||
await page.goto(gamePath('/'));
|
||
const mainUrl = page.url();
|
||
const initialHistoryLength = await page.evaluate(() => history.length);
|
||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||
|
||
const openFireAttack = async (turn: number) => {
|
||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||
const listPicker = page.getByTestId('command-picker');
|
||
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
|
||
await listPicker.getByRole('button', { name: /화계/ }).click();
|
||
const overlay = page.getByRole('dialog', { name: `화계 ${turn}턴 명령 입력` });
|
||
await expect(overlay).toBeVisible();
|
||
return overlay;
|
||
};
|
||
const measure = (overlay: ReturnType<Page['getByRole']>) =>
|
||
overlay.evaluate((element) => {
|
||
const bar = element.querySelector<HTMLElement>('[data-testid="command-input-top-bar"]')!;
|
||
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"]');
|
||
const fields = element.querySelector<HTMLElement>('.argument-fields');
|
||
const rect = (target: Element | null) => target?.getBoundingClientRect().toJSON() ?? null;
|
||
return {
|
||
parentIsBody: element.parentElement === document.body,
|
||
overlay: rect(element),
|
||
bar: rect(bar),
|
||
buttons: [...bar.querySelectorAll('button')].map((button) => ({
|
||
text: button.textContent?.trim(),
|
||
background: getComputedStyle(button).backgroundColor,
|
||
...button.getBoundingClientRect().toJSON(),
|
||
})),
|
||
submit: (() => {
|
||
const submit = element.querySelector<HTMLButtonElement>('.picker-actions > .command-submit');
|
||
if (!submit) return null;
|
||
return {
|
||
text: submit.textContent?.trim(),
|
||
disabled: submit.disabled,
|
||
background: getComputedStyle(submit).backgroundColor,
|
||
fontWeight: getComputedStyle(submit).fontWeight,
|
||
...submit.getBoundingClientRect().toJSON(),
|
||
};
|
||
})(),
|
||
title: bar.querySelector('h2')?.textContent?.replace(/\s+/g, ' ').trim(),
|
||
map: rect(map),
|
||
fields: rect(fields),
|
||
bodyOverflow: getComputedStyle(document.body).overflow,
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
};
|
||
});
|
||
|
||
let overlay = await openFireAttack(1);
|
||
await expect(overlay).toHaveAttribute('aria-modal', 'true');
|
||
await expect(overlay.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const geometry = await measure(overlay);
|
||
await writeFile(
|
||
testInfo.outputPath(`argument-overlay-${viewport.width}.json`),
|
||
JSON.stringify(geometry, null, 2)
|
||
);
|
||
await page.screenshot({ path: testInfo.outputPath(`argument-overlay-${viewport.width}.png`) });
|
||
expect(geometry.parentIsBody).toBe(true);
|
||
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: viewport.width, height: viewport.height });
|
||
expect(geometry.bar?.y).toBe(0);
|
||
expect(geometry.buttons.map((button) => button.text)).toEqual(['명령 취소', '명령 다시 선택', '검색 꺼짐']);
|
||
expect(geometry.title).toBe('화계1턴');
|
||
// 녹색은 Ref 돌아가기와 같은 이동 버튼(명령 취소)에만 쓰고, 제출은 명령명을 붙인 primary(파랑) 버튼이다.
|
||
expect(geometry.buttons.map((button) => button.background)).toEqual([
|
||
'rgb(0, 88, 44)',
|
||
'rgb(68, 68, 68)',
|
||
'rgb(68, 68, 68)',
|
||
]);
|
||
expect(geometry.submit).toMatchObject({
|
||
text: '화계 입력',
|
||
disabled: false,
|
||
background: 'rgb(55, 90, 127)',
|
||
fontWeight: '700',
|
||
});
|
||
expect(geometry.submit!.width).toBe(Math.min(360, viewport.width - 12));
|
||
expect(geometry.submit!.x + geometry.submit!.width / 2).toBeCloseTo(viewport.width / 2, 0);
|
||
expect(geometry.submit!.height).toBeGreaterThanOrEqual(40);
|
||
expect(geometry.submit!.bottom).toBeLessThanOrEqual(viewport.height);
|
||
expect(geometry.submit!.bottom).toBeGreaterThan(viewport.height - 12);
|
||
expect(geometry.bodyOverflow).toBe('hidden');
|
||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||
for (const button of geometry.buttons) {
|
||
expect(button.x).toBeGreaterThanOrEqual(0);
|
||
expect(button.right).toBeLessThanOrEqual(viewport.width);
|
||
}
|
||
if (viewport.width >= 1120) {
|
||
// 원본 700px 지도 오른쪽에 대상 목록 열을 두고 1120px 폭(1px 테두리 포함) 안에서 중앙 정렬한다.
|
||
expect(geometry.map).toMatchObject({ x: (viewport.width - 1120) / 2 + 1, width: 700 });
|
||
expect(geometry.fields?.x).toBe(geometry.map!.right);
|
||
expect(geometry.fields?.y).toBe(geometry.map!.y);
|
||
expect(geometry.fields?.right).toBe((viewport.width + 1120) / 2 - 1);
|
||
} else {
|
||
expect(geometry.fields!.y).toBeGreaterThanOrEqual(geometry.map!.bottom);
|
||
expect(geometry.map!.width).toBeLessThanOrEqual(viewport.width);
|
||
}
|
||
|
||
// 검색 토글은 상단 바에 있고 인자 폼의 검색 입력을 제어한다.
|
||
await overlay.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
await expect(overlay.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true'
|
||
);
|
||
await expect(overlay.locator('input[type=search]')).toHaveCount(1);
|
||
await overlay.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||
await expect(overlay.locator('input[type=search]')).toHaveCount(0);
|
||
|
||
// 명령 취소: overlay와 명령 목록을 함께 닫고 Back entry를 소비한다.
|
||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
|
||
expect(page.url()).toBe(mainUrl);
|
||
|
||
// 브라우저 Back: 페이지를 떠나지 않고 overlay만 닫는다.
|
||
await openFireAttack(2);
|
||
await page.goBack();
|
||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||
expect(page.url()).toBe(mainUrl);
|
||
await expect(editor).toBeVisible();
|
||
|
||
// Esc
|
||
await openFireAttack(3);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||
expect(page.url()).toBe(mainUrl);
|
||
|
||
// 명령 다시 선택: 명령 목록 popup으로 돌아가고 이후 Back은 남은 history를 소비하지 않는다.
|
||
overlay = await openFireAttack(4);
|
||
await overlay.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||
const listPicker = page.getByTestId('command-picker');
|
||
await expect(listPicker).toBeVisible();
|
||
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
|
||
await expect(listPicker.getByRole('button', { name: /화계/ })).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||
|
||
// 입력 완료 뒤에도 같은 URL과 원래 history 길이를 유지하고 payload·turnList는 그대로다.
|
||
await listPicker.getByRole('button', { name: /화계/ }).click();
|
||
overlay = page.getByRole('dialog', { name: '화계 4턴 명령 입력' });
|
||
await overlay.getByTestId('command-argument-form').locator('select').selectOption('2');
|
||
await overlay.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||
await expect(editor.locator('.action-column > div').nth(3)).toHaveText('【허창】에 화계실행');
|
||
await expect
|
||
.poll(() => JSON.stringify(requests))
|
||
.toContain('"turnList":[3],"action":"che_화계","args":{"destCityId":2}');
|
||
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
|
||
expect(page.url()).toBe(mainUrl);
|
||
await page.goBack();
|
||
await expect(page).not.toHaveURL(mainUrl);
|
||
});
|
||
}
|
||
|
||
test('opens chief argument input as the same full-screen overlay with selected turns in the title', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
await install(page);
|
||
await page.setViewportSize({ width: 1200, height: 900 });
|
||
await page.goto(gamePath('/chief-center'));
|
||
const mainUrl = page.url();
|
||
const editor = page.locator('[data-command-scope="nation"]');
|
||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const listPicker = page.getByTestId('command-picker');
|
||
await listPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||
await listPicker.getByRole('button', { name: /발령/ }).click();
|
||
const overlay = page.getByRole('dialog', { name: '발령 1턴 명령 입력' });
|
||
await expect(overlay).toBeVisible();
|
||
await expect(overlay.getByTestId('command-input-top-bar').locator('h2')).toHaveText('발령1턴');
|
||
const geometry = await overlay.evaluate((element) => ({
|
||
parentIsBody: element.parentElement === document.body,
|
||
overlay: element.getBoundingClientRect().toJSON(),
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
}));
|
||
expect(geometry.parentIsBody).toBe(true);
|
||
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
|
||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||
await expect(overlay.getByTestId('command-argument-map')).toBeVisible();
|
||
// Ref che_발령.vue는 distanceList를 받지만 표시하지 않는다.
|
||
await expect(overlay.getByTestId('city-distance-list')).toHaveCount(0);
|
||
await page.screenshot({ path: testInfo.outputPath('chief-argument-overlay-1200.png') });
|
||
await page.goBack();
|
||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||
expect(page.url()).toBe(mainUrl);
|
||
});
|
||
|
||
// Ref ProcessCity + CitiesBasedOnDistance: 화계 계열·강행·첩보는 3칸, 이동·출병은 1칸까지 현재 도시 기준
|
||
// 거리별 도시를 magenta/orange/yellow 밑줄 글자로 보여 주고, 누르면 대상 도시로 고른다.
|
||
for (const viewport of [
|
||
{ width: 1200, height: 900 },
|
||
{ width: 390, height: 844 },
|
||
] as const) {
|
||
test(`lists Ref-style cities by distance for city target commands at ${viewport.width}px`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const requests = await install(page);
|
||
await page.setViewportSize(viewport);
|
||
await page.goto(gamePath('/'));
|
||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||
const openCommand = async (turn: number, category: string, name: string) => {
|
||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||
const listPicker = page.getByTestId('command-picker');
|
||
await listPicker.getByRole('button', { name: category, exact: true }).click();
|
||
await listPicker.getByRole('button', { name, exact: true }).click();
|
||
const overlay = page.getByRole('dialog', { name: `${name} ${turn}턴 명령 입력` });
|
||
await expect(overlay).toBeVisible();
|
||
return overlay;
|
||
};
|
||
|
||
let overlay = await openCommand(1, '계략', '화계');
|
||
const list = overlay.getByTestId('city-distance-list');
|
||
await expect(list).toBeVisible();
|
||
await expect(list.locator('.city-distance-row')).toHaveText([
|
||
'1칸 떨어진 도시: 허창',
|
||
'2칸 떨어진 도시:',
|
||
'3칸 떨어진 도시:',
|
||
]);
|
||
const link = list.getByRole('button', { name: '허창', exact: true });
|
||
const style = await link.evaluate((element) => {
|
||
const computed = getComputedStyle(element);
|
||
return {
|
||
color: computed.color,
|
||
textDecorationLine: computed.textDecorationLine,
|
||
fontWeight: computed.fontWeight,
|
||
};
|
||
});
|
||
expect(style).toEqual({ color: 'rgb(255, 0, 255)', textDecorationLine: 'underline', fontWeight: '400' });
|
||
await expect(link).toHaveAttribute('aria-pressed', 'false');
|
||
|
||
await link.click();
|
||
await expect(link).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(overlay.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
||
await expect(overlay.getByTestId('command-map-selection-status')).toContainText('허창');
|
||
const geometry = await overlay.evaluate((element) => {
|
||
const fields = element.querySelector<HTMLElement>('.argument-fields')!.getBoundingClientRect();
|
||
const distance = element.querySelector<HTMLElement>('[data-testid="city-distance-list"]')!;
|
||
const rect = distance.getBoundingClientRect();
|
||
return {
|
||
fields: fields.toJSON(),
|
||
list: rect.toJSON(),
|
||
fontSize: getComputedStyle(distance).fontSize,
|
||
scrollWidth: element.scrollWidth,
|
||
clientWidth: element.clientWidth,
|
||
};
|
||
});
|
||
await writeFile(testInfo.outputPath(`city-distance-${viewport.width}.json`), JSON.stringify(geometry, null, 2));
|
||
await page.screenshot({ path: testInfo.outputPath(`city-distance-${viewport.width}.png`) });
|
||
expect(geometry.fontSize).toBe('14px');
|
||
expect(geometry.list.x).toBeGreaterThanOrEqual(geometry.fields.x);
|
||
expect(geometry.list.right).toBeLessThanOrEqual(geometry.fields.right);
|
||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||
|
||
await overlay.getByRole('button', { name: '화계 입력', exact: true }).click();
|
||
await expect
|
||
.poll(() => JSON.stringify(requests))
|
||
.toContain('"turnList":[0],"action":"che_화계","args":{"destCityId":2}');
|
||
|
||
overlay = await openCommand(2, '군사', '출병');
|
||
await expect(overlay.getByTestId('city-distance-list').locator('.city-distance-row')).toHaveText([
|
||
'1칸 떨어진 도시: 허창',
|
||
]);
|
||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||
});
|
||
}
|
||
|
||
for (const width of [1200, 390]) {
|
||
test(`groups recruitment targets by nation color without cross-nation resources at ${width}px`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const targets = buildRefGeneralTargetOptions({
|
||
actorId: 1,
|
||
actorNationId: 1,
|
||
generals: [
|
||
{ id: 1, name: '장수', nationId: 1, cityId: 1, npcState: 0, officerLevel: 5 },
|
||
{
|
||
id: 4,
|
||
name: '적장',
|
||
nationId: 2,
|
||
cityId: 2,
|
||
npcState: 0,
|
||
officerLevel: 1,
|
||
gold: 9100,
|
||
rice: 8200,
|
||
crew: 7300,
|
||
train: 90,
|
||
atmos: 80,
|
||
troopId: 4,
|
||
leadership: 75,
|
||
strength: 60,
|
||
intel: 45,
|
||
},
|
||
{ id: 5, name: '떠돌이', nationId: 0, cityId: 3, npcState: 0, officerLevel: 0, gold: 600 },
|
||
{ id: 6, name: '빙의장', nationId: 2, cityId: 2, npcState: 1, officerLevel: 1 },
|
||
{ id: 7, name: '아군', nationId: 1, cityId: 1, npcState: 0, officerLevel: 1 },
|
||
],
|
||
nationNames: new Map([
|
||
[1, '아국'],
|
||
[2, '적국'],
|
||
]),
|
||
cityNames: new Map([
|
||
[1, '업'],
|
||
[2, '허창'],
|
||
[3, '단양'],
|
||
]),
|
||
troopNames: new Map([[4, '비밀부대']]),
|
||
});
|
||
const requests = await install(page, false, {
|
||
...commandTable,
|
||
general: [{ category: '인사', values: [buildGeneralCommand('che_등용', '등용')] }],
|
||
inputOptions: { ...inputOptions, ...targets },
|
||
});
|
||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||
await page.goto(gamePath('/'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /등용/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const list = form.getByTestId('general-target-list');
|
||
const headers = list.getByTestId('general-nation-group');
|
||
// Ref ORDER BY npc,name 순서에서 국가가 처음 나타난 순서로 묶는다.
|
||
await expect(headers).toHaveText(['적국', '재야', '아국']);
|
||
// 같은 국가 장수는 한 묶음에 모이므로 빙의장(npc=1)이 적국 묶음 안에 들어간다.
|
||
await expect(list.locator('.target-option strong')).toHaveText(['적장', '빙의장', '떠돌이', '아군']);
|
||
await expect(form.locator('#command-arg-destGeneralId optgroup')).toHaveCount(3);
|
||
await expect(form.locator('#command-arg-destGeneralId optgroup').first()).toHaveAttribute('label', '적국');
|
||
await expect(form.locator('#command-arg-destGeneralId option').first()).toHaveText('적장 (적국)');
|
||
const text = await list.innerText();
|
||
for (const hidden of ['9,100', '8,200', '7,300', '허창', '단양', '비밀부대', '훈련', '사기']) {
|
||
expect(text).not.toContain(hidden);
|
||
}
|
||
await expect(list.locator('.target-option').first()).toContainText('통솔 75 · 무력 60 · 지력 45');
|
||
const style = await headers.evaluateAll((nodes) =>
|
||
nodes.map((node) => {
|
||
const computed = getComputedStyle(node);
|
||
return [computed.backgroundColor, computed.color, computed.position];
|
||
})
|
||
);
|
||
expect(style).toEqual([
|
||
['rgb(128, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||
['rgb(0, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||
['rgb(0, 128, 0)', 'rgb(255, 255, 255)', 'sticky'],
|
||
]);
|
||
// 빙의장(npc=1)은 Ref getNPCColor처럼 skyblue다.
|
||
await expect(list.locator('.target-option').nth(1).locator('strong')).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-all-${width}.png`) });
|
||
|
||
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||
await form.locator('#command-search-destGeneralId').fill('ㄸㄷ');
|
||
await expect(headers).toHaveText(['재야']);
|
||
await expect(list.locator('.target-option')).toHaveCount(1);
|
||
await list.locator('.target-option').click();
|
||
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('5');
|
||
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const geometry = await picker.evaluate((element) => ({
|
||
url: location.href,
|
||
viewport: [innerWidth, innerHeight],
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||
list: element.querySelector('[data-testid=general-target-list]')?.getBoundingClientRect().toJSON(),
|
||
headers: [...element.querySelectorAll('[data-testid=general-nation-group]')].map((node) =>
|
||
node.getBoundingClientRect().toJSON()
|
||
),
|
||
}));
|
||
// 메인 화면 자체는 Ref처럼 최소 500px 폭이라 390px에서 문서가 넓다. 전체화면 overlay 안만 확인한다.
|
||
expect(geometry.overlayOverflow).toBe(0);
|
||
await writeFile(testInfo.outputPath(`recruit-groups-${width}.json`), JSON.stringify(geometry, null, 2));
|
||
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-${width}.png`) });
|
||
|
||
await picker.getByRole('button', { name: / 입력$/ }).click();
|
||
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_등용","args":{"destGeneralId":5}');
|
||
});
|
||
}
|
||
|
||
// 2-3 국가 대상: Ref che_임관·che_장수대상임관 권유문 목록, che_물자원조 작위별 한도표, che_피장파장 전략 재사용 대기.
|
||
const nationTargetSources: NationTargetSource[] = [
|
||
{
|
||
id: 1,
|
||
name: '아국',
|
||
color: '#008000',
|
||
capitalName: '업',
|
||
level: 1,
|
||
power: 1000,
|
||
generalCount: 5,
|
||
cityCount: 2,
|
||
diplomacyState: 2,
|
||
diplomacyTerm: 0,
|
||
adjacent: false,
|
||
scoutMessage: purifyNationHtml('<p>아국 권유문</p>'),
|
||
},
|
||
{
|
||
id: 2,
|
||
name: '위',
|
||
color: '#0000FF',
|
||
capitalName: '허창',
|
||
level: 2,
|
||
power: 800,
|
||
generalCount: 4,
|
||
cityCount: 2,
|
||
diplomacyState: 0,
|
||
diplomacyTerm: 6,
|
||
adjacent: true,
|
||
// 저장·조회 경로와 같은 정제 결과만 화면에 전달한다. script와 on* 속성은 남지 않는다.
|
||
scoutMessage: purifyNationHtml(
|
||
'<div style="width: 870px; height: 300px; background-color: #334455"><b>천하를 함께</b>' +
|
||
'<script>window.__scoutXss = 1</script><span onclick="window.__scoutXss = 2">함께</span></div>'
|
||
),
|
||
},
|
||
{
|
||
id: 3,
|
||
name: '오',
|
||
color: '#FFFF00',
|
||
capitalName: '건업',
|
||
level: 2,
|
||
power: 900,
|
||
generalCount: 3,
|
||
cityCount: 1,
|
||
diplomacyState: 1,
|
||
diplomacyTerm: 12,
|
||
adjacent: false,
|
||
blockScout: true,
|
||
},
|
||
];
|
||
|
||
for (const width of [1200, 390]) {
|
||
test(`shows Ref scout messages for appointment commands at ${width}px`, async ({ page }, testInfo) => {
|
||
const nationTargets = buildRefNationTargetOptions({
|
||
actorNationId: 0,
|
||
nations: nationTargetSources,
|
||
join: { actorNpcState: 0, relYear: 5, openingPartYear: 3, initialNationGenLimit: 10 },
|
||
});
|
||
const requests = await install(page, false, {
|
||
...commandTable,
|
||
general: [
|
||
{
|
||
category: '인사',
|
||
values: [
|
||
{
|
||
...buildNationCommand('che_임관', '임관'),
|
||
},
|
||
buildGeneralCommand('che_장수대상임관', '장수대상임관'),
|
||
],
|
||
},
|
||
],
|
||
inputOptions: {
|
||
...inputOptions,
|
||
nations: nationTargets.nations,
|
||
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
|
||
nationScoutMessages: nationTargets.nationScoutMessages,
|
||
},
|
||
});
|
||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||
await page.goto(gamePath('/'));
|
||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^임관/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const list = form.getByTestId('nation-scout-list');
|
||
const rows = list.getByTestId('nation-scout-row');
|
||
await expect(list.locator('.nation-scout-header > div')).toHaveText(['국가명', '임관권유문']);
|
||
await expect(rows.locator('.nation-scout-name')).toHaveText(['아국', '위', '오']);
|
||
await expect(rows.nth(1).locator('.nation-scout-msg b')).toHaveText('천하를 함께');
|
||
expect(await list.locator('script, [onclick]').count()).toBe(0);
|
||
expect(await page.evaluate(() => (window as unknown as { __scoutXss?: number }).__scoutXss)).toBeUndefined();
|
||
// 국가명 칸은 국가색 배경이고 Ref isBrightColor 기준으로 글자색을 고른다.
|
||
const nameStyles = await rows
|
||
.locator('.nation-scout-name')
|
||
.evaluateAll((nodes) =>
|
||
nodes.map((node) => [getComputedStyle(node).backgroundColor, getComputedStyle(node).color])
|
||
);
|
||
expect(nameStyles).toEqual([
|
||
['rgb(0, 128, 0)', 'rgb(255, 255, 255)'],
|
||
['rgb(0, 0, 255)', 'rgb(255, 255, 255)'],
|
||
['rgb(255, 255, 0)', 'rgb(0, 0, 0)'],
|
||
]);
|
||
// 임관 금지 국가는 대상 카드에서 현재 불가로 남는다.
|
||
await expect(form.getByTestId('nation-target-list').locator('.target-option').nth(2)).toContainText(
|
||
'임관이 금지되어 있습니다.'
|
||
);
|
||
|
||
const toggle = list.getByRole('button', { name: /보기$/ });
|
||
const plate = rows.nth(1).locator('.nation-scout-plate');
|
||
if (width >= 940) {
|
||
// Ref media-1000px(940px 이상): 130px + 870px 두 열, 크게/작게 보기 버튼 없음.
|
||
await expect(toggle).toBeHidden();
|
||
await expect(rows.nth(1)).toHaveCSS('grid-template-columns', '130px 870px');
|
||
} else {
|
||
await expect(toggle).toHaveText('작게 보기');
|
||
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(plate).toHaveCSS('max-height', '200px');
|
||
await expect(plate).toHaveCSS('overflow-x', 'auto');
|
||
await toggle.click();
|
||
await expect(toggle).toHaveText('크게 보기');
|
||
await expect(rows.nth(1)).toHaveClass(/on-fit/);
|
||
}
|
||
await rows.nth(1).click();
|
||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||
await expect(rows.nth(1)).toHaveClass(/selected/);
|
||
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const geometry = await picker.evaluate((element) => {
|
||
const row = element.querySelectorAll<HTMLElement>('[data-testid=nation-scout-row]')[1]!;
|
||
const message = row.querySelector<HTMLElement>('.nation-scout-msg')!;
|
||
return {
|
||
url: location.href,
|
||
viewport: [innerWidth, innerHeight],
|
||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||
list: element.querySelector('[data-testid=nation-scout-list]')?.getBoundingClientRect().toJSON(),
|
||
row: row.getBoundingClientRect().toJSON(),
|
||
plate: row.querySelector('.nation-scout-plate')!.getBoundingClientRect().toJSON(),
|
||
message: message.getBoundingClientRect().toJSON(),
|
||
transform: getComputedStyle(message).transform,
|
||
};
|
||
});
|
||
expect(geometry.overlayOverflow).toBe(0);
|
||
if (width < 940) {
|
||
// on-fit: 870px 권유문을 목록 폭/870으로 줄이고, 높이는 200px × 같은 비율까지만 보인다.
|
||
const scale = geometry.list!.width / 870;
|
||
expect(geometry.message.width).toBeCloseTo(870 * scale, 0);
|
||
expect(geometry.plate.height).toBeLessThanOrEqual(200 * scale + 1);
|
||
expect(geometry.plate.right).toBeLessThanOrEqual(width);
|
||
} else {
|
||
expect(geometry.plate.width).toBeCloseTo(870, 0);
|
||
expect(geometry.transform).toBe('none');
|
||
}
|
||
await writeFile(testInfo.outputPath(`scout-list-${width}.json`), JSON.stringify(geometry, null, 2));
|
||
await picker.screenshot({ path: testInfo.outputPath(`scout-list-${width}.png`) });
|
||
await picker.getByRole('button', { name: '임관 입력', exact: true }).click();
|
||
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_임관","args":{"destNationId":2}');
|
||
|
||
// 장수대상임관은 재야를 앞에 둔 같은 목록을 보이며 행을 눌러도 국가를 고르지 않는다.
|
||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
await picker.getByRole('button', { name: /장수대상임관/ }).click();
|
||
await expect(rows.locator('.nation-scout-name')).toHaveText(['재야', '아국', '위', '오']);
|
||
await expect(rows.first().locator('.nation-scout-name')).toHaveCSS('background-color', 'rgb(0, 0, 0)');
|
||
await expect(rows.first().locator('.nation-scout-name button')).toHaveCount(0);
|
||
});
|
||
|
||
test(`shows Ref aid limits and counter-strategy cooldowns at ${width}px`, async ({ page }, testInfo) => {
|
||
const nationTargets = buildRefNationTargetOptions({ actorNationId: 1, nations: nationTargetSources });
|
||
const counterStrategy = {
|
||
...buildNationCommand('che_피장파장', '피장파장'),
|
||
inputFields: [
|
||
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||
{
|
||
key: 'commandType',
|
||
label: '전략',
|
||
kind: 'select',
|
||
required: true,
|
||
options: [
|
||
{ value: 'che_필사즉생', label: '필사즉생' },
|
||
{ value: 'che_수몰', label: '수몰' },
|
||
{ value: 'che_허보', label: '허보' },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
const aid = commandTable.nation.flatMap((group) => group.values).find((entry) => entry.key === 'che_물자원조')!;
|
||
const requests = await install(page, false, {
|
||
...commandTable,
|
||
nation: [{ category: '전략', values: [counterStrategy, aid] }],
|
||
inputOptions: {
|
||
...inputOptions,
|
||
nations: nationTargets.nations,
|
||
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
|
||
strategyCooldowns: { che_수몰: 12 },
|
||
context: { ...inputOptions.context, nationLevel: 3 },
|
||
},
|
||
});
|
||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
|
||
await page.goto(gamePath('/chief-center'));
|
||
const editor = page.locator('[data-command-scope="nation"]');
|
||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||
const picker = page.getByTestId('command-picker');
|
||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /피장파장/ }).click();
|
||
const form = picker.getByTestId('command-argument-form');
|
||
const strategy = form.locator('#command-arg-commandType');
|
||
await expect(strategy.locator('option')).toHaveText(['필사즉생', '수몰 (불가, 12턴)', '허보']);
|
||
await expect(strategy.locator('option').nth(1)).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||
await strategy.selectOption('che_수몰');
|
||
await expect(strategy).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||
await strategy.selectOption('che_허보');
|
||
await expect(strategy).not.toHaveCSS('color', 'rgb(255, 0, 0)');
|
||
// 선포·전쟁 중인 국가만 가능하고 아국은 불가다.
|
||
const cards = form.getByTestId('nation-target-list').locator('.target-option');
|
||
await expect(cards.locator('strong')).toHaveText(['오', '위', '아국']);
|
||
await expect(cards.nth(2)).toHaveClass(/unavailable/);
|
||
await expect(form.getByTestId('command-argument-guidance')).toContainText('60턴 동안');
|
||
await form.locator('#command-arg-destNationId').selectOption('2');
|
||
await picker.screenshot({ path: testInfo.outputPath(`counter-strategy-${width}.png`) });
|
||
await picker.getByRole('button', { name: '피장파장 입력', exact: true }).click();
|
||
await expect
|
||
.poll(() => JSON.stringify(requests))
|
||
.toContain('"action":"che_피장파장","args":{"destNationId":2,"commandType":"che_허보"}');
|
||
|
||
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||
await picker.getByRole('button', { name: /원조/ }).click();
|
||
const table = picker.getByTestId('aid-level-table');
|
||
await expect(table.locator('li')).toHaveText([
|
||
'방랑군: 0',
|
||
'호족: 10,000',
|
||
'군벌: 20,000',
|
||
'주자사: 30,000',
|
||
'주목: 40,000',
|
||
'공: 50,000',
|
||
'왕: 60,000',
|
||
'황제: 70,000',
|
||
]);
|
||
await expect(picker.getByTestId('command-resource-summary')).toContainText('국가 작위 주자사');
|
||
const current = table.locator('li.current .aid-level-name');
|
||
await expect(current).toHaveText('주자사');
|
||
await expect(current).toHaveCSS('font-weight', '700');
|
||
await expect(current).toHaveCSS('text-decoration-line', 'underline');
|
||
const geometry = await picker.evaluate((element) => ({
|
||
url: location.href,
|
||
viewport: [innerWidth, innerHeight],
|
||
overlayOverflow: element.scrollWidth - element.clientWidth,
|
||
table: element.querySelector('[data-testid=aid-level-table]')?.getBoundingClientRect().toJSON(),
|
||
nameWidths: [...element.querySelectorAll('.aid-level-name')].map(
|
||
(node) => node.getBoundingClientRect().width
|
||
),
|
||
fontSize: getComputedStyle(element.querySelector('[data-testid=aid-level-table]')!).fontSize,
|
||
}));
|
||
expect(geometry.overlayOverflow).toBe(0);
|
||
expect(geometry.fontSize).toBe('14px');
|
||
// Ref: width 4em inline-block.
|
||
expect(new Set(geometry.nameWidths)).toEqual(new Set([56]));
|
||
await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2));
|
||
await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) });
|
||
});
|
||
}
|