fix(frontend): add main execution status

This commit is contained in:
2026-08-13 17:30:18 +00:00
parent d37a8191c4
commit 838c240984
4 changed files with 58 additions and 24 deletions
+24 -11
View File
@@ -35,6 +35,7 @@ type NavigationFixture = {
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
scenarioTitle?: string; scenarioTitle?: string;
lastExecuted?: string | null;
latestVote?: { id: number; title: string; hasVoted: boolean } | null; latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>; globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: Array<{ id: number; text: string }>; generalRecords?: Array<{ id: number; text: string }>;
@@ -423,7 +424,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineNations: '위(1)', onlineNations: '위(1)',
onlineGenerals: '메뉴검증장수', onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>', nationNotice: '<p>국가 방침</p>',
lastExecuted: null, lastExecuted: state.lastExecuted === undefined ? '2026-08-13T00:05:06.000Z' : state.lastExecuted,
latestVote: latestVote:
state.latestVote === undefined state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false } ? { id: 9, title: '메뉴 설문', hasVoted: false }
@@ -567,6 +568,10 @@ const persistArtifact = async (page: Page, name: string) => {
globalPopup: describe('#mobile-global-menu'), globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'), nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-menu'), quickPopup: describe('#mobile-quick-menu'),
activityStatus: describe('.activity-status'),
executionStatus: describe('.execution-status'),
tournamentStatus: describe('.tournament-status'),
voteStatus: describe('.vote-status'),
commandMenu: describe('.reserved-command-editor details[open] .menu-items'), commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map( commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
(element) => { (element) => {
@@ -599,6 +604,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
stage: 1, stage: 1,
npcMode: 1, npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오', scenarioTitle: '메인 화면 검증 시나리오',
lastExecuted: '2026-08-13T00:05:06.000Z',
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
}; };
@@ -615,31 +621,35 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오'); await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문'); await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => { const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
const title = element.querySelector<HTMLElement>('.game-shell__title'); const title = element.querySelector<HTMLElement>('.game-shell__title');
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle'); const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
const activity = element.querySelector<HTMLElement>('.activity-status'); const activity = element.querySelector<HTMLElement>('.activity-status');
const execution = element.querySelector<HTMLElement>('.execution-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status'); const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status'); const survey = element.querySelector<HTMLElement>('.vote-status');
if (!title || !subtitle || !activity || !tournament || !survey) { if (!title || !subtitle || !activity || !execution || !tournament || !survey) {
throw new Error('main header status geometry is incomplete'); throw new Error('main header status geometry is incomplete');
} }
return { return {
title: title.getBoundingClientRect().toJSON(), title: title.getBoundingClientRect().toJSON(),
subtitle: subtitle.getBoundingClientRect().toJSON(), subtitle: subtitle.getBoundingClientRect().toJSON(),
activity: activity.getBoundingClientRect().toJSON(), activity: activity.getBoundingClientRect().toJSON(),
execution: execution.getBoundingClientRect().toJSON(),
tournament: tournament.getBoundingClientRect().toJSON(), tournament: tournament.getBoundingClientRect().toJSON(),
survey: survey.getBoundingClientRect().toJSON(), survey: survey.getBoundingClientRect().toJSON(),
activityColumns: getComputedStyle(activity).gridTemplateColumns, activityColumns: getComputedStyle(activity).gridTemplateColumns,
}; };
}); });
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom); expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0); expect(headerStatusGeometry.activity.width).toBeCloseTo(1000, 0);
expect(headerStatusGeometry.execution.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0); expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0); expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2); expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(3);
const tournamentStatusLink = page.locator('.tournament-status a'); const tournamentStatusLink = page.locator('.tournament-status a');
const surveyStatusLink = page.locator('.vote-status a'); const surveyStatusLink = page.locator('.vote-status a');
await tournamentStatusLink.hover(); await tournamentStatusLink.hover();
@@ -1217,6 +1227,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
stage: 6, stage: 6,
npcMode: 1, npcMode: 1,
scenarioTitle: '모바일 검증 시나리오', scenarioTitle: '모바일 검증 시나리오',
lastExecuted: null,
latestVote: null, latestVote: null,
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
@@ -1238,25 +1249,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 기록 없음');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음'); await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
const activityGeometry = await page.locator('.activity-status').evaluate((element) => { const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
const execution = element.querySelector<HTMLElement>('.execution-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status'); const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status'); const survey = element.querySelector<HTMLElement>('.vote-status');
if (!tournament || !survey) throw new Error('activity status is incomplete'); if (!execution || !tournament || !survey) throw new Error('activity status is incomplete');
return { return {
width: element.getBoundingClientRect().width, width: element.getBoundingClientRect().width,
executionWidth: execution.getBoundingClientRect().width,
tournamentWidth: tournament.getBoundingClientRect().width, tournamentWidth: tournament.getBoundingClientRect().width,
surveyWidth: survey.getBoundingClientRect().width, surveyWidth: survey.getBoundingClientRect().width,
columns: getComputedStyle(element).gridTemplateColumns, columns: getComputedStyle(element).gridTemplateColumns,
}; };
}); });
expect(activityGeometry).toMatchObject({ expect(activityGeometry.width).toBe(500);
width: 500, expect(activityGeometry.executionWidth).toBeCloseTo(166.67, 0);
tournamentWidth: 250, expect(activityGeometry.tournamentWidth).toBeCloseTo(166.67, 0);
surveyWidth: 250, expect(activityGeometry.surveyWidth).toBeCloseTo(166.67, 0);
columns: '250px 250px', expect(activityGeometry.columns.split(' ')).toHaveLength(3);
});
await expect await expect
.poll(() => .poll(() =>
page page
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed } from 'vue'; import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus'; import { resolveTournamentStageName } from '../../utils/tournamentStatus';
@@ -19,11 +20,17 @@ const props = defineProps<{
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const lastExecutedStatus = computed(() =>
formatServerDateTime(props.status?.lastExecuted, { format: 'monthDayTime', fallback: '기록 없음' })
);
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="activity-status" aria-label="설문 토너먼트 진행 현황"> <div class="activity-status" aria-label="동작 시각, 토너먼트와 설문 진행 현황">
<div class="status-row execution-status" :class="{ 'execution-status--empty': !status?.lastExecuted }">
동작 시각: {{ lastExecutedStatus }}
</div>
<div class="status-row tournament-status"> <div class="status-row tournament-status">
<RouterLink to="/tournament"> <RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }} <span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
@@ -86,9 +93,8 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
.activity-status { .activity-status {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
width: 66.666667%; width: 100%;
margin-left: auto;
} }
.activity-status .status-row { .activity-status .status-row {
@@ -106,6 +112,14 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
color: #ffc107; color: #ffc107;
} }
.execution-status {
color: cyan;
}
.execution-status--empty {
color: magenta;
}
.vote-label { .vote-label {
color: cyan; color: cyan;
} }
@@ -113,10 +127,4 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
.vote-empty { .vote-empty {
color: magenta; color: magenta;
} }
@media (max-width: 991px) {
.activity-status {
width: 100%;
}
}
</style> </style>
@@ -293,14 +293,16 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await expect(status).toContainText(marker); await expect(status).toContainText(marker);
await expect(page.locator('.online-users')).toContainText('현황검증장수'); await expect(page.locator('.online-users')).toContainText('현황검증장수');
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.'); await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
await expect(page.locator('.execution-status')).toContainText('동작 시각:');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문'); await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
const desktop = await status.evaluate((element) => { const desktop = await status.evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
const onlineRow = element.querySelector<HTMLElement>('.online-nations'); const onlineRow = element.querySelector<HTMLElement>('.online-nations');
const executionRow = element.querySelector<HTMLElement>('.execution-status');
const voteRow = element.querySelector<HTMLElement>('.vote-status'); const voteRow = element.querySelector<HTMLElement>('.vote-status');
const tournamentRow = element.querySelector<HTMLElement>('.tournament-status'); const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
if (!onlineRow || !voteRow || !tournamentRow) throw new Error('status row missing'); if (!onlineRow || !executionRow || !voteRow || !tournamentRow) throw new Error('status row missing');
return { return {
rect: element.getBoundingClientRect().toJSON(), rect: element.getBoundingClientRect().toJSON(),
fontSize: style.fontSize, fontSize: style.fontSize,
@@ -311,6 +313,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
borderTop: getComputedStyle(onlineRow).borderTop, borderTop: getComputedStyle(onlineRow).borderTop,
padding: getComputedStyle(onlineRow).padding, padding: getComputedStyle(onlineRow).padding,
}, },
executionRow: executionRow.getBoundingClientRect().toJSON(),
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(), tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
voteRow: voteRow.getBoundingClientRect().toJSON(), voteRow: voteRow.getBoundingClientRect().toJSON(),
}; };
@@ -325,6 +328,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
}, },
}); });
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0); expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
expect(desktop.executionRow.x).toBeCloseTo(0, 0);
expect(desktop.executionRow.width).toBeCloseTo(333.33, 0);
expect(desktop.executionRow.height).toBeCloseTo(36, 0);
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0); expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0); expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0); expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
@@ -335,8 +341,12 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(status).toHaveCSS('width', '500px'); await expect(status).toHaveCSS('width', '500px');
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px'); const mobileActivity = await page.locator('.activity-status').evaluate((element) => ({
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px'); columns: getComputedStyle(element).gridTemplateColumns,
widths: [...element.children].map((child) => child.getBoundingClientRect().width),
}));
expect(mobileActivity.columns.split(' ')).toHaveLength(3);
for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0);
failStatus = true; failStatus = true;
await page.getByRole('button', { name: '새로고침', exact: true }).click(); await page.getByRole('button', { name: '새로고침', exact: true }).click();
@@ -61,6 +61,9 @@ try {
}; };
return { return {
container: inspect('#container'), container: inspect('#container'),
gameInfo: inspect('.gameInfo'),
tournamentStatus: inspect('.subTournamentState'),
lastExecuted: inspect('.subLastExecuted'),
onlineNations: inspect('.onlineNations'), onlineNations: inspect('.onlineNations'),
onlineUsers: inspect('.onlineUsers'), onlineUsers: inspect('.onlineUsers'),
nationNotice: inspect('.nationNotice'), nationNotice: inspect('.nationNotice'),