feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+54 -13
View File
@@ -4,6 +4,8 @@ import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const gameBasePath = (process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/gu, '');
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${gameBasePath}:default`;
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
@@ -72,8 +74,8 @@ const generals = [
id: 10,
name: '조조',
ownerName: null,
picture: 'default.jpg',
imageServer: 0,
picture: '계정 icon.png',
imageServer: 1,
npcState: 0,
age: 35,
nationId: 1,
@@ -101,7 +103,7 @@ const generals = [
id: 20,
name: '유비',
ownerName: '통일유저',
picture: 'default.jpg',
picture: '장수/유비 1.png',
imageServer: 0,
npcState: 0,
age: 34,
@@ -145,10 +147,10 @@ const install = async (
accessPages: string[] = []
) => {
let generalDirectoryCalls = 0;
await page.addInitScript(() => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_directory');
localStorage.setItem('sammo-game-profile', 'che:default');
});
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route('**/image/general/**', (route) =>
route.fulfill({
status: 200,
@@ -156,10 +158,24 @@ const install = async (
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
})
);
await page.route('**/gateway/api/user-icons/**', (route) =>
route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
})
);
await page.route('**/image/icons/**', (route) =>
route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
})
);
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/che/api/trpc/**', async (route) => {
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
const requestBody = route.request().postDataJSON() as
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
@@ -202,12 +218,12 @@ const install = async (
};
const installAccessBoundary = async (page: Page, accessPages: string[]) => {
await page.addInitScript(() => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_access_boundary');
localStorage.setItem('sammo-game-profile', 'che:default');
});
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route('**/image/**', (route) => route.abort('failed'));
await page.route('**/che/api/trpc/**', async (route) => {
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
const requestBody = route.request().postDataJSON() as
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
@@ -315,6 +331,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');
expect(
await icon.evaluate((element) => {
const image = element as HTMLImageElement;
@@ -328,6 +345,11 @@ 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
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
.toBe(64);
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
if (artifactRoot) {
@@ -354,6 +376,23 @@ 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('a reused image element falls back for each newly broken account icon', async ({ page }) => {
await install(page);
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.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.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
});
test('a failed resort retains the selected value and existing rows', async ({ page }) => {
await install(page, 'error-after-load');
await page.goto('general-list');
@@ -368,7 +407,7 @@ test('an authenticated account without a general is redirected away from both di
await install(page, 'no-general');
for (const path of ['nation-list', 'general-list']) {
await page.goto(path);
await expect(page).toHaveURL(/\/che\/join$/);
await expect(page).toHaveURL(new RegExp(`/${gameBasePath}/join$`, 'u'));
}
});
@@ -388,7 +427,9 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page
for (const [path, pageName] of retained) {
const before = accessPages.length;
await page.goto(path);
await expect(page.locator('#app')).toHaveAttribute('data-v-app', '');
await expect.poll(() => accessPages.length).toBe(before + 1);
await page.waitForLoadState('networkidle');
expect(accessPages.at(-1)).toBe(pageName);
}
@@ -415,7 +456,7 @@ test('route access belongs only to the eight Ref page boundaries', async ({ page
for (const path of endpointOwned) {
const before = accessPages.length;
await page.goto(path);
await page.waitForTimeout(50);
await page.waitForLoadState('networkidle');
expect(accessPages).toHaveLength(before);
}
});
@@ -220,7 +220,7 @@ test.describe('scenario 903 live selection pool', () => {
).toBe(true);
expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true);
const fallbackImages = page.locator(
'.card-holder .portrait img[data-fallback-applied="true"]'
'.card-holder .portrait img[data-general-icon-fallback-source]'
);
await expect(fallbackImages).not.toHaveCount(0);
expect(assetTracker.userIconRequests).toBe(await fallbackImages.count());
@@ -389,7 +389,7 @@ test.describe('scenario 903 live selection pool', () => {
const candidateCards = page.locator('.card-holder > .general-card');
const fallbackCard = candidateCards
.filter({ has: page.locator('img[data-fallback-applied="true"]') })
.filter({ has: page.locator('img[data-general-icon-fallback-source]') })
.first();
await expect(fallbackCard).toBeVisible();
const initialName = await fallbackCard.locator('h4').first().textContent();
@@ -397,7 +397,7 @@ test.describe('scenario 903 live selection pool', () => {
await fallbackCard.locator('.select-button').click();
await expect(page.locator('.selected-card')).toHaveCount(1);
await expect(
page.locator('.selected-card img[data-fallback-applied="true"]')
page.locator('.selected-card img[data-general-icon-fallback-source]')
).toBeVisible();
expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview);
await page.locator('.custom-form select').selectOption('che_안전');