diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 301b572a..ceb0b626 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -18,6 +18,8 @@ const general = { stats: { leadership: 70, strength: 60, intelligence: 50 }, experienceLevel: 9, dedicationLevel: 1, + dedicationText: '30품관', + bill: 600, injury: 0, gold: 1000, rice: 2000, @@ -28,6 +30,24 @@ const general = { refreshScoreTotal: 10, permission: 'normal', }; +const otherGeneral = { + ...general, + id: 2, + name: '다른장수', + npcState: 1, + stats: { leadership: 40, strength: 80, intelligence: 65 }, + experienceLevel: 12, + dedicationLevel: 3, + dedicationText: '28품관', + bill: 1000, + gold: 3000, + rice: 500, + personality: { key: '용장', name: '용장', info: '공격적인 성격' }, + specialDomestic: { key: '상재', name: '상재', info: '상업 특기' }, + specialWar: { key: '돌격', name: '돌격', info: '전투 특기' }, + belong: 4, + refreshScoreTotal: 20, +}; const install = async (page: Page, secretAllowed = true) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_general'); @@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => { return response({ nation: { id: 1, name: '위', color: '#008000', level: 3 }, viewer: { generalId: 1, permission: 0 }, - generals: [general], + generals: [general, otherGeneral], }); if (operation === 'nation.getSecretGeneralList') { if (!secretAllowed) @@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns', await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('nation/generals'); await expect(page.locator('#nation-general-list')).toContainText('테스트장수'); - await expect(page.locator('#nation-general-list')).toContainText('?'); const computed = await page.locator('.general-page').evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily }; }); - expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' }); - expect(computed.fontFamily).toContain('Times New Roman'); + expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' }); + expect(computed.fontFamily).toContain('Pretendard'); expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe( 'separate' ); - expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030); - expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66); + expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000); + expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.locator('#nation-general-list')).toContainText('?'); +}); + +test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true }); + + const statGroupButton = page.getByRole('button', { name: '능력치 접기' }); + await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true'); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); + await statGroupButton.hover(); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)'); + await statGroupButton.focus(); + await expect(statGroupButton).toBeFocused(); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid'); + await statGroupButton.click(); + await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true }); + await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false'); + await expect(table.locator('thead')).toContainText('통|무|지'); + await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50'); + + await page.getByRole('button', { name: '능력치 펼치기' }).click(); + await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.getByLabel('장수명 필터').fill(''); + await page.getByLabel('통솔 필터').fill('>= 60'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.getByLabel('통솔 필터').fill(''); + + await page.getByRole('button', { name: '통솔 정렬' }).click(); + await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1'); + await page.getByRole('button', { name: '통솔 정렬' }).click(); + await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2'); + + await page.getByRole('button', { name: '능력치 접기' }).click(); + await page.getByRole('button', { name: '열 선택⌄' }).click(); + await page.getByLabel('쌀', { exact: true }).uncheck(); + await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('prompt'); + await dialog.accept('내 보기'); + }); + await page.getByRole('button', { name: /보관하기/ }).click(); + await expect + .poll(() => + page.evaluate(() => ({ + settings: localStorage.getItem('GeneralListDisplaySetting'), + last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'), + })) + ) + .toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' }); + + await page.reload(); + await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible(); + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '내 보기 설정 삭제' }).click(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'))) + .not.toContain('내 보기'); }); test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { diff --git a/app/game-frontend/src/utils/nationGeneralGrid.ts b/app/game-frontend/src/utils/nationGeneralGrid.ts new file mode 100644 index 00000000..4cf247c3 --- /dev/null +++ b/app/game-frontend/src/utils/nationGeneralGrid.ts @@ -0,0 +1,299 @@ +export type NationGeneralColumnId = + | 'icon' + | 'name' + | 'officerLevel' + | 'expDedLv_1' + | 'dedlevel' + | 'explevel' + | 'stat_1' + | 'leadership' + | 'strength' + | 'intel' + | 'troop' + | 'goldRice_1' + | 'gold' + | 'rice' + | 'city' + | 'crew' + | 'specials_1' + | 'personal' + | 'specialDomestic' + | 'specialWar' + | 'years_1' + | 'belong' + | 'killturnAndRefresh_1' + | 'refreshScoreTotal'; + +export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh'; +export type SortDirection = 'asc' | 'desc'; +export type NationGeneralViewMode = 'normal' | 'war'; + +export type NationGeneralColumnState = { + colId: NationGeneralColumnId; + width: number; + hide: boolean; + sort: SortDirection | null; + sortIndex?: number; +}; + +export type NationGeneralGroupState = { + groupId: NationGeneralGroupId; + open: boolean; +}; + +export type NationGeneralDisplaySetting = { + column: NationGeneralColumnState[]; + columnGroup: NationGeneralGroupState[]; +}; + +export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string]; + +export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting'; +export const DISPLAY_SETTINGS_VERSION = 1; +export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`; + +const baseColumns = (): NationGeneralColumnState[] => [ + { colId: 'icon', width: 80, hide: false, sort: null }, + { colId: 'name', width: 126, hide: false, sort: null }, + { colId: 'officerLevel', width: 70, hide: false, sort: null }, + { colId: 'expDedLv_1', width: 60, hide: false, sort: null }, + { colId: 'dedlevel', width: 70, hide: false, sort: null }, + { colId: 'explevel', width: 60, hide: false, sort: null }, + { colId: 'stat_1', width: 88, hide: false, sort: null }, + { colId: 'leadership', width: 60, hide: false, sort: null }, + { colId: 'strength', width: 60, hide: false, sort: null }, + { colId: 'intel', width: 60, hide: false, sort: null }, + { colId: 'troop', width: 90, hide: true, sort: null }, + { colId: 'goldRice_1', width: 80, hide: false, sort: null }, + { colId: 'gold', width: 70, hide: false, sort: null }, + { colId: 'rice', width: 70, hide: false, sort: null }, + { colId: 'city', width: 60, hide: true, sort: null }, + { colId: 'crew', width: 70, hide: true, sort: null }, + { colId: 'specials_1', width: 80, hide: false, sort: null }, + { colId: 'personal', width: 60, hide: false, sort: null }, + { colId: 'specialDomestic', width: 60, hide: false, sort: null }, + { colId: 'specialWar', width: 60, hide: false, sort: null }, + { colId: 'years_1', width: 60, hide: false, sort: null }, + { colId: 'belong', width: 60, hide: false, sort: null }, + { colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null }, + { colId: 'refreshScoreTotal', width: 70, hide: false, sort: null }, +]; + +const groupState = (overrides: Partial>): NationGeneralGroupState[] => + (['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({ + groupId, + open: overrides[groupId] ?? false, + })); + +const withColumnOverrides = ( + columns: NationGeneralColumnState[], + overrides: Partial>> +): NationGeneralColumnState[] => + columns.map((column) => ({ + ...column, + ...overrides[column.colId], + })); + +export const defaultNationGeneralDisplaySettings: Record = { + normal: { + column: withColumnOverrides(baseColumns(), { + troop: { hide: true }, + city: { hide: true }, + crew: { hide: true }, + refreshScoreTotal: { sort: 'desc', sortIndex: 0 }, + }), + columnGroup: groupState({ + expDedLv: true, + stat: true, + goldRice: true, + specials: false, + years: false, + killturnAndRefresh: true, + }), + }, + war: { + column: withColumnOverrides(baseColumns(), { + icon: { hide: true }, + officerLevel: { hide: true }, + expDedLv_1: { hide: true }, + dedlevel: { hide: true }, + explevel: { hide: true }, + troop: { hide: false }, + city: { hide: false }, + crew: { hide: false }, + specials_1: { hide: true }, + personal: { hide: true }, + specialDomestic: { hide: true }, + specialWar: { hide: true }, + years_1: { hide: true }, + belong: { hide: true }, + killturnAndRefresh_1: { hide: true }, + refreshScoreTotal: { hide: true }, + }), + columnGroup: groupState({ + expDedLv: false, + stat: false, + goldRice: true, + specials: false, + years: false, + killturnAndRefresh: true, + }), + }, +}; + +export const cloneNationGeneralDisplaySetting = ( + setting: NationGeneralDisplaySetting +): NationGeneralDisplaySetting => ({ + column: setting.column.map((column) => ({ ...column })), + columnGroup: setting.columnGroup.map((group) => ({ ...group })), +}); + +const validColumnIds = new Set(baseColumns().map((column) => column.colId)); +const validGroupIds = new Set(groupState({}).map((group) => group.groupId)); + +const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc'; + +export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => { + if (!raw || typeof raw !== 'object') { + return null; + } + const candidate = raw as { column?: unknown; columnGroup?: unknown }; + if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) { + return null; + } + + const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal); + const rawColumns = new Map>(); + for (const value of candidate.column) { + if (!value || typeof value !== 'object') continue; + const column = value as Record; + if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) { + rawColumns.set(column.colId, column); + } + } + fallback.column = fallback.column.map((column) => { + const saved = rawColumns.get(column.colId); + if (!saved) return column; + return { + ...column, + width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width, + hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide, + sort: isSortDirection(saved.sort) ? saved.sort : null, + ...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0 + ? { sortIndex: Math.trunc(saved.sortIndex) } + : {}), + }; + }); + + const rawGroups = new Map(); + for (const value of candidate.columnGroup) { + if (!value || typeof value !== 'object') continue; + const group = value as Record; + if ( + typeof group.groupId === 'string' && + validGroupIds.has(group.groupId as NationGeneralGroupId) && + typeof group.open === 'boolean' + ) { + rawGroups.set(group.groupId, group.open); + } + } + fallback.columnGroup = fallback.columnGroup.map((group) => ({ + ...group, + open: rawGroups.get(group.groupId) ?? group.open, + })); + return fallback; +}; + +export const parseStoredDisplaySettings = (raw: string | null): Map => { + if (!raw) return new Map(); + try { + const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown }; + if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map(); + const result = new Map(); + for (const entry of parsed.settings) { + if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue; + const setting = normalizeNationGeneralDisplaySetting(entry[1]); + if (setting) result.set(entry[0], setting); + } + return result; + } catch { + return new Map(); + } +}; + +export const serializeDisplaySettings = (settings: Map): string => + JSON.stringify({ + version: DISPLAY_SETTINGS_VERSION, + settings: [...settings.entries()], + }); + +export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + typeof parsed[0] !== 'boolean' || + typeof parsed[1] !== 'string' + ) { + return null; + } + if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null; + return [false, parsed[1]]; + } catch { + return null; + } +}; + +const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'; + +const hangulInitials = (value: string): string => + [...value] + .map((character) => { + const code = character.charCodeAt(0); + if (code < 0xac00 || code > 0xd7a3) return character; + return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character; + }) + .join(''); + +const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, ''); + +export const matchesKoreanSearch = (value: string, query: string): boolean => { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return true; + const normalizedValue = normalizeSearchText(value); + return ( + normalizedValue.includes(normalizedQuery) || + normalizeSearchText(hangulInitials(value)).includes(normalizedQuery) + ); +}; + +export const matchesNumberSearch = (value: number | null, query: string): boolean => { + const normalized = query.trim(); + if (!normalized) return true; + if (value === null || !Number.isFinite(value)) return false; + const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized); + if (!match) return false; + const expected = Number(match[2]); + switch (match[1] ?? '=') { + case '<': + return value < expected; + case '<=': + return value <= expected; + case '>': + return value > expected; + case '>=': + return value >= expected; + default: + return value === expected; + } +}; + +export const compareGridValues = (left: string | number | null, right: string | number | null): number => { + if (left === right) return 0; + if (left === null) return 1; + if (right === null) return -1; + if (typeof left === 'number' && typeof right === 'number') return left - right; + return String(left).localeCompare(String(right), 'ko-KR', { numeric: true }); +}; diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index 733c8741..64de7fcc 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -1,28 +1,202 @@ @@ -80,40 +484,68 @@ onMounted(load); - - - + + + + + + - - + @@ -121,100 +553,99 @@ onMounted(load);

불러오는 중...

- +
- + - - - - - - - - + - - - - - - - - - - - - - + - - - - - - - - - - - - - + - - - - - - - - - - - - + - + + +
명성/계급 ‹능력치 ‹자금 ‹특성 ›연도 ›기타 ‹ + +
아이콘장수명관직계급명성통솔무력지력요약요약벌점 ↓ + +
+ +
- - - {{ general.name }}{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}{{ general.dedicationText }}
({{ general.bill.toLocaleString() }})
Lv {{ general.experienceLevel }}
({{ general.personality?.name ?? '-' }})
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}{{ general.gold.toLocaleString() }} 금{{ general.rice.toLocaleString() }} 쌀 - {{ general.personality?.name ?? '-' }}
{{ general.specialDomestic?.name ?? '-' }} -
- {{ special(general) }} - - {{ general.refreshScoreTotal }}점
({{ general.belong ? '자주' : '안함' }}) + + + + + + +
검색 결과가 없습니다.
@@ -222,9 +653,8 @@ onMounted(load);