diff --git a/tests/unit/librarian/codecs/library-path.test.ts b/tests/unit/librarian/codecs/library-path.test.ts new file mode 100644 index 000000000..2f884947d --- /dev/null +++ b/tests/unit/librarian/codecs/library-path.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + fromSplitPath, + toSplitPath, + getSuffixParts, + getBasename, + getParentPathParts, + makeLeafPath, + makeSectionPath, + fromSectionChain, + makeLibraryPathCodecs, + type LibraryPath, +} from "../../../../src/commanders/librarian/codecs/library-path"; +import { makeSegmentIdCodecs } from "../../../../src/commanders/librarian/codecs/segment-id/make"; +import { SplitPathKind } from "../../../../src/managers/obsidian/vault-action-manager/types/split-path"; +import type { + SplitPathToFolderInsideLibrary, + SplitPathToMdFileInsideLibrary, + SplitPathToFileInsideLibrary, +} from "../../../../src/commanders/librarian/codecs/split-path-inside-library"; +import type { SectionNodeSegmentId } from "../../../../src/commanders/librarian/codecs/segment-id/types/segment-id"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +// ﹘ = SMALL_EM_DASH (U+FE58) +const SEP = "﹘"; +const sec = (name: string): SectionNodeSegmentId => + `${name}${SEP}Section${SEP}` as SectionNodeSegmentId; + +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("library-path codecs", () => { + let spy: ReturnType; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("fromSplitPath", () => { + it("creates LibraryPath from Folder split path", () => { + const sp: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + const lp = fromSplitPath(sp); + expect(lp.segments).toEqual(["Library", "parent", "child"]); + expect(lp.kind).toBe(SplitPathKind.Folder); + expect(lp.extension).toBeUndefined(); + }); + + it("creates LibraryPath from MdFile split path", () => { + const sp: SplitPathToMdFileInsideLibrary = { + basename: "Note", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + }; + const lp = fromSplitPath(sp); + expect(lp.segments).toEqual(["Library", "parent", "Note"]); + expect(lp.kind).toBe(SplitPathKind.MdFile); + expect(lp.extension).toBe("md"); + }); + + it("creates LibraryPath from File split path", () => { + const sp: SplitPathToFileInsideLibrary = { + basename: "Image", + extension: "png", + kind: SplitPathKind.File, + pathParts: ["Library"], + }; + const lp = fromSplitPath(sp); + expect(lp.segments).toEqual(["Library", "Image"]); + expect(lp.extension).toBe("png"); + }); + }); + + describe("toSplitPath", () => { + it("converts Folder LibraryPath to split path", () => { + const lp: LibraryPath = { + extension: undefined, + kind: SplitPathKind.Folder, + segments: ["Library", "parent", "child"], + }; + const sp = toSplitPath(lp); + expect(sp.basename).toBe("child"); + expect(sp.pathParts).toEqual(["Library", "parent"]); + expect(sp.kind).toBe(SplitPathKind.Folder); + }); + + it("converts MdFile LibraryPath to split path", () => { + const lp: LibraryPath = { + extension: "md", + kind: SplitPathKind.MdFile, + segments: ["Library", "parent", "Note"], + }; + const sp = toSplitPath(lp); + expect(sp.basename).toBe("Note"); + expect(sp.pathParts).toEqual(["Library", "parent"]); + expect(sp.kind).toBe(SplitPathKind.MdFile); + }); + + it("converts File LibraryPath to split path", () => { + const lp: LibraryPath = { + extension: "png", + kind: SplitPathKind.File, + segments: ["Library", "Image"], + }; + const sp = toSplitPath(lp); + expect(sp.basename).toBe("Image"); + expect(sp.pathParts).toEqual(["Library"]); + }); + }); + + describe("getSuffixParts", () => { + it("returns copy of segments for root-only path", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library"], + }; + expect(getSuffixParts(lp)).toEqual(["Library"]); + }); + + it("returns reversed segments excluding root", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library", "grandpa", "father"], + }; + expect(getSuffixParts(lp)).toEqual(["father", "grandpa"]); + }); + + it("handles deeply nested path", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library", "a", "b", "c"], + }; + expect(getSuffixParts(lp)).toEqual(["c", "b", "a"]); + }); + }); + + describe("getBasename", () => { + it("returns last segment", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library", "parent", "child"], + }; + expect(getBasename(lp)).toBe("child"); + }); + + it("returns empty string for empty segments", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: [], + }; + expect(getBasename(lp)).toBe(""); + }); + }); + + describe("getParentPathParts", () => { + it("returns all segments except last", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library", "parent", "child"], + }; + expect(getParentPathParts(lp)).toEqual(["Library", "parent"]); + }); + + it("returns empty for single segment", () => { + const lp: LibraryPath = { + kind: SplitPathKind.Folder, + segments: ["Library"], + }; + expect(getParentPathParts(lp)).toEqual([]); + }); + }); + + describe("makeLeafPath", () => { + it("creates MdFile path for md extension", () => { + const lp = makeLeafPath(["Library", "parent"], "Note", "md"); + expect(lp.segments).toEqual(["Library", "parent", "Note"]); + expect(lp.kind).toBe(SplitPathKind.MdFile); + expect(lp.extension).toBe("md"); + }); + + it("creates File path for non-md extension", () => { + const lp = makeLeafPath(["Library"], "Image", "png"); + expect(lp.segments).toEqual(["Library", "Image"]); + expect(lp.kind).toBe(SplitPathKind.File); + expect(lp.extension).toBe("png"); + }); + }); + + describe("makeSectionPath", () => { + it("creates Folder path", () => { + const lp = makeSectionPath(["Library", "parent", "child"]); + expect(lp.segments).toEqual(["Library", "parent", "child"]); + expect(lp.kind).toBe(SplitPathKind.Folder); + expect(lp.extension).toBeUndefined(); + }); + }); + + describe("fromSectionChain", () => { + it("converts section chain to LibraryPath", () => { + const rules = makeDefaultRules(); + const segmentIdCodecs = makeSegmentIdCodecs(rules); + const chain = [sec("Library"), sec("parent"), sec("child")]; + const result = fromSectionChain(chain, segmentIdCodecs); + expect(result.isOk()).toBe(true); + const lp = result._unsafeUnwrap(); + expect(lp.segments).toEqual(["Library", "parent", "child"]); + expect(lp.kind).toBe(SplitPathKind.Folder); + }); + + it("returns empty segments for empty chain", () => { + const rules = makeDefaultRules(); + const segmentIdCodecs = makeSegmentIdCodecs(rules); + const result = fromSectionChain([], segmentIdCodecs); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().segments).toEqual([]); + }); + + it("returns error for invalid segment ID in chain", () => { + const rules = makeDefaultRules(); + const segmentIdCodecs = makeSegmentIdCodecs(rules); + const chain = ["invalid" as SectionNodeSegmentId]; + const result = fromSectionChain(chain, segmentIdCodecs); + expect(result.isErr()).toBe(true); + }); + }); + + describe("fromSplitPath / toSplitPath roundtrip", () => { + it("roundtrips Folder path", () => { + const original: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + const lp = fromSplitPath(original); + const back = toSplitPath(lp); + expect(back.basename).toBe(original.basename); + expect(back.pathParts).toEqual(original.pathParts); + expect(back.kind).toBe(original.kind); + }); + + it("roundtrips MdFile path", () => { + const original: SplitPathToMdFileInsideLibrary = { + basename: "Note", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + }; + const lp = fromSplitPath(original); + const back = toSplitPath(lp); + expect(back.basename).toBe(original.basename); + expect(back.pathParts).toEqual(original.pathParts); + }); + }); + + describe("makeLibraryPathCodecs factory", () => { + it("creates codecs object with all functions", () => { + const rules = makeDefaultRules(); + const segmentIdCodecs = makeSegmentIdCodecs(rules); + const codecs = makeLibraryPathCodecs(segmentIdCodecs); + expect(typeof codecs.fromSplitPath).toBe("function"); + expect(typeof codecs.toSplitPath).toBe("function"); + expect(typeof codecs.getSuffixParts).toBe("function"); + expect(typeof codecs.getBasename).toBe("function"); + expect(typeof codecs.getParentPathParts).toBe("function"); + expect(typeof codecs.makeLeafPath).toBe("function"); + expect(typeof codecs.makeSectionPath).toBe("function"); + expect(typeof codecs.fromSectionChain).toBe("function"); + }); + }); +}); diff --git a/tests/unit/librarian/codecs/locator.test.ts b/tests/unit/librarian/codecs/locator.test.ts new file mode 100644 index 000000000..bc7693e61 --- /dev/null +++ b/tests/unit/librarian/codecs/locator.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { makeSuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import type { SuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import { makeSegmentIdCodecs } from "../../../../src/commanders/librarian/codecs/segment-id/make"; +import type { SegmentIdCodecs } from "../../../../src/commanders/librarian/codecs/segment-id/make"; +import { makeLocatorCodecs } from "../../../../src/commanders/librarian/codecs/locator/make"; +import type { LocatorCodecs } from "../../../../src/commanders/librarian/codecs/locator/make"; +import { TreeNodeKind } from "../../../../src/commanders/librarian/healer/library-tree/tree-node/types/atoms"; +import { SplitPathKind } from "../../../../src/managers/obsidian/vault-action-manager/types/split-path"; +import type { + SectionNodeSegmentId, + ScrollNodeSegmentId, + FileNodeSegmentId, +} from "../../../../src/commanders/librarian/codecs/segment-id/types/segment-id"; +import type { + CanonicalSplitPathToFolderInsideLibrary, + CanonicalSplitPathToMdFileInsideLibrary, + CanonicalSplitPathToFileInsideLibrary, +} from "../../../../src/commanders/librarian/codecs/split-path-with-separated-suffix"; +import type { NodeName } from "../../../../src/commanders/librarian/types/schemas/node-name"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +// ﹘ = SMALL_EM_DASH (U+FE58) +const SEP = "﹘"; +const sec = (name: string): SectionNodeSegmentId => + `${name}${SEP}Section${SEP}` as SectionNodeSegmentId; + +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("locator codecs", () => { + let spy: ReturnType; + let suffixCodecs: SuffixCodecs; + let segmentIdCodecs: SegmentIdCodecs; + let codecs: LocatorCodecs; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + const rules = makeDefaultRules(); + suffixCodecs = makeSuffixCodecs(rules); + segmentIdCodecs = makeSegmentIdCodecs(rules); + codecs = makeLocatorCodecs(segmentIdCodecs, suffixCodecs); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("canonicalSplitPathInsideLibraryToLocator", () => { + it("converts Folder canonical split path to Section locator", () => { + const sp: CanonicalSplitPathToFolderInsideLibrary = { + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "child" as NodeName, + suffixParts: [] as NodeName[], + }, + }; + const result = + codecs.canonicalSplitPathInsideLibraryToLocator(sp); + expect(result.isOk()).toBe(true); + const loc = result._unsafeUnwrap(); + expect(loc.targetKind).toBe(TreeNodeKind.Section); + expect(loc.segmentId).toBe(`child${SEP}Section${SEP}`); + expect(loc.segmentIdChainToParent).toEqual([ + sec("Library"), + sec("parent"), + ]); + }); + + it("converts MdFile canonical split path to Scroll locator", () => { + const sp: CanonicalSplitPathToMdFileInsideLibrary = { + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "MyNote" as NodeName, + suffixParts: ["parent" as NodeName], + }, + }; + const result = + codecs.canonicalSplitPathInsideLibraryToLocator(sp); + expect(result.isOk()).toBe(true); + const loc = result._unsafeUnwrap(); + expect(loc.targetKind).toBe(TreeNodeKind.Scroll); + expect(loc.segmentId).toBe(`MyNote${SEP}Scroll${SEP}md`); + expect(loc.segmentIdChainToParent).toEqual([ + sec("Library"), + sec("parent"), + ]); + }); + + it("converts File canonical split path to File locator", () => { + const sp: CanonicalSplitPathToFileInsideLibrary = { + extension: "png", + kind: SplitPathKind.File, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "Image" as NodeName, + suffixParts: ["parent" as NodeName], + }, + }; + const result = + codecs.canonicalSplitPathInsideLibraryToLocator(sp); + expect(result.isOk()).toBe(true); + const loc = result._unsafeUnwrap(); + expect(loc.targetKind).toBe(TreeNodeKind.File); + expect(loc.segmentId).toBe(`Image${SEP}File${SEP}png`); + }); + + it("handles deeply nested path", () => { + const sp: CanonicalSplitPathToMdFileInsideLibrary = { + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "grandpa", "father", "child"], + separatedSuffixedBasename: { + coreName: "Leaf" as NodeName, + suffixParts: [ + "child" as NodeName, + "father" as NodeName, + "grandpa" as NodeName, + ], + }, + }; + const result = + codecs.canonicalSplitPathInsideLibraryToLocator(sp); + expect(result.isOk()).toBe(true); + const loc = result._unsafeUnwrap(); + expect(loc.segmentIdChainToParent).toEqual([ + sec("Library"), + sec("grandpa"), + sec("father"), + sec("child"), + ]); + }); + }); + + describe("locatorToCanonicalSplitPathInsideLibrary", () => { + it("converts Section locator to Folder canonical split path", () => { + const loc = { + segmentId: `child${SEP}Section${SEP}` as SectionNodeSegmentId, + segmentIdChainToParent: [sec("Library"), sec("parent")] as SectionNodeSegmentId[], + targetKind: TreeNodeKind.Section as typeof TreeNodeKind.Section, + }; + const result = + codecs.locatorToCanonicalSplitPathInsideLibrary(loc); + expect(result.isOk()).toBe(true); + const sp = result._unsafeUnwrap(); + expect(sp.kind).toBe(SplitPathKind.Folder); + expect(sp.pathParts).toEqual(["Library", "parent"]); + expect(sp.separatedSuffixedBasename.coreName).toBe("child"); + expect(sp.separatedSuffixedBasename.suffixParts).toEqual([]); + }); + + it("converts Scroll locator to MdFile canonical split path", () => { + const loc = { + segmentId: + `MyNote${SEP}Scroll${SEP}md` as ScrollNodeSegmentId, + segmentIdChainToParent: [sec("Library"), sec("parent")] as SectionNodeSegmentId[], + targetKind: TreeNodeKind.Scroll as typeof TreeNodeKind.Scroll, + }; + const result = + codecs.locatorToCanonicalSplitPathInsideLibrary(loc); + expect(result.isOk()).toBe(true); + const sp = result._unsafeUnwrap(); + expect(sp.kind).toBe(SplitPathKind.MdFile); + expect(sp.pathParts).toEqual(["Library", "parent"]); + expect(sp.separatedSuffixedBasename.coreName).toBe("MyNote"); + expect(sp.separatedSuffixedBasename.suffixParts).toEqual([ + "parent", + ]); + }); + + it("converts File locator to File canonical split path", () => { + const loc = { + segmentId: + `Image${SEP}File${SEP}png` as FileNodeSegmentId, + segmentIdChainToParent: [sec("Library"), sec("parent")] as SectionNodeSegmentId[], + targetKind: TreeNodeKind.File as typeof TreeNodeKind.File, + }; + const result = + codecs.locatorToCanonicalSplitPathInsideLibrary(loc); + expect(result.isOk()).toBe(true); + const sp = result._unsafeUnwrap(); + expect(sp.kind).toBe(SplitPathKind.File); + expect(sp.separatedSuffixedBasename.coreName).toBe("Image"); + }); + }); + + describe("toLocator / fromLocator roundtrip", () => { + it("roundtrips Section locator", () => { + const originalSp: CanonicalSplitPathToFolderInsideLibrary = { + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "child" as NodeName, + suffixParts: [] as NodeName[], + }, + }; + const locResult = + codecs.canonicalSplitPathInsideLibraryToLocator(originalSp); + expect(locResult.isOk()).toBe(true); + const loc = locResult._unsafeUnwrap(); + + const spResult = + codecs.locatorToCanonicalSplitPathInsideLibrary(loc); + expect(spResult.isOk()).toBe(true); + const sp = spResult._unsafeUnwrap(); + expect(sp.kind).toBe(originalSp.kind); + expect(sp.pathParts).toEqual(originalSp.pathParts); + expect(sp.separatedSuffixedBasename.coreName).toBe( + originalSp.separatedSuffixedBasename.coreName, + ); + }); + + it("roundtrips Scroll locator", () => { + const originalSp: CanonicalSplitPathToMdFileInsideLibrary = { + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent", "child"], + separatedSuffixedBasename: { + coreName: "Leaf" as NodeName, + suffixParts: ["child" as NodeName, "parent" as NodeName], + }, + }; + const locResult = + codecs.canonicalSplitPathInsideLibraryToLocator(originalSp); + expect(locResult.isOk()).toBe(true); + const loc = locResult._unsafeUnwrap(); + + const spResult = + codecs.locatorToCanonicalSplitPathInsideLibrary(loc); + expect(spResult.isOk()).toBe(true); + const sp = spResult._unsafeUnwrap(); + expect(sp.kind).toBe(SplitPathKind.MdFile); + expect(sp.pathParts).toEqual(originalSp.pathParts); + expect(sp.separatedSuffixedBasename.coreName).toBe("Leaf"); + expect(sp.separatedSuffixedBasename.suffixParts).toEqual([ + "child", + "parent", + ]); + }); + }); +}); diff --git a/tests/unit/librarian/codecs/segment-id.test.ts b/tests/unit/librarian/codecs/segment-id.test.ts new file mode 100644 index 000000000..5e578a1f9 --- /dev/null +++ b/tests/unit/librarian/codecs/segment-id.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { makeSegmentIdCodecs } from "../../../../src/commanders/librarian/codecs/segment-id/make"; +import type { SegmentIdCodecs } from "../../../../src/commanders/librarian/codecs/segment-id/make"; +import { TreeNodeKind } from "../../../../src/commanders/librarian/healer/library-tree/tree-node/types/atoms"; +import type { TreeNodeSegmentId } from "../../../../src/commanders/librarian/codecs/segment-id/types/segment-id"; +import type { + ScrollNodeSegmentId, + SectionNodeSegmentId, + FileNodeSegmentId, +} from "../../../../src/commanders/librarian/codecs/segment-id/types/segment-id"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +// ﹘ = SMALL_EM_DASH (U+FE58) - the NodeSegmentIdSeparator +const SEP = "﹘"; + +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("segment-id codecs", () => { + let spy: ReturnType; + let codecs: SegmentIdCodecs; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + codecs = makeSegmentIdCodecs(makeDefaultRules()); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("parseSegmentId", () => { + it("parses Section segment ID", () => { + const id = `MySection${SEP}Section${SEP}` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "MySection", + targetKind: TreeNodeKind.Section, + }); + }); + + it("parses Scroll segment ID", () => { + const id = `MyScroll${SEP}Scroll${SEP}md` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "MyScroll", + extension: "md", + targetKind: TreeNodeKind.Scroll, + }); + }); + + it("parses File segment ID", () => { + const id = `MyFile${SEP}File${SEP}png` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "MyFile", + extension: "png", + targetKind: TreeNodeKind.File, + }); + }); + + it("returns error for missing parts (only one part)", () => { + const id = "JustAName" as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.kind).toBe("SegmentIdError"); + expect((result.error as { reason: string }).reason).toBe( + "MissingParts", + ); + } + }); + + it("returns error for unknown TreeNodeKind", () => { + const id = + `MyNode${SEP}UnknownKind${SEP}md` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect((result.error as { reason: string }).reason).toBe( + "UnknownType", + ); + } + }); + + it("returns error for Section with non-empty extension", () => { + const id = + `MySection${SEP}Section${SEP}md` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect((result.error as { reason: string }).reason).toBe( + "InvalidExtension", + ); + } + }); + + it("returns error for Scroll without extension", () => { + const id = `MyScroll${SEP}Scroll` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + // Only 2 parts: coreName + "Scroll", missing extension + expect(result.isErr()).toBe(true); + }); + + it("returns error for Scroll with non-md extension", () => { + const id = + `MyScroll${SEP}Scroll${SEP}txt` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect((result.error as { reason: string }).reason).toBe( + "InvalidExtension", + ); + } + }); + + it("returns error for File without extension", () => { + const id = `MyFile${SEP}File` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + }); + + it("returns error for empty coreName", () => { + const id = `${SEP}Section${SEP}` as TreeNodeSegmentId; + const result = codecs.parseSegmentId(id); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect((result.error as { reason: string }).reason).toBe( + "InvalidNodeName", + ); + } + }); + }); + + describe("type-specific convenience parsers", () => { + it("parseSectionSegmentId", () => { + const id = `Library${SEP}Section${SEP}` as SectionNodeSegmentId; + const result = codecs.parseSectionSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().targetKind).toBe( + TreeNodeKind.Section, + ); + }); + + it("parseScrollSegmentId", () => { + const id = `MyNote${SEP}Scroll${SEP}md` as ScrollNodeSegmentId; + const result = codecs.parseScrollSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().targetKind).toBe( + TreeNodeKind.Scroll, + ); + expect(result._unsafeUnwrap().extension).toBe("md"); + }); + + it("parseFileSegmentId", () => { + const id = `Image${SEP}File${SEP}png` as FileNodeSegmentId; + const result = codecs.parseFileSegmentId(id); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().targetKind).toBe(TreeNodeKind.File); + expect(result._unsafeUnwrap().extension).toBe("png"); + }); + }); + + describe("serializeSegmentId", () => { + it("serializes Section segment ID", () => { + const result = codecs.serializeSegmentId({ + coreName: "MySection" as any, + targetKind: TreeNodeKind.Section, + }); + expect(result).toBe(`MySection${SEP}Section${SEP}`); + }); + + it("serializes Scroll segment ID", () => { + const result = codecs.serializeSegmentId({ + coreName: "MyScroll" as any, + extension: "md" as any, + targetKind: TreeNodeKind.Scroll, + }); + expect(result).toBe(`MyScroll${SEP}Scroll${SEP}md`); + }); + + it("serializes File segment ID", () => { + const result = codecs.serializeSegmentId({ + coreName: "MyFile" as any, + extension: "png" as any, + targetKind: TreeNodeKind.File, + }); + expect(result).toBe(`MyFile${SEP}File${SEP}png`); + }); + }); + + describe("serializeSegmentIdUnchecked", () => { + it("validates and serializes Section", () => { + const result = codecs.serializeSegmentIdUnchecked({ + coreName: "MySection", + targetKind: TreeNodeKind.Section, + }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe( + `MySection${SEP}Section${SEP}`, + ); + }); + + it("validates and serializes Scroll", () => { + const result = codecs.serializeSegmentIdUnchecked({ + coreName: "MyScroll", + extension: "md", + targetKind: TreeNodeKind.Scroll, + }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe( + `MyScroll${SEP}Scroll${SEP}md`, + ); + }); + + it("returns error for Section with extension", () => { + const result = codecs.serializeSegmentIdUnchecked({ + coreName: "MySection", + extension: "md", + targetKind: TreeNodeKind.Section, + }); + expect(result.isErr()).toBe(true); + }); + + it("returns error for Scroll without extension", () => { + const result = codecs.serializeSegmentIdUnchecked({ + coreName: "MyScroll", + targetKind: TreeNodeKind.Scroll, + }); + expect(result.isErr()).toBe(true); + }); + + it("returns error for empty coreName", () => { + const result = codecs.serializeSegmentIdUnchecked({ + coreName: "", + targetKind: TreeNodeKind.Section, + }); + expect(result.isErr()).toBe(true); + }); + }); + + describe("parse/serialize roundtrip", () => { + it("roundtrips Section segment ID", () => { + const original = `MySection${SEP}Section${SEP}` as TreeNodeSegmentId; + const parsed = codecs.parseSegmentId(original); + expect(parsed.isOk()).toBe(true); + const serialized = codecs.serializeSegmentId( + parsed._unsafeUnwrap(), + ); + expect(serialized).toBe(original); + }); + + it("roundtrips Scroll segment ID", () => { + const original = `MyScroll${SEP}Scroll${SEP}md` as TreeNodeSegmentId; + const parsed = codecs.parseSegmentId(original); + expect(parsed.isOk()).toBe(true); + const serialized = codecs.serializeSegmentId( + parsed._unsafeUnwrap(), + ); + expect(serialized).toBe(original); + }); + + it("roundtrips File segment ID", () => { + const original = `Image${SEP}File${SEP}png` as TreeNodeSegmentId; + const parsed = codecs.parseSegmentId(original); + expect(parsed.isOk()).toBe(true); + const serialized = codecs.serializeSegmentId( + parsed._unsafeUnwrap(), + ); + expect(serialized).toBe(original); + }); + }); +}); diff --git a/tests/unit/librarian/codecs/split-path-inside-library.test.ts b/tests/unit/librarian/codecs/split-path-inside-library.test.ts new file mode 100644 index 000000000..4ebb6648b --- /dev/null +++ b/tests/unit/librarian/codecs/split-path-inside-library.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { makeSplitPathInsideLibraryCodecs } from "../../../../src/commanders/librarian/codecs/split-path-inside-library/make"; +import type { SplitPathInsideLibraryCodecs } from "../../../../src/commanders/librarian/codecs/split-path-inside-library/make"; +import { SplitPathKind } from "../../../../src/managers/obsidian/vault-action-manager/types/split-path"; +import type { AnySplitPath } from "../../../../src/managers/obsidian/vault-action-manager/types/split-path"; +import type { SplitPathToFolderInsideLibrary } from "../../../../src/commanders/librarian/codecs/split-path-inside-library"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("split-path-inside-library codecs", () => { + let spy: ReturnType; + let codecs: SplitPathInsideLibraryCodecs; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + codecs = makeSplitPathInsideLibraryCodecs(makeDefaultRules()); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("checkIfInsideLibrary", () => { + it("returns true for empty pathParts (library root)", () => { + const sp: AnySplitPath = { + basename: "Library", + kind: SplitPathKind.Folder, + pathParts: [], + }; + expect(codecs.checkIfInsideLibrary(sp)).toBe(true); + }); + + it("returns true for path starting with Library root", () => { + const sp: AnySplitPath = { + basename: "NoteName", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + }; + expect(codecs.checkIfInsideLibrary(sp)).toBe(true); + }); + + it("returns false for path not starting with Library root", () => { + const sp: AnySplitPath = { + basename: "SomeFile", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["OtherFolder", "child"], + }; + expect(codecs.checkIfInsideLibrary(sp)).toBe(false); + }); + + it("returns false for path with single non-Library part", () => { + const sp: AnySplitPath = { + basename: "readme", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["docs"], + }; + expect(codecs.checkIfInsideLibrary(sp)).toBe(false); + }); + }); + + describe("isInsideLibrary (type guard)", () => { + it("narrows to SplitPathInsideLibraryCandidate for library paths", () => { + const sp: AnySplitPath = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + expect(codecs.isInsideLibrary(sp)).toBe(true); + }); + + it("does not narrow for non-library paths", () => { + const sp: AnySplitPath = { + basename: "file", + extension: "txt", + kind: SplitPathKind.File, + pathParts: ["outside"], + }; + expect(codecs.isInsideLibrary(sp)).toBe(false); + }); + }); + + describe("toInsideLibrary", () => { + it("accepts path with empty pathParts (library root)", () => { + const sp: AnySplitPath = { + basename: "Library", + kind: SplitPathKind.Folder, + pathParts: [], + }; + const result = codecs.toInsideLibrary(sp); + expect(result.isOk()).toBe(true); + }); + + it("accepts path starting with Library root", () => { + const sp: AnySplitPath = { + basename: "Note", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + }; + const result = codecs.toInsideLibrary(sp); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap().pathParts).toEqual([ + "Library", + "parent", + ]); + }); + + it("returns error for path outside library", () => { + const sp: AnySplitPath = { + basename: "readme", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["docs"], + }; + const result = codecs.toInsideLibrary(sp); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.kind).toBe("SplitPathError"); + expect((result.error as { reason: string }).reason).toBe( + "OutsideLibrary", + ); + } + }); + }); + + describe("fromInsideLibrary", () => { + it("returns path as-is when pathParts is empty", () => { + const sp: SplitPathToFolderInsideLibrary = { + basename: "Library", + kind: SplitPathKind.Folder, + pathParts: [], + }; + const result = codecs.fromInsideLibrary(sp); + expect(result.pathParts).toEqual([]); + }); + + it("adds Library root if not present", () => { + const sp: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["parent"], + }; + const result = codecs.fromInsideLibrary(sp); + expect(result.pathParts).toEqual(["Library", "parent"]); + }); + + it("preserves pathParts if Library root already present", () => { + const sp: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + const result = codecs.fromInsideLibrary(sp); + expect(result.pathParts).toEqual(["Library", "parent"]); + }); + }); + + describe("toInsideLibrary / fromInsideLibrary roundtrip", () => { + it("roundtrips a MdFile path", () => { + const original: AnySplitPath = { + basename: "Note-parent", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent"], + }; + const inside = codecs.toInsideLibrary(original); + expect(inside.isOk()).toBe(true); + const back = codecs.fromInsideLibrary(inside._unsafeUnwrap()); + expect(back.pathParts).toEqual(original.pathParts); + expect(back.basename).toBe(original.basename); + }); + }); +}); diff --git a/tests/unit/librarian/codecs/split-path-with-separated-suffix.test.ts b/tests/unit/librarian/codecs/split-path-with-separated-suffix.test.ts new file mode 100644 index 000000000..d1c7ed4c8 --- /dev/null +++ b/tests/unit/librarian/codecs/split-path-with-separated-suffix.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { makeSuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import type { SuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import { makeSplitPathWithSeparatedSuffixCodecs } from "../../../../src/commanders/librarian/codecs/split-path-with-separated-suffix/make"; +import type { SplitPathWithSeparatedSuffixCodecs } from "../../../../src/commanders/librarian/codecs/split-path-with-separated-suffix/make"; +import { SplitPathKind } from "../../../../src/managers/obsidian/vault-action-manager/types/split-path"; +import type { + SplitPathToMdFileInsideLibrary, + SplitPathToFolderInsideLibrary, + SplitPathToFileInsideLibrary, +} from "../../../../src/commanders/librarian/codecs/split-path-inside-library"; +import type { NodeName } from "../../../../src/commanders/librarian/types/schemas/node-name"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("split-path-with-separated-suffix codecs", () => { + let spy: ReturnType; + let suffixCodecs: SuffixCodecs; + let codecs: SplitPathWithSeparatedSuffixCodecs; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + const rules = makeDefaultRules(); + suffixCodecs = makeSuffixCodecs(rules); + codecs = makeSplitPathWithSeparatedSuffixCodecs(suffixCodecs); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("splitPathInsideLibraryToWithSeparatedSuffix", () => { + it("converts MdFile split path to separated suffix form", () => { + const sp: SplitPathToMdFileInsideLibrary = { + basename: "NoteName-child-parent", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent", "child"], + }; + const result = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(sp); + expect(result.isOk()).toBe(true); + const val = result._unsafeUnwrap(); + expect(val.kind).toBe(SplitPathKind.MdFile); + expect(val.separatedSuffixedBasename).toEqual({ + coreName: "NoteName", + suffixParts: ["child", "parent"], + }); + expect(val.pathParts).toEqual(["Library", "parent", "child"]); + }); + + it("converts Folder split path to separated suffix form", () => { + const sp: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + const result = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(sp); + expect(result.isOk()).toBe(true); + const val = result._unsafeUnwrap(); + expect(val.kind).toBe(SplitPathKind.Folder); + expect(val.separatedSuffixedBasename).toEqual({ + coreName: "child", + suffixParts: [], + }); + }); + + it("converts File split path to separated suffix form", () => { + const sp: SplitPathToFileInsideLibrary = { + basename: "Image-parent", + extension: "png", + kind: SplitPathKind.File, + pathParts: ["Library", "parent"], + }; + const result = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(sp); + expect(result.isOk()).toBe(true); + const val = result._unsafeUnwrap(); + expect(val.kind).toBe(SplitPathKind.File); + expect(val.separatedSuffixedBasename).toEqual({ + coreName: "Image", + suffixParts: ["parent"], + }); + }); + + it("returns error for invalid basename", () => { + const sp: SplitPathToMdFileInsideLibrary = { + basename: "", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library"], + }; + const result = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(sp); + expect(result.isErr()).toBe(true); + }); + }); + + describe("fromSplitPathInsideLibraryWithSeparatedSuffix", () => { + it("converts MdFile separated suffix form back to split path", () => { + const sp = { + extension: "md" as const, + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent", "child"], + separatedSuffixedBasename: { + coreName: "NoteName" as NodeName, + suffixParts: ["child" as NodeName, "parent" as NodeName], + }, + }; + const result = + codecs.fromSplitPathInsideLibraryWithSeparatedSuffix(sp); + expect(result.basename).toBe("NoteName-child-parent"); + expect(result.kind).toBe(SplitPathKind.MdFile); + expect(result.pathParts).toEqual([ + "Library", + "parent", + "child", + ]); + }); + + it("converts Folder separated suffix form back to split path", () => { + const sp = { + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "child" as NodeName, + suffixParts: [] as NodeName[], + }, + }; + const result = + codecs.fromSplitPathInsideLibraryWithSeparatedSuffix(sp); + expect(result.basename).toBe("child"); + expect(result.kind).toBe(SplitPathKind.Folder); + }); + + it("converts File separated suffix form back to split path", () => { + const sp = { + extension: "png", + kind: SplitPathKind.File, + pathParts: ["Library", "parent"], + separatedSuffixedBasename: { + coreName: "Image" as NodeName, + suffixParts: ["parent" as NodeName], + }, + }; + const result = + codecs.fromSplitPathInsideLibraryWithSeparatedSuffix(sp); + expect(result.basename).toBe("Image-parent"); + expect(result.kind).toBe(SplitPathKind.File); + }); + }); + + describe("to/from roundtrip", () => { + it("roundtrips MdFile path", () => { + const original: SplitPathToMdFileInsideLibrary = { + basename: "NoteName-child-parent", + extension: "md", + kind: SplitPathKind.MdFile, + pathParts: ["Library", "parent", "child"], + }; + const withSuffix = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(original); + expect(withSuffix.isOk()).toBe(true); + const back = + codecs.fromSplitPathInsideLibraryWithSeparatedSuffix( + withSuffix._unsafeUnwrap(), + ); + expect(back.basename).toBe(original.basename); + expect(back.pathParts).toEqual(original.pathParts); + expect(back.kind).toBe(original.kind); + }); + + it("roundtrips Folder path", () => { + const original: SplitPathToFolderInsideLibrary = { + basename: "child", + kind: SplitPathKind.Folder, + pathParts: ["Library", "parent"], + }; + const withSuffix = + codecs.splitPathInsideLibraryToWithSeparatedSuffix(original); + expect(withSuffix.isOk()).toBe(true); + const back = + codecs.fromSplitPathInsideLibraryWithSeparatedSuffix( + withSuffix._unsafeUnwrap(), + ); + expect(back.basename).toBe(original.basename); + expect(back.pathParts).toEqual(original.pathParts); + }); + }); +}); diff --git a/tests/unit/librarian/codecs/suffix.test.ts b/tests/unit/librarian/codecs/suffix.test.ts new file mode 100644 index 000000000..48f83ca3b --- /dev/null +++ b/tests/unit/librarian/codecs/suffix.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeName } from "../../../../src/commanders/librarian/types/schemas/node-name"; +import { makeSuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import type { SuffixCodecs } from "../../../../src/commanders/librarian/codecs/internal/suffix"; +import type { CodecRules } from "../../../../src/commanders/librarian/codecs/rules"; +import { setupGetParsedUserSettingsSpy } from "../../common-utils/setup-spy"; +import { defaultSettingsForUnitTests } from "../../common-utils/consts"; + +// Default rules with "-" delimiter, no padding +function makeDefaultRules(): CodecRules { + return { + hideMetadata: defaultSettingsForUnitTests.hideMetadata, + languages: defaultSettingsForUnitTests.languages, + libraryRootName: "Library", + libraryRootPathParts: [], + showScrollBacklinks: defaultSettingsForUnitTests.showScrollBacklinks, + suffixDelimiter: defaultSettingsForUnitTests.suffixDelimiter, + suffixDelimiterConfig: defaultSettingsForUnitTests.suffixDelimiterConfig, + suffixDelimiterPattern: + defaultSettingsForUnitTests.suffixDelimiterPattern, + }; +} + +describe("suffix codecs", () => { + let spy: ReturnType; + let codecs: SuffixCodecs; + let rules: CodecRules; + + beforeEach(() => { + spy = setupGetParsedUserSettingsSpy(); + rules = makeDefaultRules(); + codecs = makeSuffixCodecs(rules); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + describe("parseSeparatedSuffix", () => { + it("parses basename with no suffix parts", () => { + const result = codecs.parseSeparatedSuffix("NoteName"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "NoteName", + suffixParts: [], + }); + }); + + it("parses basename with one suffix part", () => { + const result = codecs.parseSeparatedSuffix("NoteName-parent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "NoteName", + suffixParts: ["parent"], + }); + }); + + it("parses basename with multiple suffix parts", () => { + const result = + codecs.parseSeparatedSuffix("NoteName-child-parent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "NoteName", + suffixParts: ["child", "parent"], + }); + }); + + it("handles flexible spacing around delimiter", () => { + const result = + codecs.parseSeparatedSuffix("NoteName - child - parent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "NoteName", + suffixParts: ["child", "parent"], + }); + }); + + it("returns error for empty string", () => { + const result = codecs.parseSeparatedSuffix(""); + expect(result.isErr()).toBe(true); + }); + }); + + describe("serializeSeparatedSuffix", () => { + it("serializes with no suffix parts", () => { + const result = codecs.serializeSeparatedSuffix({ + coreName: "NoteName" as NodeName, + suffixParts: [], + }); + expect(result).toBe("NoteName"); + }); + + it("serializes with one suffix part", () => { + const result = codecs.serializeSeparatedSuffix({ + coreName: "NoteName" as NodeName, + suffixParts: ["parent" as NodeName], + }); + expect(result).toBe("NoteName-parent"); + }); + + it("serializes with multiple suffix parts", () => { + const result = codecs.serializeSeparatedSuffix({ + coreName: "NoteName" as NodeName, + suffixParts: ["child" as NodeName, "parent" as NodeName], + }); + expect(result).toBe("NoteName-child-parent"); + }); + }); + + describe("serializeSeparatedSuffixUnchecked", () => { + it("validates and serializes valid inputs", () => { + const result = codecs.serializeSeparatedSuffixUnchecked({ + coreName: "NoteName", + suffixParts: ["parent"], + }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe("NoteName-parent"); + }); + + it("returns error for empty coreName", () => { + const result = codecs.serializeSeparatedSuffixUnchecked({ + coreName: "", + suffixParts: [], + }); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.kind).toBe("SuffixError"); + } + }); + + it("returns error for empty suffix part", () => { + const result = codecs.serializeSeparatedSuffixUnchecked({ + coreName: "NoteName", + suffixParts: [""], + }); + expect(result.isErr()).toBe(true); + }); + }); + + describe("splitBySuffixDelimiter", () => { + it("splits single part", () => { + const result = codecs.splitBySuffixDelimiter("NoteName"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual(["NoteName"]); + }); + + it("splits multiple parts", () => { + const result = + codecs.splitBySuffixDelimiter("NoteName-child-parent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual([ + "NoteName", + "child", + "parent", + ]); + }); + + it("returns error for empty string", () => { + const result = codecs.splitBySuffixDelimiter(""); + expect(result.isErr()).toBe(true); + }); + }); + + describe("pathPartsToSuffixParts / suffixPartsToPathParts", () => { + it("reverses path parts to suffix parts", () => { + const result = codecs.pathPartsToSuffixParts([ + "grandpa", + "father", + ]); + expect(result).toEqual(["father", "grandpa"]); + }); + + it("handles empty array", () => { + const result = codecs.pathPartsToSuffixParts([]); + expect(result).toEqual([]); + }); + + it("handles single element", () => { + const result = codecs.pathPartsToSuffixParts(["only"]); + expect(result).toEqual(["only"]); + }); + + it("reverses suffix parts to path parts", () => { + const result = codecs.suffixPartsToPathParts([ + "father" as NodeName, + "grandpa" as NodeName, + ]); + expect(result).toEqual(["grandpa", "father"]); + }); + + it("roundtrip: pathParts -> suffixParts -> pathParts", () => { + const original = ["a", "b", "c"]; + const suffixParts = codecs.pathPartsToSuffixParts(original); + const roundtripped = codecs.suffixPartsToPathParts(suffixParts); + expect(roundtripped).toEqual(original); + }); + }); + + describe("pathPartsWithRootToSuffixParts", () => { + it("drops Library root and reverses", () => { + const result = codecs.pathPartsWithRootToSuffixParts([ + "Library", + "grandpa", + "father", + ]); + expect(result).toEqual(["father", "grandpa"]); + }); + + it("returns empty for Library root only", () => { + const result = + codecs.pathPartsWithRootToSuffixParts(["Library"]); + expect(result).toEqual([]); + }); + }); + + describe("parse/serialize roundtrip", () => { + it("roundtrips basename with suffix", () => { + const original = "NoteName-child-parent"; + const parsed = codecs.parseSeparatedSuffix(original); + expect(parsed.isOk()).toBe(true); + const serialized = codecs.serializeSeparatedSuffix( + parsed._unsafeUnwrap(), + ); + expect(serialized).toBe(original); + }); + + it("roundtrips basename without suffix", () => { + const original = "NoteName"; + const parsed = codecs.parseSeparatedSuffix(original); + expect(parsed.isOk()).toBe(true); + const serialized = codecs.serializeSeparatedSuffix( + parsed._unsafeUnwrap(), + ); + expect(serialized).toBe(original); + }); + }); + + describe("with padded delimiter config", () => { + let paddedCodecs: SuffixCodecs; + + beforeEach(() => { + const paddedConfig = { padded: true, symbol: "-" }; + const paddedRules: CodecRules = { + ...rules, + suffixDelimiter: " - ", + suffixDelimiterConfig: paddedConfig, + suffixDelimiterPattern: /\s*-\s*/, + }; + paddedCodecs = makeSuffixCodecs(paddedRules); + }); + + it("serializes with padded delimiter", () => { + const result = paddedCodecs.serializeSeparatedSuffix({ + coreName: "NoteName" as NodeName, + suffixParts: ["child" as NodeName, "parent" as NodeName], + }); + expect(result).toBe("NoteName - child - parent"); + }); + + it("parses with padded delimiter", () => { + const result = + paddedCodecs.parseSeparatedSuffix("NoteName - child - parent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + coreName: "NoteName", + suffixParts: ["child", "parent"], + }); + }); + }); +});