merge: 토너먼트 단계별 노출을 main에 통합한다

This commit is contained in:
2026-08-21 15:55:30 +00:00
4 changed files with 292 additions and 152 deletions
@@ -126,7 +126,15 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page, options: { applicationOpen?: boolean; tournamentType?: number } = {}) => {
const installFixture = async (
page: Page,
options: {
applicationOpen?: boolean;
tournamentType?: number;
tournamentStage?: number;
joinedGroupId?: number;
} = {}
) => {
let joined = false;
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
@@ -148,9 +156,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
if (operation === 'tournament.getSnapshot') {
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
const joinedGroupId = options.joinedGroupId ?? 0;
return response({
state: {
stage: options.applicationOpen ? 1 : 0,
stage: tournamentStage,
phase: 0,
type: options.tournamentType ?? 0,
auto: false,
@@ -158,7 +168,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
openMonth: 1,
termSeconds: 60,
nextAt: '2026-08-02T00:00:00.000Z',
winnerId: 1,
winnerId: tournamentStage === 0 ? 1 : undefined,
},
participants:
options.applicationOpen && !joined
@@ -167,8 +177,10 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
? [
{
...participants[0],
groupId: 0,
groupId: joinedGroupId,
groupNo: 0,
preliminaryGroupId: joinedGroupId,
preliminaryGroupNo: 0,
win: 0,
draw: 0,
lose: 0,
@@ -322,20 +334,36 @@ test('desktop bracket connects every real general slot to the next round', async
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
});
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => {
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { applicationOpen: true });
await installFixture(page, { applicationOpen: true, joinedGroupId: 5 });
await page.goto('tournament');
const refresh = page.getByRole('button', { name: '갱신' });
const join = page.getByRole('button', { name: '참가' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
await expect(join).toBeEnabled();
await expect(page.getByText('조별 예선 순위')).toBeVisible();
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.');
await expect(join).toBeDisabled();
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' });
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
const assignedGroup = page.locator('[data-preliminary-group="5"]');
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
const assignedGroupBounds = await assignedGroup.boundingBox();
expect(assignedGroupBounds?.y).toBeLessThan(844);
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
await persistScreenshot(
page,
'tournament-joined-group-mobile',
testInfo.outputPath('tournament-joined-group.webp')
);
for (const control of [refresh, join, close]) {
const box = await control.boundingBox();
@@ -349,6 +377,41 @@ test('join refresh shows the assigned preliminary group immediately with accessi
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('desktop join scrolls the assigned preliminary group into view without future sections', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page, { applicationOpen: true, joinedGroupId: 7 });
await page.goto('tournament');
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await page.getByRole('button', { name: '참가' }).click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.');
const assignedGroup = page.locator('[data-preliminary-group="7"]');
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
const bounds = await assignedGroup.boundingBox();
expect(bounds?.y).toBeLessThan(900);
expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0);
await persistScreenshot(
page,
'tournament-joined-group-desktop',
testInfo.outputPath('tournament-joined-group.webp')
);
});
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentStage: 3 });
await page.goto('tournament');
await expect(page.getByText('조별 예선 순위')).toBeVisible();
await expect(page.getByText('조별 본선 순위')).toBeVisible();
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
});
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page,
}, testInfo) => {
@@ -13,3 +13,18 @@ export const tournamentStageNames = [
] as const;
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
export interface TournamentSectionVisibility {
preliminary: boolean;
final: boolean;
knockout: boolean;
}
export const resolveTournamentSectionVisibility = (stage: number, winnerId?: number): TournamentSectionVisibility => {
const completed = stage === 0 && winnerId !== undefined;
return {
preliminary: stage >= 1 || completed,
final: stage >= 3 || completed,
knockout: stage >= 5 || completed,
};
};
+177 -143
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -18,6 +18,7 @@ const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const tournamentContainer = ref<HTMLElement | null>(null);
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const typeStatNames = ['종합', '통솔', '무력', '지력'];
@@ -63,6 +64,12 @@ const myBetTotals = computed(() => betting.value?.myTotals as Record<number, num
const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
);
const sectionVisibility = computed(() =>
resolveTournamentSectionVisibility(snapshot.value?.state?.stage ?? 0, snapshot.value?.state?.winnerId)
);
const preliminaryGroupIdOf = (participant: Snapshot['participants'][number]): number | undefined =>
participant.preliminaryGroupId ??
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
const groups = computed(() =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.participants ?? [])
@@ -74,10 +81,7 @@ const preliminaryGroups = computed(() =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.participants ?? [])
.filter((participant) => {
const groupId =
participant.preliminaryGroupId ??
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
return groupId === index;
return preliminaryGroupIdOf(participant) === index;
})
.map((participant) => ({
...participant,
@@ -113,15 +117,38 @@ const currentMatch = computed(() => {
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
});
const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
const participant = (snapshot.value?.participants ?? []).find((entry) => entry.id === myGeneralId.value);
if (!participant) return undefined;
const groupId = preliminaryGroupIdOf(participant);
if (groupId === undefined || groupId < 0 || groupId >= groupNames.length) return undefined;
activePreliminaryGroup.value = groupId;
await nextTick();
tournamentContainer.value
?.querySelector<HTMLElement>(`[data-preliminary-group="${groupId}"]`)
?.scrollIntoView({ block: 'center' });
return groupId;
};
const join = async () => {
actionMessage.value = null;
let joined = false;
try {
await trpc.tournament.join.mutate();
actionMessage.value = '참가 신청이 반영되었습니다.';
joined = true;
} catch (value) {
actionMessage.value = errorText(value);
} finally {
await load();
if (joined) {
const groupId = await revealMyPreliminaryGroup();
actionMessage.value =
groupId === undefined
? '참가 신청이 반영되었습니다.'
: `참가 신청이 반영되었습니다. ${groupNames[groupId]}조에 배정되었습니다.`;
}
}
};
@@ -159,7 +186,7 @@ const start = async () => {
</script>
<template>
<main id="tournament-container" class="legacy-page">
<main id="tournament-container" ref="tournamentContainer" class="legacy-page">
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
<section class="toolbar bg0">
@@ -183,145 +210,152 @@ const start = async () => {
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">16 승자전</section>
<template v-if="sectionVisibility.knockout">
<section class="section-title bg2">16 승자전</section>
<TournamentBracket
class="bg0"
:participants="snapshot?.participants ?? []"
:matches="snapshot?.matches ?? []"
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:my-bet-totals="myBetTotals"
:total-bet="totalBet"
:tournament-type="snapshot?.state?.type ?? 0"
/>
<TournamentBracket
class="bg0"
:participants="snapshot?.participants ?? []"
:matches="snapshot?.matches ?? []"
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:my-bet-totals="myBetTotals"
:total-bet="totalBet"
:tournament-type="snapshot?.state?.type ?? 0"
/>
<section v-if="currentMatch" class="fight bg0">
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
</section>
<section v-if="currentMatch" class="fight bg0">
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
</section>
</template>
<section class="section-title groups-title bg2">조별 본선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="본선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`final-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activeFinalGroup === groupIndex"
:class="{ active: activeFinalGroup === groupIndex }"
@click="activeFinalGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid bg0">
<table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
</section>
<template v-if="sectionVisibility.final">
<section class="section-title groups-title bg2">조별 본선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="본선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`final-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activeFinalGroup === groupIndex"
:class="{ active: activeFinalGroup === groupIndex }"
@click="activeFinalGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid bg0">
<table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
</section>
</template>
<section class="section-title groups-title bg2">조별 예선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="예선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`preliminary-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activePreliminaryGroup === groupIndex"
:class="{ active: activePreliminaryGroup === groupIndex }"
@click="activePreliminaryGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid preliminary-grid bg0">
<table
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
</section>
<template v-if="sectionVisibility.preliminary">
<section class="section-title groups-title bg2">조별 예선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="예선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`preliminary-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activePreliminaryGroup === groupIndex"
:class="{ active: activePreliminaryGroup === groupIndex }"
@click="activePreliminaryGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid preliminary-grid bg0">
<table
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:data-preliminary-group="groupIndex"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption>
{{
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
</section>
</template>
<div class="legacy-bracket-table-signature" hidden>
<table v-for="(rowCount, tableIndex) in [11, 11, 11, 10]" :key="tableIndex">
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
void describe('tournament status labels', () => {
void it('describes inactive and active tournament stages', () => {
@@ -14,4 +14,32 @@ void describe('tournament status labels', () => {
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
});
void it('reveals tournament sections only after their stage begins and retains completed results', () => {
assert.deepEqual(resolveTournamentSectionVisibility(0), {
preliminary: false,
final: false,
knockout: false,
});
assert.deepEqual(resolveTournamentSectionVisibility(1), {
preliminary: true,
final: false,
knockout: false,
});
assert.deepEqual(resolveTournamentSectionVisibility(3), {
preliminary: true,
final: true,
knockout: false,
});
assert.deepEqual(resolveTournamentSectionVisibility(5), {
preliminary: true,
final: true,
knockout: true,
});
assert.deepEqual(resolveTournamentSectionVisibility(0, 7), {
preliminary: true,
final: true,
knockout: true,
});
});
});