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:
浅念
2026-09-22 17:38:02 +08:00
parent f6ff1d44f2
commit 91c85e196a
15 changed files with 667 additions and 120 deletions
@@ -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();
});