diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts new file mode 100644 index 0000000..286880b --- /dev/null +++ b/src/lib/store.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useOverlayStore } from "./store"; + +const initial = useOverlayStore.getState(); + +describe("useOverlayStore", () => { + beforeEach(() => useOverlayStore.setState(initial, true)); + + it("starts with sensible defaults", () => { + const s = useOverlayStore.getState(); + expect(s.url).toBe(""); + expect(s.opacity).toBe(1.0); + expect(s.alwaysOnTop).toBe(true); + expect(s.clickThrough).toBe(false); + expect(s.isOpen).toBe(false); + expect(s.urlError).toBe(null); + expect(s.hotkeyError).toBe(null); + }); + + it("setUrl validates and stores both url and urlError", () => { + useOverlayStore.getState().setUrl("twitch.tv"); + expect(useOverlayStore.getState().url).toBe("twitch.tv"); + expect(useOverlayStore.getState().urlError).toBe(null); + + useOverlayStore.getState().setUrl("javascript:alert(1)"); + expect(useOverlayStore.getState().urlError).toMatch(/http and https/); + }); + + it("setOpacity clamps to 0.1..1.0", () => { + useOverlayStore.getState().setOpacity(0.05); + expect(useOverlayStore.getState().opacity).toBe(0.1); + useOverlayStore.getState().setOpacity(2); + expect(useOverlayStore.getState().opacity).toBe(1.0); + useOverlayStore.getState().setOpacity(0.5); + expect(useOverlayStore.getState().opacity).toBe(0.5); + }); + + it("setAlwaysOnTop and setClickThrough mutate flags", () => { + useOverlayStore.getState().setAlwaysOnTop(false); + expect(useOverlayStore.getState().alwaysOnTop).toBe(false); + useOverlayStore.getState().setClickThrough(true); + expect(useOverlayStore.getState().clickThrough).toBe(true); + }); + + it("hydrate replaces persisted fields and clears errors", () => { + useOverlayStore.getState().setUrl("javascript:alert(1)"); + useOverlayStore.getState().hydrate({ + url: "https://example.com", + opacity: 0.6, + alwaysOnTop: false, + clickThrough: true, + }); + const s = useOverlayStore.getState(); + expect(s.url).toBe("https://example.com"); + expect(s.opacity).toBe(0.6); + expect(s.alwaysOnTop).toBe(false); + expect(s.clickThrough).toBe(true); + expect(s.urlError).toBe(null); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts new file mode 100644 index 0000000..39a5ade --- /dev/null +++ b/src/lib/store.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay"; +import { validateUrl } from "./url"; + +const clamp = (v: number, min: number, max: number): number => + Math.min(max, Math.max(min, v)); + +export const useOverlayStore = create()((set) => ({ + url: "", + opacity: 1.0, + alwaysOnTop: true, + clickThrough: false, + isOpen: false, + urlError: null, + hotkeyError: null, + + setUrl: (raw) => { + const result = validateUrl(raw); + set({ + url: raw, + urlError: result.ok ? null : raw.trim() === "" ? null : result.error, + }); + }, + + setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }), + + setAlwaysOnTop: (v) => set({ alwaysOnTop: v }), + + setClickThrough: (v) => set({ clickThrough: v }), + + _setClickThroughFromBackend: (v) => + set((s) => (s.clickThrough === v ? s : { clickThrough: v })), + + setHotkeyError: (msg) => set({ hotkeyError: msg }), + + setIsOpen: (v) => set({ isOpen: v }), + + hydrate: (p: OverlayPersistedState) => + set({ + url: p.url, + opacity: clamp(p.opacity, 0.1, 1.0), + alwaysOnTop: p.alwaysOnTop, + clickThrough: p.clickThrough, + urlError: null, + }), +}));