Merge remote-tracking branch 'origin/main' into fix/command-editor-followup-20260812

This commit is contained in:
2026-08-12 15:46:46 +00:00
5 changed files with 208 additions and 11 deletions
+20 -5
View File
@@ -35,6 +35,7 @@ const history = ref<HistoryData | null>(null);
const selectedYearMonth = ref<number | null>(null);
const settingsOpen = ref(false);
const rankingBottom = ref(localStorage.getItem('yearbook-ranking-bottom') === 'true');
let historyRequestId = 0;
const serverID = computed(() => {
const value = route.query.serverID;
const raw = Array.isArray(value) ? value[0] : value;
@@ -71,19 +72,27 @@ const loadHistory = async (): Promise<void> => {
if (selectedYearMonth.value === null) {
return;
}
const requestId = ++historyRequestId;
loading.value = true;
errorMessage.value = '';
try {
const { year, month } = parseYearMonth(selectedYearMonth.value);
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
if (requestId !== historyRequestId) {
return;
}
if ('data' in result) {
history.value = result.data;
}
} catch (error) {
history.value = null;
if (requestId !== historyRequestId) {
return;
}
errorMessage.value = error instanceof Error ? error.message : '연감 데이터를 불러오지 못했습니다.';
} finally {
loading.value = false;
if (requestId === historyRequestId) {
loading.value = false;
}
}
};
@@ -129,7 +138,13 @@ onMounted(async () => {
<strong> </strong>
<button class="legacy-button close-button" type="button" @click="closePage"> 닫기</button>
<span class="settings-menu">
<button class="legacy-button legacy-button--navigation" type="button" @click="settingsOpen = !settingsOpen"> 설정</button>
<button
class="legacy-button legacy-button--navigation"
type="button"
@click="settingsOpen = !settingsOpen"
>
설정
</button>
<button v-if="settingsOpen" class="settings-item" type="button" @click="toggleRankingPosition">
국가 순서 위치 변경(모바일 전용)
</button>
@@ -164,9 +179,9 @@ onMounted(async () => {
<div v-if="errorMessage" class="yearbook-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !history" class="yearbook-message">불러오는 중...</div>
<section v-if="history" :class="['history-grid', { 'ranking-bottom': rankingBottom }]">
<section v-if="history" :class="['history-grid', { 'ranking-bottom': rankingBottom }]" :aria-busy="loading">
<div class="map-position">
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading" />
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading && !history" />
</div>
<aside class="nation-position">
<table>
@@ -28,7 +28,16 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
type LobbyFixtureOptions = {
authenticated?: boolean;
roles?: string[];
kakaoVerified?: boolean;
canCreateGeneral?: boolean;
requiresKakaoVerification?: boolean;
specialAccess?: {
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
myGeneral?: {
name: string;
picture: string;
@@ -48,7 +57,11 @@ type LobbyFixtureOptions = {
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
const {
authenticated = true,
roles = ['user'],
kakaoVerified = true,
canCreateGeneral = true,
requiresKakaoVerification = false,
specialAccess = null,
myGeneral = {
name: '선택장수',
picture: 'users/core2026/account-hash.png',
@@ -79,8 +92,8 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
id: 'lobby-user',
username: 'lobby-user',
displayName: '로비사용자',
roles: ['user'],
kakaoVerified: true,
roles,
kakaoVerified,
createdAt: '2026-07-30T00:00:00.000Z',
}
: null
@@ -109,8 +122,9 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
localAccountPolicy: {
accessAllowed: true,
canCreateGeneral,
requiresKakaoVerification: false,
requiresKakaoVerification,
graceEndsAt: null,
specialAccess,
},
},
]);
@@ -216,6 +230,51 @@ test('applies the signed general-acquisition policy to both create and possessio
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
});
test('shows the Kakao verification banner when a profile still requires verification', async ({ page }) => {
await installFixture(page, {
kakaoVerified: false,
requiresKakaoVerification: true,
});
await page.goto('lobby');
await expect(page.getByText('카카오 인증이 필요합니다.')).toBeVisible();
});
test('hides the Kakao verification banner for operator special access', async ({ page }) => {
await installFixture(page, {
roles: ['superuser'],
kakaoVerified: false,
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · OPERATOR')).toBeVisible();
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
test('hides the Kakao verification banner when a grant removes the remaining verification requirement', async ({
page,
}) => {
await installFixture(page, {
kakaoVerified: false,
specialAccess: {
kind: 'RECOVERY',
grantId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-20T00:00:00.000Z',
allowsGeneralCreation: true,
},
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · RECOVERY')).toBeVisible();
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
await installFixture(page);
await page.route('**/hwe/', async (route) => {
+6 -1
View File
@@ -46,7 +46,12 @@ const canAccessAdmin = computed(
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
) ?? false
);
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
const needsKakaoVerification = computed(
() =>
me.value !== null &&
!me.value.kakaoVerified &&
profiles.value.some((profile) => profile.localAccountPolicy?.requiresKakaoVerification === true)
);
const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
const publicMapProfiles = computed(() =>
@@ -232,8 +232,24 @@ export const canonicalFrontendFixture = {
],
},
nations: [
{ id: 1, name: '한', color: '#d32f2f', level: 5, power: 1250, cities: ['낙양', '업'] },
{ id: 2, name: '진', color: '#1976d2', level: 4, power: 980, cities: ['장안'] },
{
id: 1,
name: '한',
color: '#d32f2f',
level: 5,
power: 1250,
generalCount: 12,
cities: ['낙양', '업'],
},
{
id: 2,
name: '진',
color: '#1976d2',
level: 4,
power: 980,
generalCount: 8,
cities: ['장안'],
},
],
globalHistory: ['<C>●</> 한이 낙양을 지키고 있습니다.'],
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
@@ -971,6 +971,108 @@ test.describe('yearbook legacy parity', () => {
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByLabel('연월 선택')).toBeVisible();
});
test('keeps the rendered yearbook in place while moving between months', async ({ page }) => {
await page.goto(gameUrl('/yearbook'));
await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible();
let releaseHistory: (() => void) | undefined;
const historyReleased = new Promise<void>((resolve) => {
releaseHistory = resolve;
});
let markHistoryRequested: (() => void) | undefined;
const historyRequested = new Promise<void>((resolve) => {
markHistoryRequested = resolve;
});
await page.route('**/che/api/trpc/**', async (route) => {
if (!operationNames(route).includes('yearbook.getHistory')) {
await route.fallback();
return;
}
markHistoryRequested?.();
await historyReleased;
await fulfillOperations(route, () => ({
notModified: false,
hash: 'yearbook-previous-month-hash',
data: {
...fixture.game.yearbook.data,
year: 197,
month: 6,
map: {
...fixture.game.yearbook.data.map,
year: 197,
month: 6,
},
nations: fixture.game.yearbook.data.nations.map((nation, index) => ({
...nation,
generalCount: index === 0 ? 12 : 8,
})),
globalHistory: ['<C>●</> 이전 달의 중원 정세입니다.'],
globalAction: ['<L>●</> 이전 달의 장수 동향입니다.'],
},
}));
});
await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>('.history-grid')!;
const transition = {
grid,
mapBody: document.querySelector<HTMLElement>('.map-body')!,
nationBody: document.querySelector<HTMLElement>('.nation-position tbody')!,
removedNodes: 0,
};
new MutationObserver((records) => {
transition.removedNodes += records.reduce((count, record) => count + record.removedNodes.length, 0);
}).observe(grid, { childList: true, subtree: true });
(window as unknown as { __yearbookTransition: typeof transition }).__yearbookTransition = transition;
});
await page.getByRole('button', { name: '◀ 이전달' }).click();
await historyRequested;
await expect(page.locator('.history-grid')).toHaveAttribute('aria-busy', 'true');
await expect(page.locator('.map-body')).toHaveCount(1);
await expect(page.locator('.map-viewer .skeleton-lines')).toHaveCount(0);
await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible();
expect(
await page.evaluate(() => {
const transition = (
window as unknown as {
__yearbookTransition: {
grid: Element;
mapBody: Element;
nationBody: Element;
removedNodes: number;
};
}
).__yearbookTransition;
return {
gridIsSame: document.querySelector('.history-grid') === transition.grid,
mapIsSame: document.querySelector('.map-body') === transition.mapBody,
nationIsSame: document.querySelector('.nation-position tbody') === transition.nationBody,
removedNodes: transition.removedNodes,
};
})
).toEqual({ gridIsSame: true, mapIsSame: true, nationIsSame: true, removedNodes: 0 });
releaseHistory?.();
await expect(page.getByText('이전 달의 중원 정세입니다.')).toBeVisible();
await expect(page.locator('.history-grid')).toHaveAttribute('aria-busy', 'false');
expect(
await page.evaluate(() => {
const transition = (
window as unknown as {
__yearbookTransition: { grid: Element; mapBody: Element; nationBody: Element };
}
).__yearbookTransition;
return {
gridIsSame: document.querySelector('.history-grid') === transition.grid,
mapIsSame: document.querySelector('.map-body') === transition.mapBody,
nationIsSame: document.querySelector('.nation-position tbody') === transition.nationBody,
};
})
).toEqual({ gridIsSame: true, mapIsSame: true, nationIsSame: true });
});
});
test.describe('survey legacy parity', () => {