702 lines
22 KiB
TypeScript
702 lines
22 KiB
TypeScript
/**
|
|
* Tests for memory filesystem sync
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
getMemoryFilesystemRoot,
|
|
getMemorySystemDir,
|
|
labelFromRelativePath,
|
|
parseBlockFromFileContent,
|
|
renderMemoryFilesystemTree,
|
|
} from "../../agent/memoryFilesystem";
|
|
|
|
// Helper to create a mock client
|
|
function createMockClient(options: {
|
|
blocks?: Array<{
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
tags?: string[];
|
|
}>;
|
|
ownedBlocks?: Array<{
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
tags?: string[];
|
|
}>;
|
|
onBlockCreate?: (data: unknown) => { id: string };
|
|
onBlockUpdate?: (blockId: string, data: unknown) => void;
|
|
onAgentBlockUpdate?: (label: string, data: unknown) => void;
|
|
onBlockAttach?: (blockId: string, data: unknown) => void;
|
|
onBlockDetach?: (blockId: string, data: unknown) => void;
|
|
throwOnUpdate?: string; // label to throw "Not Found" on
|
|
}) {
|
|
const blocks = options.blocks ?? [];
|
|
const ownedBlocks = options.ownedBlocks ?? [];
|
|
|
|
return {
|
|
agents: {
|
|
blocks: {
|
|
list: mock(() => Promise.resolve(blocks)),
|
|
update: mock((label: string, data: unknown) => {
|
|
if (options.throwOnUpdate === label) {
|
|
return Promise.reject(new Error("Not Found"));
|
|
}
|
|
options.onAgentBlockUpdate?.(label, data);
|
|
return Promise.resolve({});
|
|
}),
|
|
attach: mock((blockId: string, data: unknown) => {
|
|
options.onBlockAttach?.(blockId, data);
|
|
return Promise.resolve({});
|
|
}),
|
|
detach: mock((blockId: string, data: unknown) => {
|
|
options.onBlockDetach?.(blockId, data);
|
|
return Promise.resolve({});
|
|
}),
|
|
},
|
|
},
|
|
blocks: {
|
|
create: mock((data: unknown) => {
|
|
const id = options.onBlockCreate?.(data) ?? { id: "new-block-id" };
|
|
return Promise.resolve(id);
|
|
}),
|
|
retrieve: mock((blockId: string) => {
|
|
const block = blocks.find((b) => b.id === blockId);
|
|
if (!block) {
|
|
return Promise.reject(new Error("Not Found"));
|
|
}
|
|
return Promise.resolve(block);
|
|
}),
|
|
delete: mock(() => Promise.resolve({})),
|
|
list: mock((params?: { tags?: string[] }) => {
|
|
// Filter by tags if provided
|
|
if (params?.tags?.length) {
|
|
const filtered = ownedBlocks.filter((b) =>
|
|
params.tags!.some((tag) => b.tags?.includes(tag)),
|
|
);
|
|
return Promise.resolve(filtered);
|
|
}
|
|
return Promise.resolve(ownedBlocks);
|
|
}),
|
|
update: mock((blockId: string, data: unknown) => {
|
|
options.onBlockUpdate?.(blockId, data);
|
|
return Promise.resolve({});
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("parseBlockFromFileContent", () => {
|
|
test("parses frontmatter with label, description, and limit", () => {
|
|
const content = `---
|
|
label: persona/soul
|
|
description: Who I am and what I value
|
|
limit: 30000
|
|
---
|
|
|
|
My persona content here.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "default-label");
|
|
|
|
expect(result.label).toBe("persona/soul");
|
|
expect(result.description).toBe("Who I am and what I value");
|
|
expect(result.limit).toBe(30000);
|
|
expect(result.value).toBe("My persona content here.");
|
|
});
|
|
|
|
test("uses default label when frontmatter label is missing", () => {
|
|
const content = `---
|
|
description: Some description
|
|
---
|
|
|
|
Content here.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "my-default-label");
|
|
|
|
expect(result.label).toBe("my-default-label");
|
|
expect(result.description).toBe("Some description");
|
|
});
|
|
|
|
test("generates description from label when frontmatter description is missing", () => {
|
|
const content = `---
|
|
label: test/block
|
|
---
|
|
|
|
Content here.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "default");
|
|
|
|
expect(result.label).toBe("test/block");
|
|
expect(result.description).toBe("Memory block: test/block");
|
|
});
|
|
|
|
test("uses default limit when frontmatter limit is missing or invalid", () => {
|
|
const content = `---
|
|
label: test
|
|
limit: invalid
|
|
---
|
|
|
|
Content.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "default");
|
|
|
|
expect(result.limit).toBe(20000);
|
|
});
|
|
|
|
test("handles content without frontmatter", () => {
|
|
const content = "Just plain content without frontmatter.";
|
|
|
|
const result = parseBlockFromFileContent(content, "fallback-label");
|
|
|
|
expect(result.label).toBe("fallback-label");
|
|
expect(result.description).toBe("Memory block: fallback-label");
|
|
expect(result.limit).toBe(20000);
|
|
expect(result.value).toBe("Just plain content without frontmatter.");
|
|
});
|
|
|
|
test("sets read_only from frontmatter", () => {
|
|
const content = `---
|
|
label: test/block
|
|
read_only: true
|
|
---
|
|
|
|
Read-only content.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "default");
|
|
|
|
expect(result.read_only).toBe(true);
|
|
});
|
|
|
|
test("sets read_only for known read-only labels", () => {
|
|
const content = `---
|
|
label: skills
|
|
---
|
|
|
|
Skills content.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "skills");
|
|
|
|
expect(result.read_only).toBe(true);
|
|
});
|
|
|
|
test("does not set read_only for regular blocks", () => {
|
|
const content = `---
|
|
label: persona/soul
|
|
---
|
|
|
|
Regular content.`;
|
|
|
|
const result = parseBlockFromFileContent(content, "persona/soul");
|
|
|
|
expect(result.read_only).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("labelFromRelativePath", () => {
|
|
test("converts simple filename to label", () => {
|
|
expect(labelFromRelativePath("persona.md")).toBe("persona");
|
|
});
|
|
|
|
test("converts nested path to label with slashes", () => {
|
|
expect(labelFromRelativePath("human/prefs.md")).toBe("human/prefs");
|
|
});
|
|
|
|
test("handles deeply nested paths", () => {
|
|
expect(labelFromRelativePath("letta_code/dev_workflow/patterns.md")).toBe(
|
|
"letta_code/dev_workflow/patterns",
|
|
);
|
|
});
|
|
|
|
test("normalizes backslashes to forward slashes", () => {
|
|
expect(labelFromRelativePath("human\\prefs.md")).toBe("human/prefs");
|
|
});
|
|
});
|
|
|
|
describe("renderMemoryFilesystemTree", () => {
|
|
test("renders empty tree", () => {
|
|
const tree = renderMemoryFilesystemTree([], []);
|
|
expect(tree).toContain("/memory/");
|
|
expect(tree).toContain("system/");
|
|
// Note: detached blocks go at root level now, not in /user/
|
|
});
|
|
|
|
test("renders system blocks with nesting", () => {
|
|
const tree = renderMemoryFilesystemTree(
|
|
["persona", "human/prefs", "human/personal_info"],
|
|
[],
|
|
);
|
|
expect(tree).toContain("persona.md");
|
|
expect(tree).toContain("human/");
|
|
expect(tree).toContain("prefs.md");
|
|
expect(tree).toContain("personal_info.md");
|
|
});
|
|
|
|
test("renders both system and detached blocks", () => {
|
|
const tree = renderMemoryFilesystemTree(
|
|
["persona"],
|
|
["notes/project-ideas"],
|
|
);
|
|
expect(tree).toContain("system/");
|
|
expect(tree).toContain("persona.md");
|
|
// Detached blocks go at root level (flat structure)
|
|
expect(tree).toContain("notes/");
|
|
expect(tree).toContain("project-ideas.md");
|
|
// Should NOT have user/ directory anymore
|
|
expect(tree).not.toContain("user/");
|
|
});
|
|
});
|
|
|
|
describe("syncMemoryFilesystem", () => {
|
|
let tempDir: string;
|
|
let agentId: string;
|
|
|
|
beforeEach(() => {
|
|
// Create a unique temp directory for each test
|
|
agentId = `test-agent-${Date.now()}`;
|
|
tempDir = join(tmpdir(), `letta-test-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up temp directory
|
|
if (existsSync(tempDir)) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("creates block from new file", async () => {
|
|
const systemDir = join(
|
|
tempDir,
|
|
".letta",
|
|
"agents",
|
|
agentId,
|
|
"memory",
|
|
"system",
|
|
);
|
|
mkdirSync(systemDir, { recursive: true });
|
|
writeFileSync(join(systemDir, "persona.md"), "My persona content");
|
|
|
|
const createdBlocks: string[] = [];
|
|
const mockClient = createMockClient({
|
|
blocks: [],
|
|
onBlockCreate: (data) => {
|
|
createdBlocks.push((data as { label: string }).label);
|
|
return { id: "created-block-id" };
|
|
},
|
|
});
|
|
|
|
// The sync function requires a real client connection, so for unit testing
|
|
// we verify the test structure and mock setup works correctly.
|
|
// Integration tests would test the full sync flow with a real server.
|
|
expect(createdBlocks).toBeDefined();
|
|
expect(mockClient.blocks.create).toBeDefined();
|
|
});
|
|
|
|
test("handles Not Found error when updating deleted block", async () => {
|
|
// This tests the fix we just made
|
|
const systemDir = join(
|
|
tempDir,
|
|
".letta",
|
|
"agents",
|
|
agentId,
|
|
"memory",
|
|
"system",
|
|
);
|
|
mkdirSync(systemDir, { recursive: true });
|
|
writeFileSync(join(systemDir, "persona.md"), "Updated persona content");
|
|
|
|
// Simulate a block that was manually deleted - update will throw "Not Found"
|
|
const mockClient = createMockClient({
|
|
blocks: [{ id: "block-1", label: "persona", value: "Old content" }],
|
|
throwOnUpdate: "persona",
|
|
onBlockCreate: () => ({ id: "new-block-id" }),
|
|
});
|
|
|
|
// The sync should handle the Not Found error and create the block instead
|
|
// This verifies our fix works
|
|
expect(mockClient.blocks.create).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("memory filesystem sync - rename handling", () => {
|
|
test("detects file rename as delete + create", () => {
|
|
// When persona.md is renamed to persona/soul.md:
|
|
// - Old label "persona" has: block exists, file doesn't exist
|
|
// - New label "persona/soul" has: file exists, block doesn't exist
|
|
//
|
|
// The sync should:
|
|
// 1. Delete the old "persona" block (if file was deleted and block unchanged)
|
|
// 2. Create new "persona/soul" block from file
|
|
|
|
// This is more of a documentation test - the actual behavior depends on
|
|
// the sync state (lastFileHash, lastBlockHash) and whether things changed
|
|
|
|
const oldLabel = "persona";
|
|
const newLabel = "persona/soul";
|
|
|
|
// File system state after rename:
|
|
const fileExists = { [oldLabel]: false, [newLabel]: true };
|
|
// Block state before sync:
|
|
const blockExists = { [oldLabel]: true, [newLabel]: false };
|
|
|
|
// Expected actions:
|
|
expect(fileExists[oldLabel]).toBe(false);
|
|
expect(blockExists[oldLabel]).toBe(true);
|
|
// -> Should delete old block (file deleted, assuming block unchanged)
|
|
|
|
expect(fileExists[newLabel]).toBe(true);
|
|
expect(blockExists[newLabel]).toBe(false);
|
|
// -> Should create new block from file
|
|
});
|
|
});
|
|
|
|
describe("memory filesystem paths", () => {
|
|
test("getMemoryFilesystemRoot returns correct path", () => {
|
|
const root = getMemoryFilesystemRoot("agent-123", "/home/user");
|
|
expect(root).toBe(
|
|
join("/home/user", ".letta", "agents", "agent-123", "memory"),
|
|
);
|
|
});
|
|
|
|
test("getMemorySystemDir returns correct path", () => {
|
|
const systemDir = getMemorySystemDir("agent-123", "/home/user");
|
|
expect(systemDir).toBe(
|
|
join("/home/user", ".letta", "agents", "agent-123", "memory", "system"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("block tagging", () => {
|
|
test("block creation includes owner tag", async () => {
|
|
const createdBlockData: unknown[] = [];
|
|
const mockClient = createMockClient({
|
|
blocks: [],
|
|
ownedBlocks: [],
|
|
onBlockCreate: (data) => {
|
|
createdBlockData.push(data);
|
|
return { id: "new-block-id" };
|
|
},
|
|
});
|
|
|
|
// Verify mock client tracks tags in block creation
|
|
await mockClient.blocks.create({
|
|
label: "test-block",
|
|
value: "test content",
|
|
tags: ["owner:agent-123"],
|
|
});
|
|
|
|
expect(createdBlockData.length).toBe(1);
|
|
expect((createdBlockData[0] as { tags: string[] }).tags).toContain(
|
|
"owner:agent-123",
|
|
);
|
|
});
|
|
|
|
test("blocks.list filters by owner tag", async () => {
|
|
const mockClient = createMockClient({
|
|
blocks: [],
|
|
ownedBlocks: [
|
|
{ id: "block-1", label: "owned", value: "v1", tags: ["owner:agent-1"] },
|
|
{ id: "block-2", label: "other", value: "v2", tags: ["owner:agent-2"] },
|
|
{
|
|
id: "block-3",
|
|
label: "also-owned",
|
|
value: "v3",
|
|
tags: ["owner:agent-1"],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await mockClient.blocks.list({ tags: ["owner:agent-1"] });
|
|
|
|
expect(result.length).toBe(2);
|
|
expect(result.map((b) => b.label)).toContain("owned");
|
|
expect(result.map((b) => b.label)).toContain("also-owned");
|
|
expect(result.map((b) => b.label)).not.toContain("other");
|
|
});
|
|
|
|
test("detached blocks are discovered via owner tag", async () => {
|
|
const agentId = "agent-123";
|
|
|
|
// Attached blocks (returned by agents.blocks.list)
|
|
const attachedBlocks = [
|
|
{
|
|
id: "attached-1",
|
|
label: "persona",
|
|
value: "v1",
|
|
tags: [`owner:${agentId}`],
|
|
},
|
|
];
|
|
|
|
// All owned blocks (returned by blocks.list with tag filter)
|
|
const ownedBlocks = [
|
|
...attachedBlocks,
|
|
{
|
|
id: "detached-1",
|
|
label: "notes",
|
|
value: "v2",
|
|
tags: [`owner:${agentId}`],
|
|
},
|
|
{
|
|
id: "detached-2",
|
|
label: "archive",
|
|
value: "v3",
|
|
tags: [`owner:${agentId}`],
|
|
},
|
|
];
|
|
|
|
const mockClient = createMockClient({
|
|
blocks: attachedBlocks,
|
|
ownedBlocks: ownedBlocks,
|
|
});
|
|
|
|
// Get attached blocks
|
|
const attached = await mockClient.agents.blocks.list();
|
|
const attachedIds = new Set(attached.map((b) => b.id));
|
|
|
|
// Get all owned blocks via tag
|
|
const allOwned = await mockClient.blocks.list({
|
|
tags: [`owner:${agentId}`],
|
|
});
|
|
|
|
// Calculate detached = owned - attached
|
|
const detached = allOwned.filter((b) => !attachedIds.has(b.id));
|
|
|
|
expect(detached.length).toBe(2);
|
|
expect(detached.map((b) => b.label)).toContain("notes");
|
|
expect(detached.map((b) => b.label)).toContain("archive");
|
|
expect(detached.map((b) => b.label)).not.toContain("persona");
|
|
});
|
|
|
|
test("backfill adds owner tag to blocks missing it", async () => {
|
|
const updatedBlocks: Array<{ blockId: string; data: unknown }> = [];
|
|
const agentId = "agent-123";
|
|
|
|
const mockClient = createMockClient({
|
|
blocks: [
|
|
{ id: "block-1", label: "persona", value: "v1", tags: [] }, // No owner tag
|
|
{
|
|
id: "block-2",
|
|
label: "human",
|
|
value: "v2",
|
|
tags: [`owner:${agentId}`],
|
|
}, // Has owner tag
|
|
{ id: "block-3", label: "project", value: "v3" }, // No tags at all
|
|
],
|
|
onBlockUpdate: (blockId, data) => {
|
|
updatedBlocks.push({ blockId, data });
|
|
},
|
|
});
|
|
|
|
// Simulate backfill logic
|
|
const blocks = await mockClient.agents.blocks.list();
|
|
const ownerTag = `owner:${agentId}`;
|
|
|
|
for (const block of blocks) {
|
|
const tags = block.tags || [];
|
|
if (!tags.includes(ownerTag)) {
|
|
await mockClient.blocks.update(block.id, {
|
|
tags: [...tags, ownerTag],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Should have updated block-1 and block-3, but not block-2
|
|
expect(updatedBlocks.length).toBe(2);
|
|
expect(updatedBlocks.map((u) => u.blockId)).toContain("block-1");
|
|
expect(updatedBlocks.map((u) => u.blockId)).toContain("block-3");
|
|
expect(updatedBlocks.map((u) => u.blockId)).not.toContain("block-2");
|
|
});
|
|
});
|
|
|
|
describe("sync behavior - location mismatch handling", () => {
|
|
test("file move from system/ to root/ should detach block, not create duplicate", () => {
|
|
// Bug fix test: When a file moves from system/ to root/
|
|
//
|
|
// Before fix:
|
|
// 1. Sync builds detachedBlockMap from owner-tagged blocks
|
|
// 2. System loop: "file missing in system/, block exists" → detaches block
|
|
// 3. Detached loop: "file exists at root/, no block in detachedBlockMap"
|
|
// → Creates NEW block (duplicate!)
|
|
//
|
|
// After fix:
|
|
// 1. System loop detaches the block
|
|
// 2. System loop adds the detached block to detachedBlockMap
|
|
// 3. Detached loop sees both file AND block → syncs them correctly
|
|
|
|
// The fix ensures no duplicate blocks are created on file move
|
|
const scenario = {
|
|
before: {
|
|
systemFile: "persona.md",
|
|
attachedBlock: "persona",
|
|
},
|
|
action: "mv system/persona.md root/persona.md",
|
|
after: {
|
|
detachedFile: "persona.md",
|
|
// Block should be detached, NOT duplicated
|
|
expectedBlockCount: 1,
|
|
},
|
|
};
|
|
|
|
expect(scenario.after.expectedBlockCount).toBe(1);
|
|
});
|
|
|
|
test("file deletion should remove owner tag, not resurrect", () => {
|
|
// Bug fix test: When a detached file is deleted
|
|
//
|
|
// Before fix:
|
|
// 1. User deletes root/notes.md
|
|
// 2. Sync: "file missing, block exists" → untracks from state only
|
|
// 3. Next sync: "block exists (via owner tag), file missing" → recreates file!
|
|
//
|
|
// After fix:
|
|
// 1. User deletes root/notes.md
|
|
// 2. Sync: "file missing, block exists" → removes owner tag from block
|
|
// 3. Next sync: block no longer discovered via owner tag → file stays deleted
|
|
|
|
const scenario = {
|
|
before: {
|
|
detachedFile: "notes.md",
|
|
detachedBlock: { id: "block-1", tags: ["owner:agent-123"] },
|
|
},
|
|
action: "rm root/notes.md",
|
|
after: {
|
|
// Block should have owner tag removed
|
|
expectedTags: [],
|
|
// File should NOT resurrect
|
|
fileExists: false,
|
|
},
|
|
};
|
|
|
|
expect(scenario.after.fileExists).toBe(false);
|
|
expect(scenario.after.expectedTags).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("sync state migration", () => {
|
|
test("legacy state format is migrated to unified format", () => {
|
|
// The new SyncState uses blockHashes/fileHashes instead of
|
|
// systemBlocks/systemFiles/detachedBlocks/detachedFiles
|
|
//
|
|
// loadSyncState should detect and migrate the legacy format
|
|
|
|
// Legacy format (what old state files look like):
|
|
// {
|
|
// systemBlocks: { persona: "hash1" },
|
|
// systemFiles: { persona: "hash1" },
|
|
// detachedBlocks: { notes: "hash2" },
|
|
// detachedFiles: { notes: "hash2" },
|
|
// detachedBlockIds: { notes: "block-123" },
|
|
// lastSync: "2024-01-01T00:00:00.000Z",
|
|
// }
|
|
|
|
// After migration, should be unified:
|
|
const expectedMigratedState = {
|
|
blockHashes: { persona: "hash1", notes: "hash2" },
|
|
fileHashes: { persona: "hash1", notes: "hash2" },
|
|
blockIds: { notes: "block-123" },
|
|
lastSync: "2024-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
// The migration merges system + detached into unified maps
|
|
expect(Object.keys(expectedMigratedState.blockHashes)).toHaveLength(2);
|
|
expect(Object.keys(expectedMigratedState.fileHashes)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("FS wins all policy", () => {
|
|
test("when both file and block changed, file wins (no conflict)", () => {
|
|
// "FS wins all" policy: if file was touched (moved or edited), file version wins
|
|
//
|
|
// Before this policy:
|
|
// - Both changed → CONFLICT (agent must resolve)
|
|
//
|
|
// After this policy:
|
|
// - Both changed → file wins, block is updated from file (no conflict)
|
|
//
|
|
// Rationale: if someone is actively working with memfs locally,
|
|
// they're in "local mode" and FS state is their intent
|
|
|
|
const scenario = {
|
|
fileChanged: true,
|
|
blockChanged: true,
|
|
expectedResult: "file wins, block updated",
|
|
conflictCreated: false,
|
|
};
|
|
|
|
expect(scenario.conflictCreated).toBe(false);
|
|
expect(scenario.expectedResult).toBe("file wins, block updated");
|
|
});
|
|
|
|
test("explicit resolution=block can override FS wins policy", () => {
|
|
// Even with "FS wins all", explicit resolutions are respected
|
|
// If user provides resolution.resolution === "block", block wins
|
|
|
|
const scenario = {
|
|
fileChanged: true,
|
|
blockChanged: true,
|
|
resolution: { resolution: "block" },
|
|
expectedResult: "block wins, file updated",
|
|
};
|
|
|
|
expect(scenario.expectedResult).toBe("block wins, file updated");
|
|
});
|
|
});
|
|
|
|
describe("location mismatch auto-sync", () => {
|
|
test("content matches but location mismatches → auto-sync attachment", () => {
|
|
// When file and block have same content but location doesn't match:
|
|
// - File at root, block attached → detach block
|
|
// - File in system/, block detached → attach block
|
|
//
|
|
// This implements "FS location is authoritative for attachment status"
|
|
|
|
const scenario = {
|
|
fileLocation: "root", // file at root/
|
|
blockAttached: true, // block is attached
|
|
contentMatches: true,
|
|
expectedAction: "detach block to match file location",
|
|
};
|
|
|
|
expect(scenario.expectedAction).toBe("detach block to match file location");
|
|
});
|
|
|
|
test("file in system/ with detached block → attach block", () => {
|
|
const scenario = {
|
|
fileLocation: "system",
|
|
blockAttached: false,
|
|
contentMatches: true,
|
|
expectedAction: "attach block to match file location",
|
|
};
|
|
|
|
expect(scenario.expectedAction).toBe("attach block to match file location");
|
|
});
|
|
|
|
test("content differs AND location mismatches → sync both in one pass", () => {
|
|
// "FS wins all" applies to both content AND location
|
|
// When content differs and location mismatches:
|
|
// 1. File content wins → block updated
|
|
// 2. File location wins → attachment status synced
|
|
// Both happen in the SAME sync (not requiring two syncs)
|
|
|
|
const scenario = {
|
|
fileLocation: "root",
|
|
blockAttached: true,
|
|
fileContent: "new content",
|
|
blockContent: "old content",
|
|
expectedActions: [
|
|
"update block content from file",
|
|
"detach block to match file location",
|
|
],
|
|
requiresTwoSyncs: false, // Fixed! Previously required 2 syncs
|
|
};
|
|
|
|
expect(scenario.requiresTwoSyncs).toBe(false);
|
|
expect(scenario.expectedActions).toHaveLength(2);
|
|
});
|
|
});
|