feat: 오픈 게임 취소와 유산 정산 경로 추가
별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
|
||||
const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_archive');
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
@@ -22,14 +22,17 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean }
|
||||
seasons: [
|
||||
{
|
||||
sourceProfile: 'che',
|
||||
source: 'legacy',
|
||||
source: options.abandoned ? 'current' : 'legacy',
|
||||
serverId: 'che_2024_01',
|
||||
openedAt: '2024-01-31T00:00:00.000Z',
|
||||
date: '2024-01-31T00:00:00.000Z',
|
||||
season: 51,
|
||||
season: options.abandoned ? null : 51,
|
||||
scenario: 2,
|
||||
scenarioName: '천하쟁패',
|
||||
dynastyId: 7,
|
||||
status: options.abandoned ? 'ABANDONED' : 'LEGACY',
|
||||
cancellationId: options.abandoned ? '12345678-full-id' : null,
|
||||
cancelledAt: options.abandoned ? '2024-02-01T00:00:00.000Z' : null,
|
||||
dynastyId: options.abandoned ? null : 7,
|
||||
generals: [
|
||||
{
|
||||
generalNo: 17,
|
||||
@@ -153,6 +156,45 @@ test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가
|
||||
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
|
||||
});
|
||||
|
||||
test('취소 게임은 정식 기수 번호와 왕조 링크 없이 별도 기록으로 표시된다', async ({ page }, testInfo) => {
|
||||
await installArchive(page, { abandoned: true });
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('past-plays');
|
||||
|
||||
const seasonCard = page.locator('.season-card');
|
||||
await expect(seasonCard.getByText('취소 게임', { exact: true })).toBeVisible();
|
||||
await expect(seasonCard.getByText('취소 ID 12345678')).toBeVisible();
|
||||
await expect(seasonCard).toContainText('천하쟁패');
|
||||
await expect(seasonCard).not.toContainText('51기');
|
||||
await expect(seasonCard.getByRole('link', { name: '이 기수 국가 정보' })).toHaveCount(0);
|
||||
const desktop = await seasonCard.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const heading = element.querySelector('.season-heading')!.getBoundingClientRect();
|
||||
return { x: rect.x, width: rect.width, headingHeight: heading.height };
|
||||
});
|
||||
expect(desktop).toMatchObject({ x: 100, width: 1000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobile = await seasonCard.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(mobile.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobile.x + mobile.width).toBeLessThanOrEqual(mobile.viewportWidth);
|
||||
expect(mobile.documentScrollWidth).toBeLessThanOrEqual(mobile.viewportWidth);
|
||||
await writeFile(
|
||||
testInfo.outputPath('abandoned-past-play-metrics.json'),
|
||||
JSON.stringify({ desktop, mobile }, null, 2)
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -20,6 +20,9 @@ type ArchiveSeason = Archive['seasons'][number] & {
|
||||
source?: string;
|
||||
openedAt?: string | null;
|
||||
date?: string | null;
|
||||
status?: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY';
|
||||
cancellationId?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
};
|
||||
|
||||
type ArchiveGeneral = {
|
||||
@@ -129,6 +132,12 @@ const formatOpenedAt = (season: ArchiveSeason): string => {
|
||||
if (Number.isNaN(date.getTime())) return '개장일 미상';
|
||||
return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
|
||||
};
|
||||
const archiveLabel = (season: ArchiveSeason): string =>
|
||||
season.status === 'ABANDONED' ? '취소 게임' : '이전 서버 기록';
|
||||
const archiveIdentifier = (season: ArchiveSeason): string =>
|
||||
season.status === 'ABANDONED' && season.cancellationId
|
||||
? `취소 ID ${season.cancellationId.slice(0, 8)}`
|
||||
: season.serverId;
|
||||
|
||||
const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
|
||||
const key = detailKey(season, generalNo);
|
||||
@@ -197,7 +206,7 @@ onMounted(() => {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<p class="page-note">이전 서버에서 종료된 기수에 보관된 내 장수 기록입니다.</p>
|
||||
<p class="page-note">종료된 기수와 관리자가 보존한 취소 게임의 내 장수 기록입니다.</p>
|
||||
<p v-if="error" class="error-row">{{ error }}</p>
|
||||
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
|
||||
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
|
||||
@@ -205,15 +214,19 @@ onMounted(() => {
|
||||
<section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
|
||||
<div class="season-heading legacy-bg2">
|
||||
<div class="season-identity">
|
||||
<strong class="archive-label">이전 서버 기록</strong>
|
||||
<strong class="archive-label" :class="{ abandoned: season.status === 'ABANDONED' }">
|
||||
{{ archiveLabel(season) }}
|
||||
</strong>
|
||||
<strong>{{ seasonSourceProfile(season) }}</strong>
|
||||
<span>{{ season.serverId }}</span>
|
||||
<span>{{ archiveIdentifier(season) }}</span>
|
||||
</div>
|
||||
<div class="season-meta">
|
||||
<span>{{ formatOpenedAt(season) }}</span>
|
||||
<span>
|
||||
{{ season.scenarioName ?? '시나리오 미상' }}
|
||||
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
||||
<template v-if="season.status !== 'ABANDONED' && season.season !== null">
|
||||
· {{ season.season }}기
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,6 +438,11 @@ onMounted(() => {
|
||||
background: #00582c;
|
||||
}
|
||||
|
||||
.archive-label.abandoned {
|
||||
border-color: #956f38;
|
||||
background: #66471f;
|
||||
}
|
||||
|
||||
.season-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user