Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
selectLatestMessageKey,
} from "./timelineSnapshot.ts";

// Local-midnight unix-second timestamps so isSameDay (local time) is stable
// regardless of the machine's timezone.
function dayAt(year, month, day, hour = 12) {
return Math.floor(
new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000,
);
}

function message(overrides) {
return {
id: "message",
renderKey: undefined,
createdAt: dayAt(2026, 6, 14),
pubkey: "author",
author: "Author",
avatarUrl: null,
role: undefined,
personaDisplayName: undefined,
time: "12:00 PM",
body: "body",
parentId: null,
rootId: null,
depth: 0,
accent: false,
pending: undefined,
edited: false,
kind: 9,
tags: [],
reactions: undefined,
...overrides,
};
}

// --- sticky-bottom autoscroll -------------------------------------------------

test("isNearBottomMetrics: true when within threshold of the bottom", () => {
// distance = scrollHeight - clientHeight - scrollTop = 1000 - 600 - 380 = 20
assert.equal(
isNearBottomMetrics({
scrollHeight: 1_000,
clientHeight: 600,
scrollTop: 380,
}),
true,
);
});

test("isNearBottomMetrics: true exactly at the threshold boundary", () => {
const scrollTop = 1_000 - 600 - BOTTOM_THRESHOLD_PX; // distance === threshold
assert.equal(
isNearBottomMetrics({ scrollHeight: 1_000, clientHeight: 600, scrollTop }),
true,
);
});

test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => {
// distance = 1000 - 600 - 100 = 300 > 72
assert.equal(
isNearBottomMetrics({
scrollHeight: 1_000,
clientHeight: 600,
scrollTop: 100,
}),
false,
);
});

test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => {
assert.equal(selectLatestMessageKey([]), undefined);
assert.equal(
selectLatestMessageKey([message({ id: "a" }), message({ id: "b" })]),
"b",
);
assert.equal(
selectLatestMessageKey([message({ id: "b", renderKey: "local-b" })]),
"local-b",
);
});

test("selectLatestMessageKey: detects a newly arrived latest message", () => {
const before = [message({ id: "a" }), message({ id: "b" })];
const after = [
...before,
message({ id: "c", createdAt: dayAt(2026, 6, 14, 13) }),
];
assert.notEqual(
selectLatestMessageKey(before),
selectLatestMessageKey(after),
);
});

// --- day dividers -------------------------------------------------------------

test("buildDayGroupBoundaries: empty snapshot has no groups", () => {
assert.deepEqual(buildDayGroupBoundaries([]), []);
});

test("buildDayGroupBoundaries: messages on one day form a single group", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 10) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 23) }),
];
const groups = buildDayGroupBoundaries(messages);
assert.equal(groups.length, 1);
assert.deepEqual(
{ startIndex: groups[0].startIndex, count: groups[0].count },
{ startIndex: 0, count: 3 },
);
});

test("buildDayGroupBoundaries: a day boundary opens a new group", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 13, 22) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 1) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 2) }),
message({ id: "d", createdAt: dayAt(2026, 6, 15, 8) }),
];
const groups = buildDayGroupBoundaries(messages);
assert.deepEqual(
groups.map((g) => ({ startIndex: g.startIndex, count: g.count })),
[
{ startIndex: 0, count: 1 },
{ startIndex: 1, count: 2 },
{ startIndex: 3, count: 1 },
],
);
});

test("buildDayGroupBoundaries: group counts always sum to message count", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 13) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14) }),
];
const total = buildDayGroupBoundaries(messages).reduce(
(n, g) => n + g.count,
0,
);
assert.equal(total, messages.length);
});

// --- jump-to-message deep links ----------------------------------------------

test("resolveDeepLinkTarget: unresolved with no target", () => {
const messages = [message({ id: "a" })];
assert.deepEqual(resolveDeepLinkTarget(messages, null), {
resolved: false,
index: -1,
});
assert.deepEqual(resolveDeepLinkTarget(messages, undefined), {
resolved: false,
index: -1,
});
});

test("resolveDeepLinkTarget: resolves a present target to its index", () => {
const messages = [
message({ id: "a" }),
message({ id: "b" }),
message({ id: "c" }),
];
assert.deepEqual(resolveDeepLinkTarget(messages, "b"), {
resolved: true,
index: 1,
});
});

test("resolveDeepLinkTarget: unresolved when the target is not in the snapshot", () => {
const messages = [message({ id: "a" }), message({ id: "b" })];
assert.deepEqual(resolveDeepLinkTarget(messages, "missing"), {
resolved: false,
index: -1,
});
});

// --- shared-snapshot / no-tearing guarantee ----------------------------------
//
// All three must-keep decisions must read off the SAME snapshot. If the deep-link
// decision reads a fresh snapshot while the rendered list / scroll math still
// read a stale one, the jump fires against a row that hasn't committed and
// silently fails.

test("no-tearing: a target only in the fresh snapshot does NOT resolve against the stale one", () => {
const stale = [message({ id: "a" }), message({ id: "b" })];
const fresh = [
...stale,
message({ id: "target", createdAt: dayAt(2026, 6, 15) }),
];

// Reading the deep link against the stale snapshot (what the painted DOM
// still shows) must report unresolved — you can't scroll to an uncommitted row.
assert.equal(resolveDeepLinkTarget(stale, "target").resolved, false);
// Against the fresh snapshot it resolves — proving the gate is which snapshot
// you feed, not the function.
assert.equal(resolveDeepLinkTarget(fresh, "target").resolved, true);
});

test("no-tearing: all three decisions agree when fed one shared snapshot", () => {
const snapshot = [
message({ id: "a", createdAt: dayAt(2026, 6, 13) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14) }),
message({
id: "target",
renderKey: "rk-target",
createdAt: dayAt(2026, 6, 14, 18),
}),
];

// Deep link resolves to the last index...
const link = resolveDeepLinkTarget(snapshot, "target");
// ...the day grouping covers that same index...
const groups = buildDayGroupBoundaries(snapshot);
const coveredCount = groups.reduce((n, g) => n + g.count, 0);
// ...and the sticky-bottom latest-key points at that same final message.
const latestKey = selectLatestMessageKey(snapshot);

assert.equal(link.index, snapshot.length - 1);
assert.equal(coveredCount, snapshot.length);
assert.equal(latestKey, snapshot[snapshot.length - 1].renderKey);
});

test("no-tearing: stale snapshot keeps all three decisions internally consistent", () => {
// Feeding the stale list everywhere keeps the decisions consistent with each
// other — none of them see the uncommitted row.
const stale = [
message({ id: "a", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 10) }),
];

const link = resolveDeepLinkTarget(stale, "target");
const coveredCount = buildDayGroupBoundaries(stale).reduce(
(n, g) => n + g.count,
0,
);
const latestKey = selectLatestMessageKey(stale);

assert.equal(link.resolved, false);
assert.equal(coveredCount, stale.length);
assert.equal(latestKey, "b");
});

// --- deferred reply-list render state (thread side pane) --------------------
//
// When MessageThreadPanel gates its reply render behind useDeferredValue, the
// painted (deferred) snapshot lags the live one for a frame. selectDeferredList
// RenderState picks which of three states the reply region paints so we never
// flash "No replies" over a list that's streaming in on the deferred commit.

test("deferred-render: paints the list whenever the deferred snapshot has rows", () => {
// deferred caught up — normal steady state.
assert.equal(selectDeferredListRenderState(3, 3), "list");
// deferred still showing the OLD non-empty list while a new one streams in;
// we keep painting rows (no flash) — the dim-pending styling handles the lag.
assert.equal(selectDeferredListRenderState(2, 5), "list");
});

test("deferred-render: empty state only when the LIVE list is genuinely empty", () => {
// Both empty — the thread truly has no replies.
assert.equal(selectDeferredListRenderState(0, 0), "empty");
});

test("deferred-render: pending when deferred is empty but live has content", () => {
// Deferred snapshot hasn't committed the rows yet but the live list is
// non-empty. Must NOT report "empty" — that would flash the "No replies"
// affordance for a frame on thread-open.
assert.equal(selectDeferredListRenderState(0, 4), "pending");
assert.notEqual(selectDeferredListRenderState(0, 4), "empty");
});

test("deferred-render: keys the empty decision off the live count, not deferred", () => {
// Same deferred count (0), opposite verdicts — proving the live count is the
// tie-breaker. This is the no-tearing guarantee for the empty affordance:
// the empty state is a function of the LIVE list, never the lagging one.
assert.equal(selectDeferredListRenderState(0, 0), "empty");
assert.equal(selectDeferredListRenderState(0, 1), "pending");
});
Loading