diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index eafccdf3..0d84baff 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -416,43 +416,45 @@ test('nation directory reuses only the public general-directory row on hover and 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.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).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(); + const card = element.querySelector('[data-general-card-id]')!.getBoundingClientRect(); + const trigger = document.querySelector('[data-general-preview-trigger="10"]')!.getBoundingClientRect(); + const verticalGap = rect.bottom <= trigger.top ? trigger.top - rect.bottom : rect.top - trigger.bottom; return { x: rect.x, + right: window.innerWidth - rect.right, width: rect.width, - bottom: window.innerHeight - rect.bottom, + verticalGap, position: style.position, - rowHeight: row.height, + cardHeight: card.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, - }); + expect(previewGeometry.position).toBe('fixed'); + expect(previewGeometry.width).toBe(viewport.name === 'desktop' ? 500 : 484); + expect(previewGeometry.x).toBeGreaterThanOrEqual(8); + expect(previewGeometry.right).toBeGreaterThanOrEqual(8); + expect(previewGeometry.verticalGap).toBe(8); + expect(previewGeometry.cardHeight).toBeGreaterThan(100); + expect(previewGeometry.documentWidth).toBe(viewport.width); + expect(previewGeometry.documentHeight).toBe(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('유비'); + await expect(preview.locator('[data-general-card-id="20"]')).toContainText('유비'); expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true'); expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed'); @@ -477,6 +479,135 @@ test('nation directory reuses only the public general-directory row on hover and expect(requestedOperations).not.toContain('general.me'); }); +test('nation and general directories rearrange for mobile and keep the tapped preview near its trigger', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + const requestedOperations: string[] = []; + const mobileMeasurements: Record = {}; + + try { + await install(page, 'general', [], requestedOperations); + await page.goto('nation-list'); + await page.waitForLoadState('networkidle'); + + const nationMetrics = await page.locator('.directory-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + x: rect.x, + width: rect.width, + innerWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(nationMetrics.x).toBe(0); + expect(nationMetrics.width).toBe(500); + expect(nationMetrics.scrollWidth).toBeLessThanOrEqual(nationMetrics.innerWidth); + mobileMeasurements.nation = nationMetrics; + + const mobileNationTables = page.locator('.nation-table'); + await expect(mobileNationTables).toHaveCount(2); + for (const nationTable of await mobileNationTables.all()) { + const mobileNationRows = nationTable.locator('.mobile-only:visible'); + await expect(mobileNationRows).toHaveCount(7); + expect(await mobileNationRows.evaluateAll((rows) => rows.map((row) => row.children.length))).toEqual([ + 4, 4, 4, 4, 4, 4, 4, + ]); + } + await expect(page.locator('.nation-table .desktop-only:visible')).toHaveCount(0); + const nationCellWidths = await page + .locator('.nation-table .mobile-only:visible') + .first() + .locator('td') + .evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width)); + expect(nationCellWidths).toHaveLength(4); + expect(nationCellWidths[0]).toBeCloseTo(80, 0); + expect(nationCellWidths[1]).toBeCloseTo(170, 0); + mobileMeasurements.nationCellWidths = nationCellWidths; + + const firstTrigger = page.locator('[data-general-preview-trigger="10"]'); + await firstTrigger.tap(); + const preview = page.locator('#nation-general-preview'); + await expect(preview).toBeVisible(); + await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조'); + const touchGeometry = await preview.evaluate((element) => { + const previewRect = element.getBoundingClientRect(); + const triggerRect = document.querySelector('[data-general-preview-trigger="10"]')!.getBoundingClientRect(); + return { + left: previewRect.left, + right: window.innerWidth - previewRect.right, + width: previewRect.width, + verticalGap: + previewRect.bottom <= triggerRect.top + ? triggerRect.top - previewRect.bottom + : previewRect.top - triggerRect.bottom, + }; + }); + expect(touchGeometry.left).toBe(8); + expect(touchGeometry.right).toBeGreaterThanOrEqual(8); + expect(touchGeometry.width).toBe(484); + expect(touchGeometry.verticalGap).toBe(8); + mobileMeasurements.touchPreview = touchGeometry; + expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1); + + await page.goto('general-list'); + await page.waitForLoadState('networkidle'); + 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'); + + const generalMetrics = await page.locator('.directory-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const cardElement = element.querySelector('[data-general-card-id]')!; + const card = cardElement.getBoundingClientRect(); + return { + width: rect.width, + cardWidth: card.width, + cardHeight: card.height, + cardGridColumns: getComputedStyle(cardElement).gridTemplateColumns, + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + }; + }); + expect(generalMetrics.width).toBe(500); + expect(generalMetrics.cardWidth).toBe(500); + expect(generalMetrics.cardHeight).toBeGreaterThan(100); + expect(generalMetrics.cardGridColumns).toMatch(/^66px /u); + expect(generalMetrics.scrollWidth).toBe(generalMetrics.innerWidth); + mobileMeasurements.general = generalMetrics; + + const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR; + if (artifactRoot) { + const output = resolve(artifactRoot); + await mkdir(output, { recursive: true }); + await writeFile( + resolve(output, 'mobile-computed-dom.json'), + `${JSON.stringify(mobileMeasurements, null, 2)}\n` + ); + await page.screenshot({ path: resolve(output, 'general-directory-mobile.png'), fullPage: true }); + await page.goto('nation-list'); + await firstTrigger.tap(); + await expect(preview).toBeVisible(); + await page.screenshot({ path: resolve(output, 'nation-directory-tap-mobile.png'), fullPage: true }); + } + } finally { + await context.close(); + } + + 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) => { diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue index 5de66dc4..ac0b74ea 100644 --- a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -8,9 +8,11 @@ withDefaults( defineProps<{ generals: GeneralDirectoryGeneral[]; loading?: boolean; + layout?: 'responsive' | 'card'; }>(), { loading: false, + layout: 'responsive', } ); @@ -18,7 +20,7 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value diff --git a/app/game-frontend/src/views/GeneralListView.vue b/app/game-frontend/src/views/GeneralListView.vue index be69559d..d74ec57b 100644 --- a/app/game-frontend/src/views/GeneralListView.vue +++ b/app/game-frontend/src/views/GeneralListView.vue @@ -136,4 +136,19 @@ onMounted(() => { text-align: center; color: #ff7373; } + +@media (max-width: 600px) { + .directory-page { + width: 100%; + max-width: 500px; + margin: 0; + } + .directory-table { + width: 100%; + } + .directory-error { + box-sizing: border-box; + width: 100%; + } +} diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue index e0d92787..552a99d8 100644 --- a/app/game-frontend/src/views/NationListView.vue +++ b/app/game-frontend/src/views/NationListView.vue @@ -1,5 +1,5 @@ @@ -132,7 +185,7 @@ onMounted(() => { 【 {{ nation.name }} 】 - + 성 향 {{ Array.from(nation.type.name).join(' ') }} 작 위 @@ -142,27 +195,59 @@ onMounted(() => { 장수 / 속령 {{ nation.generalCount }} / {{ nation.cityCount }} - + + 성 향 + {{ Array.from(nation.type.name).join(' ') }} + 작 위 + {{ formatNationLevelText(nation.level) }} + + + 국 력 + {{ nation.power }} + 장수 / 속령 + {{ nation.generalCount }} / {{ nation.cityCount }} + + - + + + + 외교권자 {{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }} @@ -170,6 +255,14 @@ onMounted(() => { 조언자 {{ nation.auditorCount }}명 + + 외교권자 + + {{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }} + + 조언자 + {{ nation.auditorCount }}명 +