diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 560e2b67..bcd7b16b 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -431,13 +431,20 @@ test('nation and general directories preserve the fixed legacy Chromium geometry } }); -test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => { - await install(page); +test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); await page.goto('general-list'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); + await page.getByRole('button', { name: '정렬하기' }).click(); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1); + await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81'); await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); @@ -489,10 +496,10 @@ test('directory sort controls stay legible in dark mode and sortable headers app expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px'); await page.mouse.up(); - await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); + await page.getByRole('button', { name: /^삭턴 내림차순/u }).click(); await expect(select).toHaveValue('8'); - await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); - await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴'); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 500, height: 844 }); @@ -501,6 +508,82 @@ test('directory sort controls stay legible in dark mode and sortable headers app await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true }); }); +test('name and mixed-direction stable sorting reuse one response until explicit refresh', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); + await page.goto('general-list'); + + const rows = page.locator('tbody tr[data-general-id]'); + await page.getByRole('button', { name: /^이름 내림차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + await page.getByRole('button', { name: /^이름 오름차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '20'); + await page.getByRole('button', { name: /^이름 정렬 해제/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + + await page.getByRole('button', { name: /^통솔 내림차순/u }).click(); + await page.getByRole('button', { name: /^무력 내림차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '20'); + await expect( + page.getByRole('columnheader').filter({ hasText: '통솔' }).locator('.legacy-sort-indicator') + ).toHaveText('▼2'); + await page.getByRole('button', { name: /^무력 오름차순/u }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1); + + await page.getByRole('button', { name: '갱 신' }).click(); + await expect + .poll(() => requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory').length) + .toBe(2); + await expect(rows.first()).toHaveAttribute('data-general-id', '10'); +}); + +test('trait and injury explanations appear on pointer hover and keyboard focus', async ({ page }, testInfo) => { + await install(page); + await page.goto('general-list'); + + const personality = page.locator('[data-directory-tooltip="personality-10"]'); + await personality.hover(); + await expect(personality.getByRole('tooltip')).toContainText('성격 · 대담'); + await expect(personality.getByRole('tooltip')).toContainText('대담한 성격'); + + const domestic = page.locator('[data-directory-tooltip="special-domestic-10"]'); + await domestic.hover(); + await expect(domestic.getByRole('tooltip')).toContainText('내정 특기 · 상재'); + await expect(domestic.getByRole('tooltip')).toContainText('상업 특기'); + + const war = page.locator('[data-directory-tooltip="special-war-10"]'); + await war.focus(); + await expect(war.getByRole('tooltip')).toContainText('전투 특기 · 귀모'); + await expect(war.getByRole('tooltip')).toContainText('전투 특기'); + + const injury = page.locator('[data-directory-tooltip="injury-leadership-10"]'); + await injury.hover(); + await expect(injury.getByRole('tooltip')).toContainText('부상 10%'); + await expect(injury.getByRole('tooltip')).toContainText('원래 통솔 90 → 적용 81'); + const tooltipGeometry = await injury.getByRole('tooltip').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: window.innerWidth - rect.right, + display: style.display, + background: style.backgroundColor, + color: style.color, + fontSize: style.fontSize, + }; + }); + expect(tooltipGeometry.left).toBeGreaterThanOrEqual(8); + expect(tooltipGeometry.right).toBeGreaterThanOrEqual(8); + expect(tooltipGeometry).toMatchObject({ + display: 'block', + background: 'rgb(16, 16, 16)', + color: 'rgb(245, 245, 245)', + fontSize: '12.5px', + }); + await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true }); +}); + test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => { await install(page); await page.goto('npc-list'); @@ -544,7 +627,8 @@ test('nation directory reuses only the public general-directory row on hover and await expect(preview.locator('[data-general-card-id]')).toHaveCount(1); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); - await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); + await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); + await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('secret'); @@ -687,8 +771,27 @@ test('nation and general directories rearrange for mobile and keep the tapped pr await expect(page.locator('.general-table')).toBeHidden(); await expect(page.locator('.general-card-list')).toBeVisible(); await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); - await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); - await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6'); + await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); + await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); + await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText( + '81' + ); + await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6'); + const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]'); + await mobileInjury.focus(); + await expect(mobileInjury.getByRole('tooltip')).toBeVisible(); + const mobileTooltipGeometry = await mobileInjury.getByRole('tooltip').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: window.innerWidth - rect.right, + width: rect.width, + innerWidth: window.innerWidth, + }; + }); + expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8); + expect(mobileTooltipGeometry.right).toBeGreaterThanOrEqual(8); + expect(mobileTooltipGeometry.width).toBeLessThanOrEqual(mobileTooltipGeometry.innerWidth - 16); const generalMetrics = await page.locator('.directory-page').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -760,11 +863,12 @@ test('a reused image element falls back for each newly broken account icon', asy await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); }); -test('a failed resort retains the selected value and existing rows', async ({ page }) => { +test('a failed explicit refresh retains the local sort selection and existing rows', async ({ page }) => { await install(page, 'error-after-load'); await page.goto('general-list'); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await page.getByRole('button', { name: '갱 신' }).click(); await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue new file mode 100644 index 00000000..9106f8e3 --- /dev/null +++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue new file mode 100644 index 00000000..a753c651 --- /dev/null +++ b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue index 141d09cb..0d219719 100644 --- a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera import { formatOfficerLevelText } from '../../utils/nationFormat'; import { getNpcColor } from '../../utils/npcColor'; import type { GeneralDirectoryGeneral } from '../../types/directory'; +import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort'; +import DirectoryTooltip from './DirectoryTooltip.vue'; +import GeneralDirectoryStat from './GeneralDirectoryStat.vue'; type SortDirection = 'ascending' | 'descending'; type Header = { label: string; - sort?: number; - direction?: SortDirection; - title?: string; + sort?: GeneralDirectorySortKey; }; const props = withDefaults( @@ -17,12 +18,12 @@ const props = withDefaults( generals: GeneralDirectoryGeneral[]; loading?: boolean; layout?: 'responsive' | 'card'; - activeSort?: number; + sortCriteria?: readonly GeneralDirectorySortCriterion[]; }>(), { loading: false, layout: 'responsive', - activeSort: undefined, + sortCriteria: () => [], } ); @@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>(); const headers: ReadonlyArray
= [ { label: '얼 굴' }, - { label: '이 름' }, - { label: '연령', sort: 14, direction: 'descending' }, - { label: '성격', sort: 11, direction: 'descending' }, + { label: '이 름', sort: 0 }, + { label: '연령', sort: 14 }, + { label: '성격', sort: 11 }, { label: '특기' }, - { label: '레 벨', sort: 10, direction: 'descending' }, - { label: '국 가', sort: 1, direction: 'ascending' }, - { label: '명 성', sort: 5, direction: 'descending' }, - { label: '계 급', sort: 6, direction: 'descending' }, - { label: '관 직', sort: 7, direction: 'descending' }, - { label: '통솔', sort: 2, direction: 'descending' }, - { label: '무력', sort: 3, direction: 'descending' }, - { label: '지력', sort: 4, direction: 'descending' }, - { label: '삭턴', sort: 8, direction: 'ascending' }, - { label: '벌점', sort: 9, direction: 'descending' }, + { label: '레 벨', sort: 10 }, + { label: '국 가', sort: 1 }, + { label: '명 성', sort: 5 }, + { label: '계 급', sort: 6 }, + { label: '관 직', sort: 7 }, + { label: '통솔', sort: 2 }, + { label: '무력', sort: 3 }, + { label: '지력', sort: 4 }, + { label: '삭턴', sort: 8 }, + { label: '벌점', sort: 9 }, ]; +const criterionIndex = (header: Header): number => + header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort); +const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => { + const index = criterionIndex(header); + return index < 0 ? undefined : props.sortCriteria[index]; +}; const ariaSort = (header: Header): SortDirection | undefined => - header.sort === props.activeSort ? header.direction : undefined; - -const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); + criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined; +const sortIndicator = (header: Header): string => { + const index = criterionIndex(header); + if (index < 0) return '↕'; + const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼'; + return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow; +}; +const nextSortAction = (header: Header): string => { + const direction = criterionFor(header)?.direction; + if (!direction) return '내림차순'; + return direction === 'descending' ? '오름차순' : '정렬 해제'; +}; +const sortHelp = (header: Header): string => + `${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;