mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-17 10:56:52 +02:00
- Redesign SpellbookDropdown with clear status indicators (ownership, storage) - Add SpellbookStatus component showing you/other and local/published/network - Enhance activeSpellbook type with source, localId, isPublished fields - Fix PublishSpellbook action to properly yield events (caller handles side-effects) - Add k tags extraction from REQ windows for kind-based filtering/discovery - Update terminology from "Layout" to "Spellbook" consistently - Add comprehensive tests for k tags and source tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
createSpellbook,
|
|
parseSpellbook,
|
|
loadSpellbook,
|
|
slugify,
|
|
} from "./spellbook-manager";
|
|
import { GrimoireState, WindowInstance, Workspace } from "@/types/app";
|
|
import { SPELLBOOK_KIND, SpellbookEvent } from "@/types/spell";
|
|
|
|
// Mock Data
|
|
const mockWindow1: WindowInstance = {
|
|
id: "win-1",
|
|
appId: "profile",
|
|
props: { pubkey: "abc" },
|
|
spellId: "spell-1",
|
|
};
|
|
|
|
const mockWindow2: WindowInstance = {
|
|
id: "win-2",
|
|
appId: "kind",
|
|
props: { kind: 1 },
|
|
};
|
|
|
|
// REQ window with filter.kinds for k tag extraction
|
|
const mockReqWindow: WindowInstance = {
|
|
id: "win-req",
|
|
appId: "req",
|
|
props: {
|
|
filter: {
|
|
kinds: [1, 6, 7, 30023],
|
|
authors: ["abc"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockReqWindow2: WindowInstance = {
|
|
id: "win-req-2",
|
|
appId: "req",
|
|
props: {
|
|
filter: {
|
|
kinds: [1, 4], // kind 1 duplicated to test dedup
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockWorkspace1: Workspace = {
|
|
id: "ws-1",
|
|
number: 1,
|
|
layout: "win-1",
|
|
windowIds: ["win-1"],
|
|
};
|
|
|
|
const mockWorkspace2: Workspace = {
|
|
id: "ws-2",
|
|
number: 2,
|
|
layout: {
|
|
direction: "row",
|
|
first: "win-2",
|
|
second: "win-1",
|
|
},
|
|
windowIds: ["win-1", "win-2"],
|
|
};
|
|
|
|
const mockWorkspaceWithReq: Workspace = {
|
|
id: "ws-req",
|
|
number: 3,
|
|
layout: {
|
|
direction: "row",
|
|
first: "win-req",
|
|
second: "win-req-2",
|
|
},
|
|
windowIds: ["win-req", "win-req-2"],
|
|
};
|
|
|
|
const mockState: GrimoireState = {
|
|
__version: 6,
|
|
windows: {
|
|
"win-1": mockWindow1,
|
|
"win-2": mockWindow2,
|
|
},
|
|
workspaces: {
|
|
"ws-1": mockWorkspace1,
|
|
"ws-2": mockWorkspace2,
|
|
},
|
|
activeWorkspaceId: "ws-1",
|
|
layoutConfig: {
|
|
insertionMode: "smart",
|
|
splitPercentage: 50,
|
|
insertionPosition: "second",
|
|
},
|
|
};
|
|
|
|
const mockStateWithReq: GrimoireState = {
|
|
__version: 6,
|
|
windows: {
|
|
"win-req": mockReqWindow,
|
|
"win-req-2": mockReqWindow2,
|
|
},
|
|
workspaces: {
|
|
"ws-req": mockWorkspaceWithReq,
|
|
},
|
|
activeWorkspaceId: "ws-req",
|
|
layoutConfig: {
|
|
insertionMode: "smart",
|
|
splitPercentage: 50,
|
|
insertionPosition: "second",
|
|
},
|
|
};
|
|
|
|
describe("Spellbook Manager", () => {
|
|
describe("slugify", () => {
|
|
it("converts titles to slugs", () => {
|
|
expect(slugify("Hello World")).toBe("hello-world");
|
|
expect(slugify("My Cool Dashboard!")).toBe("my-cool-dashboard");
|
|
expect(slugify(" Trim Me ")).toBe("trim-me");
|
|
expect(slugify("Mixed Case Title")).toBe("mixed-case-title");
|
|
});
|
|
});
|
|
|
|
describe("createSpellbook", () => {
|
|
it("creates a valid spellbook from state", () => {
|
|
const result = createSpellbook({
|
|
state: mockState,
|
|
title: "My Backup",
|
|
description: "Test description",
|
|
workspaceIds: ["ws-1"],
|
|
});
|
|
|
|
const { eventProps, referencedSpells } = result;
|
|
const content = JSON.parse(eventProps.content);
|
|
|
|
// Check event props
|
|
expect(eventProps.kind).toBe(SPELLBOOK_KIND);
|
|
expect(eventProps.tags).toContainEqual(["d", "my-backup"]);
|
|
expect(eventProps.tags).toContainEqual(["title", "My Backup"]);
|
|
expect(eventProps.tags).toContainEqual([
|
|
"description",
|
|
"Test description",
|
|
]);
|
|
expect(eventProps.tags).toContainEqual(["client", "grimoire"]);
|
|
|
|
// Check referenced spells (e tags)
|
|
expect(referencedSpells).toContain("spell-1");
|
|
expect(eventProps.tags).toContainEqual(["e", "spell-1", "", "mention"]);
|
|
|
|
// Check content structure
|
|
expect(content.version).toBe(1);
|
|
expect(Object.keys(content.workspaces)).toHaveLength(1);
|
|
expect(content.workspaces["ws-1"]).toBeDefined();
|
|
|
|
// Should only include windows referenced in the workspace
|
|
expect(Object.keys(content.windows)).toHaveLength(1);
|
|
expect(content.windows["win-1"]).toBeDefined();
|
|
expect(content.windows["win-2"]).toBeUndefined();
|
|
});
|
|
|
|
it("includes all workspaces if no IDs provided", () => {
|
|
const result = createSpellbook({
|
|
state: mockState,
|
|
title: "Full Backup",
|
|
});
|
|
|
|
const content = JSON.parse(result.eventProps.content);
|
|
expect(Object.keys(content.workspaces)).toHaveLength(2);
|
|
expect(Object.keys(content.windows)).toHaveLength(2);
|
|
});
|
|
|
|
it("extracts k tags from REQ windows and deduplicates", () => {
|
|
const result = createSpellbook({
|
|
state: mockStateWithReq,
|
|
title: "REQ Dashboard",
|
|
});
|
|
|
|
const kTags = result.eventProps.tags.filter((t) => t[0] === "k");
|
|
|
|
// Should have k tags for kinds: 1, 4, 6, 7, 30023 (deduped and sorted)
|
|
expect(kTags).toContainEqual(["k", "1"]);
|
|
expect(kTags).toContainEqual(["k", "4"]);
|
|
expect(kTags).toContainEqual(["k", "6"]);
|
|
expect(kTags).toContainEqual(["k", "7"]);
|
|
expect(kTags).toContainEqual(["k", "30023"]);
|
|
|
|
// Should be sorted by kind number
|
|
expect(kTags).toHaveLength(5);
|
|
expect(kTags[0]).toEqual(["k", "1"]);
|
|
expect(kTags[kTags.length - 1]).toEqual(["k", "30023"]);
|
|
});
|
|
|
|
it("does not include k tags for non-REQ windows", () => {
|
|
const result = createSpellbook({
|
|
state: mockState,
|
|
title: "No REQ",
|
|
workspaceIds: ["ws-1"],
|
|
});
|
|
|
|
const kTags = result.eventProps.tags.filter((t) => t[0] === "k");
|
|
expect(kTags).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("parseSpellbook", () => {
|
|
it("parses a valid spellbook event", () => {
|
|
const content = {
|
|
version: 1,
|
|
workspaces: { "ws-1": mockWorkspace1 },
|
|
windows: { "win-1": mockWindow1 },
|
|
};
|
|
|
|
const event: SpellbookEvent = {
|
|
id: "evt-1",
|
|
pubkey: "pub-1",
|
|
created_at: 123456,
|
|
kind: SPELLBOOK_KIND,
|
|
tags: [
|
|
["d", "my-slug"],
|
|
["title", "My Title"],
|
|
["description", "Desc"],
|
|
["e", "spell-1"],
|
|
],
|
|
content: JSON.stringify(content),
|
|
sig: "sig",
|
|
};
|
|
|
|
const parsed = parseSpellbook(event);
|
|
|
|
expect(parsed.slug).toBe("my-slug");
|
|
expect(parsed.title).toBe("My Title");
|
|
expect(parsed.description).toBe("Desc");
|
|
expect(parsed.content).toEqual(content);
|
|
expect(parsed.referencedSpells).toContain("spell-1");
|
|
});
|
|
|
|
it("handles parsing errors gracefully", () => {
|
|
const event = {
|
|
kind: SPELLBOOK_KIND,
|
|
content: "invalid json",
|
|
tags: [],
|
|
} as any;
|
|
|
|
expect(() => parseSpellbook(event)).toThrow(
|
|
"Failed to parse spellbook content",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("loadSpellbook", () => {
|
|
it("replaces all workspaces with imported ones and regenerates IDs", () => {
|
|
const spellbookContent = {
|
|
version: 1,
|
|
workspaces: { "ws-1": mockWorkspace1 },
|
|
windows: { "win-1": mockWindow1 },
|
|
};
|
|
|
|
const parsed = {
|
|
slug: "test",
|
|
title: "Test",
|
|
content: spellbookContent,
|
|
referencedSpells: [],
|
|
event: {} as any,
|
|
};
|
|
|
|
const newState = loadSpellbook(mockState, parsed);
|
|
|
|
// Should have only 1 workspace (replaces all existing)
|
|
expect(Object.keys(newState.workspaces)).toHaveLength(1);
|
|
|
|
// Get the new workspace
|
|
const newWs = Object.values(newState.workspaces)[0];
|
|
|
|
// IDs should be regenerated
|
|
expect(newWs.id).not.toBe("ws-1");
|
|
|
|
// Number should be 1 (sequential assignment)
|
|
expect(newWs.number).toBe(1);
|
|
|
|
// Window IDs should be regenerated
|
|
const newWinId = newWs.windowIds[0];
|
|
expect(newWinId).not.toBe("win-1");
|
|
expect(newState.windows[newWinId]).toBeDefined();
|
|
expect(newState.windows[newWinId].appId).toBe("profile");
|
|
|
|
// Layout should reference new window ID
|
|
expect(newWs.layout).toBe(newWinId);
|
|
});
|
|
|
|
it("updates layout tree with new window IDs", () => {
|
|
const spellbookContent = {
|
|
version: 1,
|
|
workspaces: { "ws-2": mockWorkspace2 },
|
|
windows: { "win-1": mockWindow1, "win-2": mockWindow2 },
|
|
};
|
|
|
|
const parsed = {
|
|
slug: "test",
|
|
title: "Test",
|
|
content: spellbookContent,
|
|
referencedSpells: [],
|
|
event: {} as any,
|
|
};
|
|
|
|
const newState = loadSpellbook(mockState, parsed);
|
|
// Since loadSpellbook replaces all workspaces, the imported workspace gets number 1
|
|
const newWs = Object.values(newState.workspaces).find(
|
|
(w) => w.number === 1,
|
|
)!;
|
|
|
|
expect(typeof newWs.layout).toBe("object");
|
|
if (typeof newWs.layout === "object" && newWs.layout !== null) {
|
|
// Check that leaf nodes are new UUIDs, not old IDs
|
|
expect(newWs.layout.first).not.toBe("win-2");
|
|
expect(newWs.layout.second).not.toBe("win-1");
|
|
|
|
// Check that they match the windowIds list
|
|
expect(newWs.windowIds).toContain(newWs.layout.first);
|
|
expect(newWs.windowIds).toContain(newWs.layout.second);
|
|
}
|
|
});
|
|
|
|
it("sets activeSpellbook with source tracking from network event", () => {
|
|
const spellbookContent = {
|
|
version: 1,
|
|
workspaces: { "ws-1": mockWorkspace1 },
|
|
windows: { "win-1": mockWindow1 },
|
|
};
|
|
|
|
const event: SpellbookEvent = {
|
|
id: "event-123",
|
|
pubkey: "author-pubkey",
|
|
created_at: 123456,
|
|
kind: SPELLBOOK_KIND,
|
|
tags: [["d", "test"]],
|
|
content: JSON.stringify(spellbookContent),
|
|
sig: "sig",
|
|
};
|
|
|
|
const parsed = {
|
|
slug: "test",
|
|
title: "Test Title",
|
|
description: "Test Description",
|
|
content: spellbookContent,
|
|
referencedSpells: [],
|
|
event,
|
|
// Simulating network-loaded spellbook (no localId)
|
|
source: "network" as const,
|
|
isPublished: true,
|
|
};
|
|
|
|
const newState = loadSpellbook(mockState, parsed);
|
|
|
|
// Check activeSpellbook has enhanced fields
|
|
expect(newState.activeSpellbook).toBeDefined();
|
|
expect(newState.activeSpellbook?.slug).toBe("test");
|
|
expect(newState.activeSpellbook?.title).toBe("Test Title");
|
|
expect(newState.activeSpellbook?.description).toBe("Test Description");
|
|
expect(newState.activeSpellbook?.pubkey).toBe("author-pubkey");
|
|
expect(newState.activeSpellbook?.source).toBe("network");
|
|
expect(newState.activeSpellbook?.isPublished).toBe(true);
|
|
expect(newState.activeSpellbook?.localId).toBeUndefined();
|
|
});
|
|
|
|
it("sets activeSpellbook with source tracking from local spellbook", () => {
|
|
const spellbookContent = {
|
|
version: 1,
|
|
workspaces: { "ws-1": mockWorkspace1 },
|
|
windows: { "win-1": mockWindow1 },
|
|
};
|
|
|
|
const parsed = {
|
|
slug: "local-test",
|
|
title: "Local Test",
|
|
content: spellbookContent,
|
|
referencedSpells: [],
|
|
// Simulating local-loaded spellbook
|
|
localId: "local-uuid-123",
|
|
source: "local" as const,
|
|
isPublished: false,
|
|
};
|
|
|
|
const newState = loadSpellbook(mockState, parsed);
|
|
|
|
// Check activeSpellbook has enhanced fields
|
|
expect(newState.activeSpellbook).toBeDefined();
|
|
expect(newState.activeSpellbook?.slug).toBe("local-test");
|
|
expect(newState.activeSpellbook?.source).toBe("local");
|
|
expect(newState.activeSpellbook?.isPublished).toBe(false);
|
|
expect(newState.activeSpellbook?.localId).toBe("local-uuid-123");
|
|
expect(newState.activeSpellbook?.pubkey).toBeUndefined();
|
|
});
|
|
|
|
it("infers source from event presence when not provided", () => {
|
|
const spellbookContent = {
|
|
version: 1,
|
|
workspaces: { "ws-1": mockWorkspace1 },
|
|
windows: { "win-1": mockWindow1 },
|
|
};
|
|
|
|
const event: SpellbookEvent = {
|
|
id: "event-abc",
|
|
pubkey: "some-pubkey",
|
|
created_at: 123456,
|
|
kind: SPELLBOOK_KIND,
|
|
tags: [],
|
|
content: JSON.stringify(spellbookContent),
|
|
sig: "sig",
|
|
};
|
|
|
|
const parsed = {
|
|
slug: "inferred",
|
|
title: "Inferred",
|
|
content: spellbookContent,
|
|
referencedSpells: [],
|
|
event, // Has event, so should infer network
|
|
// No source, localId, or isPublished provided
|
|
};
|
|
|
|
const newState = loadSpellbook(mockState, parsed);
|
|
|
|
// Should infer source as "network" and isPublished as true from event presence
|
|
expect(newState.activeSpellbook?.source).toBe("network");
|
|
expect(newState.activeSpellbook?.isPublished).toBe(true);
|
|
});
|
|
});
|
|
});
|