fix: 전용 장수 아이콘 뒤 기본 이미지 중첩 제거
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { devices, expect, test, type Page, type Route } from '@playwright/test';
|
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 { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
@@ -11,6 +11,7 @@ type FixtureState = {
|
|||||||
role: Role;
|
role: Role;
|
||||||
failNextRate?: boolean;
|
failNextRate?: boolean;
|
||||||
failPersonnelLoad?: boolean;
|
failPersonnelLoad?: boolean;
|
||||||
|
testIcons?: boolean;
|
||||||
rate: number;
|
rate: number;
|
||||||
appointedGeneralId?: number;
|
appointedGeneralId?: number;
|
||||||
appointedCityId?: number;
|
appointedCityId?: number;
|
||||||
@@ -98,8 +99,8 @@ const personnelFixture = (state: FixtureState) => {
|
|||||||
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
|
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
|
||||||
const fullGenerals = [
|
const fullGenerals = [
|
||||||
general(1, '조조', 12),
|
general(1, '조조', 12),
|
||||||
general(2, '순욱', 11),
|
general(2, '순욱', 11, state.testIcons ? { picture: 'transparent.png', imageServer: 1 } : {}),
|
||||||
general(3, '하후돈', 4),
|
general(3, '하후돈', 4, state.testIcons ? { picture: 'missing.png', imageServer: 1 } : {}),
|
||||||
general(4, '곽가', 3),
|
general(4, '곽가', 3),
|
||||||
general(5, '정욱', 2),
|
general(5, '정욱', 2),
|
||||||
general(6, '장료', 1, { npcState: 2 }),
|
general(6, '장료', 1, { npcState: 2 }),
|
||||||
@@ -1022,3 +1023,69 @@ test('finance adopts the server-purified notice and scout message before renderi
|
|||||||
)
|
)
|
||||||
.toBeUndefined();
|
.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: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="12" fill="red"/></svg>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import { useDefaultGeneralIcon } from '../../utils/generalIcon';
|
||||||
|
|
||||||
type SelectionMetric = {
|
type SelectionMetric = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,7 +13,7 @@ type SelectionItem = {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
accent?: 'current' | 'assigned' | 'available';
|
accent?: 'current' | 'assigned' | 'available';
|
||||||
iconBackground?: string;
|
iconUrl?: string;
|
||||||
badges?: string[];
|
badges?: string[];
|
||||||
stats?: SelectionMetric[];
|
stats?: SelectionMetric[];
|
||||||
details?: SelectionMetric[];
|
details?: SelectionMetric[];
|
||||||
@@ -168,11 +169,13 @@ onBeforeUnmount(() => {
|
|||||||
:aria-pressed="selectedId === item.id"
|
:aria-pressed="selectedId === item.id"
|
||||||
@click="select(item.id)"
|
@click="select(item.id)"
|
||||||
>
|
>
|
||||||
<span
|
<img
|
||||||
v-if="item.iconBackground"
|
v-if="item.iconUrl"
|
||||||
class="personnel-picker-portrait"
|
class="personnel-picker-portrait"
|
||||||
:style="{ backgroundImage: item.iconBackground }"
|
:src="item.iconUrl"
|
||||||
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
/>
|
/>
|
||||||
<span v-else class="personnel-picker-city-icon" aria-hidden="true">城</span>
|
<span v-else class="personnel-picker-city-icon" aria-hidden="true">城</span>
|
||||||
|
|
||||||
@@ -366,9 +369,7 @@ small {
|
|||||||
background-color: #0b0c0a;
|
background-color: #0b0c0a;
|
||||||
}
|
}
|
||||||
.personnel-picker-portrait {
|
.personnel-picker-portrait {
|
||||||
background-position: center;
|
object-fit: cover;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
}
|
||||||
.personnel-picker-city-icon,
|
.personnel-picker-city-icon,
|
||||||
.vacancy-icon {
|
.vacancy-icon {
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ export const resolveGeneralIconUrl = (
|
|||||||
return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`;
|
return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveGeneralIconBackgroundImage = (source: GeneralIconSource, options?: GeneralIconOptions): string => {
|
|
||||||
const resolved = resolveGeneralIconUrl(source, options);
|
|
||||||
return `url(${JSON.stringify(resolved)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveMessageGeneralIconUrl = (
|
export const resolveMessageGeneralIconUrl = (
|
||||||
icon: string | null | undefined,
|
icon: string | null | undefined,
|
||||||
userIconBaseUrl = configuredUserIconPublicUrl()
|
userIconBaseUrl = configuredUserIconPublicUrl()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
|||||||
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
|
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
|
||||||
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
|
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
|
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
|
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
|
||||||
@@ -25,7 +25,7 @@ type SelectionDialogItem = {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
accent: 'current' | 'assigned' | 'available';
|
accent: 'current' | 'assigned' | 'available';
|
||||||
iconBackground?: string;
|
iconUrl?: string;
|
||||||
badges: string[];
|
badges: string[];
|
||||||
stats?: Array<{ label: string; value: string }>;
|
stats?: Array<{ label: string; value: string }>;
|
||||||
details: Array<{ label: string; value: string }>;
|
details: Array<{ label: string; value: string }>;
|
||||||
@@ -77,7 +77,7 @@ const chiefAssignments = computed(() => data.value?.chiefAssignments ?? {});
|
|||||||
const cityNameMap = computed(() => new Map((data.value?.cityAssignments ?? []).map((city) => [city.id, city.name])));
|
const cityNameMap = computed(() => new Map((data.value?.cityAssignments ?? []).map((city) => [city.id, city.name])));
|
||||||
const generalMap = computed(() => new Map((data.value?.generals ?? []).map((general) => [general.id, general])));
|
const generalMap = computed(() => new Map((data.value?.generals ?? []).map((general) => [general.id, general])));
|
||||||
|
|
||||||
const imageBackground = (general: GeneralEntry | undefined): string => resolveGeneralIconBackgroundImage(general ?? {});
|
const imageUrl = (general: GeneralEntry | undefined): string => resolveGeneralIconUrl(general ?? {});
|
||||||
const officerLocked = (value: number, level: number): boolean => (value & (1 << level)) !== 0;
|
const officerLocked = (value: number, level: number): boolean => (value & (1 << level)) !== 0;
|
||||||
const chiefLocked = (level: number): boolean => officerLocked(data.value?.nation.chiefSet ?? 0, level);
|
const chiefLocked = (level: number): boolean => officerLocked(data.value?.nation.chiefSet ?? 0, level);
|
||||||
const cityOfficerLocked = (city: PersonnelResponse['cityAssignments'][number], level: number): boolean =>
|
const cityOfficerLocked = (city: PersonnelResponse['cityAssignments'][number], level: number): boolean =>
|
||||||
@@ -197,7 +197,7 @@ const generalSelectionItem = (general: GeneralEntry, currentGeneralId: number):
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
accent: isCurrent ? 'current' : isAssigned ? 'assigned' : 'available',
|
accent: isCurrent ? 'current' : isAssigned ? 'assigned' : 'available',
|
||||||
iconBackground: imageBackground(general),
|
iconUrl: imageUrl(general),
|
||||||
badges,
|
badges,
|
||||||
stats: [
|
stats: [
|
||||||
{ label: '통솔', value: general.stats.leadership.toLocaleString('ko-KR') },
|
{ label: '통솔', value: general.stats.leadership.toLocaleString('ko-KR') },
|
||||||
@@ -355,10 +355,12 @@ onMounted(() => void loadPersonnel());
|
|||||||
<span class="chief-entry-role">{{
|
<span class="chief-entry-role">{{
|
||||||
formatOfficerLevelText(level, nationLevel)
|
formatOfficerLevelText(level, nationLevel)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<img
|
||||||
class="general-icon"
|
class="general-icon"
|
||||||
:style="{ backgroundImage: imageBackground(chiefAssignments[level]) }"
|
:src="imageUrl(chiefAssignments[level])"
|
||||||
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
/>
|
/>
|
||||||
<span class="chief-entry-copy">
|
<span class="chief-entry-copy">
|
||||||
<strong>{{ chiefAssignments[level]?.name ?? '공석' }}</strong>
|
<strong>{{ chiefAssignments[level]?.name ?? '공석' }}</strong>
|
||||||
@@ -736,9 +738,7 @@ select {
|
|||||||
grid-area: icon;
|
grid-area: icon;
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
background-repeat: no-repeat;
|
object-fit: fill;
|
||||||
background-position: center;
|
|
||||||
background-size: 64px 64px;
|
|
||||||
}
|
}
|
||||||
.chief-entry-copy {
|
.chief-entry-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -943,7 +943,6 @@ select {
|
|||||||
.general-icon {
|
.general-icon {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background-size: 48px 48px;
|
|
||||||
}
|
}
|
||||||
.chief-entry-copy strong {
|
.chief-entry-copy strong {
|
||||||
font-size: var(--sammo-font-size-personnel-mobile-name);
|
font-size: var(--sammo-font-size-personnel-mobile-name);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { describe, it } from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_GENERAL_ICON_URL,
|
DEFAULT_GENERAL_ICON_URL,
|
||||||
resolveGeneralIconBackgroundImage,
|
|
||||||
resolveGeneralIconUrl,
|
resolveGeneralIconUrl,
|
||||||
resolveMessageGeneralIconUrl,
|
resolveMessageGeneralIconUrl,
|
||||||
} from '../src/utils/generalIcon.ts';
|
} from '../src/utils/generalIcon.ts';
|
||||||
@@ -38,18 +37,11 @@ void describe('generalIcon', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
void it('uses a deterministic default and safe layered fallback for CSS backgrounds', () => {
|
void it('uses a deterministic default when the picture is missing', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveGeneralIconUrl({ picture: null, imageServer: 0 }),
|
resolveGeneralIconUrl({ picture: null, imageServer: 0 }),
|
||||||
'https://sam-image.hided.net/icons/default.jpg'
|
'https://sam-image.hided.net/icons/default.jpg'
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
resolveGeneralIconBackgroundImage(
|
|
||||||
{ picture: 'custom.jpg', imageServer: 1 },
|
|
||||||
{ userIconBaseUrl: '/gateway/api/user-icons' }
|
|
||||||
),
|
|
||||||
'url("/gateway/api/user-icons/custom.jpg"), url("https://sam-image.hided.net/icons/default.jpg")'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void it('resolves stored NPC message pictures like directory icons across scenarios', () => {
|
void it('resolves stored NPC message pictures like directory icons across scenarios', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user