Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";

import { swapSectionOrder } from "./channelSectionsHelpers.ts";

function makeStore(sections, assignments = {}) {
return { version: 1, sections, assignments };
}

function makeSection(id, name, order) {
return { id, name, order };
}

test("move up succeeds: middle section swaps order with the one above", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 1),
makeSection("c", "C", 2),
]);
const result = swapSectionOrder(store, "b", "up");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId["b"], 0);
assert.equal(byId["a"], 1);
assert.equal(byId["c"], 2);
});

test("move down succeeds: middle section swaps order with the one below", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 1),
makeSection("c", "C", 2),
]);
const result = swapSectionOrder(store, "b", "down");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId["b"], 2);
assert.equal(byId["c"], 1);
assert.equal(byId["a"], 0);
});

test("move up at top boundary returns null", () => {
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
assert.equal(swapSectionOrder(store, "a", "up"), null);
});

test("move down at bottom boundary returns null", () => {
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
assert.equal(swapSectionOrder(store, "b", "down"), null);
});

test("non-existent section returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "z", "up"), null);
});

test("single section move up returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "a", "up"), null);
});

test("single section move down returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "a", "down"), null);
});

test("non-contiguous orders: swap uses actual order values not indices", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 5),
makeSection("c", "C", 10),
]);
const result = swapSectionOrder(store, "b", "up");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId["b"], 0);
assert.equal(byId["a"], 5);
assert.equal(byId["c"], 10);
});
21 changes: 21 additions & 0 deletions desktop/src/features/sidebar/lib/channelSectionsHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ChannelSectionStore } from "./channelSectionsStorage";

export function swapSectionOrder(
prev: ChannelSectionStore,
sectionId: string,
direction: "up" | "down",
): ChannelSectionStore | null {
const target = prev.sections.find((s) => s.id === sectionId);
if (!target) return null;
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((s) => s.id === sectionId);
const neighborIdx = direction === "up" ? idx - 1 : idx + 1;
if (neighborIdx < 0 || neighborIdx >= sorted.length) return null;
const neighbor = sorted[neighborIdx];
const sections = prev.sections.map((s) => {
if (s.id === target.id) return { ...s, order: neighbor.order };
if (s.id === neighbor.id) return { ...s, order: target.order };
return s;
});
return { ...prev, sections };
}
204 changes: 204 additions & 0 deletions desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
DEFAULT_STORE,
parseChannelSectionPayload,
readChannelSectionsStore,
storageKey,
stripOrphanedAssignments,
writeChannelSectionsStore,
} from "./channelSectionsStorage.ts";

if (typeof globalThis.window === "undefined") {
const storage = new Map();
globalThis.window = {
localStorage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key),
},
};
}

function makeStore(overrides = {}) {
return {
version: 1,
sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }],
assignments: overrides.assignments ?? {},
...overrides,
};
}

function makeSection(overrides = {}) {
return { id: "s1", name: "Test", order: 0, ...overrides };
}

test("parseChannelSectionPayload: valid complete payload returns correct store", () => {
const payload = {
version: 1,
sections: [{ id: "s1", name: "Work", order: 0 }],
assignments: { chan1: "s1" },
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result, {
version: 1,
sections: [{ id: "s1", name: "Work", order: 0 }],
assignments: { chan1: "s1" },
});
});

test("parseChannelSectionPayload: null input returns null", () => {
assert.equal(parseChannelSectionPayload(null), null);
});

test("parseChannelSectionPayload: non-object input returns null", () => {
assert.equal(parseChannelSectionPayload("string"), null);
assert.equal(parseChannelSectionPayload(42), null);
assert.equal(parseChannelSectionPayload(true), null);
});

test("parseChannelSectionPayload: missing sections returns empty sections array", () => {
const result = parseChannelSectionPayload({ assignments: {} });
assert.deepEqual(result?.sections, []);
});

test("parseChannelSectionPayload: malformed section entries are filtered out", () => {
const payload = {
sections: [
{ id: 123, name: "Bad ID", order: 0 },
{ id: "s1", name: 456, order: 0 },
{ id: "s2", name: "Good", order: "not-a-number" },
null,
"string-entry",
],
assignments: {},
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result?.sections, []);
});

test("parseChannelSectionPayload: valid sections with some invalid ones filters correctly", () => {
const payload = {
sections: [
{ id: "s1", name: "Valid", order: 0 },
{ id: 99, name: "Bad ID", order: 1 },
{ id: "s2", name: "Also Valid", order: 2 },
],
assignments: {},
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result?.sections, [
{ id: "s1", name: "Valid", order: 0 },
{ id: "s2", name: "Also Valid", order: 2 },
]);
});

test("parseChannelSectionPayload: missing assignments returns empty assignments object", () => {
const result = parseChannelSectionPayload({ sections: [] });
assert.deepEqual(result?.assignments, {});
});

test("parseChannelSectionPayload: assignments with non-string values are filtered out", () => {
const payload = {
sections: [{ id: "s1", name: "Test", order: 0 }],
assignments: { chan1: "s1", chan2: 42, chan3: null, chan4: true },
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result?.assignments, { chan1: "s1" });
});

test("parseChannelSectionPayload: orphaned assignments are stripped", () => {
const payload = {
sections: [{ id: "s1", name: "Exists", order: 0 }],
assignments: { chan1: "s1", chan2: "missing-section" },
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result?.assignments, { chan1: "s1" });
});

test("stripOrphanedAssignments: store with no orphans returns same reference", () => {
const store = makeStore({
sections: [makeSection({ id: "s1" })],
assignments: { chan1: "s1" },
});
assert.equal(stripOrphanedAssignments(store), store);
});

test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => {
const store = makeStore({
sections: [makeSection({ id: "s1" })],
assignments: { chan1: "s1", chan2: "ghost" },
});
const result = stripOrphanedAssignments(store);
assert.notEqual(result, store);
assert.deepEqual(result.assignments, { chan1: "s1" });
});

test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => {
const store = makeStore({
sections: [
makeSection({ id: "s1" }),
makeSection({ id: "s2", name: "B", order: 1 }),
],
assignments: { chan1: "s1", chan2: "s2" },
});
assert.equal(stripOrphanedAssignments(store), store);
});

test("stripOrphanedAssignments: empty store returns same reference", () => {
const store = makeStore({ sections: [], assignments: {} });
assert.equal(stripOrphanedAssignments(store), store);
});

test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => {
const pubkey = "pk-roundtrip";
const store = makeStore({
sections: [makeSection({ id: "s1", name: "Work", order: 0 })],
assignments: { chan1: "s1" },
});
const written = writeChannelSectionsStore(pubkey, store);
assert.equal(written, true);
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, store);
});

test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => {
const result = readChannelSectionsStore("pk-does-not-exist-xyz");
assert.deepEqual(result, DEFAULT_STORE);
});

test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => {
const pubkey = "pk-corrupt";
window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{");
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, DEFAULT_STORE);
});

test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => {
const pubkey = "pk-wrong-version";
window.localStorage.setItem(
storageKey(pubkey),
JSON.stringify({ version: 2, sections: [], assignments: {} }),
);
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, DEFAULT_STORE);
});

test("writeChannelSectionsStore: returns false when setItem throws", () => {
const pubkey = "pk-throws";
const original = window.localStorage.setItem;
window.localStorage.setItem = () => {
throw new Error("storage full");
};
try {
const result = writeChannelSectionsStore(pubkey, makeStore());
assert.equal(result, false);
} finally {
window.localStorage.setItem = original;
}
});

test("storageKey: returns expected format with pubkey", () => {
assert.equal(storageKey("abc123"), "sprout-channel-sections.v1:abc123");
});
Loading