task-94: 移动端与桌面端响应式页面验收截图计划
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 响应式截图计划(Task 94)。
|
||||
*
|
||||
* 为多页应用生成移动端/桌面端响应式验收截图清单:入口 × 视口 的笛卡尔积
|
||||
* (先页面后视口,顺序稳定),每个 shot 有可预期且唯一的文件名
|
||||
* `<entry>-<label>.png`。同一入口/视口重复登记自动去重;maxShots 限制
|
||||
* 截图总数(超出部分截断并计数 dropped)。
|
||||
*
|
||||
* 校验失败 fail-fast(入口/视口非数组、label 为空、宽高非正数、maxShots
|
||||
* 非正数),零状态变更;视口列表读取抛错时调用失败,不产生部分计划,
|
||||
* 依赖恢复后同一输入可重新计算。输入对象永不修改。
|
||||
*/
|
||||
export interface ShotViewport {
|
||||
/** 视口标识(也用于文件名),不能为空 */
|
||||
label: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ShotEntry {
|
||||
entry: string
|
||||
viewport: ShotViewport
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface ResponsiveShotPlanOptions {
|
||||
/** 页面入口列表 */
|
||||
entries: string[]
|
||||
/** 视口列表(重复 label 去重) */
|
||||
viewports: ShotViewport[]
|
||||
/** 截图总数上限,必须为正数,默认 1000 */
|
||||
maxShots?: number
|
||||
}
|
||||
|
||||
export interface ResponsiveShotPlan {
|
||||
/** 有序截图清单 */
|
||||
shots: ShotEntry[]
|
||||
shotCount: number
|
||||
/** 因 maxShots 截断而丢弃的数量 */
|
||||
droppedCount: number
|
||||
/** 每个视口 label 的截图数量统计 */
|
||||
viewportStats: () => Record<string, number>
|
||||
}
|
||||
|
||||
export function createResponsiveShotPlan(options: ResponsiveShotPlanOptions): ResponsiveShotPlan {
|
||||
if (!Array.isArray(options.entries)) {
|
||||
throw new Error('entries 必须是数组')
|
||||
}
|
||||
if (!Array.isArray(options.viewports)) {
|
||||
throw new Error('viewports 必须是数组')
|
||||
}
|
||||
const maxShots = options.maxShots ?? 1000
|
||||
if (!(maxShots > 0)) {
|
||||
throw new Error('maxShots 必须为正数: ' + maxShots)
|
||||
}
|
||||
|
||||
const entries = Array.from(new Set(options.entries.filter((entry) => typeof entry === 'string' && entry.length > 0)))
|
||||
const viewports: ShotViewport[] = []
|
||||
for (const viewport of options.viewports) {
|
||||
if (typeof viewport.label !== 'string' || viewport.label.length === 0) {
|
||||
throw new Error('label 不能为空')
|
||||
}
|
||||
if (!(viewport.width > 0)) {
|
||||
throw new Error('width 必须为正数: ' + viewport.width)
|
||||
}
|
||||
if (!(viewport.height > 0)) {
|
||||
throw new Error('height 必须为正数: ' + viewport.height)
|
||||
}
|
||||
if (!viewports.some((item) => item.label === viewport.label)) {
|
||||
viewports.push({ label: viewport.label, width: viewport.width, height: viewport.height })
|
||||
}
|
||||
}
|
||||
|
||||
const shots: ShotEntry[] = []
|
||||
let droppedCount = 0
|
||||
for (const entry of entries) {
|
||||
for (const viewport of viewports) {
|
||||
if (shots.length >= maxShots) {
|
||||
droppedCount += 1
|
||||
continue
|
||||
}
|
||||
shots.push({ entry, viewport, filename: `${entry}-${viewport.label}.png` })
|
||||
}
|
||||
}
|
||||
|
||||
function viewportStats(): Record<string, number> {
|
||||
const stats: Record<string, number> = {}
|
||||
for (const shot of shots) {
|
||||
stats[shot.viewport.label] = (stats[shot.viewport.label] ?? 0) + 1
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
return { shots, shotCount: shots.length, droppedCount, viewportStats }
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createResponsiveShotPlan } from '../src/shared/responsive-shot-plan.ts'
|
||||
|
||||
test('test_task_094_task_normal_default_path', () => {
|
||||
const plan = createResponsiveShotPlan({
|
||||
entries: ['publish', 'dedupe'],
|
||||
viewports: [
|
||||
{ label: 'mobile', width: 375, height: 812 },
|
||||
{ label: 'desktop', width: 1280, height: 800 },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
plan.shots.map((s) => `${s.entry}@${s.viewport.label}`),
|
||||
['publish@mobile', 'publish@desktop', 'dedupe@mobile', 'dedupe@desktop'],
|
||||
'默认路径:页面 × 视口 笛卡尔积,先页面后视口',
|
||||
)
|
||||
assert.equal(plan.shotCount, 4)
|
||||
assert.equal(plan.shots[0].viewport.width, 375)
|
||||
assert.equal(plan.shots[0].viewport.height, 812)
|
||||
assert.equal(plan.shots[3].viewport.height, 800)
|
||||
assert.deepEqual(plan.viewportStats(), { mobile: 2, desktop: 2 })
|
||||
})
|
||||
|
||||
test('test_task_094_task_normal_multiple_items', () => {
|
||||
const entries = ['a', 'b', 'c', 'd']
|
||||
const plan = createResponsiveShotPlan({
|
||||
entries,
|
||||
viewports: [
|
||||
{ label: 'mobile', width: 375, height: 812 },
|
||||
{ label: 'tablet', width: 768, height: 1024 },
|
||||
{ label: 'desktop', width: 1280, height: 800 },
|
||||
],
|
||||
})
|
||||
assert.equal(plan.shotCount, 12)
|
||||
assert.equal(plan.shots.length, 12)
|
||||
// 批量结果不丢失、顺序稳定
|
||||
assert.deepEqual(
|
||||
plan.shots.map((s) => s.entry),
|
||||
['a', 'a', 'a', 'b', 'b', 'b', 'c', 'c', 'c', 'd', 'd', 'd'],
|
||||
)
|
||||
assert.deepEqual(plan.viewportStats(), { mobile: 4, tablet: 4, desktop: 4 })
|
||||
// 文件名唯一且可预期
|
||||
assert.deepEqual(
|
||||
new Set(plan.shots.map((s) => s.filename)).size,
|
||||
12,
|
||||
)
|
||||
assert.equal(plan.shots[0].filename, 'a-mobile.png')
|
||||
})
|
||||
|
||||
test('test_task_094_task_normal_repeated_operation_is_idempotent', () => {
|
||||
const options = {
|
||||
entries: ['publish'],
|
||||
viewports: [
|
||||
{ label: 'mobile', width: 375, height: 812 },
|
||||
{ label: 'mobile', width: 375, height: 812 },
|
||||
{ label: 'desktop', width: 1280, height: 800 },
|
||||
],
|
||||
}
|
||||
const plan = createResponsiveShotPlan(options)
|
||||
assert.equal(plan.shotCount, 2, '重复视口去重,不产生重复截图')
|
||||
// 输入对象不被修改
|
||||
assert.equal(options.viewports.length, 3)
|
||||
// 重复计算幂等
|
||||
const again = createResponsiveShotPlan(options)
|
||||
assert.deepEqual(plan.shots, again.shots)
|
||||
// 同一页面重复登记也去重
|
||||
const dedup = createResponsiveShotPlan({ entries: ['a', 'a'], viewports: [{ label: 'v', width: 100, height: 200 }] })
|
||||
assert.equal(dedup.shotCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_094_task_boundary_empty_input', () => {
|
||||
const plan = createResponsiveShotPlan({ entries: [], viewports: [{ label: 'mobile', width: 375, height: 812 }] })
|
||||
assert.deepEqual(plan.shots, [])
|
||||
assert.equal(plan.shotCount, 0)
|
||||
assert.deepEqual(plan.viewportStats(), {})
|
||||
// 空视口同理
|
||||
const empty = createResponsiveShotPlan({ entries: ['a'], viewports: [] })
|
||||
assert.equal(empty.shotCount, 0)
|
||||
})
|
||||
|
||||
test('test_task_094_task_boundary_single_item', () => {
|
||||
const plan = createResponsiveShotPlan({
|
||||
entries: ['withdraw'],
|
||||
viewports: [{ label: 'mobile', width: 375, height: 812 }],
|
||||
})
|
||||
assert.equal(plan.shotCount, 1)
|
||||
assert.equal(plan.shots[0].entry, 'withdraw')
|
||||
assert.equal(plan.shots[0].viewport.label, 'mobile')
|
||||
assert.equal(plan.shots[0].filename, 'withdraw-mobile.png')
|
||||
})
|
||||
|
||||
test('test_task_094_task_boundary_limit_and_overflow', () => {
|
||||
// 截图数量有界:超过 maxShots 截断,不发生无界截图
|
||||
const plan = createResponsiveShotPlan({
|
||||
entries: ['a', 'b', 'c', 'd'],
|
||||
viewports: [
|
||||
{ label: 'mobile', width: 375, height: 812 },
|
||||
{ label: 'desktop', width: 1280, height: 800 },
|
||||
],
|
||||
maxShots: 5,
|
||||
})
|
||||
assert.equal(plan.shotCount, 5)
|
||||
assert.equal(plan.droppedCount, 3)
|
||||
assert.deepEqual(
|
||||
plan.shots.map((s) => s.filename),
|
||||
['a-mobile.png', 'a-desktop.png', 'b-mobile.png', 'b-desktop.png', 'c-mobile.png'],
|
||||
)
|
||||
// maxShots 足够时全部保留
|
||||
const full = createResponsiveShotPlan({
|
||||
entries: ['a', 'b'],
|
||||
viewports: [{ label: 'v', width: 100, height: 100 }],
|
||||
maxShots: 8,
|
||||
})
|
||||
assert.equal(full.shotCount, 2)
|
||||
assert.equal(full.droppedCount, 0)
|
||||
})
|
||||
|
||||
test('test_task_094_task_invalid_input_rejected', () => {
|
||||
assert.throws(() => createResponsiveShotPlan({} as never), /entries 必须是数组/)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: 'a' as never, viewports: [] }),
|
||||
/entries 必须是数组/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: 'x' as never }),
|
||||
/viewports 必须是数组/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: '', width: 1, height: 1 }] }),
|
||||
/label 不能为空/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: 'v', width: 0, height: 100 }] }),
|
||||
/width 必须为正数/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: [{ label: 'v', width: 100, height: -1 }] }),
|
||||
/height 必须为正数/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: [], maxShots: 0 }),
|
||||
/maxShots 必须为正数/,
|
||||
)
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: [], maxShots: -5 }),
|
||||
/maxShots 必须为正数/,
|
||||
)
|
||||
})
|
||||
|
||||
test('test_task_094_task_dependency_failure_releases_resources', () => {
|
||||
// 视口列表读取失败:抛错,不产生部分截图计划;恢复后可重算
|
||||
let broken = true
|
||||
const poisoned = new Proxy([{ label: 'mobile', width: 375, height: 812 }], {
|
||||
get(target, prop, receiver) {
|
||||
if (broken && prop === '0') throw new Error('viewport getter down')
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
ownKeys(target) {
|
||||
if (broken) throw new Error('viewport ownKeys down')
|
||||
return Reflect.ownKeys(target)
|
||||
},
|
||||
})
|
||||
assert.throws(
|
||||
() => createResponsiveShotPlan({ entries: ['a'], viewports: poisoned as unknown as Array<{ label: string; width: number; height: number }> }),
|
||||
/viewport/,
|
||||
)
|
||||
broken = false
|
||||
const plan = createResponsiveShotPlan({
|
||||
entries: ['a'],
|
||||
viewports: poisoned as unknown as Array<{ label: string; width: number; height: number }>,
|
||||
})
|
||||
assert.equal(plan.shotCount, 1)
|
||||
assert.equal(plan.shots[0].filename, 'a-mobile.png')
|
||||
})
|
||||
Reference in New Issue
Block a user