merge: 자동 화면 폭과 검색 확대 수정을 main에 통합
This commit is contained in:
@@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly
|
||||
expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder);
|
||||
expect(audit.visualOrder).toEqual(expectedOrder);
|
||||
expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true);
|
||||
expect(
|
||||
audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)
|
||||
).toBe(true);
|
||||
expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe(
|
||||
true
|
||||
);
|
||||
for (const panel of audit.panels) {
|
||||
expect(panel.display, `${panel.id}: display`).not.toBe('none');
|
||||
expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position);
|
||||
@@ -1125,9 +1125,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
||||
await expect(page.locator('.layout-desktop')).toBeVisible();
|
||||
await expect(page.locator('.layout-mobile')).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
|
||||
await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
|
||||
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
|
||||
@@ -1279,7 +1277,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => {
|
||||
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
@@ -2794,6 +2794,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async (
|
||||
}
|
||||
});
|
||||
|
||||
test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||
if (typeof configuredBaseUrl !== 'string') {
|
||||
throw new Error('Playwright baseURL is required for the automatic screen-mode contract');
|
||||
}
|
||||
|
||||
const measurements: Record<string, unknown> = {};
|
||||
for (const deviceWidth of [699, 700, 820]) {
|
||||
const context = await browser.newContext({
|
||||
baseURL: configuredBaseUrl,
|
||||
viewport: { width: deviceWidth, height: 1180 },
|
||||
screen: { width: deviceWidth, height: 1180 },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const mobilePage = await context.newPage();
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 6,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installFixture(mobilePage, state);
|
||||
await waitForMain(mobilePage);
|
||||
|
||||
const expectedWideLayout = deviceWidth >= 700;
|
||||
await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible();
|
||||
expect(
|
||||
await mobilePage.evaluate(() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content)
|
||||
).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1');
|
||||
const modeMeasurements: Record<string, unknown> = {
|
||||
auto: await mobilePage.locator('.main-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||
screenWidth: screen.availWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
layoutViewportWidth: document.documentElement.clientWidth,
|
||||
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||
visualViewportScale: window.visualViewport?.scale ?? null,
|
||||
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
|
||||
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
|
||||
canvas: { left: rect.left, right: rect.right, width: rect.width },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
if (deviceWidth === 820) {
|
||||
await mobilePage.evaluate(() => {
|
||||
localStorage.setItem('sam.screenMode', '500px');
|
||||
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
|
||||
});
|
||||
await expect(mobilePage.locator('.layout-mobile')).toBeVisible();
|
||||
expect(
|
||||
await mobilePage.evaluate(
|
||||
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
|
||||
)
|
||||
).toBe('width=500');
|
||||
modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => {
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||
layoutViewportWidth: document.documentElement.clientWidth,
|
||||
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
|
||||
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
|
||||
};
|
||||
});
|
||||
|
||||
await mobilePage.evaluate(() => {
|
||||
localStorage.setItem('sam.screenMode', '1000px');
|
||||
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
|
||||
});
|
||||
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
|
||||
expect(
|
||||
await mobilePage.evaluate(
|
||||
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
|
||||
)
|
||||
).toBe('width=1000');
|
||||
modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => {
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||
layoutViewportWidth: document.documentElement.clientWidth,
|
||||
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
|
||||
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
|
||||
};
|
||||
});
|
||||
|
||||
await mobilePage.evaluate(() => {
|
||||
localStorage.setItem('sam.screenMode', 'auto');
|
||||
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
|
||||
});
|
||||
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
|
||||
expect(
|
||||
await mobilePage.evaluate(
|
||||
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
|
||||
)
|
||||
).toBe('width=1000');
|
||||
}
|
||||
|
||||
measurements[String(deviceWidth)] = modeMeasurements;
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await mobilePage.screenshot({
|
||||
path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
|
||||
if (artifactRoot) {
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'),
|
||||
`${JSON.stringify(measurements, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 1,
|
||||
|
||||
@@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000);
|
||||
const generalSearch = page.getByLabel('장수명 필터');
|
||||
await expect(generalSearch).toHaveCSS('touch-action', 'manipulation');
|
||||
const viewportContract = await page.evaluate(() => ({
|
||||
content: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content ?? '',
|
||||
scale: window.visualViewport?.scale ?? 1,
|
||||
}));
|
||||
expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u);
|
||||
await generalSearch.focus();
|
||||
await expect(generalSearch).toBeFocused();
|
||||
expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale);
|
||||
await nameMenuButton.click();
|
||||
await expect(namePopup).toBeVisible();
|
||||
await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
|
||||
await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -63,6 +63,15 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Firefox for Android may zoom to a focused search field. `manipulation`
|
||||
* suppresses that focus-only zoom while retaining ordinary pan and pinch zoom.
|
||||
*/
|
||||
input[type='search'],
|
||||
input[inputmode='search'] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the
|
||||
* element stays transparent where the texture does not cover it. Adding a
|
||||
|
||||
@@ -4,8 +4,10 @@ import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { installImageAssetCssVariables } from './utils/imageAssets';
|
||||
import { installScreenModeViewport } from './utils/screenModeViewport';
|
||||
|
||||
installImageAssetCssVariables();
|
||||
installScreenModeViewport();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode';
|
||||
|
||||
export type ScreenMode = 'auto' | '500px' | '1000px';
|
||||
|
||||
export type AutoViewportMeasurements = {
|
||||
deviceWidth: number;
|
||||
viewportHeight: number;
|
||||
targetHeight?: number;
|
||||
};
|
||||
|
||||
export const normalizeScreenMode = (value: string | null): ScreenMode =>
|
||||
value === '500px' || value === '1000px' ? value : 'auto';
|
||||
|
||||
export const resolveAutoViewportContent = ({
|
||||
deviceWidth,
|
||||
viewportHeight,
|
||||
targetHeight = 700,
|
||||
}: AutoViewportMeasurements): string => {
|
||||
if (deviceWidth < 500) {
|
||||
return 'width=500';
|
||||
}
|
||||
|
||||
if (viewportHeight < targetHeight) {
|
||||
const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight;
|
||||
return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`;
|
||||
}
|
||||
|
||||
return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1';
|
||||
};
|
||||
|
||||
export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => {
|
||||
if (mode === '500px') return 'width=500';
|
||||
if (mode === '1000px') return 'width=1000';
|
||||
return resolveAutoViewportContent(measurements);
|
||||
};
|
||||
|
||||
const findOrCreateViewportMeta = (): HTMLMetaElement => {
|
||||
const existing = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
|
||||
if (existing) return existing;
|
||||
|
||||
const viewportMeta = document.createElement('meta');
|
||||
viewportMeta.name = 'viewport';
|
||||
document.head.appendChild(viewportMeta);
|
||||
return viewportMeta;
|
||||
};
|
||||
|
||||
export const installScreenModeViewport = (targetHeight = 700): void => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const viewportMeta = findOrCreateViewportMeta();
|
||||
let previousMode: ScreenMode | null = null;
|
||||
let previousDeviceWidth: number | null = null;
|
||||
|
||||
const adjustViewport = () => {
|
||||
const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
|
||||
const deviceWidth = window.screen.availWidth;
|
||||
|
||||
if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return;
|
||||
if (mode === previousMode && mode !== 'auto') return;
|
||||
|
||||
previousMode = mode;
|
||||
previousDeviceWidth = deviceWidth;
|
||||
viewportMeta.content = resolveViewportContent(mode, {
|
||||
deviceWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
targetHeight,
|
||||
});
|
||||
};
|
||||
|
||||
adjustViewport();
|
||||
window.addEventListener('resize', adjustViewport);
|
||||
window.addEventListener('orientationchange', adjustViewport);
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === SCREEN_MODE_KEY) adjustViewport();
|
||||
});
|
||||
document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
|
||||
import {
|
||||
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
|
||||
loadMobileMainPanelOrder,
|
||||
@@ -19,11 +20,9 @@ import {
|
||||
type MobileMainPanelId,
|
||||
} from '../utils/mobileMainPanelOrder';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
||||
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
|
||||
type ScreenMode = 'auto' | '500px' | '1000px';
|
||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
@@ -373,7 +372,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
|
||||
|
||||
watch(screenMode, (mode) => {
|
||||
localStorage.setItem(SCREEN_MODE_KEY, mode);
|
||||
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
|
||||
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
|
||||
});
|
||||
|
||||
watch(customCss, (text) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeScreenMode,
|
||||
resolveAutoViewportContent,
|
||||
resolveViewportContent,
|
||||
} from '../src/utils/screenModeViewport.ts';
|
||||
|
||||
void test('automatic mode follows the Ref physical-screen thresholds', () => {
|
||||
assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500');
|
||||
assert.equal(
|
||||
resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }),
|
||||
'width=device-width, initial-scale=1'
|
||||
);
|
||||
assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000');
|
||||
assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000');
|
||||
});
|
||||
|
||||
void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => {
|
||||
assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700');
|
||||
assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000');
|
||||
});
|
||||
|
||||
void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => {
|
||||
const phone = { deviceWidth: 390, viewportHeight: 844 };
|
||||
const tablet = { deviceWidth: 820, viewportHeight: 1180 };
|
||||
|
||||
assert.equal(resolveViewportContent('1000px', phone), 'width=1000');
|
||||
assert.equal(resolveViewportContent('500px', tablet), 'width=500');
|
||||
assert.equal(normalizeScreenMode('1000px'), '1000px');
|
||||
assert.equal(normalizeScreenMode('500px'), '500px');
|
||||
assert.equal(normalizeScreenMode('unexpected'), 'auto');
|
||||
assert.equal(normalizeScreenMode(null), 'auto');
|
||||
});
|
||||
Reference in New Issue
Block a user