diff --git a/src/features/plugins/indexedMarkers.test.ts b/src/features/plugins/indexedMarkers.test.ts index 5092389c3..8a4babef5 100644 --- a/src/features/plugins/indexedMarkers.test.ts +++ b/src/features/plugins/indexedMarkers.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { EditorState } from "@codemirror/state"; +import { EditorState, type RangeSet, type StateEffect } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { offsetsToRange } from "@/editor/lsp/positions"; import type { FileMarkers } from "@/platform/ipc"; @@ -17,6 +17,7 @@ afterEach(() => { view?.destroy(); view = undefined; usePluginMarkers.setState({ entries: new Map() }); + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -38,6 +39,13 @@ function setup() { return { entry, target }; } const button = () => view!.dom.querySelector(".cm-plugin-mapping-button"); +function pendingAssets() { + let resolve!: (assets: [string, string][]) => void; + const promise = new Promise<[string, string][]>((done) => { + resolve = done; + }); + return { promise, resolve }; +} it("puts a button on the marker's line that goes to its target", async () => { const { target } = setup(); @@ -114,3 +122,108 @@ it("redraws when a marker icon declaration goes away", async () => { await vi.waitFor(() => expect(button()!.querySelector("circle")).toBeNull()); expect(button()!.title).toBe("Query.java:1"); }); + +it.each(["edit", "remove", "replace", "dispose"])("drops pending icons after %s", async (change) => { + vi.useFakeTimers(); + const pending = pendingAssets(); + calls.assets.mockReturnValue(pending.promise); + const remove = projectMarkerIcon("p", { id: "slow", icon: "icons/slow.svg", tooltip: "Old marker" }); + try { + const { entry } = setup(); + usePluginMarkers.setState({ + entries: new Map([[entry.path, { ...entry, markers: [{ ...entry.markers[0]!, icon: "slow" }] }]]), + }); + await vi.advanceTimersByTimeAsync(0); + if (change === "edit") view!.dispatch({ changes: { from: 0, to: view!.state.doc.length, insert: "short" } }); + if (change === "remove") usePluginMarkers.setState({ entries: new Map() }); + if (change === "replace") usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) }); + if (change === "dispose") view!.destroy(); + await vi.advanceTimersByTimeAsync(0); + const dispatch = vi.spyOn(view!, "dispatch"); + pending.resolve([["markerIcon/slow/light", '']]); + await vi.advanceTimersByTimeAsync(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(button()?.querySelector("circle")).toBeFalsy(); + if (change === "replace") expect(button()!.title).toBe("Query.java:1"); + else expect(button()).toBeNull(); + if (change === "dispose") view = undefined; + } finally { + remove(); + } +}); + +it("positions a new indexed result against the edited document while icons load", async () => { + vi.useFakeTimers(); + const pending = pendingAssets(); + calls.assets.mockReturnValue(pending.promise); + const remove = projectMarkerIcon("p", { id: "slow", icon: "icons/slow.svg" }); + try { + const { entry } = setup(); + const marker = { ...entry.markers[0]!, icon: "slow" }; + usePluginMarkers.setState({ entries: new Map([[entry.path, { ...entry, markers: [marker] }]]) }); + await vi.advanceTimersByTimeAsync(0); + view!.dispatch({ changes: { from: 0, insert: "a longer first line\n" } }); + const range = { + start: { ...marker.range.start, line: marker.range.start.line + 1 }, + end: { ...marker.range.end, line: marker.range.end.line + 1 }, + }; + usePluginMarkers.setState({ + entries: new Map([[entry.path, { ...entry, version: 2, markers: [{ ...marker, range }] }]]), + }); + await vi.advanceTimersByTimeAsync(0); + const dispatch = vi.spyOn(view!, "dispatch"); + pending.resolve([["markerIcon/slow/light", '']]); + await vi.advanceTimersByTimeAsync(0); + expect(button()!.querySelector("circle")).not.toBeNull(); + const transaction = dispatch.mock.calls[0]![0] as { effects: StateEffect> }; + expect(transaction.effects.value.iter().from).toBe(view!.state.doc.line(3).from); + } finally { + remove(); + } +}); + +it("merges two plugins on one line and uses the first declared icon with a plain-text tooltip", async () => { + calls.assets.mockImplementation((pluginId: string) => + Promise.resolve([ + [ + "markerIcon/shared/light", + pluginId === "first" + ? '' + : '', + ], + ]), + ); + const tooltip = ' & usages'; + const removers = ["first", "second"].map((id) => + projectMarkerIcon(id, { id: "shared", icon: "icons/shared.svg", tooltip: id === "first" ? tooltip : "Second" }), + ); + try { + const { entry, target } = setup(); + const other = { ...target, path: "/p/Other.java" }; + usePluginMarkers.setState({ + entries: new Map([ + [ + entry.path, + { + ...entry, + markers: [ + { ...entry.markers[0]!, pluginId: "first", icon: "shared" }, + { ...entry.markers[0]!, pluginId: "second", icon: "shared", targets: [target, other] }, + ], + }, + ], + ]), + }); + await vi.waitFor(() => expect(button()!.querySelector("circle")).not.toBeNull()); + expect(view!.dom.querySelectorAll(".cm-plugin-mapping-button")).toHaveLength(1); + expect(button()!.querySelector("rect, img")).toBeNull(); + expect(button()!.title).toBe(`${tooltip}\nQuery.java:1\nOther.java:1`); + button()!.click(); + await vi.waitFor(() => expect(calls.popup).toHaveBeenCalledOnce()); + removers[0]!(); + await vi.waitFor(() => expect(button()!.querySelector("rect")).not.toBeNull()); + expect(button()!.title).toBe("Second\nQuery.java:1\nOther.java:1"); + } finally { + removers.forEach((remove) => remove()); + } +}); diff --git a/src/features/plugins/markerIcons.test.ts b/src/features/plugins/markerIcons.test.ts index 02fd74a15..f09b1e37b 100644 --- a/src/features/plugins/markerIcons.test.ts +++ b/src/features/plugins/markerIcons.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { gutterIconMarkup } from "@/ui/icons"; -import { projectMarkerIcon, resolveMarkerIcon } from "./markerIcons"; +import { projectMarkerIcon, resolveIndexedMarkerIcon, resolveMarkerIcon } from "./markerIcons"; import { markerIcon } from "./lineMarkerGutter"; const mocks = vi.hoisted(() => ({ assets: vi.fn() })); @@ -42,7 +42,22 @@ it("keeps a replacement when an older declaration is disposed", async () => { const removeNew = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" }); removeOld(); expect(await resolveMarkerIcon("p", "icons/resource.svg")).toMatch(/^ext:/); + expect((await resolveIndexedMarkerIcon("p", "resource"))?.icon).toMatch(/^ext:/); removeNew(); + expect(await resolveIndexedMarkerIcon("p", "resource")).toBeUndefined(); +}); + +it("preserves line-marker lookup with projected indexed-marker defaults", async () => { + mocks.assets.mockResolvedValue([["markerIcon/resource/light", svg('')]]); + const declaration = { id: "resource", icon: "icons/resource.svg", markers: [], tooltip: null }; + const remove = projectMarkerIcon("p", declaration); + try { + const icon = await resolveMarkerIcon("p", declaration.icon); + expect(icon).toMatch(/^ext:/); + expect(await resolveIndexedMarkerIcon("p", declaration.id)).toEqual({ icon, tooltip: null }); + } finally { + remove(); + } }); it("falls back when an asset fails to load", async () => {