사령부 명령 목록을 자기 수뇌 열과 클릭한 턴에 맞춘다

1000px 사령부 desktop에서 compact 명령 목록이 `position: fixed`로 첫째 줄 첫째 열에
고정되어, 다른 수뇌 자리에서 입력해도 군주 열 위에 떠 있던 문제를 고친다.
Ref ChiefReservedCommand처럼 목록을 자기 수뇌 열 안에 두고, 턴을 누르면 그 행
바로 아래에, 고급 모드의 턴 미지정 목록은 턴 표 하단 기준으로 위로 펼친다.
수뇌 12·6·9·5 위치별 Chromium geometry 회귀 테스트를 추가한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:49:45 +00:00
co-authored by Claude Opus 5.5
parent 08be92b6fa
commit 3c75de16e9
2 changed files with 150 additions and 34 deletions
+132 -6
View File
@@ -853,7 +853,8 @@ const install = async (
clockRecovery: { startsAt: string; endsAt: string } | null; clockRecovery: { startsAt: string; endsAt: string } | null;
turnEngineRunning: boolean; turnEngineRunning: boolean;
}, },
decoratedNpcChiefs = false decoratedNpcChiefs = false,
chiefOfficerLevel = 5
) => { ) => {
const requests: unknown[] = []; const requests: unknown[] = [];
const currentGeneralContext = { const currentGeneralContext = {
@@ -999,7 +1000,19 @@ const install = async (
npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!, npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!,
})), })),
} }
: chiefCenter : chiefOfficerLevel !== chiefCenter.me.officerLevel
? {
...chiefCenter,
me: { ...chiefCenter.me, officerLevel: chiefOfficerLevel },
chiefs: chiefCenter.chiefs.map((chief) => ({
...chief,
name:
chief.officerLevel === chiefOfficerLevel
? '장수'
: `수뇌${chief.officerLevel}`,
})),
}
: chiefCenter
); );
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 }); return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
@@ -1590,6 +1603,98 @@ test('shows general command durations from metadata on desktop and mobile', asyn
expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}'); expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}');
}); });
for (const { officerLevel, row, column } of [
{ officerLevel: 12, row: 0, column: 0 },
{ officerLevel: 6, row: 0, column: 3 },
{ officerLevel: 9, row: 1, column: 1 },
{ officerLevel: 5, row: 1, column: 3 },
]) {
test(`anchors the chief command list to officer ${officerLevel} column and clicked turn`, async ({ page }) => {
await install(page, false, refChiefCommandTable, 1, undefined, false, officerLevel);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
const editor = page.locator('[data-command-scope="nation"]');
const gridRow = page.locator('.chief-grid-row').nth(row);
await expect(gridRow.locator('[data-command-scope="nation"]')).toHaveCount(1);
const picker = page.getByTestId('command-picker');
const pageBox = (await page.locator('.layout-desktop').boundingBox())!;
const artifacts: Record<string, unknown>[] = [];
for (const turn of [1, 7, 12]) {
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
await expect(picker.locator('.category-btn').first()).toBeVisible();
const geometry = await page.evaluate((turnIndex) => {
const editorElement = document.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
const pickerElement = document.querySelector<HTMLElement>('[data-testid="command-picker"]')!;
const rowElement = editorElement.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`)!;
const rect = (element: Element) => {
const { left, top, right, bottom, width, height } = element.getBoundingClientRect();
return { left, top, right, bottom, width, height };
};
return {
editor: rect(editorElement),
picker: rect(pickerElement),
row: rect(rowElement),
position: getComputedStyle(pickerElement).position,
horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
pickerOverflow: pickerElement.scrollWidth - pickerElement.clientWidth,
};
}, turn - 1);
artifacts.push({ turn, ...geometry });
// Ref PageChiefCenter는 24px 턴 번호 열 뒤에 238px 수뇌 열 4개를 둔다.
expect(geometry.editor.left - pageBox.x).toBeCloseTo(24 + column * 238, 0);
expect(geometry.position).toBe('absolute');
expect(geometry.picker.left).toBeCloseTo(geometry.editor.left, 0);
expect(geometry.picker.width).toBeCloseTo(geometry.editor.width, 0);
expect(geometry.picker.top).toBeCloseTo(geometry.row.bottom, 0);
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(geometry.pickerOverflow).toBeLessThanOrEqual(0);
// 둘째 줄 마지막 턴에서도 목록이 문서 밖으로 잘리지 않고 스크롤로 전부 보여야 한다.
await picker.evaluate((element) => element.scrollIntoView({ block: 'nearest' }));
const visible = await picker.evaluate((element) => {
const { top, bottom } = element.getBoundingClientRect();
return { top, bottom, viewportHeight: window.innerHeight };
});
expect(visible.top).toBeGreaterThanOrEqual(0);
expect(visible.bottom).toBeLessThanOrEqual(visible.viewportHeight);
if (turn !== 1) {
await page.screenshot({
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-turn-${turn}-1200.png`),
});
}
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
await expect(picker).toHaveCount(0);
}
// 고급 모드의 `명령 선택 ▾`은 Ref 편집 모드(`bottom: 0`)처럼 자기 열 턴 표 하단에 맞춰 위로 펼친다.
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
await expect(picker.locator('.category-btn').first()).toBeVisible();
const advanced = await page.evaluate(() => {
const rect = (selector: string) => {
const { left, top, bottom, width } = document.querySelector(selector)!.getBoundingClientRect();
return { left, top, bottom, width };
};
return {
editor: rect('[data-command-scope="nation"]'),
picker: rect('[data-testid="command-picker"]'),
queue: rect('[data-command-scope="nation"] .queue-area'),
};
});
artifacts.push({ turn: 'advanced', ...advanced });
expect(advanced.picker.left).toBeCloseTo(advanced.editor.left, 0);
expect(advanced.picker.width).toBeCloseTo(advanced.editor.width, 0);
expect(advanced.picker.top).toBeGreaterThanOrEqual(advanced.editor.top + 24);
expect(advanced.picker.bottom).toBeCloseTo(advanced.queue.bottom, 0);
await page.screenshot({
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-advanced-1200.png`),
});
await writeFile(
test.info().outputPath(`chief-picker-officer-${officerLevel}-geometry.json`),
JSON.stringify(artifacts, null, 2)
);
});
}
test('shows every Ref chief command in the exact category and command order', async ({ page }) => { test('shows every Ref chief command in the exact category and command order', async ({ page }) => {
await install(page, false, refChiefCommandTable); await install(page, false, refChiefCommandTable);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -4620,7 +4725,13 @@ for (const width of [1200, 390]) {
{ value: 'weapon', label: '무기' }, { value: 'weapon', label: '무기' },
], ],
}, },
{ key: 'itemCode', label: '장비', kind: 'select', required: true, optionSource: 'items' }, {
key: 'itemCode',
label: '장비',
kind: 'select',
required: true,
optionSource: 'items',
},
], ],
}, },
{ {
@@ -4661,7 +4772,13 @@ for (const width of [1200, 390]) {
required: true, required: true,
optionSource: 'nationTypes', optionSource: 'nationTypes',
}, },
{ key: 'colorType', label: '국기 색상', kind: 'select', required: true, optionSource: 'colors' }, {
key: 'colorType',
label: '국기 색상',
kind: 'select',
required: true,
optionSource: 'colors',
},
], ],
}, },
], ],
@@ -4704,7 +4821,11 @@ for (const width of [1200, 390]) {
}, },
], ],
}, },
context: { ...inputOptions.context, citySecurity: 2_000, dexterity: { '1': 1001, '2': 3000, '3': 500_000 } }, context: {
...inputOptions.context,
citySecurity: 2_000,
dexterity: { '1': 1001, '2': 3000, '3': 500_000 },
},
}, },
}); });
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
@@ -4787,7 +4908,12 @@ for (const width of [1200, 390]) {
columns: getComputedStyle(row).gridTemplateColumns, columns: getComputedStyle(row).gridTemplateColumns,
cells: [...row.children].map((cell) => { cells: [...row.children].map((cell) => {
const rect = cell.getBoundingClientRect(); const rect = cell.getBoundingClientRect();
return { text: cell.textContent?.trim(), left: rect.left, right: rect.right, scroll: cell.scrollWidth }; return {
text: cell.textContent?.trim(),
left: rect.left,
right: rect.right,
scroll: cell.scrollWidth,
};
}), }),
})), })),
})); }));
@@ -135,7 +135,7 @@ const labelMap = computed(() => {
const displayRows = computed(() => const displayRows = computed(() =>
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
); );
const quickPickerTop = ref('38px'); const quickPickerStyle = ref<Record<string, string> | undefined>();
const isRecruitmentCommand = computed( const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
); );
@@ -183,20 +183,23 @@ const finishDrag = (next: Set<number>) => {
dragKind.value = null; dragKind.value = null;
}; };
const openPicker = (turnIndex?: number) => { // 명령 목록 popup은 편집기(사령부에서는 자기 수뇌 열) 안에서 위치를 잡는다. 턴을 누르면 Ref
if (!props.compact && editorElement.value) { // commandQuickReserveFormAnchor처럼 그 행 바로 아래에 붙이고, 사령부 고급 모드의 턴 미지정 목록은 Ref 편집 모드
const editorRect = editorElement.value.getBoundingClientRect(); // CommandSelectForm(`bottom: 0`)처럼 턴 표 하단에 맞춰 위로 펼친다. 모바일 사령부와 장수 턴 미지정은 CSS 기본 위치다.
const anchor = const anchoredPickerStyle = (turnIndex?: number): Record<string, string> | undefined => {
turnIndex === undefined const editor = editorElement.value;
? editorElement.value.querySelector<HTMLElement>('.control-pad') if (!editor || (props.compact && props.mobile)) return undefined;
: editorElement.value.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`); const editorRect = editor.getBoundingClientRect();
if (anchor) { if (turnIndex !== undefined) {
const anchorRect = anchor.getBoundingClientRect(); const row = editor.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`);
quickPickerTop.value = `${ return row ? { top: `${row.getBoundingClientRect().top - editorRect.top + 30}px` } : undefined;
turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30
}px`;
}
} }
if (!props.compact) return undefined;
const queue = editor.querySelector<HTMLElement>('.queue-area');
return queue ? { top: 'auto', bottom: `${editorRect.bottom - queue.getBoundingClientRect().bottom}px` } : undefined;
};
const openPicker = (turnIndex?: number) => {
quickPickerStyle.value = anchoredPickerStyle(turnIndex);
quickTarget.value = turnIndex ?? null; quickTarget.value = turnIndex ?? null;
pickerOpen.value = true; pickerOpen.value = true;
selectedCommand.value = null; selectedCommand.value = null;
@@ -805,9 +808,7 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker" class="command-picker"
:class="{ 'argument-overlay': isArgumentOverlay, 'recruitment-picker': isRecruitmentCommand }" :class="{ 'argument-overlay': isArgumentOverlay, 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker" data-testid="command-picker"
:style=" :style="isArgumentOverlay ? undefined : quickPickerStyle"
isArgumentOverlay || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
"
:role="isArgumentOverlay ? 'dialog' : undefined" :role="isArgumentOverlay ? 'dialog' : undefined"
:aria-modal="isArgumentOverlay ? 'true' : undefined" :aria-modal="isArgumentOverlay ? 'true' : undefined"
:aria-label=" :aria-label="
@@ -1460,17 +1461,6 @@ small {
} }
} }
@media (min-width: 1025px) {
.compact:not(.mobile) .command-picker {
position: fixed;
z-index: 1000;
top: 86px;
right: auto;
left: calc(50% - 476px);
width: 238px;
}
}
.mobile.compact .editor-layout { .mobile.compact .editor-layout {
min-height: 370px; min-height: 370px;
display: grid; display: grid;