diff --git a/app/game-api/test/directoryRouter.test.ts b/app/game-api/test/directoryRouter.test.ts index a7e0d2a7..3fe3e2e0 100644 --- a/app/game-api/test/directoryRouter.test.ts +++ b/app/game-api/test/directoryRouter.test.ts @@ -250,7 +250,7 @@ describe('legacy global nation/general directories', () => { await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); - it('keeps the same read-only result for wandering, ordinary, chief, and possessed actors', async () => { + it('keeps hover-safe public rows identical for wandering, ordinary, chief, and possessed actors', async () => { const viewers = [ actor({ nationId: 0, officerLevel: 0 }), actor({ nationId: 1, officerLevel: 1 }), @@ -270,6 +270,23 @@ describe('legacy global nation/general directories', () => { }) ); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); + for (const result of results) { + const serializedGeneralRows = JSON.stringify(result.generals); + for (const privateField of [ + 'userId', + 'meta', + 'penalty', + 'gold', + 'rice', + 'crew', + 'train', + 'atmos', + 'turnTime', + 'lastTurn', + ]) { + expect(serializedGeneralRows).not.toContain(`"${privateField}"`); + } + } }); it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => { diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index abd5dfc2..eafccdf3 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -134,11 +134,10 @@ const parseSort = (route: Route): number => { try { const request = route.request(); const queryInput = new URL(request.url()).searchParams.get('input'); - const input = (request.postData() - ? request.postDataJSON() - : queryInput - ? JSON.parse(queryInput) - : {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } }; + const input = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as { + 0?: { json?: { sort?: number }; sort?: number }; + json?: { sort?: number }; + }; return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9; } catch { return 9; @@ -148,7 +147,8 @@ const parseSort = (route: Route): number => { const install = async ( page: Page, mode: 'general' | 'no-general' | 'error-after-load' = 'general', - accessPages: string[] = [] + accessPages: string[] = [], + requestedOperations: string[] = [] ) => { let generalDirectoryCalls = 0; await page.addInitScript((profile) => { @@ -176,13 +176,22 @@ const install = async ( body: '', }) ); + await page.route('https://sam-image.hided.net/icons/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }) + ); await page.route('**/image/game/**', (route) => route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) ); await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => { const requestBody = route.request().postDataJSON() as Record | undefined; - const results = operationNames(route).map((operation, operationIndex) => { + const operations = operationNames(route); + requestedOperations.push(...operations); + const results = operations.map((operation, operationIndex) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } }); @@ -335,7 +344,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); const icon = page.locator('.general-icon').first(); await expect(icon).toBeVisible(); - await expect(icon).toHaveAttribute('src', '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon.png'); + await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/%EA%B3%84%EC%A0%95%20icon.png'); expect( await icon.evaluate((element) => { const image = element as HTMLImageElement; @@ -350,7 +359,10 @@ test('nation and general directories preserve the fixed legacy Chromium geometry }) ).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' }); const nestedLegacyIcon = page.locator('.general-icon').nth(1); - await expect(nestedLegacyIcon).toHaveAttribute('src', '/image/general/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png'); + await expect(nestedLegacyIcon).toHaveAttribute( + 'src', + 'https://sam-image.hided.net/icons/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png' + ); await expect .poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth)) .toBe(64); @@ -380,20 +392,115 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true); }); +test('nation directory reuses only the public general-directory row on hover and keyboard focus', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); + + const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR; + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 844 }, + ] as const) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto('nation-list'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0); + await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4); + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength( + viewport.name === 'desktop' ? 0 : 1 + ); + + const documentHeightBefore = await page.evaluate(() => document.documentElement.scrollHeight); + const firstTrigger = page.locator('[data-general-preview-trigger="10"]'); + await firstTrigger.hover(); + + const preview = page.locator('#nation-general-preview'); + await expect(preview).toBeVisible(); + await expect(preview.locator('tr[data-general-id]')).toHaveCount(1); + await expect(preview.locator('tr[data-general-id="10"]')).toContainText('조조'); + await expect(preview.locator('tr[data-general-id="10"]')).toContainText('대담'); + await expect(preview.locator('tr[data-general-id="10"]')).toContainText('상재 / 귀모'); + await expect(preview).not.toContainText('user-'); + await expect(preview).not.toContainText('secret'); + + const previewGeometry = await preview.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const row = element.querySelector('tr[data-general-id]')!.getBoundingClientRect(); + return { + x: rect.x, + width: rect.width, + bottom: window.innerHeight - rect.bottom, + position: style.position, + rowHeight: row.height, + documentWidth: document.documentElement.scrollWidth, + documentHeight: document.documentElement.scrollHeight, + }; + }); + expect(previewGeometry).toEqual({ + x: viewport.name === 'desktop' ? 100 : 0, + width: 1000, + bottom: 12, + position: 'fixed', + rowHeight: 65, + documentWidth: Math.max(viewport.width, 1000), + documentHeight: documentHeightBefore, + }); + + await page.locator('.nation-title').first().hover(); + await expect(preview).toHaveCount(0); + + const foreignTrigger = page.locator('[data-general-preview-trigger="20"]'); + await foreignTrigger.focus(); + await expect(preview.locator('tr[data-general-id="20"]')).toContainText('유비'); + expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true'); + expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed'); + + await page.getByRole('button', { name: '창 닫기' }).first().focus(); + await expect(preview).toHaveCount(0); + + if (artifactRoot) { + const output = resolve(artifactRoot); + await mkdir(output, { recursive: true }); + await firstTrigger.hover(); + await expect(preview).toBeVisible(); + await page.screenshot({ + path: resolve(output, `nation-directory-hover-${viewport.name}.png`), + fullPage: true, + }); + } + } + + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(2); + expect(requestedOperations).not.toContain('nation.getSecretGeneralList'); + expect(requestedOperations).not.toContain('nation.getPersonnelInfo'); + expect(requestedOperations).not.toContain('general.me'); +}); + test('a reused image element falls back for each newly broken account icon', async ({ page }) => { await install(page); + await page.route('https://sam-image.hided.net/icons/**', (route) => { + if (route.request().url().endsWith('/default.jpg')) { + return route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + } + return route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' }); + }); await page.route('**/gateway/api/user-icons/**', (route) => route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' }) ); await page.goto('general-list'); const icon = page.locator('.general-icon').first(); - await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); + await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg'); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); await icon.evaluate((element) => { (element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png'; }); - await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); + await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg'); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); }); diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue new file mode 100644 index 00000000..5de66dc4 --- /dev/null +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/app/game-frontend/src/types/directory.ts b/app/game-frontend/src/types/directory.ts new file mode 100644 index 00000000..ad535479 --- /dev/null +++ b/app/game-frontend/src/types/directory.ts @@ -0,0 +1,4 @@ +import type { trpc } from '../utils/trpc'; + +export type GeneralDirectory = Awaited>; +export type GeneralDirectoryGeneral = GeneralDirectory['generals'][number]; diff --git a/app/game-frontend/src/views/GeneralListView.vue b/app/game-frontend/src/views/GeneralListView.vue index 4febfac9..be69559d 100644 --- a/app/game-frontend/src/views/GeneralListView.vue +++ b/app/game-frontend/src/views/GeneralListView.vue @@ -2,13 +2,10 @@ import { onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; -import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; -import { formatOfficerLevelText } from '../utils/nationFormat'; -import { getNpcColor } from '../utils/npcColor'; +import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; +import type { GeneralDirectoryGeneral } from '../types/directory'; import { trpc } from '../utils/trpc'; -type Directory = Awaited>; -type General = Directory['generals'][number]; type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; const sortOptions: Array<{ value: SortKey; label: string }> = [ @@ -30,7 +27,7 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [ ]; const sort = ref(9); -const generals = ref([]); +const generals = ref([]); const loading = ref(false); const error = ref(''); const router = useRouter(); @@ -48,9 +45,6 @@ const loadDirectory = async () => { } }; -const imageUrl = (general: General): string => resolveGeneralIconUrl(general); -const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); - onMounted(() => { void loadDirectory(); }); @@ -84,114 +78,7 @@ onMounted(() => { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
얼 굴이 름연령성격특기레 벨국 가명 성계 급관 직통솔무력지력삭턴벌점
불러오는 중...
- - - {{ general.name }} - - {{ general.age }}세 - {{ general.personality.name }} - - {{ general.specialDomestic.name }} / - {{ general.specialWar.name }} - Lv {{ general.experienceLevel }}{{ general.nationName }}{{ general.honorText }}{{ general.dedicationText }}{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }} - {{ - general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership - }}+{{ general.leadershipBonus }} - - {{ - general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength - }} - - {{ - general.injury > 0 - ? injuredStat(general.intelligence, general.injury) - : general.intelligence - }} - {{ general.killturn }}{{ general.refreshScoreTotal }}
【{{ general.refreshText }}】
+ @@ -241,34 +128,6 @@ onMounted(() => { .sort-form button { font-size: 14px; } -.header-cell { - height: 18px; - text-align: center; - background-color: #14241b; - background-image: var(--sammo-texture-green); -} -.general-icon { - display: inline; - width: 64px; - min-width: 64px; - max-width: none; - height: 64px; - object-fit: fill; - vertical-align: middle; -} -.center { - text-align: center; -} -.wounded { - color: red; -} -.leadership-bonus { - color: cyan; -} -.loading-cell { - height: 64px; - text-align: center; -} .directory-error { width: 998px; margin: 0; diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue index 4b4ee10e..30c2f250 100644 --- a/app/game-frontend/src/views/NationListView.vue +++ b/app/game-frontend/src/views/NationListView.vue @@ -1,6 +1,8 @@