From a18891f66479642e8806e55122f678e70c04b742 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 17:51:13 +0000 Subject: [PATCH 1/4] feat(game-ui): add mobile auto refresh control --- app/game-frontend/e2e/mainNavigation.spec.ts | 61 +++++++++++++- .../components/main/MainMobileBottomBar.vue | 84 +++++++++++++++++-- app/game-frontend/src/views/MainView.vue | 3 + 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 32760799..2c8fdbd4 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -572,6 +572,8 @@ const persistArtifact = async (page: Page, name: string) => { executionStatus: describe('.execution-status'), tournamentStatus: describe('.tournament-status'), voteStatus: describe('.vote-status'), + autoRefresh: describe('[data-bottom-menu="auto-refresh"]'), + manualRefresh: describe('[data-bottom-menu="manual-refresh"]'), commandMenu: describe('.reserved-command-editor details[open] .menu-items'), commandDividers: [...document.querySelectorAll('.reserved-command-editor details[open] .menu-divider')].map( (element) => { @@ -1472,6 +1474,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return generalMeCalls: 0, operations: [], }; + await installRealtimeHarness(page); await installFixture(page, state); await page.setViewportSize({ width: 500, height: 900 }); await waitForMain(page); @@ -1494,9 +1497,65 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.locator(selector)).toBeVisible(); } + const autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' }); + const manualRefresh = page.getByRole('button', { name: '직접 갱신' }); + await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true'); + await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)'); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => { + const auto = controls.querySelector('[data-bottom-menu="auto-refresh"]'); + const manual = controls.querySelector('[data-bottom-menu="manual-refresh"]'); + if (!auto || !manual) throw new Error('mobile refresh controls are incomplete'); + const controlsRect = controls.getBoundingClientRect(); + const autoRect = auto.getBoundingClientRect(); + const manualRect = manual.getBoundingClientRect(); + return { + controls: { left: controlsRect.left, right: controlsRect.right, width: controlsRect.width }, + auto: { left: autoRect.left, right: autoRect.right, width: autoRect.width }, + manual: { left: manualRect.left, right: manualRect.right, width: manualRect.width }, + overflow: controls.scrollWidth - controls.clientWidth, + }; + }); + expect(refreshGeometry.controls.width).toBe(125); + expect(refreshGeometry.auto.width).toBe(85); + expect(refreshGeometry.manual.width).toBe(40); + expect(refreshGeometry.auto.left).toBe(refreshGeometry.controls.left); + expect(refreshGeometry.auto.right).toBe(refreshGeometry.manual.left); + expect(refreshGeometry.manual.right).toBe(refreshGeometry.controls.right); + expect(refreshGeometry.overflow).toBeLessThanOrEqual(0); + + await autoRefresh.focus(); + await expect(autoRefresh).toBeFocused(); + await autoRefresh.hover(); + await expect(autoRefresh).toHaveCSS('filter', 'brightness(1.14)'); + await autoRefresh.click(); + const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' }); + await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false'); + await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(false); + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`); + + state.generalName = '직접갱신된장수'; const callsBeforeRefresh = state.generalMeCalls; - await page.getByRole('button', { name: '갱 신' }).click(); + await manualRefresh.click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); + await expect(page.locator('.general-title')).toContainText('직접갱신된장수'); + + const callsBeforeEnable = state.generalMeCalls; + await page.getByRole('button', { name: '자동 갱신 OFF' }).click(); + await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`); await page.evaluate(() => { localStorage.setItem('sammo-session-token', 'session_navigation'); diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index e607497d..f361704f 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -18,10 +18,13 @@ const props = defineProps<{ tournamentStage: number; nationColor: string; npcMode: number; + realtimeEnabled: boolean; + refreshing: boolean; }>(); const emit = defineEmits<{ refresh: []; + toggleRealtime: []; lobby: []; quick: [item: QuickNavigationItem]; }>(); @@ -189,14 +192,31 @@ const onQuick = (item: QuickNavigationItem) => { - +
+ + +
@@ -221,6 +241,17 @@ const onQuick = (item: QuickNavigationItem) => { position: relative; } +.bottom-refresh-controls { + display: grid; + width: 125px; + height: 45px; + grid-template-columns: minmax(0, 1fr) 40px; +} + +.bottom-refresh-controls > .bottom-trigger { + width: auto; +} + .bottom-trigger { box-sizing: border-box; width: 125px; @@ -245,6 +276,43 @@ const onQuick = (item: QuickNavigationItem) => { background: #212529; } +.auto-refresh-trigger { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2px 1px; + font-size: 12px; + line-height: 1.15; +} + +.auto-refresh-trigger strong { + color: #bbb; + font-size: 11px; + line-height: 1; +} + +.auto-refresh-trigger.active { + background-color: #164f2c; +} + +.auto-refresh-trigger.active strong { + color: #9ef0b8; +} + +.manual-refresh-trigger { + padding: 0; + background: #212529; + font-size: 22px; + line-height: 1; +} + +.manual-refresh-trigger:disabled { + cursor: wait; + filter: grayscale(0.6); + opacity: 0.55; +} + .bottom-trigger:hover, .bottom-trigger:focus-visible, .bottom-trigger[aria-expanded='true'] { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 1e592f27..6b8e0c57 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -455,7 +455,10 @@ watch( :tournament-stage="tournamentStage" :nation-color="nationColor" :npc-mode="npcMode" + :realtime-enabled="realtimeEnabled" + :refreshing="refreshing" @refresh="loadMainData" + @toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)" @lobby="moveLobby" @quick="moveQuick" /> From 00c70225198215feff0397ac9b7d754157ec57ff Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 17:54:28 +0000 Subject: [PATCH 2/4] fix(game-frontend): restore nation general filter menus --- .../e2e/nationGeneralSecret.spec.ts | 87 ++++- .../src/utils/nationGeneralGrid.ts | 114 +++++++ .../src/views/NationGeneralsView.vue | 314 ++++++++++++++++-- .../test/nationGeneralGrid.test.ts | 53 +++ .../reference-nation-general-filter-menu.mjs | 116 +++++++ 5 files changed, 661 insertions(+), 23 deletions(-) create mode 100644 tools/frontend-legacy-parity/reference-nation-general-filter-menu.mjs diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index ceb0b626..c4a95cf8 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -171,7 +171,7 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('장수명 필터').fill(''); - await page.getByLabel('통솔 필터').fill('>= 60'); + await page.getByLabel('통솔 필터').fill('70'); await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); await page.getByLabel('통솔 필터').fill(''); @@ -215,6 +215,91 @@ test('nation generals restores Ref group, saved view, sort, and Korean search be .not.toContain('내 보기'); }); +test('nation generals filter buttons open Ref operator menus and apply compound conditions', async ({ + page, +}, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + + const nameMenuButton = page.getByRole('button', { name: '장수명 상세 필터 열기' }); + await expect(nameMenuButton).toHaveAttribute('title', 'Open Filter Menu'); + await nameMenuButton.hover(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await nameMenuButton.focus(); + await expect(nameMenuButton).toBeFocused(); + expect(await nameMenuButton.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid'); + await nameMenuButton.click(); + + const namePopup = page.getByRole('dialog', { name: '장수명 상세 필터' }); + await expect(namePopup).toBeVisible(); + expect((await namePopup.boundingBox())?.width).toBe(190); + expect(await namePopup.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(45, 52, 54)'); + const nameOperator = page.getByLabel('장수명 첫 번째 필터 연산자'); + expect(await nameOperator.locator('option').allTextContents()).toEqual([ + 'Contains', + 'Not contains', + 'Equals', + 'Not equal', + 'Starts with', + 'Ends with', + 'Blank', + 'Not blank', + ]); + await nameOperator.selectOption('notContains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('테스트'); + await expect(page.getByRole('searchbox', { name: '장수명 필터', exact: true })).toHaveValue('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + + await nameOperator.selectOption('contains'); + await page.getByLabel('장수명 첫 번째 필터 값').fill('장수'); + await page.getByLabel('장수명 두 번째 필터 연산자').selectOption('notContains'); + await page.getByLabel('장수명 두 번째 필터 값').fill('테스트'); + await expect(table.locator('tr[data-general-id="1"]')).toHaveCount(0); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await namePopup.getByLabel('OR').check(); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('core-text-filter-menu.png'), fullPage: true }); + + await page.getByLabel('장수명 두 번째 필터 값').fill(''); + await page.getByLabel('장수명 첫 번째 필터 값').fill(''); + await page.getByRole('button', { name: '통솔 상세 필터 열기' }).click(); + const numberPopup = page.getByRole('dialog', { name: '통솔 상세 필터' }); + const numberOperator = page.getByLabel('통솔 첫 번째 필터 연산자'); + expect(await numberOperator.locator('option').allTextContents()).toEqual([ + 'Equals', + 'Not equal', + 'Less than', + 'Less than or equals', + 'Greater than', + 'Greater than or equals', + 'In range', + 'Blank', + 'Not blank', + ]); + await numberOperator.selectOption('inRange'); + await page.getByLabel('통솔 첫 번째 필터 값').fill('45'); + await page.getByLabel('통솔 첫 번째 필터 끝값').fill('75'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('core-number-filter-menu.png'), fullPage: true }); + await numberOperator.selectOption('blank'); + await expect(table.locator('tr[data-general-id]')).toHaveCount(0); + await expect(numberPopup.getByPlaceholder('Filter...')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(numberPopup).toHaveCount(0); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + await nameMenuButton.click(); + await expect(namePopup).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); +}); + test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { await install(page); await page.setViewportSize({ width: 500, height: 900 }); diff --git a/app/game-frontend/src/utils/nationGeneralGrid.ts b/app/game-frontend/src/utils/nationGeneralGrid.ts index 4cf247c3..c204d4f7 100644 --- a/app/game-frontend/src/utils/nationGeneralGrid.ts +++ b/app/game-frontend/src/utils/nationGeneralGrid.ts @@ -48,6 +48,48 @@ export type NationGeneralDisplaySetting = { export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string]; +export type TextFilterOperator = + 'contains' | 'notContains' | 'equals' | 'notEqual' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank'; +export type NumberFilterOperator = + | 'equals' + | 'notEqual' + | 'lessThan' + | 'lessThanOrEqual' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'inRange' + | 'blank' + | 'notBlank'; +export type NationGeneralFilterOperator = TextFilterOperator | NumberFilterOperator; +export type NationGeneralFilterCondition = { + operator: NationGeneralFilterOperator; + value: string; + valueTo: string; +}; + +export const textFilterOperators: readonly { value: TextFilterOperator; label: string }[] = [ + { value: 'contains', label: 'Contains' }, + { value: 'notContains', label: 'Not contains' }, + { value: 'equals', label: 'Equals' }, + { value: 'notEqual', label: 'Not equal' }, + { value: 'startsWith', label: 'Starts with' }, + { value: 'endsWith', label: 'Ends with' }, + { value: 'blank', label: 'Blank' }, + { value: 'notBlank', label: 'Not blank' }, +]; + +export const numberFilterOperators: readonly { value: NumberFilterOperator; label: string }[] = [ + { value: 'equals', label: 'Equals' }, + { value: 'notEqual', label: 'Not equal' }, + { value: 'lessThan', label: 'Less than' }, + { value: 'lessThanOrEqual', label: 'Less than or equals' }, + { value: 'greaterThan', label: 'Greater than' }, + { value: 'greaterThanOrEqual', label: 'Greater than or equals' }, + { value: 'inRange', label: 'In range' }, + { value: 'blank', label: 'Blank' }, + { value: 'notBlank', label: 'Not blank' }, +]; + export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting'; export const DISPLAY_SETTINGS_VERSION = 1; export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`; @@ -269,6 +311,78 @@ export const matchesKoreanSearch = (value: string, query: string): boolean => { ); }; +const normalizedSearchValues = (value: string): [string, string] => [ + normalizeSearchText(value), + normalizeSearchText(hangulInitials(value)), +]; + +export const matchesTextFilterCondition = (value: string | null, condition: NationGeneralFilterCondition): boolean => { + const blank = value === null || value === ''; + if (condition.operator === 'blank') return blank; + if (condition.operator === 'notBlank') return !blank; + + const query = normalizeSearchText(condition.value); + if (!query) return true; + if (value === null) return false; + const candidates = normalizedSearchValues(value); + const matches = (predicate: (candidate: string) => boolean): boolean => candidates.some(predicate); + switch (condition.operator) { + case 'notContains': + return !matches((candidate) => candidate.includes(query)); + case 'equals': + return matches((candidate) => candidate === query); + case 'notEqual': + return !matches((candidate) => candidate === query); + case 'startsWith': + return matches((candidate) => candidate.startsWith(query)); + case 'endsWith': + return matches((candidate) => candidate.endsWith(query)); + default: + return matches((candidate) => candidate.includes(query)); + } +}; + +const parseFiniteNumber = (raw: string): number | null => { + const normalized = raw.trim(); + if (!normalized) return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const matchesNumberFilterCondition = ( + value: number | null, + condition: NationGeneralFilterCondition +): boolean => { + const blank = value === null || !Number.isFinite(value); + if (condition.operator === 'blank') return blank; + if (condition.operator === 'notBlank') return !blank; + if (blank) return false; + + const expected = parseFiniteNumber(condition.value); + if (expected === null) return true; + switch (condition.operator) { + case 'notEqual': + return value !== expected; + case 'lessThan': + return value < expected; + case 'lessThanOrEqual': + return value <= expected; + case 'greaterThan': + return value > expected; + case 'greaterThanOrEqual': + return value >= expected; + case 'inRange': { + const expectedTo = parseFiniteNumber(condition.valueTo); + return ( + expectedTo === null || + (value >= Math.min(expected, expectedTo) && value <= Math.max(expected, expectedTo)) + ); + } + default: + return value === expected; + } +}; + export const matchesNumberSearch = (value: number | null, query: string): boolean => { const normalized = query.trim(); if (!normalized) return true; diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index 64de7fcc..a89c52c1 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -1,5 +1,5 @@