generated from nhcarrigan/template
feat: multiple UI improvements, font settings, and memory file display names (#175)
## Summary - **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load - **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach - **feat**: Add configurable max output tokens setting - **feat**: Use random creative names for conversation tabs - **test**: Significantly expanded frontend unit test coverage - **docs**: Require tests for all changes in CLAUDE.md - **feat**: Allow users to specify a custom terminal font (Closes #176) - **feat**: Display friendly names for memory files derived from the first heading (Closes #177) - **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs) - **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #175 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #175.
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
configStore,
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
applyCustomFont,
|
||||
applyCustomUiFont,
|
||||
applyTheme,
|
||||
applyCustomThemeColors,
|
||||
clearCustomThemeColors,
|
||||
isDarkTheme,
|
||||
isStreamerMode,
|
||||
isCompactMode,
|
||||
shouldHidePaths,
|
||||
showThinkingBlocks,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -15,12 +23,17 @@ import {
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("config store", () => {
|
||||
describe("font size constants", () => {
|
||||
it("has correct MIN_FONT_SIZE", () => {
|
||||
@@ -196,9 +209,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -247,9 +265,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -797,9 +820,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
@@ -840,4 +868,607 @@ describe("config store", () => {
|
||||
expect(lastSaved.font_size).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig error path", () => {
|
||||
it("resets to default config and logs error when loadConfig fails", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
|
||||
|
||||
await configStore.loadConfig();
|
||||
|
||||
expect(configStore.getConfig().theme).toBe("dark");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore sidebar methods", () => {
|
||||
it("openSidebar sets isSidebarOpen to true", () => {
|
||||
configStore.closeSidebar();
|
||||
configStore.openSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it("closeSidebar sets isSidebarOpen to false", () => {
|
||||
configStore.openSidebar();
|
||||
configStore.closeSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleSidebar switches from false to true", () => {
|
||||
configStore.closeSidebar();
|
||||
configStore.toggleSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleSidebar switches from true to false", () => {
|
||||
configStore.openSidebar();
|
||||
configStore.toggleSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore setTheme method", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("setTheme updates the theme via invoke", async () => {
|
||||
await configStore.setTheme("light");
|
||||
expect(configStore.getConfig().theme).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme with custom colors updates custom_theme_colors", async () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#001122",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
await configStore.setTheme("custom", colors);
|
||||
|
||||
expect(configStore.getConfig().theme).toBe("custom");
|
||||
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore setCustomThemeColors with custom theme active", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.setTheme("custom");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearCustomThemeColors();
|
||||
});
|
||||
|
||||
it("applies custom colors to DOM when current theme is custom", async () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#aabbcc",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
await configStore.setCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore font size methods", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("setFontSize updates to the given value", async () => {
|
||||
await configStore.setFontSize(18);
|
||||
expect(configStore.getConfig().font_size).toBe(18);
|
||||
});
|
||||
|
||||
it("setFontSize clamps to minimum", async () => {
|
||||
await configStore.setFontSize(1);
|
||||
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("setFontSize clamps to maximum", async () => {
|
||||
await configStore.setFontSize(99);
|
||||
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("increaseFontSize increases font size by 2", async () => {
|
||||
await configStore.increaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
|
||||
});
|
||||
|
||||
it("increaseFontSize does not exceed maximum", async () => {
|
||||
await configStore.setFontSize(MAX_FONT_SIZE);
|
||||
await configStore.increaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("decreaseFontSize decreases font size by 2", async () => {
|
||||
await configStore.decreaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
|
||||
});
|
||||
|
||||
it("decreaseFontSize does not go below minimum", async () => {
|
||||
await configStore.setFontSize(MIN_FONT_SIZE);
|
||||
await configStore.decreaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("resetFontSize restores the default font size", async () => {
|
||||
await configStore.setFontSize(20);
|
||||
await configStore.resetFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore removeAutoGrantedTool", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ auto_granted_tools: [] });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("removes an existing tool", async () => {
|
||||
await configStore.addAutoGrantedTool("Bash");
|
||||
await configStore.removeAutoGrantedTool("Bash");
|
||||
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
|
||||
});
|
||||
|
||||
it("is a no-op when the tool is not in the list", async () => {
|
||||
await configStore.removeAutoGrantedTool("NonExistentTool");
|
||||
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore toggle methods", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
|
||||
await configStore.toggleStreamerMode();
|
||||
expect(configStore.getConfig().streamer_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true });
|
||||
await configStore.toggleStreamerMode();
|
||||
expect(configStore.getConfig().streamer_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleCompactMode flips compact_mode from false to true", async () => {
|
||||
await configStore.toggleCompactMode();
|
||||
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleCompactMode flips compact_mode from true to false", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
await configStore.toggleCompactMode();
|
||||
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("setCompactMode enables compact mode", async () => {
|
||||
await configStore.setCompactMode(true);
|
||||
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("setCompactMode disables compact mode", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
await configStore.setCompactMode(false);
|
||||
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores (live subscriptions)", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("isDarkTheme is true when theme is dark", async () => {
|
||||
await configStore.updateConfig({ theme: "dark" });
|
||||
expect(get(isDarkTheme)).toBe(true);
|
||||
});
|
||||
|
||||
it("isDarkTheme is false when theme is not dark", async () => {
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
expect(get(isDarkTheme)).toBe(false);
|
||||
});
|
||||
|
||||
it("isStreamerMode reflects streamer_mode config", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true });
|
||||
expect(get(isStreamerMode)).toBe(true);
|
||||
await configStore.updateConfig({ streamer_mode: false });
|
||||
expect(get(isStreamerMode)).toBe(false);
|
||||
});
|
||||
|
||||
it("isCompactMode reflects compact_mode config", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
expect(get(isCompactMode)).toBe(true);
|
||||
await configStore.updateConfig({ compact_mode: false });
|
||||
expect(get(isCompactMode)).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
|
||||
expect(get(shouldHidePaths)).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
|
||||
expect(get(shouldHidePaths)).toBe(false);
|
||||
});
|
||||
|
||||
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
|
||||
await configStore.updateConfig({ show_thinking_blocks: true });
|
||||
expect(get(showThinkingBlocks)).toBe(true);
|
||||
});
|
||||
|
||||
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
|
||||
await configStore.updateConfig({ show_thinking_blocks: false });
|
||||
expect(get(showThinkingBlocks)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
|
||||
beforeEach(() => {
|
||||
// Remove any style element left by previous tests
|
||||
document.getElementById("hikari-custom-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||
readFileMock.mockReset();
|
||||
});
|
||||
|
||||
it("removes CSS variable when both path and family are null", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await applyCustomFont(null, null);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS variable when both path and family are empty strings", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await applyCustomFont("", "");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("injects @import for a CSS stylesheet URL", async () => {
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain(
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'Fira Code', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
|
||||
expect(style?.textContent).toContain("'MyFont'");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'MyFont', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.ttf)", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
|
||||
expect(style?.textContent).toContain("'MyTtfFont'");
|
||||
});
|
||||
|
||||
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||
});
|
||||
|
||||
it("reads local file and injects @font-face with data URL", async () => {
|
||||
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||
expect(style?.textContent).toContain("'MyFont'");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'MyFont', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
|
||||
const fakeData = new Uint8Array([1, 2, 3]);
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
|
||||
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||
expect(style?.textContent).toContain("font/woff2");
|
||||
});
|
||||
|
||||
it("sets CSS variable when only family is provided (no path)", async () => {
|
||||
await applyCustomFont("", "SystemFont");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'SystemFont', monospace"
|
||||
);
|
||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces a previously injected style element", async () => {
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
|
||||
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
|
||||
const styles = document.querySelectorAll("#hikari-custom-font");
|
||||
expect(styles.length).toBe(1);
|
||||
expect(styles[0].textContent).toContain("Roboto+Mono");
|
||||
});
|
||||
|
||||
it("uses correct MIME type for .otf local files", async () => {
|
||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("font/otf");
|
||||
});
|
||||
|
||||
it("falls back to font/ttf MIME for unknown extension local files", async () => {
|
||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("font/ttf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCustomFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||
readFileMock.mockReset();
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("saves config and applies the font", async () => {
|
||||
await configStore.setCustomFont(null, null);
|
||||
await configStore.setCustomFont(
|
||||
"https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||
"Fira Code"
|
||||
);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||
custom_font_family: "Fira Code",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'Fira Code', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("clears font when called with nulls", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await configStore.setCustomFont(null, null);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomUiFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--ui-font-family");
|
||||
document.body.style.removeProperty("font-family");
|
||||
readFileMock.mockReset();
|
||||
});
|
||||
|
||||
it("removes CSS variable and body font-family when both path and family are null", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await applyCustomUiFont(null, null);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await applyCustomUiFont("", "");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain(
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
|
||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
|
||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||
'"HikariCustomUiFont", sans-serif'
|
||||
);
|
||||
});
|
||||
|
||||
it("reads local file and embeds as data URL, applies font to body", async () => {
|
||||
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
|
||||
await applyCustomUiFont("", "SystemUiFont");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'SystemUiFont', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||
'"SystemUiFont", sans-serif'
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a previously injected style element", async () => {
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
||||
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
||||
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
||||
expect(styles.length).toBe(1);
|
||||
expect(styles[0].textContent).toContain("Roboto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCustomUiFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--ui-font-family");
|
||||
document.body.style.removeProperty("font-family");
|
||||
readFileMock.mockReset();
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("saves config and applies the UI font", async () => {
|
||||
await configStore.setCustomUiFont(null, null);
|
||||
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
||||
custom_ui_font_family: "Inter",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("clears UI font when called with nulls", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await configStore.setCustomUiFont(null, null);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user