import { describe, expect, it } from "vitest";
import {
commentModerationOf,
countCommentsByCommit,
filterCommentsByCommit,
groupCommentsByVersion,
threadsOf,
withOlderPage,
withRemoved,
type CommentView
} from "#lib/components/comment/comments.ts";
import type { CommitSummary } from "#lib/api/repo.ts";
import type { Did, ResourceUri } from "@atcute/lexicons/syntax";
import { COMMENT_COLLECTION } from "#lib/api/comment.ts";
import { ACCOUNT_MODERATION, recordModeration } from "#lib/api/moderationLabels.ts";
import type { LabelsDescriptor, Sidecar } from "#lib/api/enrich.ts";
const labeled = (ref: string, of: LabelsDescriptor, val: string): Sidecar => ({
[ref]: {
[of.source]: {
[of.type]: {
labels: [{ src: "did:plc:labeler", uri: ref, val, cts: "2026-10-01T00:00:00.000Z" }]
}
}
}
});
const comment = (rkey: string, createdAt: string): CommentView => ({
uri: `at://did:plc:alice/sh.tangled.feed.comment/${rkey}`,
rkey,
authorDid: "did:plc:alice",
authorHandle: "alice.test",
createdAt,
body: rkey
});
const versions = [{ createdAt: "2026-01-01T00:00:00Z" }, { createdAt: "2026-01-03T00:00:00Z" }];
const layout = (comments: CommentView[]) =>
groupCommentsByVersion(comments, versions).map((group) => [
group.version,
group.comments.map((c) => c.rkey)
]);
describe("groupCommentsByVersion", () => {
it("files a comment under the newest version at or before it", () => {
// on the boundary belongs to that version, not the one before
expect(
layout([
comment("boundary", "2026-01-01T00:00:00Z"),
comment("during-v0", "2026-01-02T00:00:00Z"),
comment("after-v1", "2026-01-04T00:00:00Z")
])
).toEqual([
[0, ["boundary", "during-v0"]],
[1, ["after-v1"]]
]);
});
it("keeps a version with no comments, so the timeline stays complete", () => {
expect(layout([comment("only", "2026-01-02T00:00:00Z")])).toEqual([
[0, ["only"]],
[1, []]
]);
});
it("puts anything older than v0 in a headerless leading group", () => {
expect(layout([comment("early", "2025-12-31T00:00:00Z")])).toEqual([
[undefined, ["early"]],
[0, []],
[1, []]
]);
});
it("orders comments itself rather than trusting the caller", () => {
expect(
layout([
comment("late", "2026-01-04T00:00:00Z"),
comment("early", "2026-01-02T00:00:00Z")
])
).toEqual([
[0, ["early"]],
[1, ["late"]]
]);
});
it("has nothing to group without versions", () => {
expect(groupCommentsByVersion([comment("a", "2026-01-02T00:00:00Z")], [])).toEqual([
{ version: undefined, comments: [expect.objectContaining({ rkey: "a" })] }
]);
});
});
const pinned = (rkey: string, oid: string): CommentView => ({
...comment(rkey, "2026-01-02T00:00:00Z"),
embed: { repo: "did:plc:alice", commit: oid }
});
describe("countCommentsByCommit", () => {
it("sums the comments pinned to one commit and skips the unpinned ones", () => {
const counts = countCommentsByCommit([
pinned("a", "cafe1234"),
pinned("b", "cafe1234"),
pinned("c", "beef5678"),
comment("loose", "2026-01-02T00:00:00Z")
]);
expect([...counts]).toEqual([
["cafe1234", 2],
["beef5678", 1]
]);
});
it("leaves an unpinned commit out rather than reporting 0", () => {
const counts = countCommentsByCommit([pinned("a", "cafe1234")]);
expect(counts.has("beef5678")).toBe(false);
});
});
const commit = (hash: string): CommitSummary => ({
hash,
shortHash: hash.slice(0, 8),
subject: hash,
body: "",
authorName: "alice",
authorEmail: "alice@test",
when: "2026-01-02T00:00:00Z"
});
describe("filterCommentsByCommit", () => {
const commits = [commit("cafe1234"), commit("beef5678")];
const rkeys = (comments: CommentView[]) => comments.map((c) => c.rkey);
it("drops an unpinned comment and a pin the filter rejects", () => {
expect(
rkeys(
filterCommentsByCommit(
[
comment("loose", "2026-01-02T00:00:00Z"),
pinned("in", "cafe1234"),
pinned("out", "beef5678")
],
commits,
(c) => c.hash === "cafe1234"
)
)
).toEqual(["in"]);
});
it("drops a pin that names no known commit", () => {
// an older version's commit, or one a force-push dropped: never in range
expect(
rkeys(filterCommentsByCommit([pinned("gone", "d00d9999")], commits, () => true))
).toEqual([]);
});
it("filters nothing without a filter", () => {
const comments = [pinned("gone", "d00d9999"), comment("loose", "2026-01-02T00:00:00Z")];
expect(filterCommentsByCommit(comments, commits)).toBe(comments);
});
});
describe("commentModerationOf", () => {
const item: { uri: ResourceUri; editor: Did } = {
uri: "at://did:plc:repo/org.tangled.feed.comment/3c",
editor: "did:plc:bob"
};
const commentLabels = recordModeration(COMMENT_COLLECTION);
it("covers just the comment when only the comment is labeled", () => {
expect(commentModerationOf(labeled(item.uri, commentLabels, "!hide"), item)).toEqual({
level: "hide",
target: item.uri
});
});
it("reads the editor's account, not the repo the uri names", () => {
const docs = {
...labeled("did:plc:repo", ACCOUNT_MODERATION, "!hide"),
...labeled(item.editor, ACCOUNT_MODERATION, "!warn")
};
expect(commentModerationOf(docs, item)).toEqual({ level: "warn", target: item.editor });
});
it("lets the stronger decision win", () => {
const docs = {
...labeled(item.uri, commentLabels, "!hide"),
...labeled(item.editor, ACCOUNT_MODERATION, "!warn")
};
expect(commentModerationOf(docs, item)).toEqual({ level: "hide", target: item.uri });
});
it("prefers the author's account on a tie", () => {
const docs = {
...labeled(item.uri, commentLabels, "!warn"),
...labeled(item.editor, ACCOUNT_MODERATION, "!warn")
};
expect(commentModerationOf(docs, item)).toEqual({ level: "warn", target: item.editor });
});
});
describe("withOlderPage", () => {
it("fills in what the newest page only stood in for once older pages load", () => {
const a = comment("a", "2026-01-01T00:00:00Z");
const gone = "at://did:plc:alice/sh.tangled.feed.comment/gone";
const newest = {
inputs: [
{ comment: comment("r2", "2026-01-05T00:00:00Z"), replyTo: a.uri },
{ comment: comment("rg", "2026-01-06T00:00:00Z"), replyTo: gone },
{ comment: comment("c", "2026-01-07T00:00:00Z"), replyTo: null }
],
cursor: "older",
total: 6
};
expect(threadsOf(newest)[0].self).toMatchObject({
uri: a.uri,
deleted: true,
unloaded: true
});
const merged = withOlderPage(newest, {
inputs: [
{ comment: a, replyTo: null },
{ comment: comment("r1", "2026-01-02T00:00:00Z"), replyTo: a.uri },
{ comment: comment("b", "2026-01-04T00:00:00Z"), replyTo: null },
{ comment: comment("c", "2026-01-07T00:00:00Z"), replyTo: null }
],
cursor: null,
total: 6
});
expect(merged.inputs).toHaveLength(6);
const threads = threadsOf(merged);
expect(
threads.map((thread) => [thread.self.rkey, thread.replies.map((r) => r.rkey)])
).toEqual([
["a", ["r1", "r2"]],
["b", []],
["gone", ["rg"]],
["c", []]
]);
expect(threads[0].self).toEqual(a);
expect(threads[2].self).toMatchObject({ deleted: true });
expect(threads[2].self.unloaded).toBeUndefined();
});
});
describe("withRemoved", () => {
it("keeps a removed parent as deleted, so its replies don't wait on an older page", () => {
const parent = comment("p", "2026-01-01T00:00:00Z");
const page = {
inputs: [
{ comment: parent, replyTo: null },
{ comment: comment("r", "2026-01-02T00:00:00Z"), replyTo: parent.uri },
{ comment: comment("solo", "2026-01-03T00:00:00Z"), replyTo: null }
],
cursor: "older",
total: 3
};
const removed = withRemoved(withRemoved(page, parent.uri), page.inputs[2].comment.uri);
expect(removed.total).toBe(1);
const threads = threadsOf(removed);
expect(threads.map((thread) => thread.self.rkey)).toEqual(["p"]);
expect(threads[0].self).toMatchObject({ deleted: true, body: "" });
expect(threads[0].self.unloaded).toBeUndefined();
});
});