feat(game-ui): add action feedback layer

This commit is contained in:
2026-08-12 15:56:58 +00:00
parent 5e80a940d4
commit 5d4d5009ee
15 changed files with 671 additions and 50 deletions
+38 -9
View File
@@ -392,7 +392,7 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
});
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }) => {
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => {
const state: BoardFixture = {
permission: 2,
canMeeting: true,
@@ -407,25 +407,54 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.locator('#board-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('실패했습니다. :접속 제한입니다.');
await dialog.accept();
});
await page.locator('#submitArticle').click();
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
await expect(articleToast).toHaveAttribute('role', 'alert');
await expect(page.locator('#board-title')).toHaveValue('새 제목');
await expect(page.locator('#board-content')).toHaveValue('새 내용');
const desktopToastGeometry = await articleToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth };
});
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
expect(desktopToastGeometry.top).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.width).toBeGreaterThan(250);
await page.screenshot({ path: testInfo.outputPath('game-toast-desktop.png'), fullPage: true });
await articleToast.getByRole('button', { name: '알림 닫기' }).click();
await expect(articleToast).toHaveCount(0);
await page.locator('#submitArticle').click();
await expect(page.getByText('새 제목', { exact: true })).toBeVisible();
await expect(page.locator('#board-title')).toHaveValue('');
const commentInput = page.locator('.comment-input').first();
await commentInput.fill('새 댓글');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('실패했습니다: 접속 제한입니다.');
await dialog.accept();
});
await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
await commentInput.press('Enter');
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
await expect(commentToast).toBeVisible();
await expect
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
.toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
right: window.innerWidth - rect.right,
bottom: window.innerHeight - rect.bottom,
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.documentWidth).toBe(documentWidthBeforeToast);
await page.screenshot({ path: testInfo.outputPath('game-toast-mobile.png') });
await expect(commentInput).toHaveValue('새 댓글');
await commentInput.press('Enter');
+4 -10
View File
@@ -838,9 +838,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
await expect
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
.toBe(true);
await expect(page.getByTestId('game-toast')).toContainText('요청 처리 결과를 확인하지 못했습니다.');
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
@@ -948,13 +946,9 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time
const timeoutReload = page.waitForEvent('framenavigated', (frame) => frame === page.mainFrame());
await deleteButton.click();
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1);
await expect
.poll(() =>
dialogs.some((message) =>
message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.')
)
)
.toBe(true);
const failureDialog = page.getByRole('alertdialog', { name: '장수 삭제 실패' });
await expect(failureDialog).toContainText('요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.');
await failureDialog.getByRole('button', { name: '확인' }).click();
await timeoutReload;
await page.waitForLoadState('networkidle');
await expect(deleteButton).toBeVisible();
+32 -2
View File
@@ -106,6 +106,13 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
});
});
await page.route('https://sam-image.hided.net/icons/**', async (route) => {
await 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('**/gateway/api/user-icons/default.jpg', async (route) => {
await route.fulfill({
status: 200,
@@ -265,7 +272,10 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
await tooltip.focus();
await expect(tooltip).toBeFocused();
await expect(tooltipPopup).toHaveText('안전을 중시합니다.');
await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg');
await expect(page.locator('.npc-card-image').first()).toHaveAttribute(
'src',
'https://sam-image.hided.net/icons/default.jpg'
);
await page.locator('#btn-load-general-list').click();
await expect(page.locator('#tb-general-list')).toBeVisible();
@@ -347,11 +357,31 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
await expect(page.locator('.npc-token-expired')).toBeVisible();
await expect(possessButton).toBeEnabled();
await expect(refreshButton).toBeDisabled();
await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeDialog = await page.evaluate(() => document.documentElement.scrollWidth);
await page.locator('#btn-retry-possession').click();
const successDialog = page.getByRole('alertdialog', { name: '완료' });
await expect(successDialog).toContainText('빙의에 성공했습니다.');
await expect(successDialog.getByRole('button', { name: '확인' })).toBeFocused();
const dialogGeometry = await successDialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
right: window.innerWidth - rect.right,
bottom: window.innerHeight - rect.bottom,
width: rect.width,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(dialogGeometry.left).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.right).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.bottom).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.documentWidth).toBe(documentWidthBeforeDialog);
await page.screenshot({ path: testInfo.outputPath('game-notice-dialog.png') });
await successDialog.getByRole('button', { name: '확인' }).click();
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
expect(state.possessInputs).toHaveLength(2);
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
expect(dialogs).toContain('빙의에 성공했습니다.');
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
await page.screenshot({
@@ -216,13 +216,15 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C
await startDaemon();
await possessButton.click();
const successDialog = page.getByRole('alertdialog', { name: '완료' });
await expect(successDialog).toContainText('빙의에 성공했습니다.');
await successDialog.getByRole('button', { name: '확인' }).click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(requestIds).toHaveLength(2);
expect(requestIds[1]).toBe(requestIds[0]);
expect(await db.general.count({ where: { userId } })).toBe(1);
await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.toBeNull();
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
expect(dialogs).toContain('빙의에 성공했습니다.');
const event = await db.inputEvent.findUniqueOrThrow({
where: { requestId: eventRequestId },
});
@@ -406,8 +406,8 @@ test.describe('scenario 903 live selection pool', () => {
await expect(page.locator('.selected-card')).toHaveCount(1);
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect.poll(() => dialogs).toContain(
'실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.'
await expect(page.getByTestId('game-toast')).toContainText(
'장수 생성에 실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.'
);
await waitForPool(page);
const retryCard = page
@@ -417,9 +417,11 @@ test.describe('scenario 903 live selection pool', () => {
await retryCard.locator('.select-button').click();
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
const createDialog = page.getByRole('alertdialog', { name: '완료' });
await expect(createDialog).toContainText('선택한 장수로 생성했습니다.');
await createDialog.getByRole('button', { name: '확인' }).click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2);
await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.');
expect(createClientRequestIds).toHaveLength(2);
expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]);
@@ -452,7 +454,9 @@ test.describe('scenario 903 live selection pool', () => {
dialogs.length = 0;
await page.goto('select-general');
await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다');
await expect(page.getByTestId('game-toast')).toContainText(
'장수 선택 정보를 불러오지 못했습니다: 아직 다시 고를 수 없습니다'
);
await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다');
const availableAt = '2026-07-29T00:00:00.000Z';
@@ -495,9 +499,11 @@ test.describe('scenario 903 live selection pool', () => {
expect(targetIndex).toBeGreaterThanOrEqual(0);
const targetName = names[targetIndex]!.trim();
await cards.nth(targetIndex).locator('.select-button').click();
const reselectDialog = page.getByRole('alertdialog', { name: '완료' });
await expect(reselectDialog).toContainText('선택한 장수로 변경했습니다.');
await reselectDialog.getByRole('button', { name: '확인' }).click();
await expect(page).toHaveURL(/\/hwe\/$/);
await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`);
await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.');
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)