qmd/test/store.helpers.unit.test.ts
Kim Junmo 9fb9de4fd2 fix: preserve original case in handelize()
The blanket .toLowerCase() in handelize() drops filename casing,
which breaks path resolution on case-sensitive filesystems (Linux).
Files like README.md, CHANGELOG.md, and SKILL.md become unreachable
when the index stores them as readme.md, changelog.md, skill.md.

Since FTS5 already performs case-insensitive matching via the
unicode61 tokenizer, lowercasing the stored path provides no search
benefit — it only corrupts the metadata used to locate files on disk.

Remove .toLowerCase() and update all affected test expectations.
2026-04-09 07:59:22 +09:00

290 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Store helper-level unit tests (pure logic, no model/runtime dependency).
*/
import { describe, test, expect } from "vitest";
import {
homedir,
resolve,
getDefaultDbPath,
_resetProductionModeForTesting,
getPwd,
getRealPath,
isVirtualPath,
parseVirtualPath,
normalizeVirtualPath,
normalizeDocid,
isDocid,
handelize,
cleanupOrphanedVectors,
sanitizeFTS5Term,
} from "../src/store";
// =============================================================================
// Path Utilities
// =============================================================================
describe("Path Utilities", () => {
test("homedir returns HOME environment variable", () => {
expect(homedir()).toBe(process.env.HOME || "/tmp");
});
test("resolve handles absolute paths", () => {
expect(resolve("/foo/bar")).toBe("/foo/bar");
expect(resolve("/foo", "/bar")).toBe("/bar");
});
test("resolve handles relative paths", () => {
const pwd = process.env.PWD || process.cwd();
expect(resolve("foo")).toBe(`${pwd}/foo`);
expect(resolve("foo", "bar")).toBe(`${pwd}/foo/bar`);
});
test("resolve normalizes . and ..", () => {
expect(resolve("/foo/bar/./baz")).toBe("/foo/bar/baz");
expect(resolve("/foo/bar/../baz")).toBe("/foo/baz");
expect(resolve("/foo/bar/../../baz")).toBe("/baz");
});
test("getDefaultDbPath throws in test mode without INDEX_PATH", () => {
const originalIndexPath = process.env.INDEX_PATH;
delete process.env.INDEX_PATH;
// Reset production mode in case another test file set it (bun runs all
// files in a single process, so module state leaks between files).
_resetProductionModeForTesting();
expect(() => getDefaultDbPath()).toThrow("Database path not set");
if (originalIndexPath) {
process.env.INDEX_PATH = originalIndexPath;
}
});
test("getDefaultDbPath uses INDEX_PATH when set", () => {
const originalIndexPath = process.env.INDEX_PATH;
process.env.INDEX_PATH = "/tmp/test-index.sqlite";
expect(getDefaultDbPath()).toBe("/tmp/test-index.sqlite");
expect(getDefaultDbPath("custom")).toBe("/tmp/test-index.sqlite");
if (originalIndexPath) {
process.env.INDEX_PATH = originalIndexPath;
} else {
delete process.env.INDEX_PATH;
}
});
test("getPwd returns current working directory", () => {
const pwd = getPwd();
expect(pwd).toBeTruthy();
expect(typeof pwd).toBe("string");
});
test("getRealPath resolves symlinks", () => {
const result = getRealPath("/tmp");
expect(result).toBeTruthy();
expect(result === "/tmp" || result === "/private/tmp").toBe(true);
});
});
// =============================================================================
// Handelize Tests
// =============================================================================
describe("cleanupOrphanedVectors", () => {
test("returns 0 when vec table exists in schema but sqlite-vec is unavailable", () => {
const prepare = (sql: string) => {
if (sql.includes("sqlite_master") && sql.includes("vectors_vec")) {
return { get: () => ({ name: "vectors_vec" }) };
}
if (sql.includes("SELECT 1 FROM vectors_vec LIMIT 0")) {
return { get: () => { throw new Error("no such module: vec0"); } };
}
throw new Error(`Unexpected SQL in test: ${sql}`);
};
const db = {
prepare,
exec: () => {
throw new Error("cleanup should not execute vector deletes when sqlite-vec is unavailable");
},
} as any;
expect(cleanupOrphanedVectors(db)).toBe(0);
});
});
// =============================================================================
// Handelize Tests
// =============================================================================
describe("handelize", () => {
test("preserves original case", () => {
expect(handelize("README.md")).toBe("README.md");
expect(handelize("MyFile.MD")).toBe("MyFile.MD");
});
test("preserves folder structure", () => {
expect(handelize("a/b/c/d.md")).toBe("a/b/c/d.md");
expect(handelize("docs/api/README.md")).toBe("docs/api/README.md");
});
test("replaces non-word characters with dash", () => {
expect(handelize("hello world.md")).toBe("hello-world.md");
expect(handelize("file (1).md")).toBe("file-1.md");
expect(handelize("foo@bar#baz.md")).toBe("foo-bar-baz.md");
});
test("collapses multiple special chars into single dash", () => {
expect(handelize("hello world.md")).toBe("hello-world.md");
expect(handelize("foo---bar.md")).toBe("foo-bar.md");
expect(handelize("a - b.md")).toBe("a-b.md");
});
test("removes leading and trailing dashes from segments", () => {
expect(handelize("-hello-.md")).toBe("hello.md");
expect(handelize("--test--.md")).toBe("test.md");
expect(handelize("a/-b-/c.md")).toBe("a/b/c.md");
});
test("converts triple underscore to folder separator", () => {
expect(handelize("foo___bar.md")).toBe("foo/bar.md");
expect(handelize("notes___2025___january.md")).toBe("notes/2025/january.md");
expect(handelize("a/b___c/d.md")).toBe("a/b/c/d.md");
});
test("handles complex real-world meeting notes", () => {
const complexName = "Money Movement Licensing Review - 20251119 10:25 EST - Notes by Gemini.md";
const result = handelize(complexName);
expect(result).toBe("Money-Movement-Licensing-Review-2025-11-19-10-25-EST-Notes-by-Gemini.md");
expect(result).not.toContain(" ");
expect(result).not.toContain("");
expect(result).not.toContain(":");
});
test("handles unicode characters", () => {
expect(handelize("日本語.md")).toBe("日本語.md");
expect(handelize("Зоны и проекты.md")).toBe("Зоны-и-проекты.md");
expect(handelize("café-notes.md")).toBe("café-notes.md");
expect(handelize("naïve.md")).toBe("naïve.md");
expect(handelize("日本語-notes.md")).toBe("日本語-notes.md");
});
test("handles emoji filenames (issue #302)", () => {
// Emoji-only filenames should convert to hex codepoints
expect(handelize("🐘.md")).toBe("1f418.md");
expect(handelize("🎉.md")).toBe("1f389.md");
// Emoji mixed with text
expect(handelize("notes 🐘.md")).toBe("notes-1f418.md");
expect(handelize("🐘 elephant.md")).toBe("1f418-elephant.md");
// Multiple emojis
expect(handelize("🐘🎉.md")).toBe("1f418-1f389.md");
// Emoji in directory names
expect(handelize("🐘/notes.md")).toBe("1f418/notes.md");
});
test("handles dates and times in filenames", () => {
expect(handelize("meeting-2025-01-15.md")).toBe("meeting-2025-01-15.md");
expect(handelize("notes 2025/01/15.md")).toBe("notes-2025/01/15.md");
expect(handelize("call_10:30_AM.md")).toBe("call-10-30-AM.md");
});
test("handles special project naming patterns", () => {
expect(handelize("PROJECT_ABC_v2.0.md")).toBe("PROJECT-ABC-v2-0.md");
expect(handelize("[WIP] Feature Request.md")).toBe("WIP-Feature-Request.md");
expect(handelize("(DRAFT) Proposal v1.md")).toBe("DRAFT-Proposal-v1.md");
});
test("handles symbol-only route filenames", () => {
expect(handelize("routes/api/auth/$.ts")).toBe("routes/api/auth/$.ts");
expect(handelize("app/routes/$id.tsx")).toBe("app/routes/$id.tsx");
});
test("filters out empty segments", () => {
expect(handelize("a//b/c.md")).toBe("a/b/c.md");
expect(handelize("/a/b/")).toBe("a/b");
expect(handelize("///test///")).toBe("test");
});
test("throws error for invalid inputs", () => {
expect(() => handelize("" )).toThrow("path cannot be empty");
expect(() => handelize(" ")).toThrow("path cannot be empty");
expect(() => handelize(".md")).toThrow("no valid filename content");
expect(() => handelize("...")).toThrow("no valid filename content");
expect(() => handelize("___")).toThrow("no valid filename content");
});
test("handles minimal valid inputs", () => {
expect(handelize("a")).toBe("a");
expect(handelize("1")).toBe("1");
expect(handelize("a.md")).toBe("a.md");
});
test("normalizes virtual paths", () => {
expect(normalizeVirtualPath("qmd://docs/readme.md")).toBe("qmd://docs/readme.md");
expect(normalizeVirtualPath("docs/readme.md")).toBe("docs/readme.md");
});
test("detects virtual paths", () => {
expect(isVirtualPath("qmd://docs/readme.md")).toBe(true);
expect(isVirtualPath("/tmp/file.md")).toBe(false);
});
test("parses virtual paths", () => {
expect(parseVirtualPath("qmd://docs/readme.md")).toEqual({
collectionName: "docs",
path: "readme.md",
});
});
test("normalizes docids", () => {
expect(normalizeDocid("123456")).toBe("123456");
expect(normalizeDocid("#123456")).toBe("123456");
});
test("checks docid validity", () => {
expect(isDocid("123456")).toBe(true);
expect(isDocid("#123456")).toBe(true);
expect(isDocid("bad-id")).toBe(false);
expect(isDocid("12345")).toBe(false);
});
});
// =============================================================================
// sanitizeFTS5Term Tests
// =============================================================================
describe("sanitizeFTS5Term", () => {
test("preserves underscores in snake_case identifiers", () => {
expect(sanitizeFTS5Term("my_variable")).toBe("my_variable");
expect(sanitizeFTS5Term("MAX_RETRIES")).toBe("max_retries");
expect(sanitizeFTS5Term("__init__")).toBe("__init__");
});
test("preserves alphanumeric characters", () => {
expect(sanitizeFTS5Term("hello123")).toBe("hello123");
expect(sanitizeFTS5Term("test")).toBe("test");
});
test("preserves apostrophes for contractions", () => {
expect(sanitizeFTS5Term("don't")).toBe("don't");
expect(sanitizeFTS5Term("it's")).toBe("it's");
});
test("strips other punctuation", () => {
expect(sanitizeFTS5Term("hello!")).toBe("hello");
expect(sanitizeFTS5Term("test@value")).toBe("testvalue");
expect(sanitizeFTS5Term("a.b")).toBe("ab");
});
test("lowercases output", () => {
expect(sanitizeFTS5Term("Hello")).toBe("hello");
expect(sanitizeFTS5Term("MY_VAR")).toBe("my_var");
});
test("handles unicode letters and numbers", () => {
expect(sanitizeFTS5Term("café")).toBe("café");
expect(sanitizeFTS5Term("日本語")).toBe("日本語");
});
});