mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-25 23:00:32 +08:00
In preparation for merging radicle-desktop into radicle-gui. It's a pure rename, so git can follow every file across the move.
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import type { Author } from "@bindings/cob/Author";
|
|
import type { Operation } from "@bindings/cob/Operation";
|
|
import type { Action } from "@bindings/cob/patch/Action";
|
|
import type { Commit } from "@bindings/repo/Commit";
|
|
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import type { ActivityItem } from "@app/lib/cobActivity";
|
|
import type { PatchActivityData } from "@app/lib/patchTimeline";
|
|
import { openingDraftOpId, patchTimeline } from "@app/lib/patchTimeline";
|
|
|
|
import { author, comment, location, revision, thread } from "./support/cobs";
|
|
|
|
const alice = author("alice");
|
|
const bob = author("bob");
|
|
|
|
function op(
|
|
id: string,
|
|
timestamp: number,
|
|
actions: Action[],
|
|
by: Author = alice,
|
|
): Operation<Action> {
|
|
return { id, author: by, timestamp, actions };
|
|
}
|
|
|
|
function revisionOp(id: string, timestamp: number, by: Author = alice) {
|
|
return op(
|
|
id,
|
|
timestamp,
|
|
[{ type: "revision", description: `Revision ${id}`, base: "b", oid: "o" }],
|
|
by,
|
|
);
|
|
}
|
|
|
|
function reviewOp(id: string, timestamp: number, of: string, by = bob) {
|
|
return op(id, timestamp, [{ type: "review", revision: of }], by);
|
|
}
|
|
|
|
function timeline(
|
|
activity: Operation<Action>[],
|
|
props: {
|
|
revisionIds?: string[];
|
|
expanded?: Record<string, boolean>;
|
|
commits?: Record<string, Commit[]>;
|
|
} = {},
|
|
) {
|
|
const revisionIds =
|
|
props.revisionIds ??
|
|
activity
|
|
.filter(o => o.actions.some(a => a.type === "revision"))
|
|
.map(o => o.id);
|
|
return patchTimeline({
|
|
activity,
|
|
revisions: revisionIds.map(id => revision({ id })),
|
|
commitsByRevision: props.commits ?? {},
|
|
threadsByReview: new Map(),
|
|
discussionThreadsByReview: new Map(),
|
|
expandedRevisionRuns: props.expanded ?? {},
|
|
});
|
|
}
|
|
|
|
// A compact view of the timeline: `opened`, `older(n)` for a fold, and the
|
|
// operation type and id for everything else.
|
|
function shape(items: ActivityItem<PatchActivityData>[]): string[] {
|
|
return items.map(item => {
|
|
const data = item.data;
|
|
if (data.kind === "opened") {
|
|
return data.openedAsDraft ? "opened-draft" : "opened";
|
|
}
|
|
if (data.kind === "olderRevisions") return `older(${data.count})`;
|
|
return `${data.op.type}:${data.op.id}`;
|
|
});
|
|
}
|
|
|
|
describe("openingDraftOpId", () => {
|
|
const draft = {
|
|
type: "lifecycle" as const,
|
|
state: { status: "draft" as const },
|
|
};
|
|
|
|
test("finds a draft set in the first or second operation", () => {
|
|
expect(openingDraftOpId([op("d", 1, [draft])])).toBe("d");
|
|
expect(openingDraftOpId([revisionOp("r", 1), op("d", 2, [draft])])).toBe(
|
|
"d",
|
|
);
|
|
});
|
|
|
|
test("goes by timestamp, not array order", () => {
|
|
expect(
|
|
openingDraftOpId([
|
|
op("d", 3, [draft]),
|
|
revisionOp("r", 1),
|
|
op("x", 2, [{ type: "label", labels: ["a"] }]),
|
|
]),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("ignores a draft set later in the patch's life", () => {
|
|
expect(
|
|
openingDraftOpId([
|
|
revisionOp("r", 1),
|
|
op("x", 2, [{ type: "label", labels: ["a"] }]),
|
|
op("d", 3, [draft]),
|
|
]),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("ignores an opening lifecycle change that isn't to draft", () => {
|
|
expect(
|
|
openingDraftOpId([
|
|
op("o", 1, [{ type: "lifecycle", state: { status: "open" } }]),
|
|
]),
|
|
).toBeUndefined();
|
|
expect(
|
|
openingDraftOpId([
|
|
op("a", 1, [{ type: "lifecycle", state: { status: "archived" } }]),
|
|
]),
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("patchTimeline", () => {
|
|
test("starts with the opening, then the first revision", () => {
|
|
expect(shape(timeline([revisionOp("r1", 1)]))).toEqual([
|
|
"opened",
|
|
"revision:r1",
|
|
]);
|
|
});
|
|
|
|
test("folds a draft at creation into the opening", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
op("d", 2, [{ type: "lifecycle", state: { status: "draft" } }]),
|
|
]),
|
|
),
|
|
).toEqual(["opened-draft", "revision:r1"]);
|
|
});
|
|
|
|
test("keeps a later switch to draft as its own entry", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
op("l", 2, [{ type: "label", labels: ["x"] }]),
|
|
op("d", 3, [{ type: "lifecycle", state: { status: "draft" } }]),
|
|
]),
|
|
),
|
|
).toEqual(["opened", "revision:r1", "label:l", "lifecycle:d"]);
|
|
});
|
|
|
|
test("places each review right after its revision", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
revisionOp("r2", 2),
|
|
reviewOp("v1", 3, "r1"),
|
|
]),
|
|
),
|
|
).toEqual(["opened", "revision:r1", "review:v1", "revision:r2"]);
|
|
});
|
|
|
|
test("orders entries by timestamp", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
op("l", 5, [{ type: "label", labels: ["x"] }]),
|
|
revisionOp("r1", 1),
|
|
]),
|
|
),
|
|
).toEqual(["opened", "revision:r1", "label:l"]);
|
|
});
|
|
|
|
test("folds review and revision edits into the entries they change", () => {
|
|
const items = timeline([
|
|
revisionOp("r1", 1),
|
|
reviewOp("v1", 2, "r1"),
|
|
op("e1", 3, [
|
|
{
|
|
type: "review.edit",
|
|
review: "v1",
|
|
verdict: "accept",
|
|
summary: "LGTM",
|
|
},
|
|
{ type: "revision.edit", revision: "r1", description: "Edited" },
|
|
]),
|
|
]);
|
|
|
|
expect(shape(items)).toEqual(["opened", "revision:r1", "review:v1"]);
|
|
expect(items[1].data).toMatchObject({
|
|
op: { description: "Edited" },
|
|
});
|
|
expect(items[2].data).toMatchObject({
|
|
op: { verdict: "accept", summary: "LGTM" },
|
|
});
|
|
});
|
|
|
|
test("drops redacted revisions and reviews", () => {
|
|
expect(
|
|
shape(
|
|
timeline(
|
|
[
|
|
revisionOp("r1", 1),
|
|
revisionOp("r2", 2),
|
|
reviewOp("v1", 3, "r1"),
|
|
op("x", 4, [
|
|
{ type: "revision.redact", revision: "r2" },
|
|
{ type: "review.redact", review: "v1" },
|
|
]),
|
|
],
|
|
{ revisionIds: ["r1"] },
|
|
),
|
|
),
|
|
).toEqual(["opened", "revision:r1"]);
|
|
});
|
|
|
|
test("keeps a review of a redacted revision, at the end", () => {
|
|
expect(
|
|
shape(
|
|
timeline(
|
|
[
|
|
revisionOp("r1", 1),
|
|
revisionOp("r2", 2),
|
|
reviewOp("v2", 3, "r2"),
|
|
op("l", 4, [{ type: "label", labels: ["x"] }]),
|
|
op("x", 5, [{ type: "revision.redact", revision: "r2" }]),
|
|
],
|
|
{ revisionIds: ["r1"] },
|
|
),
|
|
),
|
|
).toEqual(["opened", "revision:r1", "label:l", "review:v2"]);
|
|
});
|
|
|
|
test("attaches commits and marks merges and reviews as standalone", () => {
|
|
const commits = [{ id: "c1" } as Commit];
|
|
const items = timeline(
|
|
[
|
|
revisionOp("r1", 1),
|
|
reviewOp("v1", 2, "r1"),
|
|
op("m", 3, [{ type: "merge", revision: "r1", commit: "c1" }]),
|
|
],
|
|
{ commits: { r1: commits } },
|
|
);
|
|
|
|
expect(items[1].data).toMatchObject({ kind: "op", commits });
|
|
expect(items.map(i => i.standalone)).toEqual([
|
|
undefined,
|
|
false,
|
|
true,
|
|
true,
|
|
]);
|
|
});
|
|
|
|
test("attaches a review's code and discussion threads", () => {
|
|
const code = [thread(comment({ location: location("a.ts", "new", 1) }))];
|
|
const discussion = [thread(comment())];
|
|
const [, , review] = patchTimeline({
|
|
activity: [revisionOp("r1", 1), reviewOp("v1", 2, "r1")],
|
|
revisions: [revision({ id: "r1" })],
|
|
commitsByRevision: {},
|
|
threadsByReview: new Map([["v1", code]]),
|
|
discussionThreadsByReview: new Map([["v1", discussion]]),
|
|
expandedRevisionRuns: {},
|
|
});
|
|
|
|
expect(review.data).toMatchObject({
|
|
reviewThreads: code,
|
|
reviewComments: discussion,
|
|
});
|
|
});
|
|
|
|
describe("older revisions", () => {
|
|
const activity = [
|
|
revisionOp("r1", 1),
|
|
reviewOp("v1", 2, "r1", alice),
|
|
revisionOp("r2", 3),
|
|
revisionOp("r3", 4),
|
|
];
|
|
|
|
test("folds a run of older revisions, with their reviews, by one author", () => {
|
|
const items = timeline(activity);
|
|
|
|
expect(shape(items)).toEqual(["opened", "older(2)", "revision:r3"]);
|
|
expect(items[1].data).toEqual({
|
|
kind: "olderRevisions",
|
|
groupKey: "older:r1",
|
|
revisionIds: ["r1", "r2"],
|
|
count: 2,
|
|
author: alice,
|
|
expanded: false,
|
|
});
|
|
});
|
|
|
|
test("shows the folded entries when the run is expanded", () => {
|
|
expect(
|
|
shape(timeline(activity, { expanded: { "older:r1": true } })),
|
|
).toEqual([
|
|
"opened",
|
|
"older(2)",
|
|
"revision:r1",
|
|
"review:v1",
|
|
"revision:r2",
|
|
"revision:r3",
|
|
]);
|
|
});
|
|
|
|
// Current behaviour, which may not be intended: a review by someone else
|
|
// breaks the run, so reviewed revisions rarely fold.
|
|
test("a review by another author breaks the run", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
reviewOp("v1", 2, "r1", bob),
|
|
revisionOp("r2", 3),
|
|
revisionOp("r3", 4),
|
|
]),
|
|
),
|
|
).toEqual([
|
|
"opened",
|
|
"revision:r1",
|
|
"review:v1",
|
|
"revision:r2",
|
|
"revision:r3",
|
|
]);
|
|
});
|
|
|
|
test("doesn't fold a lone older revision", () => {
|
|
expect(
|
|
shape(timeline([revisionOp("r1", 1), revisionOp("r2", 2)])),
|
|
).toEqual(["opened", "revision:r1", "revision:r2"]);
|
|
});
|
|
|
|
test("a different author breaks the run", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
revisionOp("r2", 2, bob),
|
|
revisionOp("r3", 3),
|
|
revisionOp("r4", 4),
|
|
]),
|
|
),
|
|
).toEqual([
|
|
"opened",
|
|
"revision:r1",
|
|
"revision:r2",
|
|
"revision:r3",
|
|
"revision:r4",
|
|
]);
|
|
});
|
|
|
|
test("another entry between revisions breaks the run", () => {
|
|
expect(
|
|
shape(
|
|
timeline([
|
|
revisionOp("r1", 1),
|
|
op("l", 2, [{ type: "label", labels: ["x"] }]),
|
|
revisionOp("r2", 3),
|
|
revisionOp("r3", 4),
|
|
]),
|
|
),
|
|
).toEqual([
|
|
"opened",
|
|
"revision:r1",
|
|
"label:l",
|
|
"revision:r2",
|
|
"revision:r3",
|
|
]);
|
|
});
|
|
});
|
|
});
|