|
| 1 | +import type { Completion } from "@codemirror/autocomplete"; |
| 2 | +import type { EditorView } from "@codemirror/view"; |
| 3 | +import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | + |
| 5 | +import type FNote from "../../../entities/fnote"; |
| 6 | +import type LoadResults from "../../../services/load_results"; |
| 7 | + |
| 8 | +vi.mock("../../../services/search.js", () => ({ |
| 9 | + default: { searchForNotes: vi.fn() } |
| 10 | +})); |
| 11 | + |
| 12 | +import search from "../../../services/search.js"; |
| 13 | +import { buildSnippetCompletions, getCodeSnippets, isCodeSnippetChange } from "./snippets.js"; |
| 14 | + |
| 15 | +const logErrorMock = vi.fn(); |
| 16 | + |
| 17 | +interface NoteStub { |
| 18 | + noteId: string; |
| 19 | + title: string; |
| 20 | + type: string; |
| 21 | + mime: string; |
| 22 | + isArchived: boolean; |
| 23 | + isContentAvailable: () => boolean; |
| 24 | + getLabelValue: (name: string) => string | null; |
| 25 | + getContent: () => Promise<string | undefined>; |
| 26 | +} |
| 27 | + |
| 28 | +function makeNote(overrides: Partial<NoteStub> = {}): FNote { |
| 29 | + return { |
| 30 | + noteId: "note", |
| 31 | + title: "Snippet", |
| 32 | + type: "code", |
| 33 | + mime: "text/css", |
| 34 | + isArchived: false, |
| 35 | + isContentAvailable: () => true, |
| 36 | + getLabelValue: () => null, |
| 37 | + getContent: async () => "body", |
| 38 | + ...overrides |
| 39 | + } as unknown as FNote; |
| 40 | +} |
| 41 | + |
| 42 | +function makeLoadResults(opts: { |
| 43 | + attrs?: { type: string; name?: string; value?: string }[]; |
| 44 | + noteIds?: string[]; |
| 45 | +} = {}): LoadResults { |
| 46 | + return { |
| 47 | + getAttributeRows: () => opts.attrs ?? [], |
| 48 | + getNoteIds: () => opts.noteIds ?? [] |
| 49 | + } as unknown as LoadResults; |
| 50 | +} |
| 51 | + |
| 52 | +describe("buildSnippetCompletions", () => { |
| 53 | + it("formats labels with/without a description and inserts content on apply", () => { |
| 54 | + const [withDesc, withoutDesc] = buildSnippetCompletions([ |
| 55 | + { noteId: "a", title: "Header", description: "Page header", content: "# Header\n" }, |
| 56 | + { noteId: "b", title: "Footer", content: "the footer" } |
| 57 | + ]); |
| 58 | + |
| 59 | + expect(withDesc?.label).toBe("/snippet:Header - Page header"); |
| 60 | + expect(withoutDesc?.label).toBe("/snippet:Footer"); |
| 61 | + |
| 62 | + // Applying replaces the typed [from, to] token with the content, caret at its end. |
| 63 | + const apply = withoutDesc?.apply; |
| 64 | + expect(typeof apply).toBe("function"); |
| 65 | + if (typeof apply !== "function") return; |
| 66 | + |
| 67 | + const dispatched: unknown[] = []; |
| 68 | + const view = { dispatch: (tx: unknown) => dispatched.push(tx) } as unknown as EditorView; |
| 69 | + apply(view, {} as Completion, 3, 11); |
| 70 | + |
| 71 | + expect(dispatched).toEqual([{ |
| 72 | + changes: { from: 3, to: 11, insert: "the footer" }, |
| 73 | + selection: { anchor: 3 + "the footer".length } |
| 74 | + }]); |
| 75 | + }); |
| 76 | +}); |
| 77 | + |
| 78 | +describe("isCodeSnippetChange", () => { |
| 79 | + const known = new Set(["s1"]); |
| 80 | + |
| 81 | + it("reacts to snippet label/relation changes and to edits of a known snippet", () => { |
| 82 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "snippet" }] }), known)).toBe(true); |
| 83 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "snippetDescription" }] }), known)).toBe(true); |
| 84 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_markdown_snippet" }] }), known)).toBe(true); |
| 85 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_code_snippet" }] }), known)).toBe(true); |
| 86 | + // Content/title edit of a snippet the caller already holds. |
| 87 | + expect(isCodeSnippetChange(makeLoadResults({ noteIds: ["s1"] }), known)).toBe(true); |
| 88 | + }); |
| 89 | + |
| 90 | + it("ignores unrelated attribute and note changes", () => { |
| 91 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "color" }], noteIds: ["other"] }), known)).toBe(false); |
| 92 | + expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_table" }] }), known)).toBe(false); |
| 93 | + expect(isCodeSnippetChange(makeLoadResults(), known)).toBe(false); |
| 94 | + }); |
| 95 | +}); |
| 96 | + |
| 97 | +describe("getCodeSnippets", () => { |
| 98 | + beforeEach(() => { |
| 99 | + vi.mocked(search.searchForNotes).mockReset(); |
| 100 | + logErrorMock.mockReset(); |
| 101 | + vi.stubGlobal("logError", logErrorMock); |
| 102 | + }); |
| 103 | + |
| 104 | + it("maps matching notes, reading the description from #snippetDescription", async () => { |
| 105 | + vi.mocked(search.searchForNotes).mockResolvedValue([ |
| 106 | + makeNote({ |
| 107 | + noteId: "css1", |
| 108 | + title: "Reset", |
| 109 | + mime: "text/css", |
| 110 | + getContent: async () => ".x {}", |
| 111 | + getLabelValue: (name) => (name === "snippetDescription" ? "CSS reset" : null) |
| 112 | + }) |
| 113 | + ]); |
| 114 | + |
| 115 | + const result = await getCodeSnippets((note) => note.mime === "text/css"); |
| 116 | + |
| 117 | + expect(result).toEqual([{ noteId: "css1", title: "Reset", description: "CSS reset", content: ".x {}" }]); |
| 118 | + }); |
| 119 | + |
| 120 | + it("drops notes failing the predicate, archived notes, and content-unavailable (protected) notes", async () => { |
| 121 | + vi.mocked(search.searchForNotes).mockResolvedValue([ |
| 122 | + makeNote({ noteId: "keep", mime: "text/css" }), |
| 123 | + makeNote({ noteId: "wrong-mime", mime: "text/x-markdown" }), |
| 124 | + makeNote({ noteId: "archived", mime: "text/css", isArchived: true }), |
| 125 | + makeNote({ noteId: "protected", mime: "text/css", isContentAvailable: () => false }) |
| 126 | + ]); |
| 127 | + |
| 128 | + const result = await getCodeSnippets((note) => note.mime === "text/css"); |
| 129 | + |
| 130 | + expect(result.map((snippet) => snippet.noteId)).toEqual(["keep"]); |
| 131 | + }); |
| 132 | + |
| 133 | + it("defaults a missing description to undefined and missing content to an empty string", async () => { |
| 134 | + vi.mocked(search.searchForNotes).mockResolvedValue([ |
| 135 | + makeNote({ getLabelValue: () => null, getContent: async () => undefined }) |
| 136 | + ]); |
| 137 | + |
| 138 | + const [snippet] = await getCodeSnippets(() => true); |
| 139 | + |
| 140 | + expect(snippet?.description).toBeUndefined(); |
| 141 | + expect(snippet?.content).toBe(""); |
| 142 | + }); |
| 143 | + |
| 144 | + it("returns an empty list and logs when loading fails", async () => { |
| 145 | + vi.mocked(search.searchForNotes).mockRejectedValue(new Error("boom")); |
| 146 | + |
| 147 | + expect(await getCodeSnippets(() => true)).toEqual([]); |
| 148 | + expect(logErrorMock).toHaveBeenCalled(); |
| 149 | + }); |
| 150 | +}); |
0 commit comments