diff --git a/app/game-frontend/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts index a1145086..80fa9fc1 100644 --- a/app/game-frontend/e2e/auction.spec.ts +++ b/app/game-frontend/e2e/auction.spec.ts @@ -299,12 +299,16 @@ test('resource auction preserves the legacy desktop structure, geometry, and int const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' }); await bidInput.fill('800'); await page.getByRole('button', { name: '입찰', exact: true }).click(); - await expect(page.getByRole('alert')).toContainText('금이 부족합니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText( + '금이 부족합니다.' + ); await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true }); expect(state.resourceBidCount).toBe(0); await page.getByRole('button', { name: '입찰', exact: true }).click(); - await expect(page.getByRole('status')).toContainText('입찰했습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '입찰했습니다.' + ); expect(state.resourceBidCount).toBe(1); await firstRow.hover(); @@ -370,7 +374,9 @@ test('unique auction separates ongoing and finished lists and auto-loads the leg await dialog.accept(); }); await page.getByRole('button', { name: '입찰', exact: true }).click(); - await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '입찰이 완료되었습니다.' + ); expect(state.uniqueBidCount).toBe(1); await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true }); }); diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index acf920c4..12716a2c 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -596,11 +596,15 @@ test('finance enforces edit permissions and preserves the old value across an AP const input = page.getByRole('spinbutton', { name: '세율' }); await input.fill('25'); await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); - await expect(page.getByRole('alert')).toContainText('세율을 변경할 수 없습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText( + '세율을 변경할 수 없습니다.' + ); await expect(input).toHaveValue('20'); await input.fill('25'); await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); - await expect(page.getByRole('status')).toContainText('세율을 변경했습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '세율을 변경했습니다.' + ); expect(state.rate).toBe(25); const readOnly = await page.context().newPage(); diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 97e0b2ab..2def11c0 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -282,7 +282,9 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf await goldInput.fill('12345'); page.once('dialog', (dialog) => dialog.accept()); await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); - await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + 'NPC 정책이 반영되었습니다.' + ); expect(state.mutations).toContain('npc.setNationPolicy'); await screenshot(page, 'core-npc-policy-desktop.png'); }); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 9fa44672..6f7f0940 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -372,7 +372,9 @@ test('join refresh shows the assigned preliminary group immediately with accessi await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); await join.click(); - await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '참가 신청이 반영되었습니다. 六조에 배정되었습니다.' + ); await expect(join).toBeDisabled(); const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' }); await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true'); @@ -413,7 +415,9 @@ test('desktop join scrolls the assigned preliminary group into view without futu await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); await page.getByRole('button', { name: '참가' }).click(); - await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '참가 신청이 반영되었습니다. 八조에 배정되었습니다.' + ); const assignedGroup = page.locator('[data-preliminary-group="7"]'); await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); const bounds = await assignedGroup.boundingBox(); @@ -612,7 +616,9 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n ); await dialog.getByRole('button', { name: '베팅 등록' }).click(); await expect(dialog).not.toBeVisible(); - await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '베팅이 등록되었습니다.' + ); expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]); await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible(); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index 583f52b4..9d05df00 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -387,12 +387,16 @@ test('shows API failure then creates a troop successfully', async ({ page }) => const input = page.getByRole('textbox', { name: '부대명' }); await input.fill('실패대'); await page.getByRole('button', { name: '부대 창설', exact: true }).click(); - await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText( + '부대 이름이 없습니다.' + ); expect(state.me.troopId).toBe(0); await input.fill('신규대'); await page.getByRole('button', { name: '부대 창설', exact: true }).click(); - await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '신규대 부대가 생성되었습니다.' + ); await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible(); await expect(input).toBeHidden(); }); @@ -410,13 +414,17 @@ test('renames and kicks through confirm dialogs, then refreshes state', async ({ await page.getByRole('button', { name: '부대명 변경...' }).first().click(); await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); await page.getByRole('button', { name: '변경', exact: true }).click(); - await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.'); + await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( + '부대명을 변경했습니다.' + ); await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible(); await page.getByRole('button', { name: '부대원 추방...' }).click(); await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3'); await page.getByRole('button', { name: '추방', exact: true }).click(); - await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.'); + await expect( + page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' }) + ).toContainText('조운을 추방했습니다.'); await expect(page.locator('.troopMembers').first()).not.toContainText('조운'); }); diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index ed31780c..45b7f56c 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'; import { formatLog } from '../utils/formatLog'; import { trpc } from '../utils/trpc'; +import { useGameFeedback } from '../composables/useGameFeedback'; type AuctionOverview = Awaited>; type ResourceAuction = AuctionOverview['resourceAuctions'][number]; @@ -16,12 +17,12 @@ const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'un const loading = ref(false); const actionBusy = ref(false); const error = ref(null); -const message = ref(null); const overview = ref(null); const selectedResource = ref(null); const selectedUnique = ref(null); const uniqueDetail = ref(null); const bidAmount = ref(0); +const { success: showSuccessToast, error: showErrorToast } = useGameFeedback(); const openForm = reactive({ type: 'BUY_RICE' as 'BUY_RICE' | 'SELL_RICE', @@ -111,12 +112,11 @@ const runAction = async (action: () => Promise): Promise => { } actionBusy.value = true; error.value = null; - message.value = null; try { await action(); await loadOverview(); } catch (err) { - error.value = resolveErrorMessage(err); + showErrorToast(resolveErrorMessage(err)); } finally { actionBusy.value = false; } @@ -134,7 +134,7 @@ const openResourceAuction = (): Promise => openForm.type === 'BUY_RICE' ? await trpc.auction.openBuyRice.mutate(input) : await trpc.auction.openSellRice.mutate(input); - message.value = `${result.auctionId}번 경매로 등록되었습니다.`; + showSuccessToast(`${result.auctionId}번 경매로 등록되었습니다.`); }); const bidResourceAuction = (): Promise => @@ -151,7 +151,7 @@ const bidResourceAuction = (): Promise => } else { await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value }); } - message.value = '입찰했습니다.'; + showSuccessToast('입찰했습니다.'); }); const bidUniqueAuction = (): Promise => @@ -172,14 +172,13 @@ const bidUniqueAuction = (): Promise => amount: bidAmount.value, tryExtendCloseDate: false, }); - message.value = '입찰이 완료되었습니다.'; + showSuccessToast('입찰이 완료되었습니다.'); }); const closeWindow = (): void => window.close(); watch(activeTab, (tab) => { error.value = null; - message.value = null; if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) { void selectUnique(ongoingUnique.value[0]); } @@ -230,7 +229,6 @@ onMounted(() => { -

{{ message }}

불러오는 중...
@@ -756,9 +754,6 @@ input:focus-visible { .auction-notice.error { background: #842029; } -.auction-notice.success { - background: #0f5132; -} .loading-state { min-height: 120px; display: grid; diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index 9207e61e..6d1a47fd 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -4,6 +4,7 @@ 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 { useGameFeedback } from '../composables/useGameFeedback'; import type { TournamentBracketSlot } from '../utils/tournamentBracket'; import { trpc } from '../utils/trpc'; @@ -14,7 +15,6 @@ const summary = ref>>([]); const loading = ref(false); const error = ref(null); -const message = ref(null); const amounts = ref>({}); const selectedTarget = ref(null); const betDialog = ref(null); @@ -22,6 +22,7 @@ const betAmountSelect = ref(null); const placingBet = ref(false); const betError = ref(null); const activeRankingPrefix = ref('tt'); +const { success: showSuccessToast } = useGameFeedback(); const typeNames = ['전력전', '통솔전', '일기토', '설전']; const stageNames = [ '경기 없음', @@ -109,17 +110,15 @@ const placeBet = async () => { const targetId = selectedTarget.value?.id; if (targetId === null || targetId === undefined || placingBet.value) return; const amount = selectedAmount.value; - message.value = null; betError.value = null; placingBet.value = true; try { await trpc.tournament.placeBet.mutate({ targetId, amount }); - message.value = '베팅이 등록되었습니다.'; + showSuccessToast('베팅이 등록되었습니다.'); await load(); closeBetDialog(); } catch (value) { betError.value = errorText(value); - message.value = betError.value; } finally { placingBet.value = false; } @@ -138,7 +137,6 @@ const placeBet = async () => { 갱신 불러오는 중... - {{ message }}
diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 63632d38..e5963ba8 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -3,6 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { computed, onMounted, reactive, ref } from 'vue'; import { trpc } from '../utils/trpc'; +import { useGameFeedback } from '../composables/useGameFeedback'; type InheritStatus = Awaited>; type InheritLog = Awaited>[number]; @@ -110,9 +111,8 @@ const logs = ref([]); const logLoading = ref(false); const logEnd = ref(false); -const actionError = ref(null); -const actionMessage = ref(null); const actionBusy = ref(false); +const { success: showSuccessToast, error: showErrorToast } = useGameFeedback(); const joinConfig = ref(null); @@ -262,7 +262,6 @@ const syncSelections = () => { const loadStatus = async () => { loading.value = true; error.value = null; - actionError.value = null; try { status.value = await trpc.inherit.getStatus.query(); syncSelections(); @@ -292,7 +291,7 @@ const loadLogs = async (reset = false) => { logs.value = reset ? result : [...logs.value, ...result]; logEnd.value = result.length < 30; } catch (err) { - actionError.value = resolveErrorMessage(err); + showErrorToast(resolveErrorMessage(err)); } finally { logLoading.value = false; } @@ -303,17 +302,15 @@ const runAction = async (action: () => Promise, message?: string) => { return; } actionBusy.value = true; - actionError.value = null; - actionMessage.value = null; try { await action(); if (message) { - actionMessage.value = message; + showSuccessToast(message); } await loadStatus(); await loadLogs(true); } catch (err) { - actionError.value = resolveErrorMessage(err); + showErrorToast(resolveErrorMessage(err)); } finally { actionBusy.value = false; } @@ -335,7 +332,7 @@ const buyHiddenBuff = async (key: BuffKey) => { const reserveSpecialWar = async () => { if (!nextSpecialKey.value) { - actionError.value = '다음 전투 특기를 선택해주세요.'; + showErrorToast('다음 전투 특기를 선택해주세요.'); return; } const name = specialNameMap.value.get(nextSpecialKey.value) ?? nextSpecialKey.value; @@ -371,7 +368,7 @@ const resetTurnTime = async () => { const resetStats = async () => { if (resetStatErrors.value.length > 0) { - actionError.value = resetStatErrors.value[0] ?? '입력값을 확인해주세요.'; + showErrorToast(resetStatErrors.value[0] ?? '입력값을 확인해주세요.'); return; } if (!window.confirm('능력치를 초기화하시겠습니까?')) { @@ -400,16 +397,16 @@ const buyRandomUnique = async () => { const openUniqueAuction = async () => { const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim()); if (!selectedItem) { - actionError.value = '유니크를 선택해주세요.'; + showErrorToast('유니크를 선택해주세요.'); return; } const amount = Math.max(0, Math.floor(uniqueForm.amount)); if (amount <= 0) { - actionError.value = '입찰 포인트를 입력해주세요.'; + showErrorToast('입찰 포인트를 입력해주세요.'); return; } if (previousPoint.value < amount) { - actionError.value = '유산 포인트가 부족합니다.'; + showErrorToast('유산 포인트가 부족합니다.'); return; } const itemJosa = JosaUtil.pick(selectedItem.rawName, '을'); @@ -427,7 +424,7 @@ const openUniqueAuction = async () => { const checkOwner = async () => { const targetId = Number(ownerTargetId.value); if (!targetId) { - actionError.value = '확인할 장수를 선택해주세요.'; + showErrorToast('확인할 장수를 선택해주세요.'); return; } const cost = status.value?.inheritConst.inheritCheckOwnerPoint ?? 0; @@ -465,8 +462,7 @@ onMounted(() => {
- -
{{ actionMessage }}
+
불러오는 중...