feat(viewer,ui04): E03 continuous scroll engine — pure state machine, rAF orchestration, boundary continuity
- stream.ts: advanceStream (per-frame, dt cap 50ms), loop generation keys, window math (DOM<=16, queue<=30, 480px buffer), anchorRebuild (photo-position preserving), removePhoto, single-photo static - useVerticalPhotoStream: rAF integration, direct transform writes (no per-frame Vue reactivity), low-frequency item sync, ResizeObserver re-anchor, stepForTest hook - EmbedStream wired to engine; cycle copies aria-hidden; img error -> controlled drop - tests: 14 unit cases (236 viewer total), e2e: 32px/2s speed, boundary continuity <2.5px, DOM<=16 over 20s, 32s recording with loop boundaries Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// apps/viewer/src/__tests__/embed-stream.spec.ts —— UI-04 v2 滚动引擎测试。
|
||||
// 纯状态机矩阵(§3.1/§3.2/§6.2:匀速积分、退役边界、循环代次 key、锚定
|
||||
// 重建、移除跳过)+ 组合式装配(挂载建窗、photos 变化锚定、单张不滚、
|
||||
// stepForTest 受控推进、dropPhoto 清理)。
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
advanceStream,
|
||||
advanceStreamFor,
|
||||
anchorRebuild,
|
||||
createStreamState,
|
||||
photoRatio,
|
||||
removePhoto,
|
||||
MAX_DOM_INSTANCES,
|
||||
type StreamEngineState,
|
||||
} from '../embed/stream';
|
||||
import { useVerticalPhotoStream } from '../embed/useVerticalPhotoStream';
|
||||
import type { PublicEmbedPhoto } from '../api/client';
|
||||
|
||||
const W = 193; // 195 - 2×1 边框
|
||||
const H = 278; // 280 - 2
|
||||
|
||||
function photo(id: string, width: number, height: number): PublicEmbedPhoto & { photoId: string } {
|
||||
return {
|
||||
photoId: id,
|
||||
thumb: { url: `https://img.test/${id}.jpg`, expiresAt: '2099-01-01T00:00:00.000Z' },
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 全等高样本:每条高 = width 比例 × 193 + 3 缝(800×500 → 120.6+3)。 */
|
||||
function uniform(n: number, width = 800, height = 500): PublicEmbedPhoto[] {
|
||||
return Array.from({ length: n }, (_, i) => photo(`p${i}`, width, height));
|
||||
}
|
||||
|
||||
function stateOf(photos: PublicEmbedPhoto[]): StreamEngineState {
|
||||
return createStreamState({
|
||||
photos: photos.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
|
||||
innerWidth: W,
|
||||
viewportHeight: H,
|
||||
});
|
||||
}
|
||||
|
||||
describe('photoRatio 防御(§2.3)', () => {
|
||||
it('正常比例;异常宽高/超长图回退与钳制', () => {
|
||||
expect(photoRatio({ width: 800, height: 500 })).toBeCloseTo(0.625);
|
||||
expect(photoRatio({ width: 0, height: 100 })).toBe(4 / 3);
|
||||
expect(photoRatio({ width: 100, height: NaN })).toBe(4 / 3);
|
||||
expect(photoRatio({ width: 100, height: 1e9 })).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStreamState 有限窗口(§3.2)', () => {
|
||||
it('窗口覆盖 viewport+480 缓冲;DOM 实例有上限', () => {
|
||||
const s = stateOf(uniform(30));
|
||||
// 每条 120.6+3=123.6 → 覆盖 278+480=758 需 ⌈758/123.6⌉=7 条。
|
||||
expect(s.items.length).toBe(7);
|
||||
expect(s.items.length).toBeLessThanOrEqual(MAX_DOM_INSTANCES);
|
||||
expect(s.items[0]).toMatchObject({ photoId: 'p0', queueIdx: 0, gen: 0 });
|
||||
});
|
||||
|
||||
it('短队列一圈盖不满 → 循环副本、key 含代次且唯一', () => {
|
||||
const s = stateOf(uniform(2));
|
||||
expect(s.items.length).toBeGreaterThanOrEqual(7);
|
||||
const keys = s.items.map((i) => i.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
// 第 3 条起为副本(gen=1)。
|
||||
expect(s.items[2].photoId).toBe('p0');
|
||||
expect(s.items[2].gen).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
it('16px/s × 2000ms = 32px;跨界过程屏幕内容不变(offset 回扣)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const before = s.offset;
|
||||
advanceStreamFor(s, 2000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(before + 32, 5);
|
||||
});
|
||||
|
||||
it('头条目完整滚出 → 退役一次(headIdx+1,offset 减该条高+缝)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const entryH = (500 / 800) * W + 3; // ≈123.6
|
||||
advanceStreamFor(s, 8000, 16, W, H); // 128px > 123.6 → 退役 1 条
|
||||
expect(s.headIdx).toBe(1);
|
||||
expect(s.offset).toBeCloseTo(128 - entryH, 5);
|
||||
// 头实例已轮换;窗口自 p1 起覆盖 758px:p1..p5(618)+p0#1(742)+p1#1(865≥758)。
|
||||
expect(s.items[0].photoId).toBe('p1');
|
||||
expect(s.items.at(-1)?.photoId).toBe('p1');
|
||||
expect(s.items.at(-1)?.gen).toBe(1);
|
||||
expect(s.items[5]).toMatchObject({ photoId: 'p0', gen: 1 });
|
||||
});
|
||||
|
||||
it('dt 上限 50ms:长停顿不补跑', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStream(s, 60_000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(16 * 0.05, 5);
|
||||
});
|
||||
|
||||
it('极矮条目一帧跨多条:循环退役至 offset 落回头条内', () => {
|
||||
const s = stateOf(uniform(3, 1200, 400)); // 每条 64.3+3 ≈67.3
|
||||
advanceStream(s, 50, 300, W, H); // 一帧 15px? no: 300*0.05=15px —— 用 5000ms 帧长不可超50
|
||||
// 重新计算:50ms × 300px/s = 15px 不足一条。改用 32px/s × 50ms × 多帧模拟:
|
||||
let retired = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = advanceStream(s, 50, 320, W, H); // 每帧 16px
|
||||
retired = retired || r.retired;
|
||||
}
|
||||
expect(retired).toBe(true);
|
||||
expect(s.offset).toBeLessThan((400 / 1200) * W + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchorRebuild 数据更新锚定(§6.2)', () => {
|
||||
it('幸存照片屏幕位置不变:滚过 60px 后更新队列,锚照片图内偏移保留', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H); // offset ≈64
|
||||
const anchorBefore = { head: s.headIdx, offset: s.offset };
|
||||
// 服务端同序返回(新签名 URL 场景)
|
||||
anchorRebuild(
|
||||
s,
|
||||
uniform(6).map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
expect(s.headIdx).toBe(anchorBefore.head);
|
||||
expect(s.offset).toBeCloseTo(anchorBefore.offset, 5);
|
||||
});
|
||||
|
||||
it('新照片追加队尾:不入当前显示区(锚照片仍是 head)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H);
|
||||
const head = s.headIdx;
|
||||
const next = uniform(6);
|
||||
next.push(photo('p-new', 800, 500));
|
||||
anchorRebuild(s, next.map((p) => ({ photoId: p.photoId, width: p.width, height: p.height })), W, H);
|
||||
expect(s.headIdx).toBe(head);
|
||||
expect(s.queue.at(-1)).toBe('p-new');
|
||||
expect(s.items[0].photoId).toBe(`p${head}`);
|
||||
});
|
||||
|
||||
it('锚照片被移除 → 回退最近幸存(不跳屏到未知态)', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H); // head=p0(offset 64)
|
||||
// 移除 p0(当前 head 且为锚)
|
||||
removePhoto(s, 'p0', W, H);
|
||||
expect(s.queue).not.toContain('p0');
|
||||
expect(s.headIdx).toBe(0); // 回退后 head 为原次条
|
||||
expect(s.items.every((i) => i.photoId !== 'p0')).toBe(true);
|
||||
});
|
||||
|
||||
it('removePhoto 保留屏幕位置:未滚出的锚不受影响', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
advanceStreamFor(s, 4000, 16, W, H);
|
||||
const before = { head: s.headIdx, offset: s.offset };
|
||||
removePhoto(s, 'p5', W, H); // p5 在窗口尾部,不在视口顶
|
||||
expect(s.headIdx).toBe(before.head);
|
||||
expect(s.offset).toBeCloseTo(before.offset, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 组合式装配(jsdom)──
|
||||
|
||||
const Harness = defineComponent({
|
||||
props: {
|
||||
photos: { type: Array<PublicEmbedPhoto>, required: true },
|
||||
active: { type: Boolean, default: true },
|
||||
speed: { type: Number, default: 16 },
|
||||
},
|
||||
setup(props) {
|
||||
const photos = ref(props.photos);
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
const stream = useVerticalPhotoStream({
|
||||
photos,
|
||||
viewportEl,
|
||||
trackEl,
|
||||
speedPxPerSec: ref(props.speed),
|
||||
running: ref(props.active && props.photos.length > 1),
|
||||
});
|
||||
return { viewportEl, trackEl, stream, photos };
|
||||
},
|
||||
template: `<div ref="viewportEl" style="width:193px;height:278px"><div ref="trackEl"><a v-for="i of stream.items.value" :key="i.key" :data-photo="i.photoId">{{ i.photoId }}</a></div></div>`,
|
||||
});
|
||||
|
||||
describe('useVerticalPhotoStream 装配', () => {
|
||||
it('挂载建窗;stepForTest 受控推进并写 transform', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(5) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const stepFor = (ms: number): void => {
|
||||
let left = ms;
|
||||
while (left > 0) {
|
||||
const dt = Math.min(left, 50);
|
||||
wrapper.vm.stream.stepForTest(dt);
|
||||
left -= dt;
|
||||
}
|
||||
};
|
||||
expect(wrapper.vm.stream.items.value.length).toBeGreaterThan(0);
|
||||
const track = wrapper.vm.trackEl as HTMLElement;
|
||||
expect(track.style.transform).toBe('translateY(0px)');
|
||||
stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进)
|
||||
expect(track.style.transform).toBe('translateY(-16px)');
|
||||
stepFor(1000);
|
||||
expect(track.style.transform).toBe('translateY(-32px)');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('单张:静态展示不推进(§3.2.8)', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(1) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
for (let i = 0; i < 100; i++) wrapper.vm.stream.stepForTest(50);
|
||||
expect(wrapper.vm.stream.offsetView.value).toBe(0);
|
||||
expect((wrapper.vm.stream.items.value)).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('照片加载失败 → dropPhoto 移出队列并同步渲染(§3.3)', async () => {
|
||||
const wrapper = mount(Harness, { props: { photos: uniform(4) } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.stream.dropPhoto('p0');
|
||||
expect(
|
||||
wrapper.vm.stream.items.value.every((i) => i.photoId !== 'p0'),
|
||||
).toBe(true);
|
||||
wrapper.vm.stream.dropPhoto('p1');
|
||||
expect(wrapper.vm.stream.items.value.every((i) => i.photoId !== 'p1')).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user