diff --git a/package.json b/package.json index 8d9e804..0ea62ab 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml", "tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production", "openai:local": "./scripts/start_inference_server.sh", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test": "NODE_OPTIONS=--no-experimental-fetch vitest run", + "test:watch": "NODE_OPTIONS=--no-experimental-fetch vitest", + "test:coverage": "NODE_OPTIONS=--no-experimental-fetch vitest run --coverage" }, "devDependencies": { "@anthropic-ai/sdk": "^0.32.1", diff --git a/src/stores/ClientChatStore.ts b/src/stores/ClientChatStore.ts index d6bd156..84ae381 100644 --- a/src/stores/ClientChatStore.ts +++ b/src/stores/ClientChatStore.ts @@ -1,272 +1,229 @@ -import { applySnapshot, flow, Instance, types } from "mobx-state-tree"; +// --------------------------- +// stores/MessagesStore.ts +// --------------------------- +import { Instance, types } from "mobx-state-tree"; import Message from "../models/Message"; + +export const MessagesStore = types + .model("MessagesStore", { + items: types.optional(types.array(Message), []), + }) + .actions((self) => ({ + add(message: Instance) { + self.items.push(message); + }, + updateLast(content: string) { + if (self.items.length) { + self.items[self.items.length - 1].content = content; + } + }, + appendLast(content: string) { + if (self.items.length) { + self.items[self.items.length - 1].content += content; + } + }, + removeAfter(index: number) { + if (index >= 0 && index < self.items.length) { + self.items.splice(index + 1); + } + }, + reset() { + self.items.clear(); + }, + })); + +export interface IMessagesStore extends Instance {} + +// --------------------------- +// stores/UIStore.ts +// --------------------------- +import { Instance, types } from "mobx-state-tree"; + +export const UIStore = types + .model("UIStore", { + input: types.optional(types.string, ""), + isLoading: types.optional(types.boolean, false), + }) + .actions((self) => ({ + setInput(value: string) { + self.input = value; + }, + setIsLoading(value: boolean) { + self.isLoading = value; + }, + })); + +export interface IUIStore extends Instance {} + +// --------------------------- +// stores/ModelStore.ts +// --------------------------- +import { Instance, types } from "mobx-state-tree"; + +export const ModelStore = types + .model("ModelStore", { + model: types.optional( + types.string, + "meta-llama/llama-4-scout-17b-16e-instruct", + ), + imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"), + supportedModels: types.optional(types.array(types.string), []), + }) + .actions((self) => ({ + setModel(value: string) { + self.model = value; + try { + localStorage.setItem("recentModel", value); + } catch (_) {} + }, + setImageModel(value: string) { + self.imageModel = value; + }, + setSupportedModels(list: string[]) { + self.supportedModels = list; + if (!list.includes(self.model)) { + // fall back to last entry (arbitrary but predictable) + self.model = list[list.length - 1] ?? self.model; + } + }, + })); + +export interface IModelStore extends Instance {} + +// --------------------------- +// stores/StreamStore.ts +// Handles networking + SSE lifecycle. +// Depends on MessagesStore, UIStore, and ModelStore via composition. +// --------------------------- +import { + getParent, + Instance, + flow, + types, +} from "mobx-state-tree"; +import type { IMessagesStore } from "./MessagesStore"; +import type { IUIStore } from "./UIStore"; +import type { IModelStore } from "./ModelStore"; import UserOptionsStore from "./UserOptionsStore"; +import Message from "../models/Message"; -const ClientChatStore = types - .model("ClientChatStore", { - messages: types.optional(types.array(Message), []), - input: types.optional(types.string, ""), - isLoading: types.optional(types.boolean, false), - model: types.optional(types.string, "meta-llama/llama-4-scout-17b-16e-instruct"), - imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"), - supportedModels: types.optional(types.array(types.string), []) - }) - .actions((self) => ({ - cleanup() { - if (self.eventSource) { - self.eventSource.close(); - self.eventSource = null; +interface RootDeps extends IMessagesStore, IUIStore, IModelStore {} + +export const StreamStore = types + .model("StreamStore", {}) + .volatile(() => ({ + eventSource: null as EventSource | null, + })) + .actions((self) => { + // helpers + const root = getParent(self); + + function cleanup() { + if (self.eventSource) { + self.eventSource.close(); + self.eventSource = null; + } } - }, - setSupportedModels(modelsList: string[]) { - self.supportedModels = modelsList; - if(!modelsList.includes(self.model)) { - self.model = modelsList.pop() - } - }, - sendMessage: flow(function* () { - if (!self.input.trim() || self.isLoading) return; - self.cleanup(); + const sendMessage = flow(function* () { + if (!root.input.trim() || root.isLoading) return; + cleanup(); + yield UserOptionsStore.setFollowModeEnabled(true); + root.setIsLoading(true); - yield self.setFollowModeEnabled(true); - self.setIsLoading(true); - - const userMessage = Message.create({ - content: self.input, - role: "user" as const, - }); - self.addMessage(userMessage); - self.setInput(""); - - try { - const payload = { - messages: self.messages.slice(), - model: self.model, - }; - - yield new Promise((resolve) => setTimeout(resolve, 500)); - self.addMessage(Message.create({ content: "", role: "assistant" })); - - const response = yield fetch("/api/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), + const userMessage = Message.create({ + content: root.input, + role: "user" as const, }); - if (response.status === 429) { - self.updateLastMessage( - `Too many requests in the given time. Please wait a few moments and try again.`, - ); - self.cleanup(); - return; - } - if (response.status > 200) { - self.updateLastMessage(`Error! Something went wrong, try again.`); - self.cleanup(); - return; - } - - const { streamUrl } = yield response.json(); - self.eventSource = new EventSource(streamUrl); - - self.eventSource.onmessage = async (event) => { - try { - const dataString = event.data; - const parsedData = JSON.parse(dataString); - - if (parsedData.type === "error") { - self.updateLastMessage(`${parsedData.error}`); - self.cleanup(); - self.setIsLoading(false); - return; - } - - if ( - parsedData.type === "chat" && - parsedData.data.choices[0]?.finish_reason === "stop" - ) { - self.appendToLastMessage( - parsedData.data.choices[0]?.delta?.content || "", - ); - self.cleanup(); - self.setIsLoading(false); - return; - } - - if (parsedData.type === "chat") { - self.appendToLastMessage( - parsedData.data.choices[0]?.delta?.content || "", - ); - } - } catch (error) { - console.error("Error processing stream:", error); - } - }; - - self.eventSource.onerror = (e) => { - self.cleanup(); - }; - } catch (error) { - console.error("Error in sendMessage:", error); - if ( - !self.messages.length || - self.messages[self.messages.length - 1].role !== "assistant" - ) { - self.addMessage({ - content: "Sorry, there was an error.", - role: "assistant", - }); - } else { - self.updateLastMessage("Sorry, there was an error."); - } - self.cleanup(); - self.setIsLoading(false); - } finally { - } - }), - setFollowModeEnabled: flow(function* (isEnabled: boolean) { - yield UserOptionsStore.setFollowModeEnabled(isEnabled); - }), - setInput(value: string) { - self.input = value; - }, - setModel(value: string) { - self.model = value; - try { - localStorage.setItem("recentModel", value); - } catch (error) {} - }, - setImageModel(value: string) { - self.imageModel = value; - }, - addMessage(message: Instance) { - self.messages.push(message); - }, - editMessage: flow(function* (index: number, content: string) { - yield self.setFollowModeEnabled(true); - if (index >= 0 && index < self.messages.length) { - self.messages[index].setContent(content); - - self.messages.splice(index + 1); - - self.setIsLoading(true); - - yield new Promise((resolve) => setTimeout(resolve, 500)); - - self.addMessage(Message.create({ content: "", role: "assistant" })); + root.add(userMessage); + root.setInput(""); try { - const payload = { - messages: self.messages.slice(), - model: self.model, - }; + const payload = { messages: root.items.slice(), model: root.model }; + // optimistic UI delay (demo‑purpose) + yield new Promise((r) => setTimeout(r, 500)); + root.add(Message.create({ content: "", role: "assistant" })); - const response = yield fetch("/api/chat", { + const response: Response = yield fetch("/api/chat", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (response.status === 429) { - self.updateLastMessage( - `Too many requests in the given time. Please wait a few moments and try again.`, - ); - self.cleanup(); + root.updateLast("Too many requests • please slow down."); + cleanup(); return; } if (response.status > 200) { - self.updateLastMessage(`Error! Something went wrong, try again.`); - self.cleanup(); + root.updateLast("Error • something went wrong."); + cleanup(); return; } - const { streamUrl } = yield response.json(); + const { streamUrl } = (yield response.json()) as { streamUrl: string }; self.eventSource = new EventSource(streamUrl); self.eventSource.onmessage = (event) => { try { - const dataString = event.data; - const parsedData = JSON.parse(dataString); - - if (parsedData.type === "error") { - self.updateLastMessage(`${parsedData.error}`); - self.cleanup(); - self.setIsLoading(false); + const parsed = JSON.parse(event.data); + if (parsed.type === "error") { + root.updateLast(parsed.error); + cleanup(); + root.setIsLoading(false); return; } if ( - parsedData.type === "chat" && - parsedData.data.choices[0]?.finish_reason === "stop" + parsed.type === "chat" && + parsed.data.choices[0]?.finish_reason === "stop" ) { - self.cleanup(); - self.setIsLoading(false); + root.appendLast(parsed.data.choices[0]?.delta?.content ?? ""); + cleanup(); + root.setIsLoading(false); return; } - if (parsedData.type === "chat") { - self.appendToLastMessage( - parsedData.data.choices[0]?.delta?.content || "", - ); + if (parsed.type === "chat") { + root.appendLast(parsed.data.choices[0]?.delta?.content ?? ""); } - } catch (error) { - console.error("Error processing stream:", error); - } finally { + } catch (err) { + console.error("stream parse error", err); } }; - self.eventSource.onerror = (e) => { - console.log("EventSource encountered an error", JSON.stringify(e)); - }; - } catch (error) { - console.error("Error in editMessage:", error); - self.addMessage({ - content: "Sorry, there was an error.", - role: "assistant", - }); - self.cleanup(); - } finally { + self.eventSource.onerror = () => cleanup(); + } catch (err) { + console.error("sendMessage", err); + root.updateLast("Sorry • network error."); + cleanup(); + root.setIsLoading(false); } - } - }), - getIsLoading() { - return self.isLoading; - }, - reset() { - applySnapshot(self, {}); - }, - removeMessagesAfter(index: number) { - if (index >= 0 && index < self.messages.length) { - self.messages.splice(index + 1); - } - }, - updateLastMessage(content: string) { - if (self.messages.length > 0) { - self.messages[self.messages.length - 1].content = content; - } - }, - appendToLastMessage(content: string) { - if (self.messages.length > 0) { - self.messages[self.messages.length - 1].content += content; - } - }, - setIsLoading(value: boolean) { - self.isLoading = value; - }, - stopIncomingMessage() { - if (self.eventSource) { - self.eventSource.close(); - self.eventSource = null; - } - self.setIsLoading(false); - }, - })); + }); -export type IMessage = Instance; + const stopIncomingMessage = () => { + cleanup(); + root.setIsLoading(false); + }; -export type IClientChatStore = Instance; + return { sendMessage, stopIncomingMessage, cleanup }; + }); -export default ClientChatStore.create(); +export interface IStreamStore extends Instance {} + +// --------------------------- +// stores/ClientChatStore.ts (root) +// --------------------------- +import { types } from "mobx-state-tree"; +// import { MessagesStore } from "./MessagesStore"; +// import { UIStore } from "./UIStore"; +// import { ModelStore } from "./ModelStore"; +// import { StreamStore } from "./StreamStore"; + +export const ClientChatStore = types + .compose(MessagesStore, UIStore, ModelStore, StreamStore) + .named("ClientChatStore"); + +export const clientChatStore = ClientChatStore.create(); + +export type IClientChatStore = Instance; \ No newline at end of file diff --git a/src/stores/ClientTransactionStore.ts b/src/stores/ClientTransactionStore.ts index 32882d3..46231da 100644 --- a/src/stores/ClientTransactionStore.ts +++ b/src/stores/ClientTransactionStore.ts @@ -72,5 +72,5 @@ export default ClientTransactionStore.create({ amount: "", donerId: "", userConfirmed: false, - transactionId: "", + txId: "", }); diff --git a/src/stores/UserOptionsStore.ts b/src/stores/UserOptionsStore.ts index 909dca6..92ca80f 100644 --- a/src/stores/UserOptionsStore.ts +++ b/src/stores/UserOptionsStore.ts @@ -3,16 +3,21 @@ import ClientChatStore from "./ClientChatStore"; import { runInAction } from "mobx"; import Cookies from "js-cookie"; -const UserOptionsStore = types +export const UserOptionsStoreModel = types .model("UserOptionsStore", { followModeEnabled: types.optional(types.boolean, false), theme: types.optional(types.string, "darknight"), text_model: types.optional(types.string, "llama-3.3-70b-versatile"), }) .actions((self) => ({ - getFollowModeEnabled: flow(function* () { + getFollowModeEnabled() { return self.followModeEnabled; - }), + }, + resetStore() { + self.followModeEnabled = false; + self.theme = "darknight"; + self.text_model = "llama-3.3-70b-versatile"; + }, storeUserOptions() { const userOptionsCookie = document.cookie .split(";") @@ -35,7 +40,7 @@ const UserOptionsStore = types Cookies.set("user_preferences", encodedUserPreferences); }); }, - initialize: flow(function* () { + initialize() { const userPreferencesCoookie = document.cookie .split(";") .find((row) => row.startsWith("user_preferences")); @@ -53,48 +58,54 @@ const UserOptionsStore = types self.text_model = userPreferences.text_model; } - window.addEventListener("scroll", async () => { + window.addEventListener("scroll", () => { if (ClientChatStore.isLoading && self.followModeEnabled) { console.log("scrolling"); - await self.setFollowModeEnabled(false); + self.setFollowModeEnabled(false); } }); - window.addEventListener("wheel", async () => { + window.addEventListener("wheel", () => { if (ClientChatStore.isLoading && self.followModeEnabled) { console.log("wheel"); - await self.setFollowModeEnabled(false); + self.setFollowModeEnabled(false); } }); - window.addEventListener("touchmove", async () => { + window.addEventListener("touchmove", () => { console.log("touchmove"); if (ClientChatStore.isLoading && self.followModeEnabled) { - await self.setFollowModeEnabled(false); + self.setFollowModeEnabled(false); } }); - window.addEventListener("mousedown", async () => { + window.addEventListener("mousedown", () => { if (ClientChatStore.isLoading && self.followModeEnabled) { - await self.setFollowModeEnabled(false); + self.setFollowModeEnabled(false); } }); - }), + }, deleteCookie() { document.cookie = "user_preferences=; max-age=; path=/;"; }, - setFollowModeEnabled: flow(function* (followMode: boolean) { + setFollowModeEnabled(followMode: boolean) { self.followModeEnabled = followMode; - }), - toggleFollowMode: flow(function* () { + }, + toggleFollowMode() { self.followModeEnabled = !self.followModeEnabled; - }), - selectTheme: flow(function* (theme: string) { + }, + selectTheme(theme: string) { self.theme = theme; self.storeUserOptions(); - }), + }, + setTheme(theme: string) { + self.theme = theme; + }, + setTextModel(model: string) { + self.text_model = model; + }, })); -const userOptionsStore = UserOptionsStore.create(); +const userOptionsStore = UserOptionsStoreModel.create(); export default userOptionsStore; diff --git a/src/stores/__tests__/AppMenuStore.test.ts b/src/stores/__tests__/AppMenuStore.test.ts new file mode 100644 index 0000000..19df46b --- /dev/null +++ b/src/stores/__tests__/AppMenuStore.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import menuState from '../AppMenuStore'; + +describe('AppMenuStore', () => { + beforeEach(() => { + // Reset the menu state before each test + menuState.closeMenu(); + }); + + it('should have isOpen set to false initially', () => { + // Reset to initial state + menuState.closeMenu(); + expect(menuState.isOpen).toBe(false); + }); + + it('should set isOpen to true when openMenu is called', () => { + menuState.openMenu(); + expect(menuState.isOpen).toBe(true); + }); + + it('should set isOpen to false when closeMenu is called', () => { + // First open the menu + menuState.openMenu(); + expect(menuState.isOpen).toBe(true); + + // Then close it + menuState.closeMenu(); + expect(menuState.isOpen).toBe(false); + }); + + it('should toggle isOpen when toggleMenu is called', () => { + // Initially isOpen should be false (from beforeEach) + expect(menuState.isOpen).toBe(false); + + // First toggle - should set to true + menuState.toggleMenu(); + expect(menuState.isOpen).toBe(true); + + // Second toggle - should set back to false + menuState.toggleMenu(); + expect(menuState.isOpen).toBe(false); + }); +}); diff --git a/src/stores/__tests__/ClientTransactionStore.test.ts b/src/stores/__tests__/ClientTransactionStore.test.ts new file mode 100644 index 0000000..36c19ef --- /dev/null +++ b/src/stores/__tests__/ClientTransactionStore.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import clientTransactionStore from '../ClientTransactionStore'; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('ClientTransactionStore', () => { + beforeEach(() => { + // Reset the store to its initial state before each test + clientTransactionStore.resetTransaction(); + clientTransactionStore.setSelectedMethod('Ethereum'); + clientTransactionStore.setAmount(''); + clientTransactionStore.setDonerId(''); + + // Reset mocks + vi.clearAllMocks(); + + // No need to fix inconsistency anymore as we've updated the model + }); + + describe('setSelectedMethod', () => { + it('should set the selected method and reset userConfirmed', () => { + // First set userConfirmed to true to verify it gets reset + clientTransactionStore.confirmUser(); + expect(clientTransactionStore.userConfirmed).toBe(true); + + clientTransactionStore.setSelectedMethod('Bitcoin'); + + expect(clientTransactionStore.selectedMethod).toBe('Bitcoin'); + expect(clientTransactionStore.userConfirmed).toBe(false); + }); + }); + + describe('setAmount', () => { + it('should set the amount', () => { + clientTransactionStore.setAmount('100'); + expect(clientTransactionStore.amount).toBe('100'); + }); + }); + + describe('setDonerId', () => { + it('should set the donerId', () => { + clientTransactionStore.setDonerId('donor123'); + expect(clientTransactionStore.donerId).toBe('donor123'); + }); + }); + + describe('confirmUser', () => { + it('should set userConfirmed to true', () => { + clientTransactionStore.confirmUser(); + expect(clientTransactionStore.userConfirmed).toBe(true); + }); + }); + + describe('setTransactionId', () => { + it('should set the transaction ID', () => { + clientTransactionStore.setTransactionId('tx123'); + expect(clientTransactionStore.txId).toBe('tx123'); + }); + }); + + describe('setDepositAddress', () => { + it('should set the deposit address', () => { + clientTransactionStore.setDepositAddress('0xabc123'); + expect(clientTransactionStore.depositAddress).toBe('0xabc123'); + }); + }); + + describe('resetTransaction', () => { + it('should reset transaction-related properties', () => { + // Set up some values first + clientTransactionStore.setTransactionId('tx123'); + clientTransactionStore.setDepositAddress('0xabc123'); + clientTransactionStore.confirmUser(); + + // Reset the transaction + clientTransactionStore.resetTransaction(); + + // Verify reset values + expect(clientTransactionStore.txId).toBe(''); + expect(clientTransactionStore.depositAddress).toBe(null); + expect(clientTransactionStore.userConfirmed).toBe(false); + }); + }); + + describe('prepareTransaction', () => { + it('should throw an error if amount is empty', async () => { + clientTransactionStore.setDonerId('donor123'); + + await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data'); + }); + + it('should throw an error if donerId is empty', async () => { + clientTransactionStore.setAmount('100'); + + await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data'); + }); + + it('should throw an error if amount is less than or equal to 0', async () => { + clientTransactionStore.setAmount('0'); + clientTransactionStore.setDonerId('donor123'); + + await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data'); + }); + + it('should throw an error if the API request fails', async () => { + // Set up valid transaction data + clientTransactionStore.setAmount('100'); + clientTransactionStore.setDonerId('donor123'); + + // Mock a failed API response + mockFetch.mockResolvedValueOnce({ + ok: false, + }); + + await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Failed to prepare transaction'); + }); + + it('should successfully prepare an Ethereum transaction', async () => { + // Set up valid transaction data + clientTransactionStore.setAmount('100'); + clientTransactionStore.setDonerId('donor123'); + clientTransactionStore.setSelectedMethod('Ethereum'); + + // Mock a successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + txKey: 'tx123', + depositAddress: 'abc123', // Without 0x prefix to test the Ethereum-specific logic + }), + }); + + await clientTransactionStore.prepareTransaction(); + + // Verify API call + expect(mockFetch).toHaveBeenCalledWith('/api/tx', { + method: 'POST', + body: 'PREPARE_TX,donor123,ethereum,100', + }); + + // Verify store updates + expect(clientTransactionStore.txId).toBe('tx123'); + expect(clientTransactionStore.depositAddress).toBe('0xabc123'); // Should have 0x prefix added + expect(clientTransactionStore.userConfirmed).toBe(true); + }); + + it('should successfully prepare a non-Ethereum transaction', async () => { + // Set up valid transaction data + clientTransactionStore.setAmount('100'); + clientTransactionStore.setDonerId('donor123'); + clientTransactionStore.setSelectedMethod('Bitcoin'); + + // Mock a successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + txKey: 'tx123', + depositAddress: 'btc123', // Bitcoin address doesn't need prefix + }), + }); + + await clientTransactionStore.prepareTransaction(); + + // Verify API call + expect(mockFetch).toHaveBeenCalledWith('/api/tx', { + method: 'POST', + body: 'PREPARE_TX,donor123,bitcoin,100', + }); + + // Verify store updates + expect(clientTransactionStore.txId).toBe('tx123'); + expect(clientTransactionStore.depositAddress).toBe('btc123'); // Should not have prefix added + expect(clientTransactionStore.userConfirmed).toBe(true); + }); + + it('should handle API errors and rethrow them', async () => { + // Set up valid transaction data + clientTransactionStore.setAmount('100'); + clientTransactionStore.setDonerId('donor123'); + + // Mock an API error + const mockError = new Error('Network error'); + mockFetch.mockRejectedValueOnce(mockError); + + await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow(mockError); + }); + }); +}); diff --git a/src/stores/__tests__/UserOptionsStore.test.ts b/src/stores/__tests__/UserOptionsStore.test.ts new file mode 100644 index 0000000..f06fb10 --- /dev/null +++ b/src/stores/__tests__/UserOptionsStore.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import userOptionsStore, { UserOptionsStoreModel } from '../UserOptionsStore'; +import ClientChatStore from '../ClientChatStore'; +import Cookies from 'js-cookie'; + +// Mock dependencies +vi.mock('js-cookie', () => ({ + default: { + set: vi.fn(), + }, +})); + +vi.mock('../ClientChatStore', () => ({ + default: { + isLoading: false, + setIsLoading: vi.fn((value) => { + (ClientChatStore as any).isLoading = value; + }), + }, +})); + +describe('UserOptionsStore', () => { + // Mock document.cookie + let originalDocumentCookie: PropertyDescriptor | undefined; + + beforeEach(() => { + // Save original document.cookie property descriptor + originalDocumentCookie = Object.getOwnPropertyDescriptor(document, 'cookie'); + + // Mock document.cookie + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + }); + + // Reset mocks + vi.clearAllMocks(); + + // Reset store to default values + userOptionsStore.resetStore(); + }); + + afterEach(() => { + // Restore original document.cookie property descriptor + if (originalDocumentCookie) { + Object.defineProperty(document, 'cookie', originalDocumentCookie); + } + }); + + describe('getFollowModeEnabled', () => { + it('should return the current followModeEnabled value', () => { + userOptionsStore.setFollowModeEnabled(false); + expect(userOptionsStore.getFollowModeEnabled()).toBe(false); + + userOptionsStore.setFollowModeEnabled(true); + expect(userOptionsStore.getFollowModeEnabled()).toBe(true); + }); + }); + + describe('storeUserOptions', () => { + it('should store user options in a cookie', () => { + // Set up document.cookie to simulate an existing cookie + document.cookie = 'user_preferences=abc123'; + + userOptionsStore.setTheme('light'); + userOptionsStore.setTextModel('test-model'); + + userOptionsStore.storeUserOptions(); + + // Check that Cookies.set was called with the correct arguments + const expectedOptions = JSON.stringify({ + theme: 'light', + text_model: 'test-model', + }); + const encodedOptions = btoa(expectedOptions); + + expect(Cookies.set).toHaveBeenCalledWith('user_preferences', encodedOptions); + }); + }); + + describe('initialize', () => { + it('should create a cookie if none exists', () => { + // Ensure no cookie exists + document.cookie = ''; + + // Mock storeUserOptions to avoid actual implementation + const storeUserOptionsMock = vi.fn(); + const originalStoreUserOptions = userOptionsStore.storeUserOptions; + userOptionsStore.storeUserOptions = storeUserOptionsMock; + + try { + userOptionsStore.initialize(); + expect(storeUserOptionsMock).toHaveBeenCalled(); + } finally { + // Restore original method + userOptionsStore.storeUserOptions = originalStoreUserOptions; + } + }); + + it('should load preferences from existing cookie', () => { + // Create a mock cookie with preferences + const mockPreferences = { + theme: 'light', + text_model: 'test-model', + }; + const encodedPreferences = btoa(JSON.stringify(mockPreferences)); + document.cookie = `user_preferences=${encodedPreferences}`; + + userOptionsStore.initialize(); + + expect(userOptionsStore.theme).toBe('light'); + expect(userOptionsStore.text_model).toBe('test-model'); + }); + + it('should set up event listeners', () => { + // Spy on window.addEventListener + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + + userOptionsStore.initialize(); + + // Check that event listeners were added for scroll, wheel, touchmove, and mousedown + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('touchmove', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); + }); + }); + + describe('deleteCookie', () => { + it('should delete the user_preferences cookie', () => { + userOptionsStore.deleteCookie(); + + expect(document.cookie).toContain('user_preferences=; max-age=; path=/;'); + }); + }); + + describe('setFollowModeEnabled', () => { + it('should set the followModeEnabled value', () => { + userOptionsStore.setFollowModeEnabled(true); + expect(userOptionsStore.followModeEnabled).toBe(true); + + userOptionsStore.setFollowModeEnabled(false); + expect(userOptionsStore.followModeEnabled).toBe(false); + }); + }); + + describe('toggleFollowMode', () => { + it('should toggle the followModeEnabled value', () => { + userOptionsStore.setFollowModeEnabled(false); + + userOptionsStore.toggleFollowMode(); + expect(userOptionsStore.followModeEnabled).toBe(true); + + userOptionsStore.toggleFollowMode(); + expect(userOptionsStore.followModeEnabled).toBe(false); + }); + }); + + describe('selectTheme', () => { + it('should set the theme and store user options', () => { + // Mock storeUserOptions to avoid actual implementation + const storeUserOptionsMock = vi.fn(); + const originalStoreUserOptions = userOptionsStore.storeUserOptions; + userOptionsStore.storeUserOptions = storeUserOptionsMock; + + try { + userOptionsStore.selectTheme('light'); + expect(userOptionsStore.theme).toBe('light'); + expect(storeUserOptionsMock).toHaveBeenCalled(); + } finally { + // Restore original method + userOptionsStore.storeUserOptions = originalStoreUserOptions; + } + }); + }); + + describe('event listeners', () => { + it('should disable follow mode when scrolling if loading', () => { + // Create a new instance of the store for this test + const testStore = UserOptionsStoreModel.create({ + followModeEnabled: true, + theme: "darknight", + text_model: "llama-3.3-70b-versatile" + }); + + // Mock ClientChatStore.isLoading + const originalIsLoading = ClientChatStore.isLoading; + (ClientChatStore as any).isLoading = true; + + // Mock setFollowModeEnabled + const setFollowModeEnabledMock = vi.fn(); + testStore.setFollowModeEnabled = setFollowModeEnabledMock; + + // Add the event listener manually (similar to initialize) + const scrollHandler = () => { + if (ClientChatStore.isLoading && testStore.followModeEnabled) { + testStore.setFollowModeEnabled(false); + } + }; + + // Trigger the handler directly + scrollHandler(); + + // Restore original value + (ClientChatStore as any).isLoading = originalIsLoading; + + expect(setFollowModeEnabledMock).toHaveBeenCalledWith(false); + }); + + it('should not disable follow mode when scrolling if not loading', () => { + // Create a new instance of the store for this test + const testStore = UserOptionsStoreModel.create({ + followModeEnabled: true, + theme: "darknight", + text_model: "llama-3.3-70b-versatile" + }); + + // Mock ClientChatStore.isLoading + const originalIsLoading = ClientChatStore.isLoading; + (ClientChatStore as any).isLoading = false; + + // Mock setFollowModeEnabled + const setFollowModeEnabledMock = vi.fn(); + testStore.setFollowModeEnabled = setFollowModeEnabledMock; + + // Add the event listener manually (similar to initialize) + const scrollHandler = () => { + if (ClientChatStore.isLoading && testStore.followModeEnabled) { + testStore.setFollowModeEnabled(false); + } + }; + + // Trigger the handler directly + scrollHandler(); + + // Restore original value + (ClientChatStore as any).isLoading = originalIsLoading; + + expect(setFollowModeEnabledMock).not.toHaveBeenCalled(); + }); + }); +});