|
| 1 | +import { describe, it, expect, vi } from "vitest"; |
| 2 | +import { render, screen, waitFor } from "@testing-library/react"; |
| 3 | +import { |
| 4 | + getConnectionAccent, |
| 5 | + getConnectionIcon, |
| 6 | + getDriverIcon, |
| 7 | + isUrlIcon, |
| 8 | +} from "../../src/utils/driverUI"; |
| 9 | +import type { PluginManifest } from "../../src/types/plugins"; |
| 10 | +import type { SavedConnection } from "../../src/contexts/DatabaseContext"; |
| 11 | + |
| 12 | +// Avoid loading the lazy ConnectionIconImage during tests (it pulls Tauri APIs that aren't available in vitest) |
| 13 | +vi.mock("../../src/components/ConnectionIconImage", () => ({ |
| 14 | + ConnectionIconImage: (props: { path: string; size: number }) => |
| 15 | + <img data-testid="conn-icon-image" alt="" src={`mock://${props.path}`} width={props.size} height={props.size} />, |
| 16 | +})); |
| 17 | + |
| 18 | +describe("isUrlIcon", () => { |
| 19 | + it("matches http(s) URLs and data: URIs regardless of scheme case", () => { |
| 20 | + expect(isUrlIcon("http://example.com/icon.svg")).toBe(true); |
| 21 | + expect(isUrlIcon("https://example.com/icon.svg")).toBe(true); |
| 22 | + expect(isUrlIcon("HTTPS://example.com/icon.svg")).toBe(true); |
| 23 | + expect(isUrlIcon("data:image/png;base64,iVBORw0KGgo=")).toBe(true); |
| 24 | + expect(isUrlIcon("DATA:image/png;base64,iVBORw0KGgo=")).toBe(true); |
| 25 | + }); |
| 26 | + |
| 27 | + it("rejects built-in lookup keys and non-URL strings", () => { |
| 28 | + expect(isUrlIcon("postgres")).toBe(false); |
| 29 | + // "database" starts with "data" but is not a data: URI |
| 30 | + expect(isUrlIcon("database")).toBe(false); |
| 31 | + expect(isUrlIcon("ftp://example.com/icon.svg")).toBe(false); |
| 32 | + expect(isUrlIcon("")).toBe(false); |
| 33 | + }); |
| 34 | +}); |
| 35 | + |
| 36 | +/** |
| 37 | + * getDriverIcon's priority is documented as: manifest-supplied URL/data: |
| 38 | + * URI icon -> brand SVG icon -> lucide icon -> generic fallback (issue |
| 39 | + * #632). getConnectionIcon layers a per-connection override on top of that, |
| 40 | + * for the full 3-tier priority @debba specified: Connection Custom Icon > |
| 41 | + * Manifest Icon (if present) > Plugin Icon. |
| 42 | + */ |
| 43 | +describe("getDriverIcon", () => { |
| 44 | + const manifest = (icon?: string): PluginManifest => |
| 45 | + ({ icon } as PluginManifest); |
| 46 | + |
| 47 | + const renderIcon = (icon?: string, size = 14) => |
| 48 | + render(<div data-testid="wrap">{getDriverIcon(manifest(icon), size)}</div>); |
| 49 | + |
| 50 | + it("renders a registry <img> for an https:// manifest icon", () => { |
| 51 | + const { getByTestId } = renderIcon("https://example.com/icon.svg"); |
| 52 | + const img = getByTestId("wrap").querySelector("img"); |
| 53 | + expect(img).not.toBeNull(); |
| 54 | + expect(img?.getAttribute("src")).toBe("https://example.com/icon.svg"); |
| 55 | + }); |
| 56 | + |
| 57 | + it("renders a registry <img> for a data: URI manifest icon", () => { |
| 58 | + const dataUri = "data:image/png;base64,iVBORw0KGgo="; |
| 59 | + const { getByTestId } = renderIcon(dataUri); |
| 60 | + const img = getByTestId("wrap").querySelector("img"); |
| 61 | + expect(img).not.toBeNull(); |
| 62 | + expect(img?.getAttribute("src")).toBe(dataUri); |
| 63 | + }); |
| 64 | + |
| 65 | + it("still renders the built-in Postgres brand icon for the literal string 'postgres'", () => { |
| 66 | + const { getByTestId } = renderIcon("postgres"); |
| 67 | + expect(getByTestId("wrap").querySelector("svg")).not.toBeNull(); |
| 68 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 69 | + }); |
| 70 | + |
| 71 | + it("still falls through to the legacy lucide branch (not the URL branch) for the literal string 'database'", () => { |
| 72 | + // lucide-react icons are globally mocked to render null in this test |
| 73 | + // environment (see tests/setup.ts), so we can't distinguish *which* |
| 74 | + // lucide icon rendered by inspecting the DOM — but we CAN confirm it |
| 75 | + // didn't take the new URL/data: branch (no <img>) and isn't one of the |
| 76 | + // hand-written brand SVGs (no real <svg>, since those aren't mocked). |
| 77 | + const { getByTestId } = renderIcon("database"); |
| 78 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 79 | + expect(getByTestId("wrap").querySelector("svg")).toBeNull(); |
| 80 | + }); |
| 81 | + |
| 82 | + it("falls back to the generic Plug branch for an unrecognized non-URL string", () => { |
| 83 | + const { getByTestId } = renderIcon("some-random-value"); |
| 84 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 85 | + expect(getByTestId("wrap").querySelector("svg")).toBeNull(); |
| 86 | + }); |
| 87 | + |
| 88 | + it("falls back to the generic Plug branch when no icon is set at all", () => { |
| 89 | + const { getByTestId } = renderIcon(undefined); |
| 90 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 91 | + expect(getByTestId("wrap").querySelector("svg")).toBeNull(); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +describe("getConnectionAccent", () => { |
| 96 | + const manifest = { id: "mysql", color: "#0000ff", icon: "database" } as unknown as PluginManifest; |
| 97 | + |
| 98 | + it("uses override when present", () => { |
| 99 | + const c = { appearance: { accentColor: "#ff0000" } } as SavedConnection; |
| 100 | + expect(getConnectionAccent(c, manifest)).toBe("#ff0000"); |
| 101 | + }); |
| 102 | + it("falls back to manifest color when override missing", () => { |
| 103 | + expect(getConnectionAccent({} as SavedConnection, manifest)).toBe("#0000ff"); |
| 104 | + }); |
| 105 | + it("falls back to grey when both missing", () => { |
| 106 | + expect(getConnectionAccent(null, null)).toBe("#64748b"); |
| 107 | + }); |
| 108 | +}); |
| 109 | + |
| 110 | +describe("getConnectionIcon", () => { |
| 111 | + const manifest = (icon?: string): PluginManifest => ({ icon } as PluginManifest); |
| 112 | + const connection = ( |
| 113 | + icon?: SavedConnection["appearance"] extends { icon?: infer T } ? T : never, |
| 114 | + ): Pick<SavedConnection, "appearance"> => (icon ? { appearance: { icon } } : {}); |
| 115 | + |
| 116 | + const renderConnectionIcon = ( |
| 117 | + conn: Pick<SavedConnection, "appearance">, |
| 118 | + icon?: string, |
| 119 | + size = 14, |
| 120 | + ) => |
| 121 | + render( |
| 122 | + <div data-testid="wrap">{getConnectionIcon(conn, manifest(icon), size)}</div>, |
| 123 | + ); |
| 124 | + |
| 125 | + it("prefers a Connection Custom Icon (emoji override) over a URL manifest icon", () => { |
| 126 | + const { getByTestId } = renderConnectionIcon( |
| 127 | + connection({ type: "emoji", value: "🐘" }), |
| 128 | + "https://example.com/icon.svg", |
| 129 | + ); |
| 130 | + expect(getByTestId("wrap").textContent).toBe("🐘"); |
| 131 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 132 | + }); |
| 133 | + |
| 134 | + it("falls back to the Manifest Icon (URL) when there is no connection override", () => { |
| 135 | + const { getByTestId } = renderConnectionIcon( |
| 136 | + connection(undefined), |
| 137 | + "https://example.com/icon.svg", |
| 138 | + ); |
| 139 | + const img = getByTestId("wrap").querySelector("img"); |
| 140 | + expect(img?.getAttribute("src")).toBe("https://example.com/icon.svg"); |
| 141 | + }); |
| 142 | + |
| 143 | + it("falls back to the Plugin Icon (built-in brand icon) when there is neither a connection override nor a manifest icon URL", () => { |
| 144 | + const { getByTestId } = renderConnectionIcon(connection(undefined), "postgres"); |
| 145 | + expect(getByTestId("wrap").querySelector("svg")).not.toBeNull(); |
| 146 | + expect(getByTestId("wrap").querySelector("img")).toBeNull(); |
| 147 | + }); |
| 148 | + |
| 149 | + it("renders emoji when override is emoji", () => { |
| 150 | + const c = { id: "1", appearance: { icon: { type: "emoji", value: "🐘" } } } as SavedConnection; |
| 151 | + render(<>{getConnectionIcon(c, manifest("database"), 16)}</>); |
| 152 | + expect(screen.getByText("🐘")).toBeInTheDocument(); |
| 153 | + }); |
| 154 | + |
| 155 | + it("falls back to manifest icon when override missing", () => { |
| 156 | + // Smoke test: should not throw; icons are mocked to null in test env |
| 157 | + expect(() => render(<>{getConnectionIcon({ id: "1" } as SavedConnection, manifest("database"), 16)}</>)).not.toThrow(); |
| 158 | + }); |
| 159 | + |
| 160 | + it("renders the mocked image component for image overrides", async () => { |
| 161 | + const c = { id: "1", appearance: { icon: { type: "image", path: "connection-icons/foo.png" } } } as SavedConnection; |
| 162 | + render(<>{getConnectionIcon(c, manifest("database"), 16)}</>); |
| 163 | + await waitFor(() => expect(screen.getByTestId("conn-icon-image")).toBeInTheDocument()); |
| 164 | + }); |
| 165 | + |
| 166 | + it("falls back to manifest when pack id is unknown", () => { |
| 167 | + const c = { id: "1", appearance: { icon: { type: "pack", id: "this-icon-does-not-exist-xyz" } } } as SavedConnection; |
| 168 | + // Smoke test: should not throw; icons are mocked to null in test env |
| 169 | + expect(() => render(<>{getConnectionIcon(c, manifest("database"), 16)}</>)).not.toThrow(); |
| 170 | + }); |
| 171 | +}); |
0 commit comments