mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): motion has a cause, and mount is not one
Two places took a DOM node coming into existence for something happening.
A transcript card animates in whenever it mounts, while blocks of 48 mount
and unmount as the reader scrolls — so a fact from an hour ago arrives
again every time its block comes back. A settings block does the same on
every section change, and four of them flying in says four new things
appeared when the reader only walked to another page.
Entrance is now a debt, owed once and spent by the render that draws it.
The projection keeps it, because only the projection knows whether a card
is arriving or being replayed: a restored transcript owes nothing, a card
the live reducer first materialises owes exactly one, and a delta rewriting
a message it already made, a dispatch settling into its result, or a block
scrolling back into the document are all the same card and none of them is
a new fact. Kept beside Item rather than on it — what a card is does not
depend on whether its arrival has been shown yet.
Provenance would not have been enough, and that is the trap this is written
against. A card that was live three hours ago is still live-origin when
virtualization brings it back, and a mark meaning "this came in live" would
animate it a second time. What has to be asked is whether the entrance is
still owed, which is a question with an answer that changes.
The debt is spent by render and never by the animation. Reduced motion runs
no animation at all, an element can leave the document before one ends, and
either would leave the debt standing for a card to arrive again later.
Motion does not get to own the state machine.
The mark rides a display:contents frame around each row rather than a prop
threaded into twelve card components. Measured rather than assumed: every
card still has its block's exact box, content-visibility still applies, and
the turn anchor's margin still collapses to the same 28px.
The settings sheet gets the same treatment from the other end. Its blocks
stop animating and the column is keyed on the section, so changing section
is one movement of the thing that changed — the view — instead of a stagger
of everything mounted beneath it.
The rule is written down twice where it will be read: beside the motion
tokens, and as a guard over the stylesheet itself. That guard is the only
thing standing between this and someone writing `.call { animation: card }`
again, and it needed vitest to stop stubbing CSS — through ?raw it was
handed an empty string, which every rule in it would have passed.
Four sabotages, each reddening its own half: not spending the debt, giving
it to a restored transcript, re-owing it on every revision, and putting the
settings rule back. Seal A stays held, Seal B stays at 98, and the action
universe is unchanged at 418 inputs and 250 unnamed — this adds no way for
a person to act on anything, which is what a motion change should cost.
testkit gains an IntersectionObserver: jsdom has no viewport and therefore
no intersections, and the transcript asks one which block is near.
This commit is contained in:
1 parent
6272d2ebd8
commit
b0af1d1b04
11 files changed
+342
-9
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialState, reduce } from "./session";
|
||||
import type { SessionState } from "./session_types";
|
||||
import type { SessionEvent } from "./session";
|
||||
|
||||
// Motion describes a state transition, never an element's lifetime. The one
|
||||
// transition a card's entrance is about is a fact arriving, so the projection
|
||||
// — not the DOM, not a provenance label on the item — is what says whether one
|
||||
// is still owed. These hold that: a fact earns its entrance once, and nothing
|
||||
// that happens to the same fact afterwards earns it again.
|
||||
const run = (s: SessionState, ...evs: SessionEvent[]) => evs.reduce(reduce, s);
|
||||
const say = (text: string): SessionEvent => ({ kind: "text", text } as SessionEvent);
|
||||
const dispatched = (id: string, name = "read_file"): SessionEvent =>
|
||||
({ kind: "tool_dispatch", tool: { id, name, readOnly: true } } as SessionEvent);
|
||||
const resulted = (id: string, name = "read_file"): SessionEvent =>
|
||||
({ kind: "tool_result", tool: { id, name, readOnly: true, output: "ok" } } as SessionEvent);
|
||||
|
||||
describe("which cards owe an entrance", () => {
|
||||
it("gives a card that just arrived exactly one", () => {
|
||||
const s = run(initialState, { kind: "__user", text: "hi", pending: false });
|
||||
expect(s.entranceOwed.length).toBe(1);
|
||||
expect(s.items.map((i: { id: string }) => i.id)).toEqual(s.entranceOwed);
|
||||
});
|
||||
|
||||
it("owes nothing for a transcript that is being restored", () => {
|
||||
const live = run(initialState, { kind: "__user", text: "hi", pending: false });
|
||||
const cold = run(live, { kind: "__restore", items: [
|
||||
{ t: "user", id: "h1", text: "a" },
|
||||
{ t: "say", id: "h2", text: "b", done: true },
|
||||
], plan: [] } as SessionEvent);
|
||||
expect(cold.items.length).toBe(2);
|
||||
expect(cold.entranceOwed).toEqual([]);
|
||||
});
|
||||
|
||||
// The debt is carried, not recomputed: several events can land between two
|
||||
// renders, and the card that was never drawn still owes its entrance.
|
||||
it("keeps what a render has not drawn yet", () => {
|
||||
const s = run(initialState,
|
||||
{ kind: "__user", text: "one", pending: false },
|
||||
{ kind: "__user", text: "two", pending: false });
|
||||
expect(s.entranceOwed.length).toBe(2);
|
||||
});
|
||||
|
||||
it("spends only what the transcript says it drew", () => {
|
||||
const s = run(initialState,
|
||||
{ kind: "__user", text: "one", pending: false },
|
||||
{ kind: "__user", text: "two", pending: false });
|
||||
const after = run(s, { kind: "__entered", ids: [s.entranceOwed[0]] });
|
||||
expect(after.entranceOwed).toEqual([s.entranceOwed[1]]);
|
||||
});
|
||||
|
||||
// The same card, over and over. A streamed answer rewrites one item two
|
||||
// hundred times and a call goes dispatch → result on one row; none of that
|
||||
// is a new fact.
|
||||
it("does not let a streaming answer earn it again", () => {
|
||||
let s = run(initialState, say("Hel"));
|
||||
const first = s.entranceOwed;
|
||||
expect(first.length).toBe(1);
|
||||
s = run(s, { kind: "__entered", ids: first });
|
||||
s = run(s, say("lo"), say(" there"));
|
||||
expect(s.entranceOwed).toEqual([]);
|
||||
expect(s.items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not let a call earn it again when it settles", () => {
|
||||
let s = run(initialState, dispatched("t1"));
|
||||
expect(s.entranceOwed.length).toBe(1);
|
||||
s = run(s, { kind: "__entered", ids: s.entranceOwed });
|
||||
s = run(s, resulted("t1"));
|
||||
expect(s.entranceOwed).toEqual([]);
|
||||
expect(s.items.length).toBe(1);
|
||||
});
|
||||
|
||||
// Restoring in the middle of a session is still a restore: switching to
|
||||
// another conversation must not replay its whole history as if it were
|
||||
// arriving now.
|
||||
it("owes nothing when a live pane is handed a different transcript", () => {
|
||||
let s = run(initialState, { kind: "__user", text: "hi", pending: false });
|
||||
s = run(s, { kind: "__entered", ids: s.entranceOwed });
|
||||
s = run(s, { kind: "__restore", items: [
|
||||
{ t: "user", id: "h9", text: "other" },
|
||||
{ t: "say", id: "h10", text: "answer", done: true },
|
||||
], plan: [] } as SessionEvent);
|
||||
expect(s.entranceOwed).toEqual([]);
|
||||
});
|
||||
|
||||
// And a fact arriving after that restore is still a new fact.
|
||||
it("still lets the next real arrival in", () => {
|
||||
let s = run(initialState, { kind: "__restore", items: [{ t: "user", id: "h1", text: "a" }], plan: [] } as SessionEvent);
|
||||
s = run(s, { kind: "__user", text: "now", pending: false });
|
||||
expect(s.entranceOwed.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ export const initialState: SessionState = {
|
||||
terminal: null,
|
||||
runtime: [],
|
||||
items: [],
|
||||
entranceOwed: [],
|
||||
enteredThrough: 0,
|
||||
revision: 0,
|
||||
plan: [],
|
||||
outWindow: [],
|
||||
@@ -217,7 +219,33 @@ export function reduce(s: SessionState, ev: SessionEvent): SessionState {
|
||||
const next = apply(s, ev);
|
||||
if (next.items === s.items) return next;
|
||||
const streamed = ev.kind === "text" || ev.kind === "reasoning";
|
||||
return streamed ? next : { ...next, revision: next.revision + 1 };
|
||||
const bumped = streamed ? next : { ...next, revision: next.revision + 1 };
|
||||
return { ...bumped, ...entering(s, bumped, ev) };
|
||||
}
|
||||
|
||||
// The sequence a card was minted at. One monotonic source mints every id, so
|
||||
// "this projection has already let everything through n in" is a number rather
|
||||
// than a set to carry and copy.
|
||||
const seqOf = (id: string) => (id.startsWith("i") ? Number(id.slice(1)) || 0 : 0);
|
||||
const highest = (items: Item[]) => items.reduce((n, i) => Math.max(n, seqOf(i.id)), 0);
|
||||
|
||||
/** Which cards owe an entrance after this event, and how far we have let in.
|
||||
*
|
||||
* A restored transcript owes nothing: those facts are not arriving, they
|
||||
* arrived. Everything else earns it exactly once, at the event that first
|
||||
* materialised the card — a delta rewriting a message it already made, a
|
||||
* dispatch settling into a result, a block scrolling back into the document
|
||||
* are all the same card and none of them is a new fact. The debt is carried
|
||||
* rather than cleared here, because several events can land between two
|
||||
* renders and the one that was never drawn still owes its entrance. */
|
||||
function entering(prev: SessionState, next: SessionState, ev: SessionEvent): Partial<SessionState> {
|
||||
if (ev.kind === "__restore") return { entranceOwed: [], enteredThrough: highest(next.items) };
|
||||
const fresh = next.items.filter((i) => seqOf(i.id) > prev.enteredThrough).map((i) => i.id);
|
||||
if (fresh.length === 0) return {};
|
||||
return {
|
||||
entranceOwed: [...prev.entranceOwed, ...fresh],
|
||||
enteredThrough: Math.max(prev.enteredThrough, highest(next.items)),
|
||||
};
|
||||
}
|
||||
|
||||
// What the wire carries, plus the turns the client owns itself: your own
|
||||
@@ -232,10 +260,19 @@ export type SessionEvent =
|
||||
| { kind: "__queued"; id: string; itemId: string; queued: "steer" | "followup" }
|
||||
| { kind: "__decided"; id: string; verdict?: string; answers?: string[][] }
|
||||
| { kind: "__forgot"; id: string }
|
||||
| { kind: "__runtime_seen"; id: string };
|
||||
| { kind: "__runtime_seen"; id: string }
|
||||
// The transcript has drawn these cards, so their one entrance is spent.
|
||||
// Render says when, not the animation: a card can leave the document before
|
||||
// its animation ends, and reduced motion runs no animation at all.
|
||||
| { kind: "__entered"; ids: string[] };
|
||||
|
||||
function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
if (ev.kind === "__error") return { ...s, error: ev.text };
|
||||
if (ev.kind === "__entered") {
|
||||
const spent = new Set(ev.ids);
|
||||
const left = s.entranceOwed.filter((id) => !spent.has(id));
|
||||
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
|
||||
}
|
||||
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
|
||||
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
|
||||
// Both event.Message emitters carry assistant text, so nothing on the wire
|
||||
|
||||
@@ -126,6 +126,16 @@ export interface SessionState {
|
||||
// transcript as if they had been said by someone.
|
||||
runtime: RuntimeNotice[];
|
||||
items: Item[];
|
||||
// Which cards have not yet had their one entrance, and how far this
|
||||
// projection has already let in. Motion describes a state transition, and
|
||||
// the transition here is a fact arriving — not a DOM node being created. A
|
||||
// card mounts again every time virtualization brings its block back, and
|
||||
// replaying that motion says the hour-old thing just happened.
|
||||
//
|
||||
// Presentation bookkeeping, deliberately beside Item rather than on it: what
|
||||
// a card is does not depend on whether its arrival has been shown yet.
|
||||
entranceOwed: string[];
|
||||
enteredThrough: number;
|
||||
// Bumped when the transcript's composition changes — a card added, settled,
|
||||
// folded, answered — but NOT when a message still being written grows by a
|
||||
// chunk. Everything derived from the tool and user cards (the rail's panels,
|
||||
|
||||
@@ -1135,7 +1135,10 @@
|
||||
绘制也省掉。不用结构伪类挑最后一张——`:nth-last-child` 会让每次追加都
|
||||
把整条记录的样式匹配作废。 */
|
||||
.chunk { display: flow-root }
|
||||
.chunk > .call {
|
||||
/* display:contents on the frame between them, so the card is still the
|
||||
block's child everywhere layout is concerned and only the selector has to
|
||||
say so. */
|
||||
.chunk > .enterbox > .call {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 96px;
|
||||
}
|
||||
@@ -1502,7 +1505,17 @@
|
||||
|
||||
/* 份量不同,入场就不该相同:一行只淡入,一张卡带位移,一整块面板从上沿展开 */
|
||||
.term-l, .dl, .plan .s { animation: line var(--m-line) both }
|
||||
.call, .hits > *, .steps > * { animation: card var(--m-card) both }
|
||||
.hits > *, .steps > * { animation: card var(--m-card) both }
|
||||
|
||||
/* 动画说的是状态转变,从来不是元素活了多久。挂载不是原因:一条记录里的卡
|
||||
每次虚拟化把它那一块带回来都会重新挂载,跟着演一遍入场,等于说一小时前
|
||||
的事刚刚发生。所以入场是一次性的债,由投影记账、由第一次绘制花掉,这个
|
||||
不生成盒子的框只负责把那一次带到卡上。 */
|
||||
.enterbox { display: contents }
|
||||
.enterbox[data-enter] > .call { animation: card var(--m-card) both }
|
||||
/* 子代理自己的步骤是在它跑的时候出现的 —— 那也是事实在到达,只不过发生在
|
||||
一张卡里面而不是旁边。 */
|
||||
.nest-bd .call { animation: card var(--m-card) both }
|
||||
|
||||
.nest, .ask { animation: panel var(--m-panel) both }
|
||||
|
||||
@@ -3100,7 +3113,12 @@
|
||||
/* 660 是给读的段落定的行宽。扩展页是表:名字、说明、触发方式、归属、开关
|
||||
五列挤在同一个上限里会把说明截掉,所以只有这一页放宽。 */
|
||||
.prefs-main[data-sec="ext"] .prefs-col { max-width: 980px }
|
||||
.grp { margin-bottom: 30px; animation: card var(--t-card) var(--ease-out) both }
|
||||
/* 换一个分区是一次导航:换掉的是这一栏,不是下面每个设置块各自刚出现。原来
|
||||
每块各飞一次,四个块就是四次「新东西来了」,而什么都没发生 —— 你只是走到
|
||||
了另一页。 */
|
||||
.grp { margin-bottom: 30px }
|
||||
.prefs-col { animation: navin .14s var(--ease-out) both }
|
||||
@keyframes navin { from { opacity: 0; transform: translateY(3px) } to { opacity: 1; transform: none } }
|
||||
.grp:last-child { margin-bottom: 0 }
|
||||
.grp-hd { display: flex; align-items: baseline; gap: 12px }
|
||||
/* An overline, not a headline: the rows under it carry the weight, and a page
|
||||
@@ -4077,7 +4095,7 @@
|
||||
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-card, .onb-field, .onb-go, .onb-note, .onb-r path, .prefs, .grp { animation: none !important }
|
||||
.onb-card, .onb-field, .onb-go, .onb-note, .onb-r path, .prefs, .prefs-col { animation: none !important }
|
||||
.app[data-prefs] > .chrome, .app[data-prefs] > .cols { transform: none }
|
||||
/* The entry stagger is decoration; the hover reveal and the accent rule are
|
||||
not. A delayed row that never animates would stay invisible, so the delay
|
||||
|
||||
@@ -111,6 +111,12 @@
|
||||
/* 一档份量把时长和缓动一起说完。分开放着的时候,同一档的两处动画可以各
|
||||
配各的缓动,而「这是一张卡」本来就同时决定了这两件事。--m-step 是编排:
|
||||
一批元素彼此错开多久 —— 过去每个调用点自己定一个数。 */
|
||||
/* Motion describes a state transition, never an element's lifetime.
|
||||
Mount and remount are not causes. A live fact may enter once; replay and
|
||||
virtualization do not replay motion. Navigation moves the containing
|
||||
view, not every child mounted beneath it. Held by src/ui/motion.test.tsx
|
||||
— including on this stylesheet, because the tempting rule and the
|
||||
correct one differ by a selector. */
|
||||
--m-line: var(--t-line) var(--ease-out);
|
||||
--m-card: var(--t-card) var(--ease-out);
|
||||
--m-panel: var(--t-panel) var(--ease-out);
|
||||
|
||||
@@ -609,6 +609,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
|
||||
<Transcript
|
||||
items={s.items}
|
||||
entering={s.entranceOwed}
|
||||
onEntered={(ids) => dispatch({ kind: "__entered", ids } as never)}
|
||||
revision={s.revision}
|
||||
takeovers={s.takeovers}
|
||||
waiting={s.waiting}
|
||||
|
||||
@@ -417,7 +417,9 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="prefs-col">
|
||||
{/* Keyed on the section: replacing this column is the navigation,
|
||||
and it is the one thing here whose mount really is the cause. */}
|
||||
<div className="prefs-col" key={at}>
|
||||
{at === "session" && (
|
||||
<>
|
||||
<Group id="preset" title={t("执行设定")} now={preset} hint={t("决定任务完成的判定标准。切换立刻生效,不会重建运行时。")}>
|
||||
|
||||
@@ -58,6 +58,11 @@ interface Props {
|
||||
onCommitFileRevert: (planId: string, resolution?: string) => Promise<RewindResult>;
|
||||
onCommitRewind: (planId: string) => Promise<RewindResult>;
|
||||
onUndoRewind: (transactionId: string) => Promise<void>;
|
||||
/** Cards that have not had their one entrance yet. Owed by the projection,
|
||||
* spent by the first render that draws them — never by the animation, which
|
||||
* may not run at all. */
|
||||
entering: string[];
|
||||
onEntered: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
// How many settled cards share one mounting unit. Small enough that scrolling
|
||||
@@ -93,7 +98,7 @@ function useBlocks(items: Item[], cut: number, revision: number): Item[][] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function Transcript({ items, revision, waiting, scroll, hidden, onPinned, jump, focus, onApprove, onPlan, onAnswer, onSuggest, onForget, onCancelQueued, onExtInvoke, onExtSubmit, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
export function Transcript({ items, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, onApprove, onPlan, onAnswer, onSuggest, onForget, onCancelQueued, onExtInvoke, onExtSubmit, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
// A block the selection touches must not leave the DOM. Unmounting the node a
|
||||
// selection is anchored to makes the browser remap that selection onto
|
||||
// whatever is still mounted — which reads as "I selected up there and the
|
||||
@@ -398,7 +403,15 @@ export function Transcript({ items, revision, waiting, scroll, hidden, onPinned,
|
||||
if (where) land(where.block, where.into, `[data-call="${CSS.escape(focus.call)}"]`);
|
||||
}, [hidden, focus, callBlock, land]);
|
||||
|
||||
const rowProps = { onApprove, onPlan, onAnswer, onForget, onCancelQueued, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
// Drawn is spent. Not "the animation finished" — reduced motion runs none,
|
||||
// and a card can leave the document before one ends, either of which would
|
||||
// leave the debt standing and let the fact arrive a second time later.
|
||||
useEffect(() => {
|
||||
if (entering.length > 0) onEntered(entering);
|
||||
}, [entering, onEntered]);
|
||||
const owed = useMemo(() => new Set(entering), [entering]);
|
||||
|
||||
const rowProps = { owed, onApprove, onPlan, onAnswer, onForget, onCancelQueued, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
|
||||
// What you said, and where it sits. Derived from the same blocks the
|
||||
// transcript renders, so a mark always knows which block holds it — that is
|
||||
@@ -499,6 +512,7 @@ const Block = memo(function Block({
|
||||
});
|
||||
|
||||
interface RowHandlers {
|
||||
owed: Set<string>;
|
||||
onApprove: Props["onApprove"];
|
||||
onPlan: Props["onPlan"];
|
||||
onAnswer: Props["onAnswer"];
|
||||
@@ -519,6 +533,7 @@ interface RowHandlers {
|
||||
// chunk than parsing the message did.
|
||||
const Row = memo(function Row({
|
||||
it,
|
||||
owed,
|
||||
onApprove,
|
||||
onPlan,
|
||||
onForget,
|
||||
@@ -534,6 +549,16 @@ const Row = memo(function Row({
|
||||
onPrepareFileRevert,
|
||||
onCommitFileRevert,
|
||||
}: RowHandlers & { it: Item; cp?: Checkpoint }) {
|
||||
// Read once, for the life of this element. The projection spends the debt as
|
||||
// soon as this is drawn, and a card whose answer is still streaming would
|
||||
// otherwise lose the attribute mid-animation and have it cut short. A block
|
||||
// that scrolls back mounts a new Row, which asks again and is told no.
|
||||
const enter = useRef(owed.has(it.id)).current;
|
||||
// display:contents, so this frame carries the one-shot mark and the card
|
||||
// stays exactly the child of .chunk that its layout is written against.
|
||||
return (
|
||||
<div className="enterbox" data-enter={enter ? "" : undefined}>
|
||||
{(() => {
|
||||
switch (it.t) {
|
||||
case "user":
|
||||
return (
|
||||
@@ -587,6 +612,9 @@ const Row = memo(function Row({
|
||||
case "notice":
|
||||
return <NoticeCard item={it} />;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Counted from the stamp the wait carries rather than from this component's
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { Transcript } from "./Transcript";
|
||||
import type { Item } from "../state/session";
|
||||
// Not through import.meta.glob: vite's CSS handling intercepts ?raw there and
|
||||
// hands back an empty string, which every rule below would then pass.
|
||||
import css from "../styles/app.css?raw";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const user = (id: string, text: string): Item => ({ t: "user", id, text });
|
||||
|
||||
function draw(items: Item[], entering: string[], hidden = false) {
|
||||
const onEntered = vi.fn();
|
||||
const noop = async () => undefined as never;
|
||||
const view = render(
|
||||
<Transcript
|
||||
items={items} entering={entering} onEntered={onEntered} revision={1}
|
||||
waiting={{}} scroll={{ current: null }} hidden={hidden} onPinned={() => {}}
|
||||
jump={0} focus={null}
|
||||
onApprove={noop} onPlan={noop} onAnswer={noop} onSuggest={() => {}} onForget={noop}
|
||||
onCancelQueued={() => {}} onExtInvoke={() => {}} onExtSubmit={noop}
|
||||
checkpoints={new Map()} onPrepareRewind={noop} onCommitRewind={noop} onUndoRewind={noop}
|
||||
onPrepareFileRevert={noop} onCommitFileRevert={noop}
|
||||
needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
|
||||
/>,
|
||||
);
|
||||
const entered = () => [...document.querySelectorAll(".enterbox[data-enter]")].length;
|
||||
return { view, onEntered, entered };
|
||||
}
|
||||
|
||||
describe("a card's one entrance", () => {
|
||||
it("is marked on the card that has just arrived", () => {
|
||||
const { entered } = draw([user("i1", "hi")], ["i1"]);
|
||||
expect(entered()).toBe(1);
|
||||
});
|
||||
|
||||
it("is spent by the render that drew it, not by the animation ending", () => {
|
||||
const { onEntered } = draw([user("i1", "hi")], ["i1"]);
|
||||
// No animation has run — jsdom has none, and reduced motion runs none
|
||||
// either. The debt is settled all the same.
|
||||
expect(onEntered).toHaveBeenCalledWith(["i1"]);
|
||||
});
|
||||
|
||||
// What virtualization does: the same card, a new element, after the
|
||||
// projection has already spent the debt.
|
||||
it("is not given again when the same card mounts a second time", () => {
|
||||
const first = draw([user("i1", "hi")], ["i1"]);
|
||||
expect(first.entered()).toBe(1);
|
||||
cleanup();
|
||||
const again = draw([user("i1", "hi")], []);
|
||||
expect(again.entered()).toBe(0);
|
||||
});
|
||||
|
||||
it("survives its own card re-rendering while it is still owed", () => {
|
||||
const { view, entered } = draw([user("i1", "hi")], ["i1"]);
|
||||
expect(entered()).toBe(1);
|
||||
// The projection spends the debt on the next commit; the mark has to stay
|
||||
// for this element, or a streaming answer loses its entrance halfway.
|
||||
view.rerender(view.container.firstChild ? (
|
||||
<Transcript
|
||||
items={[user("i1", "hi")]} entering={[]} onEntered={() => {}} revision={2}
|
||||
waiting={{}} scroll={{ current: null }} hidden={false} onPinned={() => {}}
|
||||
jump={0} focus={null}
|
||||
onApprove={(async () => undefined) as never} onPlan={(async () => undefined) as never}
|
||||
onAnswer={(async () => undefined) as never} onSuggest={() => {}} onForget={(async () => undefined) as never}
|
||||
onCancelQueued={() => {}} onExtInvoke={() => {}} onExtSubmit={(async () => undefined) as never}
|
||||
checkpoints={new Map()} onPrepareRewind={(async () => undefined) as never}
|
||||
onCommitRewind={(async () => undefined) as never} onUndoRewind={(async () => undefined) as never}
|
||||
onPrepareFileRevert={(async () => undefined) as never} onCommitFileRevert={(async () => undefined) as never}
|
||||
needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
|
||||
/>
|
||||
) : null);
|
||||
expect(entered()).toBe(1);
|
||||
});
|
||||
|
||||
// A pane that is not the one on screen still holds its transcript. Coming
|
||||
// back to it is not a fact arriving.
|
||||
it("is spent even while the pane is not the one being looked at", () => {
|
||||
const { onEntered } = draw([user("i1", "hi")], ["i1"], true);
|
||||
expect(onEntered).toHaveBeenCalledWith(["i1"]);
|
||||
});
|
||||
});
|
||||
|
||||
// The rule underneath both of the above, held on the stylesheet itself: a
|
||||
// selector that fires an entrance the moment an element exists has taken
|
||||
// mount for a cause. Written here rather than left to review, because the
|
||||
// tempting line is one character short of the correct one.
|
||||
describe("mount is not a cause", () => {
|
||||
/** Rules that give this selector an entrance animation, by exact term. */
|
||||
const entrances = (term: string) =>
|
||||
css
|
||||
.split("\n")
|
||||
// Turning one off is not giving one: the reduced-motion block names the
|
||||
// same selectors to say they must not move.
|
||||
.filter((l) => l.includes("animation:") && !/animation:\s*none/.test(l) && !l.trimStart().startsWith("/*"))
|
||||
.filter((l) => l.split("{")[0].split(",").some((sel) => sel.trim() === term));
|
||||
|
||||
it("does not animate a transcript card into being", () => {
|
||||
expect(entrances(".call"), "a card animates whenever one exists — including replays").toEqual([]);
|
||||
});
|
||||
|
||||
it("does not animate a settings block into being", () => {
|
||||
expect(entrances(".grp"), "every block flies in on a section change, and nothing happened").toEqual([]);
|
||||
});
|
||||
|
||||
it("still animates the one that really did just arrive", () => {
|
||||
expect(entrances(".enterbox[data-enter] > .call").length).toBe(1);
|
||||
expect(entrances(".prefs-col").length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -37,3 +37,23 @@ globalThis.matchMedia ??= ((query: string) =>
|
||||
// error the file still passes with, which is exactly how a real one would go
|
||||
// unnoticed later.
|
||||
Element.prototype.scrollIntoView ??= function scrollIntoView() {};
|
||||
|
||||
// jsdom has no viewport, so it has no intersections to report. The transcript
|
||||
// mounts a block when one is near, and without this it asks an observer that
|
||||
// does not exist. Reporting everything as near is the right answer here: these
|
||||
// guards are about what a block does once it is on screen, and which blocks
|
||||
// are on screen is what the browser guards measure.
|
||||
class StubIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds: readonly number[] = [];
|
||||
readonly scrollMargin = "";
|
||||
constructor(private readonly fn: IntersectionObserverCallback) {}
|
||||
observe(el: Element) {
|
||||
this.fn([{ isIntersecting: true, target: el } as IntersectionObserverEntry], this);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords(): IntersectionObserverEntry[] { return []; }
|
||||
}
|
||||
globalThis.IntersectionObserver ??= StubIntersectionObserver;
|
||||
@@ -33,6 +33,10 @@ export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
// Stylesheets are stubbed for tests by default, `?raw` included, so a
|
||||
// guard that reads app.css to check a rule gets an empty string and passes
|
||||
// on nothing. Processing it is what makes that guard able to fail.
|
||||
test: { css: true },
|
||||
server: {
|
||||
port: 5273,
|
||||
// The dev server is cross-origin to serve, which its CSRF guard rejects.
|
||||
|
||||
Reference in new issue
Block a user