Skip to content
Merged
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
30 changes: 30 additions & 0 deletions src/__tests__/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
boardDemand,
buildBoard,
buildRoster,
inferRoster,
channelForMR,
configuredSlackChannels,
projectPathFromWebUrl,
Expand Down Expand Up @@ -360,6 +361,35 @@ describe("visibleMrsFor (the /data.json payload gate)", () => {
});
});

describe("inferRoster", () => {
function authored(username: string, name: string | null = null): BoardMR {
return { author: { id: username, username, name, avatarUrl: null } } as unknown as BoardMR;
}

test("derives one entry per distinct author, busiest first", () => {
const roster = inferRoster([authored("zoe"), authored("adam"), authored("zoe"), authored("adam"), authored("zoe")]);
expect(roster).toEqual([
{ username: "zoe", name: null, count: 3 },
{ username: "adam", name: null, count: 2 },
]);
});

test("ties break alphabetically so the order is stable across polls", () => {
const roster = inferRoster([authored("zoe"), authored("adam"), authored("mira")]);
expect(roster.map((r) => r.username)).toEqual(["adam", "mira", "zoe"]);
});

test("keeps the author's display name and tolerates a missing one", () => {
const roster = inferRoster([authored("adam", "Adam Stranger"), authored("mira")]);
expect(roster.find((r) => r.username === "adam")?.name).toBe("Adam Stranger");
expect(roster.find((r) => r.username === "mira")?.name).toBeNull();
});

test("no MRs means no roster", () => {
expect(inferRoster([])).toEqual([]);
});
});

describe("buildRoster", () => {
const members = [{ username: "alice" }, { username: "bob", name: "Bobby" }, { username: "carol" }];

Expand Down
82 changes: 81 additions & 1 deletion src/__tests__/config-store-latch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { getSetting, setSetting } from "@mattstack/rt-client";
import { loadConfigFrom, saveMemberHidden, saveSwitchboardUrl, DEFAULT_SLACK_EMOJI } from "../config.ts";
import { loadConfigFrom, saveMemberHidden, saveRosterMembers, saveSwitchboardUrl, saveTabs, DEFAULT_SLACK_EMOJI, type TabConfig } from "../config.ts";

type GetSettingFn = typeof getSetting;
type SetSettingFn = typeof setSetting;
Expand Down Expand Up @@ -210,6 +210,37 @@ describe("loadConfigFrom: board.tabs overlay", () => {
});
});

describe("saveRosterMembers: latch-gated writer", () => {
test("unowned: rewrites config.json's members, store untouched", () => {
const p = tmpConfig({ ...base, members: [{ username: "alice" }] });
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
const next = [{ username: "alice" }, { username: "bob", name: "Bob" }];
const cfg = saveRosterMembers(next, p, fakeResolve({}), fakeWrite(calls));
expect(calls).toEqual([]);
expect(cfg.members).toEqual(next);
expect(JSON.parse(readFileSync(p, "utf8")).members).toEqual(next);
});

test("owned: writes board.members (team), config.json untouched", () => {
const p = tmpConfig({ ...base, members: [{ username: "alice" }] });
const before = readFileSync(p, "utf8");
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
const next = [{ username: "alice" }, { username: "bob" }];
saveRosterMembers(next, p, fakeResolve({ "board.members": [{ username: "alice" }] }), fakeWrite(calls));
expect(calls).toEqual([{ key: "board.members", value: next, scope: "team" }]);
expect(readFileSync(p, "utf8")).toBe(before);
expect(loadConfigFrom(p, fakeResolve({ "board.members": next })).members).toEqual(next);
});

test("removal round-trips through the same writer", () => {
const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] });
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
const stored = [{ username: "alice" }, { username: "bob" }];
saveRosterMembers([{ username: "alice" }], p, fakeResolve({ "board.members": stored }), fakeWrite(calls));
expect(calls[0]!.value).toEqual([{ username: "alice" }]);
});
});

describe("saveMemberHidden: latch-gated writer", () => {
test("unowned: writes config.json's inline hidden flag, store untouched", () => {
const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] });
Expand Down Expand Up @@ -324,3 +355,52 @@ describe("slack default emoji re-export sanity", () => {
expect(DEFAULT_SLACK_EMOJI).toEqual({ looking: "eyes", commented: "speech_balloon", approved: "white_check_mark" });
});
});

describe("saveTabs: latch-gated writer", () => {
const codeowners: TabConfig = { id: "acme", label: "Acme", source: { kind: "codeowners", section: "Acme", excludeMembers: true } };
const team: TabConfig = { id: "team", label: "Team", source: { kind: "authors" } };

test("unowned: rewrites config.json's tabs, store untouched", () => {
const p = tmpConfig();
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
const cfg = saveTabs([team, codeowners], p, fakeResolve({}), fakeWrite(calls));
expect(calls).toEqual([]);
expect(cfg.tabs.map((t) => t.id)).toEqual(["team", "acme"]);
expect(JSON.parse(readFileSync(p, "utf8")).tabs).toEqual([team, codeowners]);
});

test("owned: writes board.tabs (team), config.json untouched", () => {
const p = tmpConfig({ ...base, tabs: [team] });
const before = readFileSync(p, "utf8");
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
saveTabs([team, codeowners], p, fakeResolve({ "board.tabs": [team] }), fakeWrite(calls));
expect(calls).toEqual([{ key: "board.tabs", value: [team, codeowners], scope: "team" }]);
expect(readFileSync(p, "utf8")).toBe(before);
});

test("no config.json with owned team keys establishes board.tabs ownership", () => {
const missing = join(mkdtempSync(join(tmpdir(), "board-latch-nofile-")), "config.json");
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
const owned = { "board.gitlabHost": "https://gitlab.example.com", "board.projects": ["team/repo"], "board.members": [{ username: "carol" }] };
saveTabs([team, codeowners], missing, fakeResolve(owned), fakeWrite(calls));
expect(calls).toEqual([{ key: "board.tabs", value: [team, codeowners], scope: "team" }]);
});

test("an invalid list throws before any write, on either side of the latch", () => {
const p = tmpConfig();
const before = readFileSync(p, "utf8");
const calls: Array<{ key: string; value: unknown; scope: string }> = [];
expect(() => saveTabs([], p, fakeResolve({}), fakeWrite(calls))).toThrow(/must not be empty/);
expect(() => saveTabs([team, team], p, fakeResolve({ "board.tabs": [team] }), fakeWrite(calls))).toThrow(/duplicate tab id/);
expect(() => saveTabs([{ id: "x", label: "X", source: { kind: "codeowners" } }], p, fakeResolve({}), fakeWrite(calls))).toThrow(/section/);
expect(calls).toEqual([]);
expect(readFileSync(p, "utf8")).toBe(before);
});

test("optional per-tab fields survive the round trip and absent ones stay absent", () => {
const p = tmpConfig();
const cfg = saveTabs([{ ...codeowners, slackChannel: "team-codeowners", reviewSkill: "external-review" }], p, fakeResolve({}), fakeWrite([]));
expect(cfg.tabs[0]).toEqual({ ...codeowners, slackChannel: "team-codeowners", reviewSkill: "external-review" });
expect("slackChannel" in saveTabs([team], p, fakeResolve({}), fakeWrite([])).tabs[0]!).toBe(false);
});
});
46 changes: 45 additions & 1 deletion src/__tests__/view.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { BoardMR } from "../data.ts";
import type { TabConfig } from "../config.ts";
import { filterByMember, filterByTab, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts";
import { filterByMember, filterByTab, rosterUsernamesFor, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts";

function mr(overrides: Partial<BoardMR>): BoardMR {
return {
Expand Down Expand Up @@ -29,6 +29,50 @@ describe("filterByMember", () => {
});
});

describe("rosterUsernamesFor", () => {
const configUsernames = ["ada", "grace"];
const rows = [
mr({ iid: 1, author: { username: "ada" } as any, codeownerSections: ["Acme"] } as any),
mr({ iid: 2, author: { username: "outsider" } as any, codeownerSections: ["Acme"] } as any),
mr({ iid: 3, author: { username: "drifter" } as any, codeownerSections: [] } as any),
];

test("an authors tab answers with the configured roster", () => {
const team: TabConfig = { id: "t", label: "T", source: { kind: "authors" } };
expect([...rosterUsernamesFor(rows, team, configUsernames)].sort()).toEqual(["ada", "grace"]);
});

test("a codeowners tab answers with the authors of the rows it shows", () => {
const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } };
const valid = rosterUsernamesFor(rows, q, configUsernames);
expect([...valid]).toEqual(["outsider"]); // the picked author survives re-validation
expect(valid.has("ada")).toBe(false); // excludeMembers still applies
expect(valid.has("drifter")).toBe(false); // untagged row is not on this tab
});

test("no tab falls back to the configured roster", () => {
expect([...rosterUsernamesFor(rows, undefined, configUsernames)].sort()).toEqual(["ada", "grace"]);
});

/* Board resolves the first load in two passes for this reason: the member's
valid set depends on which tab wins, so validating against the config
roster alone drops a stored codeowners-tab author on every reload. */
test("two-pass resolution keeps a stored codeowners-tab author across a reload", () => {
const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } };
const stored = { tab: "q", member: "outsider" };
const tabIds = ["t", "q"];

// Single pass drops the stored author as "not on the team" and lands on
// defaultMember, so a reload on the queue would silently filter to you.
const onePass = parseViewState("", stored, configUsernames, "ada", tabIds);
expect(onePass.tab).toBe("q");
expect(onePass.member).toBe("ada");

const valid = [...rosterUsernamesFor(rows, q, configUsernames)];
expect(parseViewState("", stored, valid, "ada", tabIds).member).toBe("outsider");
});
});

describe("filterByTab", () => {
const members = new Set(["ada"]);
const rows = [
Expand Down
48 changes: 41 additions & 7 deletions src/client/__tests__/config-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
rowKind,
scopeLabel,
setLeaf,
slugTabId,
type ConfigDef,
} from "../board/config-shapes.ts";

Expand All @@ -36,7 +37,7 @@ function def(over: Partial<ConfigDef> & { key: string }): ConfigDef {
/** Composite board.* registry keys with no edit UI yet -- rowKind's
"readonly" fallback (no COMPOSITE_SHAPES entry) is the intended
rendering for these, not a coverage gap. */
const DELIBERATELY_READONLY_COMPOSITES = ["board.tabs"];
const DELIBERATELY_READONLY_COMPOSITES: string[] = [];

describe("COMPOSITE_SHAPES", () => {
test("covers every composite board.* key in the registry except the deliberately-readonly ones", () => {
Expand All @@ -48,12 +49,6 @@ describe("COMPOSITE_SHAPES", () => {
expect(Object.keys(COMPOSITE_SHAPES).sort()).toEqual(composites);
});

test("board.tabs is a composite key deliberately left readonly", () => {
expect(allDefs().find((d) => d.key === "board.tabs")?.type).toBe("array");
expect(COMPOSITE_SHAPES["board.tabs"]).toBeUndefined();
expect(rowKind(def({ key: "board.tabs", type: "array" }))).toBe("readonly");
});

test("names no key the registry lacks", () => {
const known = new Set(allDefs().map((d) => d.key));
for (const key of Object.keys(COMPOSITE_SHAPES)) expect(known.has(key)).toBe(true);
Expand Down Expand Up @@ -212,3 +207,42 @@ describe("rosterSummary", () => {
expect(rosterSummary([{ username: "a" }], undefined)).toBe("1 member");
});
});

describe("tabs shape", () => {
const s = COMPOSITE_SHAPES["board.tabs"]!;
const team = { id: "team", label: "Team", source: { kind: "authors" } };
const acme = { id: "acme", label: "Acme", source: { kind: "codeowners", section: "Acme", excludeMembers: true }, slackChannel: "c", reviewSkill: "s" };

test("board.tabs rows are the tabs kind regardless of writability", () => {
expect(rowKind(def({ key: "board.tabs", type: "array" }))).toBe("tabs");
});

test("accepts the shapes parseTabs accepts", () => {
expect(matchesShape(s, [team])).toBe(true);
expect(matchesShape(s, [team, acme])).toBe(true);
});

test("rejects an empty list and duplicate ids, like parseTabs", () => {
expect(matchesShape(s, [])).toBe(false);
expect(matchesShape(s, [team, { ...acme, id: "team" }])).toBe(false);
});

test("rejects what parseTabs rejects", () => {
expect(matchesShape(s, "team")).toBe(false);
expect(matchesShape(s, [{ ...team, id: "" }])).toBe(false);
expect(matchesShape(s, [{ ...team, label: 3 }])).toBe(false);
expect(matchesShape(s, [{ ...team, source: { kind: "codeowners" } }])).toBe(false);
expect(matchesShape(s, [{ ...acme, source: { ...acme.source, excludeMembers: "yes" } }])).toBe(false);
expect(matchesShape(s, [{ ...team, source: { kind: "other" } }])).toBe(false);
expect(matchesShape(s, [{ ...team, slackChannel: 1 }])).toBe(false);
});
});

describe("slugTabId", () => {
test("slugs the label and dodges taken ids", () => {
expect(slugTabId("Acme Codeowners", [])).toBe("acme-codeowners");
expect(slugTabId(" Team! ", ["team"])).toBe("team-2");
expect(slugTabId("Team", ["team", "team-2"])).toBe("team-3");
expect(slugTabId("???", [])).toBe("tab");
});
});
Loading