chore: clean up mobile test files and update components
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
import { devices, Device } from '@playwright/test';
|
||||
|
||||
export interface MobileDevice {
|
||||
name: string;
|
||||
device: Device;
|
||||
viewport: { width: number; height: number };
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface ResponsiveBreakpoint {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const mobileDevices: Record<string, MobileDevice> = {
|
||||
'iPhone 12': {
|
||||
name: 'iPhone 12',
|
||||
device: devices['iPhone 12'],
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
'iPhone 14': {
|
||||
name: 'iPhone 14',
|
||||
device: devices['iPhone 14'],
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
'Galaxy S21': {
|
||||
name: 'Galaxy S21',
|
||||
device: devices['Galaxy S21'],
|
||||
viewport: { width: 360, height: 800 },
|
||||
},
|
||||
'iPad Pro': {
|
||||
name: 'iPad Pro',
|
||||
device: devices['iPad Pro'],
|
||||
viewport: { width: 1024, height: 1366 },
|
||||
},
|
||||
'iPad Mini': {
|
||||
name: 'iPad Mini',
|
||||
device: devices['iPad Mini'],
|
||||
viewport: { width: 768, height: 1024 },
|
||||
},
|
||||
};
|
||||
|
||||
export const responsiveBreakpoints: ResponsiveBreakpoint[] = [
|
||||
{
|
||||
name: 'iPhone SE',
|
||||
width: 375,
|
||||
height: 667,
|
||||
description: 'Small mobile device',
|
||||
},
|
||||
{
|
||||
name: 'iPhone 11 Pro Max',
|
||||
width: 414,
|
||||
height: 896,
|
||||
description: 'Large mobile device',
|
||||
},
|
||||
{
|
||||
name: 'iPad Portrait',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
description: 'Tablet portrait',
|
||||
},
|
||||
{
|
||||
name: 'iPad Landscape',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
description: 'Tablet landscape',
|
||||
},
|
||||
{
|
||||
name: 'Small Laptop',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
description: 'Small laptop screen',
|
||||
},
|
||||
];
|
||||
|
||||
export const coreDevices = ['iPhone 12', 'iPhone 14', 'Galaxy S21', 'iPad Pro', 'iPad Mini'];
|
||||
|
||||
export function getMobileDevice(name: string): MobileDevice | undefined {
|
||||
return mobileDevices[name];
|
||||
}
|
||||
|
||||
export function getViewportSize(deviceName: string): { width: number; height: number } | undefined {
|
||||
const device = mobileDevices[deviceName];
|
||||
return device?.viewport;
|
||||
}
|
||||
|
||||
export function isMobileDevice(userAgent: string): boolean {
|
||||
const mobilePatterns = [
|
||||
/iPhone/i,
|
||||
/iPad/i,
|
||||
/iPod/i,
|
||||
/Android/i,
|
||||
/BlackBerry/i,
|
||||
/Windows Phone/i,
|
||||
/webOS/i,
|
||||
/Mobile/i,
|
||||
];
|
||||
|
||||
return mobilePatterns.some(pattern => pattern.test(userAgent));
|
||||
}
|
||||
|
||||
export function getDeviceByViewport(width: number, height: number): string | undefined {
|
||||
for (const breakpoint of responsiveBreakpoints) {
|
||||
if (width <= breakpoint.width && height <= breakpoint.height) {
|
||||
return breakpoint.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getAllMobileDevices(): MobileDevice[] {
|
||||
return Object.values(mobileDevices);
|
||||
}
|
||||
|
||||
export function getCoreMobileDevices(): MobileDevice[] {
|
||||
return coreDevices.map(name => mobileDevices[name]).filter(Boolean) as MobileDevice[];
|
||||
}
|
||||
|
||||
export function getAllResponsiveBreakpoints(): ResponsiveBreakpoint[] {
|
||||
return [...responsiveBreakpoints];
|
||||
}
|
||||
|
||||
export function getBreakpointByWidth(width: number): ResponsiveBreakpoint | undefined {
|
||||
return responsiveBreakpoints.find(bp => width <= bp.width);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { getMobileDevice, isMobileDevice, getDeviceByViewport } from './DeviceMatrix';
|
||||
|
||||
export interface TouchEvent {
|
||||
type: 'touchstart' | 'touchmove' | 'touchend';
|
||||
touches: Array<{ x: number; y: number }>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface NetworkCondition {
|
||||
offline: boolean;
|
||||
downloadThroughput: number;
|
||||
uploadThroughput: number;
|
||||
latency: number;
|
||||
}
|
||||
|
||||
export class MobileHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async getMobileDevice(name: string) {
|
||||
return getMobileDevice(name);
|
||||
}
|
||||
|
||||
async getViewportSize(device: string) {
|
||||
const device = await this.getMobileDevice(device);
|
||||
return device?.viewport;
|
||||
}
|
||||
|
||||
async isMobileDevice(userAgent?: string): Promise<boolean> {
|
||||
const ua = userAgent || await this.page.evaluate(() => navigator.userAgent);
|
||||
return isMobileDevice(ua);
|
||||
}
|
||||
|
||||
async getCurrentViewport(): Promise<{ width: number; height: number }> {
|
||||
return await this.page.evaluate(() => {
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentDeviceName(): Promise<string | undefined> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return getDeviceByViewport(viewport.width, viewport.height);
|
||||
}
|
||||
|
||||
async isPortrait(): Promise<boolean> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return viewport.height > viewport.width;
|
||||
}
|
||||
|
||||
async isLandscape(): Promise<boolean> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return viewport.width > viewport.height;
|
||||
}
|
||||
|
||||
async getTouchSupport(): Promise<{
|
||||
maxTouchPoints: number;
|
||||
touchEvent: boolean;
|
||||
}> {
|
||||
return await this.page.evaluate(() => {
|
||||
return {
|
||||
maxTouchPoints: navigator.maxTouchPoints || 0,
|
||||
touchEvent: 'ontouchstart' in window,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async generateTouchEvent(type: 'touchstart' | 'touchmove' | 'touchend', touches: Array<{ x: number; y: number }>): Promise<TouchEvent> {
|
||||
return {
|
||||
type,
|
||||
touches,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async simulateTouch(element: string, x: number, y: number): Promise<void> {
|
||||
await this.page.locator(element).dispatchEvent('touchstart', {
|
||||
touches: [{ clientX: x, clientY: y }],
|
||||
});
|
||||
|
||||
await this.page.waitForTimeout(50);
|
||||
|
||||
await this.page.locator(element).dispatchEvent('touchend', {
|
||||
touches: [],
|
||||
});
|
||||
}
|
||||
|
||||
async simulateSwipe(startX: number, startY: number, endX: number, endY: number, duration: number = 500): Promise<void> {
|
||||
const steps = 10;
|
||||
const stepDelay = duration / steps;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = startX + (endX - startX) * (i / steps);
|
||||
const y = startY + (endY - startY) * (i / steps);
|
||||
|
||||
await this.page.mouse.move(x, y);
|
||||
await this.page.waitForTimeout(stepDelay);
|
||||
}
|
||||
}
|
||||
|
||||
async simulatePinchZoom(centerX: number, centerY: number, scale: number): Promise<void> {
|
||||
const startDistance = 100;
|
||||
const endDistance = startDistance / scale;
|
||||
|
||||
const finger1Start = { x: centerX - startDistance / 2, y: centerY };
|
||||
const finger2Start = { x: centerX + startDistance / 2, y: centerY };
|
||||
|
||||
const finger1End = { x: centerX - endDistance / 2, y: centerY };
|
||||
const finger2End = { x: centerX + endDistance / 2, y: centerY };
|
||||
|
||||
await this.page.mouse.move(finger1Start.x, finger1Start.y);
|
||||
await this.page.mouse.down();
|
||||
|
||||
await this.page.mouse.move(finger2Start.x, finger2Start.y);
|
||||
await this.page.mouse.down();
|
||||
|
||||
await this.page.mouse.move(finger1End.x, finger1End.y);
|
||||
await this.page.mouse.move(finger2End.x, finger2End.y);
|
||||
|
||||
await this.page.mouse.up();
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
async setNetworkCondition(condition: NetworkCondition): Promise<void> {
|
||||
await this.page.route('**', (route) => {
|
||||
if (condition.offline) {
|
||||
route.abort();
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await this.page.evaluate((condition) => {
|
||||
Object.defineProperty(navigator, 'connection', {
|
||||
value: {
|
||||
effectiveType: condition.downloadThroughput < 1.5 ? 'slow-2g' : '4g',
|
||||
downlink: condition.downloadThroughput,
|
||||
rtt: condition.latency,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
}, condition);
|
||||
}
|
||||
|
||||
async getNetworkCondition(): Promise<NetworkCondition> {
|
||||
return await this.page.evaluate(() => {
|
||||
const connection = (navigator as any).connection;
|
||||
return {
|
||||
offline: !navigator.onLine,
|
||||
downloadThroughput: connection?.downlink || 10,
|
||||
uploadThroughput: connection?.downlink || 10,
|
||||
latency: connection?.rtt || 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async setViewport(width: number, height: number): Promise<void> {
|
||||
await this.page.setViewportSize({ width, height });
|
||||
}
|
||||
|
||||
async setDevice(deviceName: string): Promise<void> {
|
||||
const device = await this.getMobileDevice(deviceName);
|
||||
if (device) {
|
||||
await this.page.setViewportSize(device.viewport);
|
||||
|
||||
if (device.userAgent) {
|
||||
await this.page.setExtraHTTPHeaders({
|
||||
'User-Agent': device.userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async emulateMobile(deviceName: string): Promise<void> {
|
||||
await this.setDevice(deviceName);
|
||||
|
||||
await this.page.emulateMedia({
|
||||
media: 'screen',
|
||||
colorScheme: 'light',
|
||||
});
|
||||
}
|
||||
|
||||
async getBatteryInfo(): Promise<{
|
||||
level: number;
|
||||
charging: boolean;
|
||||
}> {
|
||||
return await this.page.evaluate(() => {
|
||||
const battery = (navigator as any).getBattery();
|
||||
if (battery) {
|
||||
return {
|
||||
level: battery.level,
|
||||
charging: battery.charging,
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: 1,
|
||||
charging: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getOrientation(): Promise<'portrait' | 'landscape'> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
return viewport.height > viewport.width ? 'portrait' : 'landscape';
|
||||
}
|
||||
|
||||
async rotateDevice(): Promise<void> {
|
||||
const viewport = await this.getCurrentViewport();
|
||||
await this.page.setViewportSize({
|
||||
width: viewport.height,
|
||||
height: viewport.width,
|
||||
});
|
||||
}
|
||||
|
||||
async hideKeyboard(): Promise<void> {
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async scrollToElement(selector: string): Promise<void> {
|
||||
const element = this.page.locator(selector);
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async scrollToTop(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async scrollToBottom(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async getScrollPosition(): Promise<{ x: number; y: number }> {
|
||||
return await this.page.evaluate(() => {
|
||||
return {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async isElementInViewport(selector: string): Promise<boolean> {
|
||||
return await this.page.locator(selector).evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async waitForElementVisible(selector: string, timeout: number = 5000): Promise<void> {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout });
|
||||
}
|
||||
|
||||
async waitForElementHidden(selector: string, timeout: number = 5000): Promise<void> {
|
||||
await this.page.waitForSelector(selector, { state: 'hidden', timeout });
|
||||
}
|
||||
}
|
||||
@@ -314,201 +314,4 @@ export class PerformanceMonitor {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async measureFirstMeaningfulPaint(): Promise<number> {
|
||||
const fmp = await this.page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
resolve(entries[0].startTime);
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ['first-meaningful-paint'] });
|
||||
setTimeout(() => resolve(0), 5000);
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
return fmp;
|
||||
}
|
||||
|
||||
async measureNetworkPerformance(): Promise<{
|
||||
dnsLookup: number;
|
||||
tcpConnection: number;
|
||||
sslHandshake: number;
|
||||
requestTime: number;
|
||||
responseTime: number;
|
||||
totalTime: number;
|
||||
}> {
|
||||
const timing = await this.page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
return {
|
||||
dnsLookup: perf.domainLookupEnd - perf.domainLookupStart,
|
||||
tcpConnection: perf.connectEnd - perf.connectStart,
|
||||
sslHandshake: perf.secureConnectionStart > 0 ? perf.connectEnd - perf.secureConnectionStart : 0,
|
||||
requestTime: perf.responseStart - perf.requestStart,
|
||||
responseTime: perf.responseEnd - perf.responseStart,
|
||||
totalTime: perf.loadEventEnd - perf.fetchStart,
|
||||
};
|
||||
});
|
||||
return timing;
|
||||
}
|
||||
|
||||
async measureBatteryImpact(): Promise<{
|
||||
estimatedImpact: string;
|
||||
recommendations: string[];
|
||||
}> {
|
||||
const metrics = await this.collectMetrics();
|
||||
const recommendations: string[] = [];
|
||||
let impact = 'low';
|
||||
|
||||
if (metrics.loadTime > 3000) {
|
||||
recommendations.push('页面加载时间过长,建议优化资源加载');
|
||||
impact = 'high';
|
||||
}
|
||||
if (metrics.firstInputDelay > 100) {
|
||||
recommendations.push('首次输入延迟较高,建议优化JavaScript执行');
|
||||
impact = impact === 'high' ? 'high' : 'medium';
|
||||
}
|
||||
if (metrics.largestContentfulPaint > 2500) {
|
||||
recommendations.push('最大内容绘制时间过长,建议优化关键渲染路径');
|
||||
impact = impact === 'high' ? 'high' : 'medium';
|
||||
}
|
||||
|
||||
return {
|
||||
estimatedImpact: impact,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
async validateLCP(value: number, threshold: number = 2500): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async validateFID(value: number, threshold: number = 100): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async validateCLS(value: number, threshold: number = 0.1): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async validateTTI(value: number, threshold: number = 3500): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async validateTTFB(value: number, threshold: number = 600): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async validateFCP(value: number, threshold: number = 1800): boolean {
|
||||
return value <= threshold;
|
||||
}
|
||||
|
||||
async getCoreWebVitalsSummary(): Promise<{
|
||||
lcp: { value: number; threshold: number; passed: boolean };
|
||||
fid: { value: number; threshold: number; passed: boolean };
|
||||
cls: { value: number; threshold: number; passed: boolean };
|
||||
tti: { value: number; threshold: number; passed: boolean };
|
||||
ttfb: { value: number; threshold: number; passed: boolean };
|
||||
fcp: { value: number; threshold: number; passed: boolean };
|
||||
}> {
|
||||
const metrics = await this.collectMetrics();
|
||||
const ttfb = await this.measureFirstByteTime();
|
||||
const fcp = await this.measureFirstContentfulPaint();
|
||||
|
||||
return {
|
||||
lcp: {
|
||||
value: metrics.largestContentfulPaint,
|
||||
threshold: 2500,
|
||||
passed: await this.validateLCP(metrics.largestContentfulPaint),
|
||||
},
|
||||
fid: {
|
||||
value: metrics.firstInputDelay,
|
||||
threshold: 100,
|
||||
passed: await this.validateFID(metrics.firstInputDelay),
|
||||
},
|
||||
cls: {
|
||||
value: metrics.cumulativeLayoutShift,
|
||||
threshold: 0.1,
|
||||
passed: await this.validateCLS(metrics.cumulativeLayoutShift),
|
||||
},
|
||||
tti: {
|
||||
value: metrics.timeToInteractive,
|
||||
threshold: 3500,
|
||||
passed: await this.validateTTI(metrics.timeToInteractive),
|
||||
},
|
||||
ttfb: {
|
||||
value: ttfb,
|
||||
threshold: 600,
|
||||
passed: await this.validateTTFB(ttfb),
|
||||
},
|
||||
fcp: {
|
||||
value: fcp,
|
||||
threshold: 1800,
|
||||
passed: await this.validateFCP(fcp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async measureFirstByteTime(): Promise<number> {
|
||||
const ttfb = await this.page.evaluate(() => {
|
||||
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
return timing.responseStart - timing.fetchStart;
|
||||
});
|
||||
return ttfb;
|
||||
}
|
||||
|
||||
async measureMobileSpecificMetrics(): Promise<{
|
||||
touchResponseTime: number;
|
||||
scrollPerformance: number;
|
||||
gestureLatency: number;
|
||||
}> {
|
||||
const touchResponseTime = await this.page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const startTime = performance.now();
|
||||
document.addEventListener('touchstart', () => {
|
||||
resolve(performance.now() - startTime);
|
||||
}, { once: true });
|
||||
setTimeout(() => resolve(0), 1000);
|
||||
});
|
||||
});
|
||||
|
||||
const scrollPerformance = await this.page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const startTime = performance.now();
|
||||
let frames = 0;
|
||||
|
||||
function countFrames() {
|
||||
frames++;
|
||||
if (performance.now() - startTime >= 1000) {
|
||||
resolve(frames);
|
||||
} else {
|
||||
requestAnimationFrame(countFrames);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(countFrames);
|
||||
});
|
||||
});
|
||||
|
||||
const gestureLatency = await this.page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const startTime = performance.now();
|
||||
document.addEventListener('touchmove', () => {
|
||||
resolve(performance.now() - startTime);
|
||||
}, { once: true });
|
||||
setTimeout(() => resolve(0), 500);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
touchResponseTime,
|
||||
scrollPerformance,
|
||||
gestureLatency,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,347 +457,4 @@ ${this.generateMessage()}`;
|
||||
const suffix = Math.floor(Math.random() * 90000000 + 10000000);
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
static generateMobileDevice(): {
|
||||
name: string;
|
||||
userAgent: string;
|
||||
viewport: { width: number; height: number };
|
||||
devicePixelRatio: number;
|
||||
touchPoints: number;
|
||||
} {
|
||||
const devices = [
|
||||
{
|
||||
name: 'iPhone 12',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1',
|
||||
viewport: { width: 390, height: 844 },
|
||||
devicePixelRatio: 3,
|
||||
touchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: 'iPhone 14',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
viewport: { width: 390, height: 844 },
|
||||
devicePixelRatio: 3,
|
||||
touchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: 'Galaxy S21',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36',
|
||||
viewport: { width: 360, height: 800 },
|
||||
devicePixelRatio: 3,
|
||||
touchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: 'iPad Pro',
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1',
|
||||
viewport: { width: 1024, height: 1366 },
|
||||
devicePixelRatio: 2,
|
||||
touchPoints: 5,
|
||||
},
|
||||
];
|
||||
return devices[Math.floor(Math.random() * devices.length)]!;
|
||||
}
|
||||
|
||||
static generateMobileNetworkCondition(): {
|
||||
offline: boolean;
|
||||
downloadThroughput: number;
|
||||
uploadThroughput: number;
|
||||
latency: number;
|
||||
name: string;
|
||||
} {
|
||||
const conditions = [
|
||||
{
|
||||
offline: false,
|
||||
downloadThroughput: 10,
|
||||
uploadThroughput: 5,
|
||||
latency: 100,
|
||||
name: '4G',
|
||||
},
|
||||
{
|
||||
offline: false,
|
||||
downloadThroughput: 1.5,
|
||||
uploadThroughput: 0.75,
|
||||
latency: 300,
|
||||
name: '3G',
|
||||
},
|
||||
{
|
||||
offline: false,
|
||||
downloadThroughput: 0.4,
|
||||
uploadThroughput: 0.2,
|
||||
latency: 1000,
|
||||
name: '2G',
|
||||
},
|
||||
{
|
||||
offline: true,
|
||||
downloadThroughput: 0,
|
||||
uploadThroughput: 0,
|
||||
latency: 0,
|
||||
name: 'Offline',
|
||||
},
|
||||
];
|
||||
return conditions[Math.floor(Math.random() * conditions.length)]!;
|
||||
}
|
||||
|
||||
static generateTouchEvent(): {
|
||||
type: 'touchstart' | 'touchmove' | 'touchend';
|
||||
touches: Array<{ x: number; y: number }>;
|
||||
timestamp: number;
|
||||
} {
|
||||
const types: Array<'touchstart' | 'touchmove' | 'touchend'> = ['touchstart', 'touchmove', 'touchend'];
|
||||
const type = types[Math.floor(Math.random() * types.length)]!;
|
||||
const touches = Array.from({ length: Math.floor(Math.random() * 3) + 1 }, () => ({
|
||||
x: Math.floor(Math.random() * 400),
|
||||
y: Math.floor(Math.random() * 800),
|
||||
}));
|
||||
|
||||
return {
|
||||
type,
|
||||
touches,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
static generateSwipeGesture(): {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
duration: number;
|
||||
} {
|
||||
const startX = Math.floor(Math.random() * 300) + 50;
|
||||
const startY = Math.floor(Math.random() * 700) + 50;
|
||||
const direction = Math.random() < 0.5 ? 1 : -1;
|
||||
|
||||
return {
|
||||
startX,
|
||||
startY,
|
||||
endX: startX + Math.floor(Math.random() * 200) * direction,
|
||||
endY: startY + Math.floor(Math.random() * 200) * direction,
|
||||
duration: Math.floor(Math.random() * 500) + 200,
|
||||
};
|
||||
}
|
||||
|
||||
static generatePinchZoomGesture(): {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
scale: number;
|
||||
duration: number;
|
||||
} {
|
||||
return {
|
||||
centerX: Math.floor(Math.random() * 300) + 50,
|
||||
centerY: Math.floor(Math.random() * 700) + 50,
|
||||
scale: Math.random() * 2 + 0.5,
|
||||
duration: Math.floor(Math.random() * 500) + 200,
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobilePerformanceThresholds(): {
|
||||
lcp: { good: number; needsImprovement: number; poor: number };
|
||||
fid: { good: number; needsImprovement: number; poor: number };
|
||||
cls: { good: number; needsImprovement: number; poor: number };
|
||||
tti: { good: number; needsImprovement: number; poor: number };
|
||||
ttfb: { good: number; needsImprovement: number; poor: number };
|
||||
fcp: { good: number; needsImprovement: number; poor: number };
|
||||
} {
|
||||
return {
|
||||
lcp: { good: 2500, needsImprovement: 4000, poor: 4000 },
|
||||
fid: { good: 100, needsImprovement: 300, poor: 300 },
|
||||
cls: { good: 0.1, needsImprovement: 0.25, poor: 0.25 },
|
||||
tti: { good: 3500, needsImprovement: 5000, poor: 5000 },
|
||||
ttfb: { good: 600, needsImprovement: 1500, poor: 1500 },
|
||||
fcp: { good: 1800, needsImprovement: 3000, poor: 3000 },
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileAccessibilityTestData(): {
|
||||
touchTargetSize: { min: number; recommended: number };
|
||||
colorContrast: { normalText: number; largeText: number };
|
||||
fontScale: { min: number; max: number };
|
||||
spacing: { min: number };
|
||||
focusIndicator: { minSize: number };
|
||||
} {
|
||||
return {
|
||||
touchTargetSize: { min: 44, recommended: 48 },
|
||||
colorContrast: { normalText: 4.5, largeText: 3.0 },
|
||||
fontScale: { min: 1.0, max: 2.0 },
|
||||
spacing: { min: 8 },
|
||||
focusIndicator: { minSize: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileFormTestData(): {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
message: string;
|
||||
subject: string;
|
||||
touchTargets: Array<{ selector: string; size: { width: number; height: number } }>;
|
||||
} {
|
||||
return {
|
||||
name: this.generateName(),
|
||||
email: this.generateEmail(),
|
||||
phone: this.generatePhone(),
|
||||
company: this.generateCompany(),
|
||||
message: this.generateMessage(),
|
||||
subject: this.generateSubject(),
|
||||
touchTargets: [
|
||||
{ selector: 'input[name="name"]', size: { width: 350, height: 48 } },
|
||||
{ selector: 'input[name="email"]', size: { width: 350, height: 48 } },
|
||||
{ selector: 'input[name="phone"]', size: { width: 350, height: 48 } },
|
||||
{ selector: 'textarea[name="message"]', size: { width: 350, height: 150 } },
|
||||
{ selector: 'button[type="submit"]', size: { width: 350, height: 48 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileNavigationTestData(): {
|
||||
menuItems: Array<{ label: string; href: string; touchTarget: boolean }>;
|
||||
hamburgerMenu: { selector: string; size: { width: number; height: number } };
|
||||
breadcrumbs: Array<{ label: string; href: string }>;
|
||||
} {
|
||||
return {
|
||||
menuItems: [
|
||||
{ label: '首页', href: '#home', touchTarget: true },
|
||||
{ label: '关于我们', href: '#about', touchTarget: true },
|
||||
{ label: '服务', href: '#services', touchTarget: true },
|
||||
{ label: '产品', href: '#products', touchTarget: true },
|
||||
{ label: '案例', href: '#cases', touchTarget: true },
|
||||
{ label: '新闻', href: '#news', touchTarget: true },
|
||||
{ label: '联系我们', href: '#contact', touchTarget: true },
|
||||
],
|
||||
hamburgerMenu: {
|
||||
selector: 'button[aria-label="打开菜单"]',
|
||||
size: { width: 48, height: 48 },
|
||||
},
|
||||
breadcrumbs: [
|
||||
{ label: '首页', href: '/' },
|
||||
{ label: '服务', href: '#services' },
|
||||
{ label: '详情', href: '#details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileScrollTestData(): {
|
||||
scrollPositions: Array<{ x: number; y: number }>;
|
||||
scrollSpeeds: Array<{ pixelsPerSecond: number }>;
|
||||
scrollDirections: Array<'up' | 'down' | 'left' | 'right'>;
|
||||
} {
|
||||
return {
|
||||
scrollPositions: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 500 },
|
||||
{ x: 0, y: 1000 },
|
||||
{ x: 0, y: 1500 },
|
||||
{ x: 0, y: 2000 },
|
||||
],
|
||||
scrollSpeeds: [
|
||||
{ pixelsPerSecond: 500 },
|
||||
{ pixelsPerSecond: 1000 },
|
||||
{ pixelsPerSecond: 1500 },
|
||||
{ pixelsPerSecond: 2000 },
|
||||
],
|
||||
scrollDirections: ['up', 'down', 'left', 'right'],
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileOrientationTestData(): {
|
||||
portrait: { width: number; height: number };
|
||||
landscape: { width: number; height: number };
|
||||
rotation: { from: string; to: string }[];
|
||||
} {
|
||||
return {
|
||||
portrait: { width: 390, height: 844 },
|
||||
landscape: { width: 844, height: 390 },
|
||||
rotation: [
|
||||
{ from: 'portrait', to: 'landscape' },
|
||||
{ from: 'landscape', to: 'portrait' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileBatteryTestData(): {
|
||||
levels: Array<{ level: number; charging: boolean }>;
|
||||
lowBatteryThreshold: number;
|
||||
criticalBatteryThreshold: number;
|
||||
} {
|
||||
return {
|
||||
levels: [
|
||||
{ level: 1.0, charging: true },
|
||||
{ level: 0.75, charging: false },
|
||||
{ level: 0.5, charging: false },
|
||||
{ level: 0.25, charging: false },
|
||||
{ level: 0.1, charging: false },
|
||||
],
|
||||
lowBatteryThreshold: 0.2,
|
||||
criticalBatteryThreshold: 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileGestureTestData(): {
|
||||
tap: { duration: number; coordinates: Array<{ x: number; y: number }> };
|
||||
doubleTap: { interval: number; coordinates: Array<{ x: number; y: number }> };
|
||||
longPress: { duration: number; coordinates: Array<{ x: number; y: number }> };
|
||||
swipe: { directions: Array<'left' | 'right' | 'up' | 'down'>; distance: number };
|
||||
pinch: { scales: Array<number>; duration: number };
|
||||
} {
|
||||
return {
|
||||
tap: {
|
||||
duration: 100,
|
||||
coordinates: [
|
||||
{ x: 200, y: 400 },
|
||||
{ x: 300, y: 500 },
|
||||
{ x: 150, y: 600 },
|
||||
],
|
||||
},
|
||||
doubleTap: {
|
||||
interval: 300,
|
||||
coordinates: [
|
||||
{ x: 200, y: 400 },
|
||||
{ x: 300, y: 500 },
|
||||
],
|
||||
},
|
||||
longPress: {
|
||||
duration: 1000,
|
||||
coordinates: [
|
||||
{ x: 200, y: 400 },
|
||||
{ x: 300, y: 500 },
|
||||
],
|
||||
},
|
||||
swipe: {
|
||||
directions: ['left', 'right', 'up', 'down'],
|
||||
distance: 200,
|
||||
},
|
||||
pinch: {
|
||||
scales: [0.5, 1.5, 2.0],
|
||||
duration: 500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static generateMobileErrorScenarios(): {
|
||||
networkError: { message: string; selector: string };
|
||||
timeoutError: { message: string; timeout: number };
|
||||
touchError: { message: string; selector: string };
|
||||
viewportError: { message: string; viewport: { width: number; height: number } };
|
||||
} {
|
||||
return {
|
||||
networkError: {
|
||||
message: '网络连接失败,请检查您的网络设置',
|
||||
selector: '.network-error',
|
||||
},
|
||||
timeoutError: {
|
||||
message: '请求超时,请稍后重试',
|
||||
timeout: 30000,
|
||||
},
|
||||
touchError: {
|
||||
message: '触摸目标不可用',
|
||||
selector: '.touch-error',
|
||||
},
|
||||
viewportError: {
|
||||
message: '当前视口大小不支持',
|
||||
viewport: { width: 320, height: 480 },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user