adds more tests

This commit is contained in:
geoffsee
2025-05-30 21:58:43 -04:00
committed by Geoff Seemueller
parent 33baf588b6
commit ebbfd4d31a
7 changed files with 699 additions and 256 deletions

View File

@@ -19,9 +19,9 @@
"tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml", "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", "tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production",
"openai:local": "./scripts/start_inference_server.sh", "openai:local": "./scripts/start_inference_server.sh",
"test": "vitest run", "test": "NODE_OPTIONS=--no-experimental-fetch vitest run",
"test:watch": "vitest", "test:watch": "NODE_OPTIONS=--no-experimental-fetch vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "NODE_OPTIONS=--no-experimental-fetch vitest run --coverage"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.32.1", "@anthropic-ai/sdk": "^0.32.1",

View File

@@ -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"; import Message from "../models/Message";
export const MessagesStore = types
.model("MessagesStore", {
items: types.optional(types.array(Message), []),
})
.actions((self) => ({
add(message: Instance<typeof Message>) {
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<typeof MessagesStore> {}
// ---------------------------
// 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<typeof UIStore> {}
// ---------------------------
// 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<typeof ModelStore> {}
// ---------------------------
// 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 UserOptionsStore from "./UserOptionsStore";
import Message from "../models/Message";
const ClientChatStore = types interface RootDeps extends IMessagesStore, IUIStore, IModelStore {}
.model("ClientChatStore", {
messages: types.optional(types.array(Message), []), export const StreamStore = types
input: types.optional(types.string, ""), .model("StreamStore", {})
isLoading: types.optional(types.boolean, false), .volatile(() => ({
model: types.optional(types.string, "meta-llama/llama-4-scout-17b-16e-instruct"), eventSource: null as EventSource | null,
imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"), }))
supportedModels: types.optional(types.array(types.string), []) .actions((self) => {
}) // helpers
.actions((self) => ({ const root = getParent<RootDeps>(self);
cleanup() {
if (self.eventSource) { function cleanup() {
self.eventSource.close(); if (self.eventSource) {
self.eventSource = null; 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); const userMessage = Message.create({
self.setIsLoading(true); content: root.input,
role: "user" as const,
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),
}); });
if (response.status === 429) { root.add(userMessage);
self.updateLastMessage( root.setInput("");
`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<typeof Message>) {
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" }));
try { try {
const payload = { const payload = { messages: root.items.slice(), model: root.model };
messages: self.messages.slice(), // optimistic UI delay (demopurpose)
model: self.model, 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", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (response.status === 429) { if (response.status === 429) {
self.updateLastMessage( root.updateLast("Too many requests • please slow down.");
`Too many requests in the given time. Please wait a few moments and try again.`, cleanup();
);
self.cleanup();
return; return;
} }
if (response.status > 200) { if (response.status > 200) {
self.updateLastMessage(`Error! Something went wrong, try again.`); root.updateLast("Error • something went wrong.");
self.cleanup(); cleanup();
return; return;
} }
const { streamUrl } = yield response.json(); const { streamUrl } = (yield response.json()) as { streamUrl: string };
self.eventSource = new EventSource(streamUrl); self.eventSource = new EventSource(streamUrl);
self.eventSource.onmessage = (event) => { self.eventSource.onmessage = (event) => {
try { try {
const dataString = event.data; const parsed = JSON.parse(event.data);
const parsedData = JSON.parse(dataString); if (parsed.type === "error") {
root.updateLast(parsed.error);
if (parsedData.type === "error") { cleanup();
self.updateLastMessage(`${parsedData.error}`); root.setIsLoading(false);
self.cleanup();
self.setIsLoading(false);
return; return;
} }
if ( if (
parsedData.type === "chat" && parsed.type === "chat" &&
parsedData.data.choices[0]?.finish_reason === "stop" parsed.data.choices[0]?.finish_reason === "stop"
) { ) {
self.cleanup(); root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
self.setIsLoading(false); cleanup();
root.setIsLoading(false);
return; return;
} }
if (parsedData.type === "chat") { if (parsed.type === "chat") {
self.appendToLastMessage( root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
parsedData.data.choices[0]?.delta?.content || "",
);
} }
} catch (error) { } catch (err) {
console.error("Error processing stream:", error); console.error("stream parse error", err);
} finally {
} }
}; };
self.eventSource.onerror = (e) => { self.eventSource.onerror = () => cleanup();
console.log("EventSource encountered an error", JSON.stringify(e)); } catch (err) {
}; console.error("sendMessage", err);
} catch (error) { root.updateLast("Sorry • network error.");
console.error("Error in editMessage:", error); cleanup();
self.addMessage({ root.setIsLoading(false);
content: "Sorry, there was an error.",
role: "assistant",
});
self.cleanup();
} finally {
} }
} });
}),
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<typeof Message>; const stopIncomingMessage = () => {
cleanup();
root.setIsLoading(false);
};
export type IClientChatStore = Instance<typeof this>; return { sendMessage, stopIncomingMessage, cleanup };
});
export default ClientChatStore.create(); export interface IStreamStore extends Instance<typeof StreamStore> {}
// ---------------------------
// 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<typeof ClientChatStore>;

View File

@@ -72,5 +72,5 @@ export default ClientTransactionStore.create({
amount: "", amount: "",
donerId: "", donerId: "",
userConfirmed: false, userConfirmed: false,
transactionId: "", txId: "",
}); });

View File

@@ -3,16 +3,21 @@ import ClientChatStore from "./ClientChatStore";
import { runInAction } from "mobx"; import { runInAction } from "mobx";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
const UserOptionsStore = types export const UserOptionsStoreModel = types
.model("UserOptionsStore", { .model("UserOptionsStore", {
followModeEnabled: types.optional(types.boolean, false), followModeEnabled: types.optional(types.boolean, false),
theme: types.optional(types.string, "darknight"), theme: types.optional(types.string, "darknight"),
text_model: types.optional(types.string, "llama-3.3-70b-versatile"), text_model: types.optional(types.string, "llama-3.3-70b-versatile"),
}) })
.actions((self) => ({ .actions((self) => ({
getFollowModeEnabled: flow(function* () { getFollowModeEnabled() {
return self.followModeEnabled; return self.followModeEnabled;
}), },
resetStore() {
self.followModeEnabled = false;
self.theme = "darknight";
self.text_model = "llama-3.3-70b-versatile";
},
storeUserOptions() { storeUserOptions() {
const userOptionsCookie = document.cookie const userOptionsCookie = document.cookie
.split(";") .split(";")
@@ -35,7 +40,7 @@ const UserOptionsStore = types
Cookies.set("user_preferences", encodedUserPreferences); Cookies.set("user_preferences", encodedUserPreferences);
}); });
}, },
initialize: flow(function* () { initialize() {
const userPreferencesCoookie = document.cookie const userPreferencesCoookie = document.cookie
.split(";") .split(";")
.find((row) => row.startsWith("user_preferences")); .find((row) => row.startsWith("user_preferences"));
@@ -53,48 +58,54 @@ const UserOptionsStore = types
self.text_model = userPreferences.text_model; self.text_model = userPreferences.text_model;
} }
window.addEventListener("scroll", async () => { window.addEventListener("scroll", () => {
if (ClientChatStore.isLoading && self.followModeEnabled) { if (ClientChatStore.isLoading && self.followModeEnabled) {
console.log("scrolling"); console.log("scrolling");
await self.setFollowModeEnabled(false); self.setFollowModeEnabled(false);
} }
}); });
window.addEventListener("wheel", async () => { window.addEventListener("wheel", () => {
if (ClientChatStore.isLoading && self.followModeEnabled) { if (ClientChatStore.isLoading && self.followModeEnabled) {
console.log("wheel"); console.log("wheel");
await self.setFollowModeEnabled(false); self.setFollowModeEnabled(false);
} }
}); });
window.addEventListener("touchmove", async () => { window.addEventListener("touchmove", () => {
console.log("touchmove"); console.log("touchmove");
if (ClientChatStore.isLoading && self.followModeEnabled) { if (ClientChatStore.isLoading && self.followModeEnabled) {
await self.setFollowModeEnabled(false); self.setFollowModeEnabled(false);
} }
}); });
window.addEventListener("mousedown", async () => { window.addEventListener("mousedown", () => {
if (ClientChatStore.isLoading && self.followModeEnabled) { if (ClientChatStore.isLoading && self.followModeEnabled) {
await self.setFollowModeEnabled(false); self.setFollowModeEnabled(false);
} }
}); });
}), },
deleteCookie() { deleteCookie() {
document.cookie = "user_preferences=; max-age=; path=/;"; document.cookie = "user_preferences=; max-age=; path=/;";
}, },
setFollowModeEnabled: flow(function* (followMode: boolean) { setFollowModeEnabled(followMode: boolean) {
self.followModeEnabled = followMode; self.followModeEnabled = followMode;
}), },
toggleFollowMode: flow(function* () { toggleFollowMode() {
self.followModeEnabled = !self.followModeEnabled; self.followModeEnabled = !self.followModeEnabled;
}), },
selectTheme: flow(function* (theme: string) { selectTheme(theme: string) {
self.theme = theme; self.theme = theme;
self.storeUserOptions(); 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; export default userOptionsStore;

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});