Files
Genarrative/src/mobileViewportKeyboardFocus.test.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

211 lines
6.2 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
isEditableKeyboardTarget,
resolveMobileKeyboardExposedFill,
resolveMobileKeyboardState,
stabilizeMobileViewportKeyboardFocus,
} from './mobileViewportKeyboardFocus';
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const originalInnerHeight = window.innerHeight;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalVisualViewport = window.visualViewport;
function defineWindowValue<Key extends keyof Window>(
key: Key,
value: Window[Key],
) {
Object.defineProperty(window, key, {
configurable: true,
value,
});
}
function defineNavigatorValue<Key extends keyof Navigator>(
key: Key,
value: Navigator[Key],
) {
Object.defineProperty(navigator, key, {
configurable: true,
value,
});
}
beforeEach(() => {
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute(
'data-mobile-viewport-keyboard-focus',
);
document.documentElement.removeAttribute('style');
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute(
'data-mobile-viewport-keyboard-focus',
);
document.documentElement.removeAttribute('style');
defineWindowValue('matchMedia', originalMatchMedia);
defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame);
defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame);
defineWindowValue('innerHeight', originalInnerHeight);
defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints);
defineWindowValue('visualViewport', originalVisualViewport);
});
describe('isEditableKeyboardTarget', () => {
it('matches controls that open the mobile keyboard', () => {
const input = document.createElement('input');
input.type = 'text';
const textarea = document.createElement('textarea');
const buttonInput = document.createElement('input');
buttonInput.type = 'file';
expect(isEditableKeyboardTarget(input)).toBe(true);
expect(isEditableKeyboardTarget(textarea)).toBe(true);
expect(isEditableKeyboardTarget(buttonInput)).toBe(false);
});
it('ignores disabled and readonly controls', () => {
const disabledInput = document.createElement('input');
disabledInput.disabled = true;
const readonlyInput = document.createElement('input');
readonlyInput.readOnly = true;
expect(isEditableKeyboardTarget(disabledInput)).toBe(false);
expect(isEditableKeyboardTarget(readonlyInput)).toBe(false);
});
});
describe('resolveMobileKeyboardExposedFill', () => {
it('uses the active platform shell fill for exposed mini-program keyboard space', () => {
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
></div>
`;
expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)');
});
it('falls back to the light platform fill before the shell mounts', () => {
document.body.innerHTML = '';
expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9');
});
});
describe('resolveMobileKeyboardState', () => {
it('detects the keyboard inset without asking the app shell to shift', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 300,
});
});
it('stays closed when no editable target is focused', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: false,
}),
).toEqual({
isOpen: false,
insetBottom: 0,
});
});
it('accounts for browser visual viewport panning without returning a shell offset', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 120,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 180,
});
});
});
describe('stabilizeMobileViewportKeyboardFocus', () => {
it('marks H5 keyboard state without applying a global shell transform offset', () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
>
<input id="theme-input" />
</div>
`;
defineNavigatorValue('maxTouchPoints', 1);
defineWindowValue(
'matchMedia',
vi
.fn()
.mockReturnValue({ matches: true }) as unknown as Window['matchMedia'],
);
defineWindowValue('requestAnimationFrame', ((
callback: FrameRequestCallback,
) => {
callback(0);
return 1;
}) as Window['requestAnimationFrame']);
defineWindowValue(
'cancelAnimationFrame',
vi.fn() as unknown as Window['cancelAnimationFrame'],
);
defineWindowValue('innerHeight', 800);
defineWindowValue('visualViewport', {
height: 500,
offsetTop: 120,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as VisualViewport);
stabilizeMobileViewportKeyboardFocus();
document.getElementById('theme-input')?.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
vi.runAllTimers();
expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-focus-offset',
),
).toBe('0px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-inset-bottom',
),
).toBe('180px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-exposed-fill',
),
).toContain('rgb(255, 253, 249)');
});
});