feat(viewer,ui04): E05 realtime hearts + effect-demo alignment (initial offset, heart params, decode gate, bidirectional manual scroll)
- reactions.ts pure fns: delta->hearts sampling, burstId dedupe (TTL/capacity), pending-visible cache (5s/50), 250ms emission throttle with merge - useEmbedReactions: read-only RealtimeClient subscription on album.publicToken (no viewer-page side effects, A15), rebind teardown, 10s grace disconnect, no fake bubbles on WS failure - ReactionLayer: demo-frozen heart effect (12/16/20 sizes, two colors, 110px rise, 1.6s ease-out, 300ms stagger, max 8, drop-shadow) in unscoped block (created elements lack scoped hash) - demo alignment: initial offset 32px, decode+500ms startup gate, 1:1 drag, arrows +-40, focus-tip, bidirectional manual scroll (manualScroll), loader handle 100x44 exact styles, <=599px offset default 12 - tests: +8 reactions unit (244 viewer), e2e E05: real cross-context like -> heart on correct photo (EMB-A09), zero likes/visits submission (EMB-A15), 16s hearts recording Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// apps/viewer/src/__tests__/embed-reactions.spec.ts —— UI-04 E05 气泡纯函数
|
||||
// 与订阅编排测试(§5.2):视觉抽样(delta→颗数)、burstId 去重(TTL/容量)、
|
||||
// 不可见缓存(5s/50)、发射节流(250ms 合并)、useEmbedReactions 事件管道
|
||||
// (ingestForTest 注入:可见归属/去重/节流/pending 转可见)。
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BurstDeduper,
|
||||
EmissionThrottle,
|
||||
heartsForDelta,
|
||||
PendingVisibleCache,
|
||||
} from '../embed/reactions';
|
||||
import { useEmbedReactions } from '../embed/useEmbedReactions';
|
||||
import type { ServerMessage } from '@photo/contracts/realtime';
|
||||
|
||||
describe('heartsForDelta 视觉抽样(§5.2)', () => {
|
||||
it('1→1;2–5→2;更大→3;非法→0', () => {
|
||||
expect(heartsForDelta(1)).toBe(1);
|
||||
expect(heartsForDelta(2)).toBe(2);
|
||||
expect(heartsForDelta(5)).toBe(2);
|
||||
expect(heartsForDelta(6)).toBe(3);
|
||||
expect(heartsForDelta(100)).toBe(3);
|
||||
expect(heartsForDelta(0)).toBe(0);
|
||||
expect(heartsForDelta(-3)).toBe(0);
|
||||
expect(heartsForDelta(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BurstDeduper(§5.2 TTL+容量)', () => {
|
||||
it('首见接受、重复拒绝、TTL 过期后重见接受', () => {
|
||||
const d = new BurstDeduper(1_000);
|
||||
expect(d.accept('b1', 0)).toBe(true);
|
||||
expect(d.accept('b1', 100)).toBe(false);
|
||||
expect(d.accept('b1', 1_200)).toBe(true); // 超 TTL
|
||||
});
|
||||
|
||||
it('容量满时驱逐最旧', () => {
|
||||
const d = new BurstDeduper(60_000, 2);
|
||||
d.accept('a', 0);
|
||||
d.accept('b', 1);
|
||||
d.accept('c', 2); // 驱逐 a
|
||||
expect(d.accept('a', 3)).toBe(true); // a 已被驱逐 → 重见接受
|
||||
expect(d.accept('c', 3)).toBe(false); // c 仍在
|
||||
});
|
||||
});
|
||||
|
||||
describe('PendingVisibleCache(§5.2 5s/50)', () => {
|
||||
it('可见时取出;超时丢弃;同照片累加封顶 3', () => {
|
||||
const c = new PendingVisibleCache(1_000, 50);
|
||||
c.add('p1', 2, 0);
|
||||
c.add('p1', 2, 100); // 合并 4 → 封顶 3
|
||||
expect(c.take('p1', 200)).toBe(3);
|
||||
expect(c.take('p1', 300)).toBe(0); // 已取走
|
||||
c.add('p2', 1, 0);
|
||||
expect(c.take('p2', 2_000)).toBe(0); // 超 TTL 丢弃
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmissionThrottle(§5.2 250ms 合并)', () => {
|
||||
it('冷却内并入队列;窗口到点整批放行;同照片封顶 3', () => {
|
||||
const t = new EmissionThrottle(250);
|
||||
const first = t.offer('p1', 1, 0);
|
||||
expect(first).not.toBeNull(); // 首发立即放行
|
||||
expect(t.offer('p2', 2, 100)).toBeNull(); // 冷却内
|
||||
expect(t.offer('p2', 3, 150)).toBeNull(); // 合并 5 → 3
|
||||
const batch = t.offer('p3', 1, 300); // 窗口到点
|
||||
expect(batch).not.toBeNull();
|
||||
expect(batch!.get('p2')).toBe(3);
|
||||
expect(batch!.get('p3')).toBe(1);
|
||||
expect(batch!.has('p1')).toBe(false); // 首发已放行
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEmbedReactions 事件管道(jsdom 挂载 + ingestForTest 注入)──
|
||||
|
||||
const Harness = defineComponent({
|
||||
props: {
|
||||
visible: { type: Array<string>, required: true },
|
||||
albumToken: { type: String, default: 'tok123' },
|
||||
},
|
||||
setup(props) {
|
||||
const emitted: Array<Map<string, number>> = [];
|
||||
const reactions = useEmbedReactions({
|
||||
albumToken: ref(props.albumToken),
|
||||
enabled: ref(true),
|
||||
active: ref(true),
|
||||
isPhotoVisible: (id) => props.visible.includes(id),
|
||||
onEmit: (batch) => emitted.push(batch),
|
||||
});
|
||||
return { reactions, emitted };
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
function burst(photoId: string, delta: number, burstId: string): ServerMessage {
|
||||
return { type: 'likes.burst', photoId, albumId: 'a1', delta, total: 1, countVersion: 1, burstId, originBatchIds: [] };
|
||||
}
|
||||
|
||||
describe('useEmbedReactions 管道(§5.2 归属/去重/节流/pending)', () => {
|
||||
it('可见照片 burst → 立即出泡;重复 burstId 不加计;delta 抽样生效', async () => {
|
||||
const wrapper = mount(Harness, { props: { visible: ['p1'] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'b1'));
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
expect(wrapper.vm.emitted[0].get('p1')).toBe(1);
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 5, 'b1')); // 重复 burstId → 忽略
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 100, 'b2')); // delta 100 → 3
|
||||
expect(wrapper.vm.emitted).toHaveLength(2);
|
||||
expect(wrapper.vm.emitted[1].get('p1')).toBe(3);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('非法 delta 忽略;冷却内合并为一批', async () => {
|
||||
const wrapper = mount(Harness, { props: { visible: ['p1', 'p2'] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 0, 'bad')); // delta<1 → 忽略
|
||||
expect(wrapper.vm.emitted).toHaveLength(0);
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'a'));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p2', 1, 'b')); // 冷却内并入
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'c')); // 放行合并批
|
||||
expect(wrapper.vm.emitted).toHaveLength(2);
|
||||
expect(wrapper.vm.emitted[1].get('p2')).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('不可见照片缓存 → 转可见后 500ms 巡检出泡(5s 内)', async () => {
|
||||
const visibleRef = ref<string[]>([]);
|
||||
const wrapper = mount(Harness, { props: { visible: [] } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
wrapper.vm.reactions.ingestForTest(burst('hidden-p', 1, 'h1'));
|
||||
expect(wrapper.vm.emitted).toHaveLength(0);
|
||||
// 转可见(真实定时器:sweep 间隔 500ms)。
|
||||
await wrapper.setProps({ visible: ['hidden-p'] });
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(wrapper.vm.emitted).toHaveLength(1);
|
||||
expect(wrapper.vm.emitted[0].get('hidden-p')).toBe(1);
|
||||
void visibleRef;
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -83,9 +83,9 @@ describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
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 条
|
||||
advanceStreamFor(s, 8000, 16, W, H); // 初 32+128=160 > 123.6 → 退役 1 条
|
||||
expect(s.headIdx).toBe(1);
|
||||
expect(s.offset).toBeCloseTo(128 - entryH, 5);
|
||||
expect(s.offset).toBeCloseTo(32 + 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');
|
||||
@@ -95,8 +95,9 @@ describe('advanceStream 匀速推进(§3.1)', () => {
|
||||
|
||||
it('dt 上限 50ms:长停顿不补跑', () => {
|
||||
const s = stateOf(uniform(6));
|
||||
const initial = s.offset; // 32(效果示例初始偏移)
|
||||
advanceStream(s, 60_000, 16, W, H);
|
||||
expect(s.offset).toBeCloseTo(16 * 0.05, 5);
|
||||
expect(s.offset).toBeCloseTo(initial + 16 * 0.05, 5);
|
||||
});
|
||||
|
||||
it('极矮条目一帧跨多条:循环退役至 offset 落回头条内', () => {
|
||||
@@ -199,11 +200,12 @@ describe('useVerticalPhotoStream 装配', () => {
|
||||
};
|
||||
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);
|
||||
// 初始偏移 32(效果示例 offset=32:顶部露首照上缘片段;多张才有)。
|
||||
expect(track.style.transform).toBe('translateY(-32px)');
|
||||
stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进)
|
||||
expect(track.style.transform).toBe('translateY(-48px)');
|
||||
stepFor(1000);
|
||||
expect(track.style.transform).toBe('translateY(-64px)');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user