diff options
Diffstat (limited to 'src/features/settings')
| -rw-r--r-- | src/features/settings/index.ts | 9 | ||||
| -rw-r--r-- | src/features/settings/logic/view-model.test.ts | 61 | ||||
| -rw-r--r-- | src/features/settings/logic/view-model.ts | 60 | ||||
| -rw-r--r-- | src/features/settings/ui/ChatLimitField.svelte | 104 | ||||
| -rw-r--r-- | src/features/settings/ui/ChatLimitField.test.ts | 89 |
5 files changed, 323 insertions, 0 deletions
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..69e7cd0 --- /dev/null +++ b/src/features/settings/index.ts @@ -0,0 +1,9 @@ +export type { ChatLimitParse, ChatLimitSaveResult, SaveChatLimit } from "./logic/view-model"; +export { chatLimitChanged, parseChatLimit } from "./logic/view-model"; +export { default as ChatLimitField } from "./ui/ChatLimitField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "settings", + description: "FE-local settings (chat limit)", +} as const; diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts new file mode 100644 index 0000000..65fcae9 --- /dev/null +++ b/src/features/settings/logic/view-model.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks"; +import { chatLimitChanged, parseChatLimit } from "./view-model"; + +describe("parseChatLimit", () => { + it("parses a plain integer", () => { + expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 }); + expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 }); + }); + + it("trims surrounding whitespace", () => { + expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 }); + }); + + it("floors a decimal", () => { + expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 }); + }); + + it("clamps below the floor to MIN_CHAT_LIMIT", () => { + expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + }); + + it("clamps above the ceiling to MAX_CHAT_LIMIT", () => { + expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT }); + }); + + it("rejects an empty string", () => { + expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) }); + }); + + it("rejects non-numeric input", () => { + expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) }); + }); +}); + +describe("chatLimitChanged", () => { + it("is false when the typed value normalizes to the current limit", () => { + expect(chatLimitChanged("256", 256)).toBe(false); + }); + + it("is true when the typed value differs", () => { + expect(chatLimitChanged("100", 256)).toBe(true); + expect(chatLimitChanged("256", 100)).toBe(true); + }); + + it("is false for empty / invalid input (nothing submittable)", () => { + expect(chatLimitChanged("", 256)).toBe(false); + expect(chatLimitChanged("abc", 256)).toBe(false); + }); + + it("is false when the typed value clamps to the current limit", () => { + // 5 clamps to MIN (10); current is already 10 → no change. + expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false); + // A huge value clamps to MAX; current is already MAX → no change. + expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false); + }); +}); diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts new file mode 100644 index 0000000..caf76ee --- /dev/null +++ b/src/features/settings/logic/view-model.ts @@ -0,0 +1,60 @@ +import { normalizeChatLimit } from "../../../core/chunks"; + +/** + * Pure core for the settings feature — zero DOM, zero effects, zero Svelte. + * + * The settings feature exposes FE-local, user-tunable settings. Currently the + * chat limit (max loaded chunks per conversation; the policy lives in + * `core/chunks/trim.ts`): this module is the view-model seam that parses + + * validates a typed value into a normalized limit, so the UI can disable submit + * and show an error on garbage input. The bound constants + normalization are + * OWNED by `core/chunks` (the single source of truth); this module never + * redefines them. + */ + +// ── Injected port (consumer-defines-port; the composition root adapts the +// store's localStorage persistence to this shape). ────────────────────────── + +/** Outcome of persisting a chat-limit setting. */ +export type ChatLimitSaveResult = + | { readonly ok: true; readonly chatLimit: number } + | { readonly ok: false; readonly error: string }; + +export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>; + +// ── chat-limit parse / validate ─────────────────────────────────────────────── + +/** Result of parsing a typed chat-limit string. */ +export type ChatLimitParse = + | { readonly ok: true; readonly value: number } + | { readonly ok: false; readonly error: string }; + +/** + * Parse a typed chat-limit string into a normalized limit (floored + clamped to + * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT] via `normalizeChatLimit`). Empty or + * non-numeric input is an ERROR (so the UI can disable submit + message), NOT a + * silent default — the default only applies at boot for an unset key, never + * from user typing. + */ +export function parseChatLimit(raw: string): ChatLimitParse { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Enter a number." }; + } + const n = Number(trimmed); + if (!Number.isFinite(n)) { + return { ok: false, error: "Must be a number." }; + } + return { ok: true, value: normalizeChatLimit(n) }; +} + +/** + * Whether saving `typed` would change the `current` chat limit. A no-op save + * (empty/invalid, or equal after normalization) should be disabled. This is the + * dirty-check for the input — it must NOT mutate or clamp, only compare. + */ +export function chatLimitChanged(typed: string, current: number): boolean { + const parsed = parseChatLimit(typed); + if (!parsed.ok) return false; + return parsed.value !== current; +} diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte new file mode 100644 index 0000000..9b9911a --- /dev/null +++ b/src/features/settings/ui/ChatLimitField.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks"; + import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model"; + + let { + chatLimit, + save, + }: { + /** The persisted chat limit (max loaded chunks per conversation). */ + chatLimit: number; + save: SaveChatLimit; + } = $props(); + + // Seed from the prop; the $effect below re-seeds on external changes (a live + // apply from elsewhere) but only while the field is untouched, so an in-flight + // change can't clobber what the user typed. + let value = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + let savedValue = $state<number | null>(null); + + $effect(() => { + const incoming = String(chatLimit); + untrack(() => { + if (value === lastSeed) value = incoming; + lastSeed = incoming; + }); + }); + + const dirty = $derived(chatLimitChanged(value, chatLimit)); + + async function handleSave() { + if (saving || !dirty) return; + const parsed = parseChatLimit(value); + if (!parsed.ok) { + error = parsed.error; + return; + } + saving = true; + error = null; + justSaved = false; + const result = await save(parsed.value); + saving = false; + if (result.ok) { + justSaved = true; + savedValue = result.chatLimit; + // Reflect the clamped / persisted value back into the input immediately + // (the prop will also re-assert it via the effect above). + value = String(result.chatLimit); + lastSeed = value; + } else { + error = result.error; + } + } + + function onInput() { + justSaved = false; + error = null; + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span> + <div class="flex items-center gap-2"> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-sm w-full font-mono text-xs" + placeholder={String(chatLimit)} + bind:value + disabled={saving} + oninput={onInput} + onkeydown={(e) => { + if (e.key === "Enter") handleSave(); + }} + aria-label="Chat limit" + /> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={saving || !dirty} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved && !dirty} + <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p> + {:else} + <p class="text-xs opacity-50"> + Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads + older history; raise it and use "Show earlier" to page back in. + </p> + {/if} +</div> diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts new file mode 100644 index 0000000..73bc449 --- /dev/null +++ b/src/features/settings/ui/ChatLimitField.test.ts @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { ChatLimitSaveResult } from "../logic/view-model"; +import ChatLimitField from "./ChatLimitField.svelte"; + +// A fake save that resolves ok with the value it was given. +function fakeSave(): { + saves: number[]; + last: number | null; + impl: (value: number) => Promise<ChatLimitSaveResult>; +} { + const saves: number[] = []; + return { + saves, + last: null, + impl: async (value: number) => { + saves.push(value); + return { ok: true, chatLimit: value }; + }, + }; +} + +describe("ChatLimitField", () => { + it("seeds the input from the persisted limit and disables Set while unchanged", () => { + render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } }); + expect(screen.getByLabelText("Chat limit")).toHaveValue("256"); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); + + it("enables Set when the typed value differs (regression: number-binding coercion)", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "10"); + + expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); + }); + + it("keeps Set disabled for non-numeric input", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "abc"); + + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); + + it("clicking Set calls save with the parsed value and shows the confirmation", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + const { rerender } = render(ChatLimitField, { + props: { chatLimit: 256, save: save.impl }, + }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "50"); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(save.saves).toEqual([50]); + // In the real app the reactive `chatLimit` prop updates to the saved value + // (the store sets it synchronously), which clears `dirty` and reveals the + // "Saved" badge. Rerender simulates that propagation. + rerender({ chatLimit: 50, save: save.impl }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("clamps a below-floor value when saving", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "5"); // clamps to MIN (10) + await user.click(screen.getByRole("button", { name: "Set" })); + + // The save port receives the clamped value (the view-model clamps before save). + expect(save.saves).toEqual([10]); + expect(input).toHaveValue("10"); + }); +}); |
