@hoox-sh/hoox-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tests for file-backed TUI state storage (Bun has no localStorage).
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
10
+ import { unlink } from "fs/promises";
11
+ import {
12
+ readJsonState,
13
+ writeJsonState,
14
+ removeJsonState,
15
+ TuiStateFiles,
16
+ } from "./tui-storage";
17
+ import { resolveTuiStatePath, ensureTuiStateDir } from "./hoox-path-service";
18
+
19
+ const TEST_FILE = "tui-storage-test.json";
20
+
21
+ describe("tui-storage", () => {
22
+ beforeEach(async () => {
23
+ await ensureTuiStateDir();
24
+ await removeJsonState(TEST_FILE);
25
+ });
26
+
27
+ afterEach(async () => {
28
+ await removeJsonState(TEST_FILE);
29
+ });
30
+
31
+ it("returns fallback when file does not exist", async () => {
32
+ const value = await readJsonState(TEST_FILE, { empty: true });
33
+ expect(value).toEqual({ empty: true });
34
+ });
35
+
36
+ it("round-trips JSON values", async () => {
37
+ const payload = { messages: [{ role: "user", content: "hi" }], n: 2 };
38
+ await writeJsonState(TEST_FILE, payload);
39
+ const loaded = await readJsonState<typeof payload | null>(TEST_FILE, null);
40
+ expect(loaded).toEqual(payload);
41
+ });
42
+
43
+ it("removeJsonState deletes the file", async () => {
44
+ await writeJsonState(TEST_FILE, { gone: false });
45
+ await removeJsonState(TEST_FILE);
46
+ const loaded = await readJsonState(TEST_FILE, { gone: true });
47
+ expect(loaded).toEqual({ gone: true });
48
+ });
49
+
50
+ it("exports well-known state file names", () => {
51
+ expect(TuiStateFiles.chatHistory).toBe("chat-history.json");
52
+ expect(TuiStateFiles.dbQueryHistory).toBe("db-query-history.json");
53
+ });
54
+
55
+ it("resolveTuiStatePath places files under .tui-state", () => {
56
+ const path = resolveTuiStatePath(TuiStateFiles.chatHistory);
57
+ expect(path.includes(".tui-state")).toBe(true);
58
+ expect(path.endsWith("chat-history.json")).toBe(true);
59
+ });
60
+
61
+ it("removeJsonState is idempotent for missing files", async () => {
62
+ await expect(
63
+ removeJsonState("definitely-missing-xyz.json")
64
+ ).resolves.toBeUndefined();
65
+ // Extra safety: unlink of missing path must not throw either
66
+ await unlink(resolveTuiStatePath("definitely-missing-xyz.json")).catch(
67
+ () => undefined
68
+ );
69
+ });
70
+
71
+ it("writeJsonState refuses path traversal silently", async () => {
72
+ // Should not throw to callers; best-effort persistence
73
+ await expect(
74
+ writeJsonState("../escape-tui-storage.json", { bad: true })
75
+ ).resolves.toBeUndefined();
76
+ });
77
+
78
+ it("atomic write leaves no .tmp sibling on success", async () => {
79
+ await writeJsonState(TEST_FILE, { atomic: true });
80
+ const path = resolveTuiStatePath(TEST_FILE);
81
+ const dir = path.slice(0, path.lastIndexOf("/"));
82
+ const entries = await Array.fromAsync(
83
+ new Bun.Glob(`${TEST_FILE}*.tmp`).scan({ cwd: dir })
84
+ );
85
+ expect(entries.length).toBe(0);
86
+ const loaded = await readJsonState<{ atomic: boolean } | null>(
87
+ TEST_FILE,
88
+ null
89
+ );
90
+ expect(loaded).toEqual({ atomic: true });
91
+ });
92
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * TUI file-backed JSON storage.
8
+ *
9
+ * Bun has no `localStorage` global. Persist small TUI state under
10
+ * `$HOME/.hoox/.tui-state/` (via hoox-path-service) so chat history,
11
+ * query history, and similar UI state survive restarts.
12
+ *
13
+ * Writes are atomic (temp file + rename) to avoid truncated JSON on crash.
14
+ * Paths are constrained under the TUI state directory (no secret material
15
+ * is written here — only UI state).
16
+ */
17
+ import { rename, unlink } from "fs/promises";
18
+ import { ensureTuiStateDir, resolveTuiStatePath } from "./hoox-path-service";
19
+
20
+ /** Well-known state file names under `.tui-state/`. */
21
+ export const TuiStateFiles = {
22
+ chatHistory: "chat-history.json",
23
+ dbQueryHistory: "db-query-history.json",
24
+ } as const;
25
+
26
+ /**
27
+ * Read a JSON document from the TUI state directory.
28
+ * Returns `fallback` when the file is missing or unreadable.
29
+ */
30
+ export async function readJsonState<T>(
31
+ filename: string,
32
+ fallback: T
33
+ ): Promise<T> {
34
+ try {
35
+ await ensureTuiStateDir();
36
+ const path = resolveTuiStatePath(filename);
37
+ const file = Bun.file(path);
38
+ if (!(await file.exists())) return fallback;
39
+ const parsed = (await file.json()) as T;
40
+ return parsed ?? fallback;
41
+ } catch {
42
+ return fallback;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Write a JSON document to the TUI state directory (atomic rename).
48
+ * Failures are silent — persistence is best-effort.
49
+ */
50
+ export async function writeJsonState(
51
+ filename: string,
52
+ value: unknown
53
+ ): Promise<void> {
54
+ let tmpPath: string | null = null;
55
+ try {
56
+ await ensureTuiStateDir();
57
+ const path = resolveTuiStatePath(filename);
58
+ // Unique temp sibling so concurrent writers don't clobber each other.
59
+ tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
60
+ await Bun.write(tmpPath, JSON.stringify(value, null, 0));
61
+ await rename(tmpPath, path);
62
+ tmpPath = null;
63
+ } catch {
64
+ // Non-fatal: disk full, permissions, path traversal, etc.
65
+ if (tmpPath) {
66
+ try {
67
+ await unlink(tmpPath);
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Remove a JSON document from the TUI state directory.
77
+ * Missing files are ignored.
78
+ */
79
+ export async function removeJsonState(filename: string): Promise<void> {
80
+ try {
81
+ const path = resolveTuiStatePath(filename);
82
+ await unlink(path);
83
+ } catch {
84
+ // Missing or unreadable — ignore
85
+ }
86
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Config Store Tests — Persistence, update, reset, shortcuts, notifications.
8
+ *
9
+ * Tests Zustand store with persist + immer middleware.
10
+ * Uses Bun test runner.
11
+ */
12
+ import { describe, it, expect, beforeEach } from "bun:test";
13
+ import { useConfigStore } from "@hoox-sh/hoox-shared/stores/config-store";
14
+ import type {
15
+ ViewId,
16
+ NotificationPreferences,
17
+ LogLevel,
18
+ } from "@hoox-sh/hoox-shared";
19
+
20
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ const DEFAULT_STATE = {
23
+ theme: "dark" as const,
24
+ refreshIntervalMs: 500,
25
+ defaultView: "dashboard" as ViewId,
26
+ activeExchanges: [] as string[],
27
+ keyboardShortcuts: {
28
+ "command-palette": "Ctrl+P",
29
+ "toggle-sidebar": "Ctrl+B",
30
+ "force-refresh": "Ctrl+R",
31
+ quit: "Ctrl+Q",
32
+ "view-1": "Ctrl+1",
33
+ "view-2": "Ctrl+2",
34
+ "view-3": "Ctrl+3",
35
+ "view-4": "Ctrl+4",
36
+ "view-5": "Ctrl+5",
37
+ "view-6": "Ctrl+6",
38
+ "view-7": "Ctrl+7",
39
+ "view-8": "Ctrl+8",
40
+ "view-9": "Ctrl+9",
41
+ "view-10": "Ctrl+0",
42
+ },
43
+ logFilters: {
44
+ levels: ["info", "warn", "error"] as LogLevel[],
45
+ workers: [] as string[],
46
+ searchText: "",
47
+ },
48
+ notifications: {
49
+ alerts: true,
50
+ trades: false,
51
+ debug: false,
52
+ system: true,
53
+ },
54
+ soundEnabled: false,
55
+ };
56
+
57
+ function resetStore() {
58
+ useConfigStore.setState(DEFAULT_STATE);
59
+ }
60
+
61
+ // ─── Test Suite ───────────────────────────────────────────────────────────────
62
+
63
+ describe("useConfigStore", () => {
64
+ beforeEach(() => {
65
+ resetStore();
66
+ });
67
+
68
+ // ── Initial State ─────────────────────────────────────────────────────────
69
+
70
+ describe("initial state", () => {
71
+ it("starts with dark theme", () => {
72
+ expect(useConfigStore.getState().theme).toBe("dark");
73
+ });
74
+
75
+ it("starts with 500ms refresh interval", () => {
76
+ expect(useConfigStore.getState().refreshIntervalMs).toBe(500);
77
+ });
78
+
79
+ it("starts with dashboard as default view", () => {
80
+ expect(useConfigStore.getState().defaultView).toBe("dashboard");
81
+ });
82
+
83
+ it("starts with empty active exchanges", () => {
84
+ expect(useConfigStore.getState().activeExchanges).toEqual([]);
85
+ });
86
+
87
+ it("starts with default keyboard shortcuts (14 entries)", () => {
88
+ const shortcuts = Object.keys(
89
+ useConfigStore.getState().keyboardShortcuts
90
+ );
91
+ expect(shortcuts.length).toBe(14);
92
+ });
93
+
94
+ it("starts with sound disabled", () => {
95
+ expect(useConfigStore.getState().soundEnabled).toBe(false);
96
+ });
97
+
98
+ it("starts with default log filters (info, warn, error)", () => {
99
+ expect(useConfigStore.getState().logFilters.levels).toEqual([
100
+ "info",
101
+ "warn",
102
+ "error",
103
+ ]);
104
+ });
105
+ });
106
+
107
+ // ── updateConfig ──────────────────────────────────────────────────────────
108
+
109
+ describe("updateConfig", () => {
110
+ it("updates a single config field", () => {
111
+ useConfigStore.getState().updateConfig({ theme: "light" });
112
+ expect(useConfigStore.getState().theme).toBe("light");
113
+ });
114
+
115
+ it("updates multiple config fields at once", () => {
116
+ useConfigStore.getState().updateConfig({
117
+ theme: "light",
118
+ refreshIntervalMs: 1000,
119
+ soundEnabled: true,
120
+ });
121
+ const state = useConfigStore.getState();
122
+ expect(state.theme).toBe("light");
123
+ expect(state.refreshIntervalMs).toBe(1000);
124
+ expect(state.soundEnabled).toBe(true);
125
+ });
126
+
127
+ it("does not change unspecified fields", () => {
128
+ useConfigStore.getState().updateConfig({ theme: "light" });
129
+ const state = useConfigStore.getState();
130
+ expect(state.refreshIntervalMs).toBe(500); // unchanged
131
+ expect(state.defaultView).toBe("dashboard"); // unchanged
132
+ });
133
+
134
+ it("updates defaultView to a valid view", () => {
135
+ useConfigStore.getState().updateConfig({ defaultView: "trade-monitor" });
136
+ expect(useConfigStore.getState().defaultView).toBe("trade-monitor");
137
+ });
138
+
139
+ it("updates activeExchanges", () => {
140
+ useConfigStore
141
+ .getState()
142
+ .updateConfig({ activeExchanges: ["binance", "bybit"] });
143
+ expect(useConfigStore.getState().activeExchanges).toEqual([
144
+ "binance",
145
+ "bybit",
146
+ ]);
147
+ });
148
+
149
+ it("updates soundEnabled", () => {
150
+ useConfigStore.getState().updateConfig({ soundEnabled: true });
151
+ expect(useConfigStore.getState().soundEnabled).toBe(true);
152
+ });
153
+
154
+ it("updates keyboard shortcuts", () => {
155
+ useConfigStore.getState().updateConfig({
156
+ keyboardShortcuts: {
157
+ ...DEFAULT_STATE.keyboardShortcuts,
158
+ quit: "Ctrl+X",
159
+ },
160
+ });
161
+ expect(useConfigStore.getState().keyboardShortcuts.quit).toBe("Ctrl+X");
162
+ });
163
+
164
+ it("updates log filters", () => {
165
+ useConfigStore.getState().updateConfig({
166
+ logFilters: {
167
+ levels: ["error"],
168
+ workers: ["worker1"],
169
+ searchText: "test",
170
+ },
171
+ });
172
+ expect(useConfigStore.getState().logFilters.levels).toEqual(["error"]);
173
+ expect(useConfigStore.getState().logFilters.searchText).toBe("test");
174
+ });
175
+
176
+ it("updates notification preferences", () => {
177
+ useConfigStore.getState().updateConfig({
178
+ notifications: {
179
+ alerts: false,
180
+ trades: true,
181
+ debug: true,
182
+ system: false,
183
+ },
184
+ });
185
+ const n = useConfigStore.getState().notifications;
186
+ expect(n.alerts).toBe(false);
187
+ expect(n.trades).toBe(true);
188
+ expect(n.debug).toBe(true);
189
+ expect(n.system).toBe(false);
190
+ });
191
+
192
+ it("handles empty partial (no-ops gracefully)", () => {
193
+ const before = { ...useConfigStore.getState() };
194
+ useConfigStore.getState().updateConfig({});
195
+ const after = useConfigStore.getState();
196
+ // Key fields should be unchanged
197
+ expect(after.theme).toBe(before.theme);
198
+ expect(after.refreshIntervalMs).toBe(before.refreshIntervalMs);
199
+ });
200
+ });
201
+
202
+ // ── resetDefaults ─────────────────────────────────────────────────────────
203
+
204
+ describe("resetDefaults", () => {
205
+ it("resets all fields to factory defaults", () => {
206
+ // First, mutate everything
207
+ useConfigStore.getState().updateConfig({
208
+ theme: "light",
209
+ refreshIntervalMs: 2000,
210
+ defaultView: "settings",
211
+ activeExchanges: ["binance"],
212
+ soundEnabled: true,
213
+ });
214
+ useConfigStore.getState().setShortcut("quit", "Ctrl+X");
215
+ useConfigStore.getState().toggleNotification("alerts");
216
+
217
+ // Then reset
218
+ useConfigStore.getState().resetDefaults();
219
+
220
+ const state = useConfigStore.getState();
221
+ expect(state.theme).toBe("dark");
222
+ expect(state.refreshIntervalMs).toBe(500);
223
+ expect(state.defaultView).toBe("dashboard");
224
+ expect(state.soundEnabled).toBe(false);
225
+ expect(state.activeExchanges).toEqual([]);
226
+ expect(state.keyboardShortcuts.quit).toBe("Ctrl+Q");
227
+ expect(state.notifications.alerts).toBe(true);
228
+ });
229
+
230
+ it("resetDefaults restores default notifications", () => {
231
+ useConfigStore.getState().updateConfig({
232
+ notifications: {
233
+ alerts: false,
234
+ trades: true,
235
+ debug: true,
236
+ system: false,
237
+ },
238
+ });
239
+ useConfigStore.getState().resetDefaults();
240
+
241
+ const n = useConfigStore.getState().notifications;
242
+ expect(n.alerts).toBe(true);
243
+ expect(n.trades).toBe(false);
244
+ expect(n.debug).toBe(false);
245
+ expect(n.system).toBe(true);
246
+ });
247
+
248
+ it("resetDefaults clears active exchanges", () => {
249
+ useConfigStore
250
+ .getState()
251
+ .updateConfig({ activeExchanges: ["binance", "bybit", "mexc"] });
252
+ useConfigStore.getState().resetDefaults();
253
+ expect(useConfigStore.getState().activeExchanges).toEqual([]);
254
+ });
255
+ });
256
+
257
+ // ── setShortcut ───────────────────────────────────────────────────────────
258
+
259
+ describe("setShortcut", () => {
260
+ it("updates an existing shortcut", () => {
261
+ useConfigStore.getState().setShortcut("toggle-sidebar", "Ctrl+K");
262
+ expect(
263
+ useConfigStore.getState().keyboardShortcuts["toggle-sidebar"]
264
+ ).toBe("Ctrl+K");
265
+ });
266
+
267
+ it("adds a new shortcut key", () => {
268
+ useConfigStore.getState().setShortcut("custom-action", "Ctrl+Y");
269
+ expect(useConfigStore.getState().keyboardShortcuts["custom-action"]).toBe(
270
+ "Ctrl+Y"
271
+ );
272
+ });
273
+
274
+ it("does not affect other shortcuts", () => {
275
+ const before = useConfigStore.getState().keyboardShortcuts["quit"];
276
+ useConfigStore.getState().setShortcut("toggle-sidebar", "Ctrl+K");
277
+ expect(useConfigStore.getState().keyboardShortcuts["quit"]).toBe(before);
278
+ });
279
+ });
280
+
281
+ // ── toggleNotification ────────────────────────────────────────────────────
282
+
283
+ describe("toggleNotification", () => {
284
+ it("toggles alerts from true to false", () => {
285
+ expect(useConfigStore.getState().notifications.alerts).toBe(true);
286
+ useConfigStore.getState().toggleNotification("alerts");
287
+ expect(useConfigStore.getState().notifications.alerts).toBe(false);
288
+ });
289
+
290
+ it("toggles trades from false to true", () => {
291
+ expect(useConfigStore.getState().notifications.trades).toBe(false);
292
+ useConfigStore.getState().toggleNotification("trades");
293
+ expect(useConfigStore.getState().notifications.trades).toBe(true);
294
+ });
295
+
296
+ it("toggles debug from false to true", () => {
297
+ useConfigStore.getState().toggleNotification("debug");
298
+ expect(useConfigStore.getState().notifications.debug).toBe(true);
299
+ });
300
+
301
+ it("toggles system from true to false", () => {
302
+ useConfigStore.getState().toggleNotification("system");
303
+ expect(useConfigStore.getState().notifications.system).toBe(false);
304
+ });
305
+
306
+ it("toggle is idempotent after two calls", () => {
307
+ const before = useConfigStore.getState().notifications.alerts;
308
+ useConfigStore.getState().toggleNotification("alerts");
309
+ useConfigStore.getState().toggleNotification("alerts");
310
+ expect(useConfigStore.getState().notifications.alerts).toBe(before);
311
+ });
312
+ });
313
+
314
+ // ── Persistence (structural) ──────────────────────────────────────────────
315
+
316
+ describe("persist configuration", () => {
317
+ it("store has persist middleware configured", () => {
318
+ // Zustand persist adds .persist property to the store
319
+ expect(useConfigStore.persist).toBeDefined();
320
+ });
321
+
322
+ it("has configured storage name", () => {
323
+ // The store should be set up with 'hoox-config' name
324
+ // verify via the persist API
325
+ const persistOptions = (useConfigStore.persist as any)?.getOptions?.();
326
+ // Different zustand versions expose this differently;
327
+ // we at minimum verify the store was created with persist
328
+ expect(typeof useConfigStore.getState).toBe("function");
329
+ expect(typeof useConfigStore.setState).toBe("function");
330
+ });
331
+
332
+ it("only partializes known top-level keys", () => {
333
+ const state = useConfigStore.getState();
334
+ // The partialize function should flatten to known keys
335
+ const partialized = {
336
+ theme: state.theme,
337
+ refreshIntervalMs: state.refreshIntervalMs,
338
+ defaultView: state.defaultView,
339
+ activeExchanges: state.activeExchanges,
340
+ keyboardShortcuts: state.keyboardShortcuts,
341
+ logFilters: state.logFilters,
342
+ notifications: state.notifications,
343
+ soundEnabled: state.soundEnabled,
344
+ };
345
+ // All keys should be primitive/serializable (no functions)
346
+ expect(typeof partialized.theme).toBe("string");
347
+ expect(typeof partialized.refreshIntervalMs).toBe("number");
348
+ expect(typeof partialized.soundEnabled).toBe("boolean");
349
+ expect(Array.isArray(partialized.activeExchanges)).toBe(true);
350
+ });
351
+
352
+ it("can update and read back config through store", () => {
353
+ useConfigStore.getState().updateConfig({ refreshIntervalMs: 1500 });
354
+ expect(useConfigStore.getState().refreshIntervalMs).toBe(1500);
355
+
356
+ useConfigStore.getState().updateConfig({ refreshIntervalMs: 500 });
357
+ expect(useConfigStore.getState().refreshIntervalMs).toBe(500);
358
+ });
359
+ });
360
+ });