merge: 추가된 최신 main을 도시정보 명령 요약에 반영한다

This commit is contained in:
2026-08-21 16:35:35 +00:00
9 changed files with 602 additions and 95 deletions
+113 -9
View File
@@ -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 }) => { test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => {
await install(page); const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
await page.goto('general-list'); await page.goto('general-list');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); 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 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"] .wounded').first()).toHaveText('81');
await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); 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'); expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px');
await page.mouse.up(); await page.mouse.up();
await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); await page.getByRole('button', { name: /^ /u }).click();
await expect(select).toHaveValue('8'); await expect(select).toHaveValue('8');
await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴');
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); 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.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 844 }); 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 }); 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) => { test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.goto('npc-list'); 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]')).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-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('user-');
await expect(preview).not.toContainText('secret'); 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-table')).toBeHidden();
await expect(page.locator('.general-card-list')).toBeVisible(); 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('.general-card-list [data-general-card-id]')).toHaveCount(2);
await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6'); 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 generalMetrics = await page.locator('.directory-page').evaluate((element) => {
const rect = element.getBoundingClientRect(); 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); 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 install(page, 'error-after-load');
await page.goto('general-list'); await page.goto('general-list');
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); await page.getByRole('button', { name: '정렬하기' }).click();
await page.getByRole('button', { name: '갱 신' }).click();
await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.getByRole('alert')).toContainText('권한 확인 실패');
await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('#viewType')).toHaveValue('8');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
@@ -0,0 +1,89 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string | null;
testId?: string;
}>();
</script>
<template>
<span
class="directory-tooltip"
:class="{ 'directory-tooltip--enabled': description }"
:tabindex="description ? 0 : undefined"
:data-directory-tooltip="testId"
>
<slot />
<span v-if="description" class="directory-tooltip__content" role="tooltip">
<strong>{{ title }}</strong>
<span>{{ description }}</span>
</span>
</span>
</template>
<style scoped>
.directory-tooltip {
position: relative;
display: inline;
min-width: 0;
}
.directory-tooltip--enabled {
cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
}
.directory-tooltip--enabled:focus-visible {
border-radius: 2px;
outline: 1px solid #6fc7ff;
outline-offset: 1px;
}
.directory-tooltip__content {
display: none;
position: absolute;
z-index: 30;
left: 50%;
bottom: calc(100% + 5px);
box-sizing: border-box;
width: max-content;
max-width: min(280px, calc(100vw - 16px));
transform: translateX(-50%);
border: 1px solid #8c8c8c;
border-radius: 3px;
padding: 7px 9px;
background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5;
font-family: var(--sammo-font-sans);
font-size: 12.5px;
font-weight: 400;
line-height: 1.45;
text-align: left;
white-space: normal;
word-break: keep-all;
}
.directory-tooltip__content strong,
.directory-tooltip__content span {
display: block;
}
.directory-tooltip__content strong {
margin-bottom: 4px;
color: #7fd4ff;
font-size: 13px;
}
.directory-tooltip--enabled:hover > .directory-tooltip__content,
.directory-tooltip--enabled:focus > .directory-tooltip__content {
display: block;
}
@media (max-width: 600px) {
.directory-tooltip__content {
position: fixed;
right: 8px;
bottom: 8px;
left: 8px;
width: auto;
max-width: none;
transform: none;
}
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import DirectoryTooltip from './DirectoryTooltip.vue';
const props = withDefaults(
defineProps<{
label: string;
value: number;
injury: number;
bonus?: number;
testId?: string;
}>(),
{ bonus: 0, testId: undefined }
);
const displayedValue = computed(() =>
props.injury > 0 ? Math.trunc((props.value * (100 - props.injury)) / 100) : props.value
);
const injuryDescription = computed(() =>
props.injury > 0
? `부상 ${props.injury}% · 원래 ${props.label} ${props.value} → 적용 ${displayedValue.value}`
: null
);
</script>
<template>
<DirectoryTooltip :title="`${label} 부상`" :description="injuryDescription" :test-id="testId">
<span :class="{ wounded: injury > 0 }">{{ displayedValue }}</span>
</DirectoryTooltip>
<span v-if="bonus > 0" class="leadership-bonus">+{{ bonus }}</span>
</template>
<style scoped>
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
</style>
@@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera
import { formatOfficerLevelText } from '../../utils/nationFormat'; import { formatOfficerLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor'; import { getNpcColor } from '../../utils/npcColor';
import type { GeneralDirectoryGeneral } from '../../types/directory'; 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 SortDirection = 'ascending' | 'descending';
type Header = { type Header = {
label: string; label: string;
sort?: number; sort?: GeneralDirectorySortKey;
direction?: SortDirection;
title?: string;
}; };
const props = withDefaults( const props = withDefaults(
@@ -17,12 +18,12 @@ const props = withDefaults(
generals: GeneralDirectoryGeneral[]; generals: GeneralDirectoryGeneral[];
loading?: boolean; loading?: boolean;
layout?: 'responsive' | 'card'; layout?: 'responsive' | 'card';
activeSort?: number; sortCriteria?: readonly GeneralDirectorySortCriterion[];
}>(), }>(),
{ {
loading: false, loading: false,
layout: 'responsive', layout: 'responsive',
activeSort: undefined, sortCriteria: () => [],
} }
); );
@@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>();
const headers: ReadonlyArray<Header> = [ const headers: ReadonlyArray<Header> = [
{ label: '얼 굴' }, { label: '얼 굴' },
{ label: '이 름' }, { label: '이 름', sort: 0 },
{ label: '연령', sort: 14, direction: 'descending' }, { label: '연령', sort: 14 },
{ label: '성격', sort: 11, direction: 'descending' }, { label: '성격', sort: 11 },
{ label: '특기' }, { label: '특기' },
{ label: '레 벨', sort: 10, direction: 'descending' }, { label: '레 벨', sort: 10 },
{ label: '국 가', sort: 1, direction: 'ascending' }, { label: '국 가', sort: 1 },
{ label: '명 성', sort: 5, direction: 'descending' }, { label: '명 성', sort: 5 },
{ label: '계 급', sort: 6, direction: 'descending' }, { label: '계 급', sort: 6 },
{ label: '관 직', sort: 7, direction: 'descending' }, { label: '관 직', sort: 7 },
{ label: '통솔', sort: 2, direction: 'descending' }, { label: '통솔', sort: 2 },
{ label: '무력', sort: 3, direction: 'descending' }, { label: '무력', sort: 3 },
{ label: '지력', sort: 4, direction: 'descending' }, { label: '지력', sort: 4 },
{ label: '삭턴', sort: 8, direction: 'ascending' }, { label: '삭턴', sort: 8 },
{ label: '벌점', sort: 9, direction: 'descending' }, { 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 => const ariaSort = (header: Header): SortDirection | undefined =>
header.sort === props.activeSort ? header.direction : undefined; criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined;
const sortIndicator = (header: Header): string => {
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); 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)}. 같은 값은 이전 정렬 순서를 유지합니다.`;
</script> </script>
<template> <template>
@@ -81,17 +99,14 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
:aria-sort="ariaSort(header)" :aria-sort="ariaSort(header)"
> >
<button <button
v-if="header.sort !== undefined && activeSort !== undefined" v-if="header.sort !== undefined"
class="legacy-sort-header" class="legacy-sort-header"
type="button" type="button"
:aria-label="`${header.label.replaceAll(' ', '')} 기준 정렬`" :aria-label="sortHelp(header)"
:title="header.title ?? `${header.label.replaceAll(' ', '')} 기준 정렬`" :title="sortHelp(header)"
@click="emit('sort', header.sort)" @click="emit('sort', header.sort)"
> >
{{ header.label {{ header.label }}<span class="legacy-sort-indicator">{{ sortIndicator(header) }}</span>
}}<span class="legacy-sort-indicator">{{
header.sort === activeSort ? (header.direction === 'ascending' ? '▲' : '▼') : '↕'
}}</span>
</button> </button>
<template v-else>{{ header.label }}</template> <template v-else>{{ header.label }}</template>
</th> </th>
@@ -132,11 +147,30 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</td> </td>
<td class="center">{{ general.age }}세</td> <td class="center">{{ general.age }}세</td>
<td class="center"> <td class="center">
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</td> </td>
<td class="center"> <td class="center">
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> / <DirectoryTooltip
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span> :title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`special-domestic-${general.id}`"
>
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</td> </td>
<td class="center">Lv {{ general.experienceLevel }}</td> <td class="center">Lv {{ general.experienceLevel }}</td>
<td class="center">{{ general.nationName }}</td> <td class="center">{{ general.nationName }}</td>
@@ -144,22 +178,29 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
<td class="center">{{ general.dedicationText }}</td> <td class="center">{{ general.dedicationText }}</td>
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td> <td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership label="통솔"
}}</span :value="general.leadership"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :injury="general.injury"
>+{{ general.leadershipBonus }}</span :bonus="general.leadershipBonus"
> :test-id="`injury-leadership-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`injury-strength-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`injury-intelligence-${general.id}`"
/>
</td> </td>
<td class="center">{{ general.killturn }}</td> <td class="center">{{ general.killturn }}</td>
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td> <td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
@@ -207,13 +248,33 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">성격</span> <span class="field-label">성격</span>
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`card-personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">특기</span> <span class="field-label">특기</span>
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`" <span>
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span <DirectoryTooltip
:title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`card-special-domestic-${general.id}`"
> >
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`card-special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span> <span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
@@ -233,26 +294,31 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">통솔</span> <span class="field-label">통솔</span>
<span> <GeneralDirectoryStat
<span :class="{ wounded: general.injury > 0 }">{{ label="통솔"
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership :value="general.leadership"
}}</span :injury="general.injury"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :bonus="general.leadershipBonus"
>+{{ general.leadershipBonus }}</span :test-id="`card-injury-leadership-${general.id}`"
> />
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">무력</span> <span class="field-label">무력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`card-injury-strength-${general.id}`"
/>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">지력</span> <span class="field-label">지력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`card-injury-intelligence-${general.id}`"
/>
</div> </div>
<div class="general-card-field penalty-field"> <div class="general-card-field penalty-field">
<span class="field-label">벌점</span> <span class="field-label">벌점</span>
@@ -298,12 +364,6 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
.center { .center {
text-align: center; text-align: center;
} }
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.loading-cell { .loading-cell {
height: 64px; height: 64px;
text-align: center; text-align: center;
@@ -113,7 +113,7 @@ watch(
background: #101010; background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%); box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5; color: #f5f5f5;
font-family: Pretendard, sans-serif; font-family: var(--sammo-font-sans);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;
text-align: left; text-align: left;
@@ -0,0 +1,100 @@
export type GeneralDirectorySortKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GeneralDirectorySortDirection = 'ascending' | 'descending';
export type GeneralDirectorySortCriterion = {
key: GeneralDirectorySortKey;
direction: GeneralDirectorySortDirection;
};
type TraitValue = { key: string };
export type GeneralDirectorySortable = {
name: string;
nationId: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
officerLevel: number;
killturn: number;
refreshScoreTotal: number;
personality: TraitValue;
specialDomestic: TraitValue;
specialWar: TraitValue;
age: number;
npcState: number;
};
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
const compareString = (left: string, right: string): number => {
if (left === right) return 0;
return left < right ? -1 : 1;
};
const compareByKey = <T extends GeneralDirectorySortable>(left: T, right: T, key: GeneralDirectorySortKey): number => {
switch (key) {
case 0:
return koreanNameCollator.compare(left.name, right.name);
case 1:
return left.nationId - right.nationId;
case 2:
return left.leadership - right.leadership;
case 3:
return left.strength - right.strength;
case 4:
return left.intelligence - right.intelligence;
case 5:
case 10:
return left.experience - right.experience;
case 6:
return left.dedication - right.dedication;
case 7:
return left.officerLevel - right.officerLevel;
case 8:
return left.killturn - right.killturn;
case 9:
return left.refreshScoreTotal - right.refreshScoreTotal;
case 11:
return compareString(left.personality.key, right.personality.key);
case 12:
return compareString(left.specialDomestic.key, right.specialDomestic.key);
case 13:
return compareString(left.specialWar.key, right.specialWar.key);
case 14:
return left.age - right.age;
case 15:
return left.npcState - right.npcState;
}
};
export const advanceGeneralDirectorySort = (
current: readonly GeneralDirectorySortCriterion[],
key: GeneralDirectorySortKey
): GeneralDirectorySortCriterion[] => {
const existing = current.find((criterion) => criterion.key === key);
const remaining = current.filter((criterion) => criterion.key !== key);
if (!existing) return [{ key, direction: 'descending' }, ...remaining];
if (existing.direction === 'descending') return [{ key, direction: 'ascending' }, ...remaining];
return remaining;
};
export const sortGeneralDirectory = <T extends GeneralDirectorySortable>(
source: readonly T[],
criteria: readonly GeneralDirectorySortCriterion[]
): T[] => {
if (criteria.length === 0) return [...source];
return source
.map((general, originalIndex) => ({ general, originalIndex }))
.sort((left, right) => {
for (const criterion of criteria) {
const compared = compareByKey(left.general, right.general, criterion.key);
if (compared !== 0) return criterion.direction === 'ascending' ? compared : -compared;
}
return left.originalIndex - right.originalIndex;
})
.map(({ general }) => general);
};
+41 -15
View File
@@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue'; import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import type { GeneralDirectoryGeneral } from '../types/directory'; import type { GeneralDirectoryGeneral } from '../types/directory';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortCriterion,
type GeneralDirectorySortKey,
} from '../utils/generalDirectorySort';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
{ value: 0, label: '이름' },
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 1, label: '국가' }, { value: 1, label: '국가' },
{ value: 2, label: '통솔' }, { value: 2, label: '통솔' },
{ value: 3, label: '무력' }, { value: 3, label: '무력' },
@@ -27,18 +33,26 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 15, label: 'NPC' }, { value: 15, label: 'NPC' },
]; ];
const sort = ref<SortKey>(9); const selectedSort = ref<GeneralDirectorySortKey>(9);
const generals = ref<GeneralDirectoryGeneral[]>([]); const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const router = useRouter(); const router = useRouter();
const { info: showInfoToast } = useGameFeedback();
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
const loadDirectory = async () => { const loadDirectory = async () => {
if (loading.value) {
showInfoToast('이미 장수 일람을 갱신하고 있습니다.');
return;
}
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value }); const result = await trpc.world.getGeneralDirectory.query({ sort: 9 });
generals.value = result.generals; sourceGenerals.value = Array.isArray(result.generals) ? result.generals : [];
} catch (cause) { } catch (cause) {
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.'; error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
} finally { } finally {
@@ -47,12 +61,12 @@ const loadDirectory = async () => {
}; };
const updateSort = (value: number): void => { const updateSort = (value: number): void => {
sort.value = value as SortKey; selectedSort.value = value as GeneralDirectorySortKey;
}; };
const sortByHeader = (value: number): void => { const sortByHeader = (value: number): void => {
updateSort(value); updateSort(value);
void loadDirectory(); sortCriteria.value = advanceGeneralDirectorySort(sortCriteria.value, selectedSort.value);
}; };
onMounted(() => { onMounted(() => {
@@ -66,8 +80,15 @@ onMounted(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
<br /><button class="legacy-button" type="button" @click="router.push('/')"> <br />
닫기 <button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button>
<button
class="legacy-button"
type="button"
:aria-busy="loading || undefined"
@click="loadDirectory"
>
</button> </button>
</td> </td>
</tr> </tr>
@@ -75,11 +96,11 @@ onMounted(() => {
<td> <td>
<LegacySortControls <LegacySortControls
control-id="viewType" control-id="viewType"
:model-value="sort" :model-value="selectedSort"
:options="sortOptions" :options="sortOptions"
:busy="loading" :busy="loading"
@update:model-value="updateSort" @update:model-value="updateSort"
@submit="loadDirectory" @submit="sortByHeader(selectedSort)"
/> />
</td> </td>
</tr> </tr>
@@ -87,7 +108,12 @@ onMounted(() => {
</table> </table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p> <p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<GeneralDirectoryTable :generals="generals" :loading="loading" :active-sort="sort" @sort="sortByHeader" /> <GeneralDirectoryTable
:generals="generals"
:loading="loading"
:sort-criteria="sortCriteria"
@sort="sortByHeader"
/>
<table class="directory-table title-table legacy-bg0"> <table class="directory-table title-table legacy-bg0">
<tbody> <tbody>
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortable,
} from '../src/utils/generalDirectorySort.ts';
type Row = GeneralDirectorySortable & { id: number };
const row = (id: number, name: string, leadership: number, strength: number): Row => ({
id,
name,
nationId: id,
leadership,
strength,
intelligence: 0,
experience: 0,
dedication: 0,
officerLevel: 0,
killturn: 0,
refreshScoreTotal: 0,
personality: { key: '' },
specialDomestic: { key: '' },
specialWar: { key: '' },
age: 0,
npcState: 0,
});
void describe('general directory client sorting', () => {
void it('cycles each column through descending, ascending, and reset', () => {
const descending = advanceGeneralDirectorySort([], 2);
assert.deepEqual(descending, [{ key: 2, direction: 'descending' }]);
const ascending = advanceGeneralDirectorySort(descending, 2);
assert.deepEqual(ascending, [{ key: 2, direction: 'ascending' }]);
assert.deepEqual(advanceGeneralDirectorySort(ascending, 2), []);
});
void it('keeps the previous descending key as a tie-breaker for a later ascending key', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
assert.deepEqual(
sortGeneralDirectory(source, strengthAscending).map(({ id }) => id),
[3, 2, 1]
);
});
void it('removes only the reset column and restores the older stable ordering', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
const resetStrength = advanceGeneralDirectorySort(strengthAscending, 3);
assert.deepEqual(resetStrength, leadership);
assert.deepEqual(
sortGeneralDirectory(source, resetStrength).map(({ id }) => id),
[2, 3, 1]
);
});
void it('sorts Korean names in both directions without mutating the source', () => {
const source = [row(1, '조조', 0, 0), row(2, '가후', 0, 0), row(3, '유비', 0, 0)];
const descending = advanceGeneralDirectorySort([], 0);
const ascending = advanceGeneralDirectorySort(descending, 0);
assert.deepEqual(
sortGeneralDirectory(source, descending).map(({ name }) => name),
['조조', '유비', '가후']
);
assert.deepEqual(
sortGeneralDirectory(source, ascending).map(({ name }) => name),
['가후', '유비', '조조']
);
assert.deepEqual(
source.map(({ id }) => id),
[1, 2, 3]
);
});
});
+6 -3
View File
@@ -31,9 +31,12 @@ The Ref-style directory pages share a second, deliberately compact control
family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the
explicit dark select/option palette, the raised submit button, and the explicit dark select/option palette, the raised submit button, and the
focus/active states for sortable table headers. A page supplies only the focus/active states for sortable table headers. A page supplies only the
available legacy sort keys, their fixed directions, and placement. Columns available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정 방향을
without an unambiguous legacy sort key remain plain headers rather than 유지합니다. 장수 일람은 사용자 조작 계약에 따라 로드한 snapshot 위에서
inventing a new ordering contract. `내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다.
## Button composition ## Button composition