071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
211 lines
6.2 KiB
TypeScript
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)');
|
|
});
|
|
});
|