- 6 modified + handoff JSON per CHANGES-HEL-824.json, all hashes verified against git 4321aeb and package bytes
- rework_browser_check: skip full-text sheet at micro height (<420px design hides the control); migrate stale 返回日子 selector to 结束交谈 · 回到日子
- fresh run evidence: web build 25/25 unit tests, backend 142/142 (py3.12), all four browser checks PASS incl. new compact first-press and 700ms slow-frame sections (slow_frames_new_turn_to_reply_s=2.32s), layout regression 320/390/1024/1600 both themes
Co-authored-by: multica-agent <github@multica.ai>
125 lines
8.6 KiB
TypeScript
125 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { anchorOf, chunks, cursorAt, dwellFor, graphemes, groupTurns, pagesOf, ReadingClock, STALL, type ShownMessage } from '../src/chat/reading';
|
|
import { Camera, FACE, springStep } from '../src/stage/camera';
|
|
|
|
const desk = { width: 900, font: 23, compact: false }, phone = { width: 370, font: 18, compact: false }, squat = { width: 300, font: 16, compact: true };
|
|
const LONG = '今天望着窗外的雨,想到小时候放学回家的那条路。那时候总觉得路很长,下雨时又不舍得走快,想听雨落在不同屋檐上的声音。后来搬到高一点的地方,雨声变远了,能看见的灯却多了。我还是会在这种时候把手边的事放一放,看看窗外,给自己泡一杯热茶。\n\n有时候,一天里也没有发生什么大事。可能只是花店换了橱窗,或者听到一首特别喜欢的歌。但如果能把这些细小的事情讲给你听,这一天就像多了一盏亮着的灯。\n\n最后这一句也完整保留:愿今晚的雨声让你睡得安稳。';
|
|
const NOPUNCT = '没有标点的长句'.repeat(40) + '🌙👩👩👧';
|
|
const msg = (id: string, role: ShownMessage['role'], text: string, extra: Partial<ShownMessage> = {}): ShownMessage => ({ id, role, text, lifeIds: [], ...extra });
|
|
|
|
describe('one or two lines at a time', () => {
|
|
it('keeps every character, never yields an empty line and respects the line budget', () => {
|
|
for (const metrics of [desk, phone, squat]) for (const text of [LONG, NOPUNCT, '好。', '」开头的标点']) {
|
|
const parts = chunks(text, metrics);
|
|
expect(parts.join('')).toBe(text.replace(/\n/g, ''));
|
|
expect(parts.every(p => p.trim().length > 0)).toBe(true);
|
|
const max = Math.max(14, Math.min(60, Math.floor(Math.floor(Math.max(220, Math.min(metrics.width, 900) - 24) / metrics.font) * (metrics.compact ? 1.6 : 2))));
|
|
expect(Math.max(...parts.map(p => graphemes(p).length))).toBeLessThanOrEqual(max + 2);
|
|
}
|
|
});
|
|
it('never splits an emoji or a joined grapheme', () => {
|
|
const parts = chunks(NOPUNCT, squat);
|
|
expect(parts.at(-1)).toContain('👩👩👧');
|
|
});
|
|
it('a growing stream only ever appends lines: shown lines never change or re-key', () => {
|
|
const final = pagesOf({ key: 't', items: [msg('u', 'user', '慢慢讲'), msg('c', 'companion', LONG)] }, desk);
|
|
const units = graphemes(LONG);
|
|
for (let n = 1; n < units.length; n += 3) {
|
|
const growing = pagesOf({ key: 't', items: [msg('u', 'user', '慢慢讲'), msg('live', 'companion', units.slice(0, n).join(''), { live: 'generating' })] }, desk);
|
|
growing.forEach((p, i) => { expect(p.key).toBe(final[i].key); expect(p.text).toBe(final[i].text); });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('turns follow the real record', () => {
|
|
it('groups by server turn id, starts a new turn at your message or when she speaks on her own', () => {
|
|
const turns = groupTurns([
|
|
msg('1', 'companion', '旧记录没有轮次'), msg('2', 'user', '你好', { turnId: 'a' }), msg('3', 'companion', '你好呀', { turnId: 'a' }),
|
|
msg('4', 'companion', '雨停了', { proactive: true }), msg('5', 'user', '真的?', { turnId: 'b' }), msg('6', 'companion', '嗯', { turnId: 'b' }), msg('7', 'companion', '迟到的旧轮', { turnId: 'a' }),
|
|
]);
|
|
expect(turns.map(t => t.items.map(m => m.id))).toEqual([['1'], ['2', '3'], ['4'], ['5', '6'], ['7']]);
|
|
expect(turns[1].key).toBe('a');
|
|
});
|
|
it('a stopped reply keeps what was said, in its own turn and marked as not finished', () => {
|
|
const turns = groupTurns([msg('2', 'user', '多说一会儿', { turnId: 'a', status: 'cancelled' }), msg('cut', 'companion', '今天望着窗外的雨,', { turnId: 'a', live: 'cancelled' })]);
|
|
const pages = pagesOf(turns[0], desk);
|
|
expect(pages.at(-1)).toMatchObject({ last: true, text: '今天望着窗外的雨,' });
|
|
expect(pages.at(-1)?.message.live).toBe('cancelled');
|
|
});
|
|
});
|
|
|
|
describe('reading position survives a re-chunk', () => {
|
|
it('keeps the same message and place in the text when the width changes', () => {
|
|
const turn = { key: 't', items: [msg('u', 'user', '慢慢讲'), msg('c', 'companion', LONG)] };
|
|
const wide = pagesOf(turn, desk), narrow = pagesOf(turn, squat);
|
|
for (let i = 0; i < wide.length; i++) {
|
|
const a = anchorOf(wide, i)!, j = cursorAt(narrow, a), start = narrow.slice(0, j).filter(p => p.mi === a.mi).reduce((n, p) => n + p.text.length, 0);
|
|
expect(narrow[j].mi).toBe(wide[i].mi);
|
|
expect(start).toBeLessThanOrEqual(a.offset);
|
|
expect(start + narrow[j].text.length).toBeGreaterThan(a.offset);
|
|
}
|
|
expect(cursorAt(wide, null)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('reading clock (HEL-824: stop, then a new turn at once)', () => {
|
|
const turn = (live?: ShownMessage['live']) => pagesOf({ key: 'b', items: [msg('u', 'user', '新轮', { turnId: 'b' }), msg('c', 'companion', LONG, { turnId: 'b', live })] }, phone);
|
|
// Frames `gap` ms apart (rAF time, always > 0) until the line is due; returns the wall time it took.
|
|
const due = (clock: ReadingClock, gap: number, start = 1000) => { const first = turn()[0]; let t = start; clock.tick(t, first, true); while (!clock.tick(t += gap, first, true)) if (t - start > 60000) return Infinity; return t - start; };
|
|
it('your line stays its designed time at any frame rate (100 ms per frame used to stretch 1.4 s to about 14 s)', () => {
|
|
const total = dwellFor(turn()[0]);
|
|
expect(total).toBe(1400);
|
|
for (const gap of [16, 100, 250, 700, 1000]) {
|
|
const took = due(new ReadingClock(), gap);
|
|
expect(took).toBeGreaterThanOrEqual(total);
|
|
expect(took).toBeLessThan(total + gap);
|
|
}
|
|
});
|
|
it('a growing or finished reply never restarts the line on screen', () => {
|
|
const clock = new ReadingClock(), seen: number[] = [];
|
|
let t = 1000;
|
|
for (const live of ['generating', 'generating', undefined] as const) for (let i = 0; i < 40; i++) { clock.tick(t += 16, turn(live)[0], true); seen.push(clock.progress); }
|
|
expect(seen.every((p, i) => i === 0 || p >= seen[i - 1])).toBe(true);
|
|
expect(clock.progress).toBe(1);
|
|
});
|
|
it('a new line, review or resume starts fresh; holds and hidden time are not reading', () => {
|
|
const clock = new ReadingClock(), [mine, hers] = turn();
|
|
clock.tick(1000, mine, true); clock.tick(1350, mine, true);
|
|
expect(clock.progress).toBe(.25);
|
|
clock.tick(6000, mine, false); // held (hover, selection, open sheet) or paused
|
|
expect(clock.progress).toBe(.25);
|
|
clock.pause(); clock.tick(61000, mine, true); // back from a hidden page
|
|
expect(clock.progress).toBe(.25);
|
|
clock.tick(91000, mine, true); // one stalled frame counts at most STALL
|
|
expect(clock.dwell).toBe(350 + STALL);
|
|
clock.tick(91016, hers, true); // next line: starts from its own first frame
|
|
expect(clock.key).toBe(hers.key);
|
|
expect(clock.dwell).toBe(16);
|
|
clock.tick(91032, hers, true); clock.restart();
|
|
expect(clock.progress).toBe(0);
|
|
clock.forget(); clock.tick(91048, hers, true);
|
|
expect(clock.dwell).toBe(16);
|
|
});
|
|
});
|
|
|
|
describe('one room, one camera', () => {
|
|
it('starts on the room still and lands exactly on the approved talk composition', () => {
|
|
for (const [w, h] of [[1600, 1000], [1024, 768], [390, 844], [320, 568]]) {
|
|
const cam = new Camera(w, h), A = cam.rest('room'), B = cam.rest('talk'), start = cam.at(0), end = cam.at(1);
|
|
expect(start.room.s).toBeCloseTo(A.s, 6); expect(start.room.o[0]).toBeCloseTo(A.o[0], 3); expect(start.room.o[1]).toBeCloseTo(A.o[1], 3);
|
|
expect(end.talk.s).toBeCloseTo(B.s, 6);
|
|
expect(end.talk.o[0] + end.talk.s * FACE.talk[0]).toBeCloseTo(B.o[0] + B.s * FACE.talk[0], 2);
|
|
expect(end.talk.o[1] + end.talk.s * FACE.talk[1]).toBeCloseTo(B.o[1] + B.s * FACE.talk[1], 2);
|
|
}
|
|
});
|
|
it('the spring settles, and reversing mid-way keeps its velocity (no jump)', () => {
|
|
let s = { t: 0, v: 0, settled: false }, n = 0;
|
|
while (!s.settled && n++ < 600) s = springStep(s.t, s.v, 1, 1 / 60);
|
|
expect(s.t).toBe(1); expect(n).toBeLessThan(120);
|
|
let r = { t: 0, v: 0, settled: false };
|
|
for (let i = 0; i < 20; i++) r = springStep(r.t, r.v, 1, 1 / 60);
|
|
const before = r.t, back = springStep(r.t, r.v, 0, 1 / 60);
|
|
expect(Math.abs(back.t - before)).toBeLessThan(.05);
|
|
});
|
|
});
|