diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 1c5bd571..80877f6c 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -853,7 +853,8 @@ const install = async ( clockRecovery: { startsAt: string; endsAt: string } | null; turnEngineRunning: boolean; }, - decoratedNpcChiefs = false + decoratedNpcChiefs = false, + chiefOfficerLevel = 5 ) => { const requests: unknown[] = []; const currentGeneralContext = { @@ -999,7 +1000,19 @@ const install = async ( 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') 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":{}'); }); +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[] = []; + 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('[data-command-scope="nation"]')!; + const pickerElement = document.querySelector('[data-testid="command-picker"]')!; + const rowElement = editorElement.querySelector(`[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 }) => { await install(page, false, refChiefCommandTable); await page.setViewportSize({ width: 1200, height: 900 }); @@ -4620,7 +4725,13 @@ for (const width of [1200, 390]) { { 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, 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 }); @@ -4787,7 +4908,12 @@ for (const width of [1200, 390]) { columns: getComputedStyle(row).gridTemplateColumns, cells: [...row.children].map((cell) => { 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, + }; }), })), })); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 263d2c66..1653e62c 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -135,7 +135,7 @@ const labelMap = computed(() => { const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) ); -const quickPickerTop = ref('38px'); +const quickPickerStyle = ref | undefined>(); const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); @@ -183,20 +183,23 @@ const finishDrag = (next: Set) => { dragKind.value = null; }; -const openPicker = (turnIndex?: number) => { - if (!props.compact && editorElement.value) { - const editorRect = editorElement.value.getBoundingClientRect(); - const anchor = - turnIndex === undefined - ? editorElement.value.querySelector('.control-pad') - : editorElement.value.querySelector(`[data-turn-index="${turnIndex}"]`); - if (anchor) { - const anchorRect = anchor.getBoundingClientRect(); - quickPickerTop.value = `${ - turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30 - }px`; - } +// 명령 목록 popup은 편집기(사령부에서는 자기 수뇌 열) 안에서 위치를 잡는다. 턴을 누르면 Ref +// commandQuickReserveFormAnchor처럼 그 행 바로 아래에 붙이고, 사령부 고급 모드의 턴 미지정 목록은 Ref 편집 모드 +// CommandSelectForm(`bottom: 0`)처럼 턴 표 하단에 맞춰 위로 펼친다. 모바일 사령부와 장수 턴 미지정은 CSS 기본 위치다. +const anchoredPickerStyle = (turnIndex?: number): Record | undefined => { + const editor = editorElement.value; + if (!editor || (props.compact && props.mobile)) return undefined; + const editorRect = editor.getBoundingClientRect(); + if (turnIndex !== undefined) { + const row = editor.querySelector(`[data-turn-index="${turnIndex}"]`); + return row ? { top: `${row.getBoundingClientRect().top - editorRect.top + 30}px` } : undefined; } + if (!props.compact) return undefined; + const queue = editor.querySelector('.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; pickerOpen.value = true; selectedCommand.value = null; @@ -805,9 +808,7 @@ const clickOutsideMenu = (event: Event) => { class="command-picker" :class="{ 'argument-overlay': isArgumentOverlay, 'recruitment-picker': isRecruitmentCommand }" data-testid="command-picker" - :style=" - isArgumentOverlay || quickTarget === null || props.compact ? undefined : { top: quickPickerTop } - " + :style="isArgumentOverlay ? undefined : quickPickerStyle" :role="isArgumentOverlay ? 'dialog' : undefined" :aria-modal="isArgumentOverlay ? 'true' : undefined" :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 { min-height: 370px; display: grid;