diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts
index 64ac687c..b01b2ae5 100644
--- a/app/game-frontend/e2e/nationOffices.spec.ts
+++ b/app/game-frontend/e2e/nationOffices.spec.ts
@@ -1,5 +1,5 @@
import { devices, expect, test, type Page, type Route } from '@playwright/test';
-import { mkdir, readFile } from 'node:fs/promises';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -11,6 +11,7 @@ type FixtureState = {
role: Role;
failNextRate?: boolean;
failPersonnelLoad?: boolean;
+ testIcons?: boolean;
rate: number;
appointedGeneralId?: number;
appointedCityId?: number;
@@ -98,8 +99,8 @@ const personnelFixture = (state: FixtureState) => {
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
const fullGenerals = [
general(1, '조조', 12),
- general(2, '순욱', 11),
- general(3, '하후돈', 4),
+ general(2, '순욱', 11, state.testIcons ? { picture: 'transparent.png', imageServer: 1 } : {}),
+ general(3, '하후돈', 4, state.testIcons ? { picture: 'missing.png', imageServer: 1 } : {}),
general(4, '곽가', 3),
general(5, '정욱', 2),
general(6, '장료', 1, { npcState: 2 }),
@@ -1022,3 +1023,69 @@ test('finance adopts the server-purified notice and scout message before renderi
)
.toBeUndefined();
});
+
+for (const width of [1365, 390]) {
+ test(`personnel custom icons never layer the default at ${width}px`, async ({ page }, testInfo) => {
+ await installFixture(page, { role: 'leader', rate: 20, testIcons: true });
+ await page.setViewportSize({ width, height: 900 });
+ await page.route('**/transparent.png', (route) =>
+ route.fulfill({
+ contentType: 'image/svg+xml',
+ body: '',
+ })
+ );
+ await page.route('**/missing.png', (route) => route.fulfill({ status: 404, body: '' }));
+ await page.route('**/icons/default.jpg', async (route) =>
+ route.fulfill({
+ contentType: 'image/jpeg',
+ body: await referenceAsset('icons/default.jpg'),
+ })
+ );
+ await gotoOffice(page, 'nation/personnel');
+ const chief = page.locator('.chief-entry').filter({ hasText: '순욱' }).locator('.general-icon');
+ await expect(chief).toHaveCSS('background-image', 'none');
+ await expect(chief).toHaveAttribute('src', /transparent\.png$/);
+ await expect(page.locator('.chief-entry').filter({ hasText: '조조' }).locator('.general-icon')).toHaveAttribute(
+ 'src',
+ /default\.jpg$/
+ );
+ await page.getByRole('button', { name: '주부 변경하기', exact: true }).click();
+ const picker = page.getByTestId('personnel-selection-dialog');
+ const custom = picker
+ .locator('.personnel-picker-card')
+ .filter({ hasText: '순욱' })
+ .locator('.personnel-picker-portrait');
+ const missing = picker
+ .locator('.personnel-picker-card')
+ .filter({ hasText: '하후돈' })
+ .locator('.personnel-picker-portrait');
+ await expect(custom).toHaveCSS('background-image', 'none');
+ await expect(custom).toHaveAttribute('src', /transparent\.png$/);
+ await expect(missing).toHaveAttribute('src', /default\.jpg$/);
+ await expect.poll(() => custom.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBe(64);
+ await page.evaluate(() => document.fonts.ready);
+ const metrics = await page.locator('.general-icon, .personnel-picker-portrait').evaluateAll((elements) =>
+ elements.map((el) => ({
+ src: (el as HTMLImageElement).currentSrc,
+ rect: el.getBoundingClientRect().toJSON(),
+ background: getComputedStyle(el).backgroundImage,
+ objectFit: getComputedStyle(el).objectFit,
+ naturalWidth: (el as HTMLImageElement).naturalWidth,
+ naturalHeight: (el as HTMLImageElement).naturalHeight,
+ }))
+ );
+ expect(metrics.every((entry) => entry.background === 'none')).toBe(true);
+ await writeFile(testInfo.outputPath('icon-geometry.json'), JSON.stringify(metrics, null, 2));
+ await writeFile(testInfo.outputPath('icon-dom.html'), await page.content());
+ await testInfo.attach('icon-geometry', {
+ body: JSON.stringify(metrics, null, 2),
+ contentType: 'application/json',
+ });
+ await page.screenshot({
+ path: testInfo.outputPath('personnel-icons.png'),
+ fullPage: true,
+ animations: 'disabled',
+ });
+ await testInfo.attach('icon-dom', { body: await page.content(), contentType: 'text/html' });
+ });
+}
diff --git a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue
index 5d7387a5..48af18b7 100644
--- a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue
+++ b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue
@@ -1,5 +1,6 @@