1import { UiSettingsProfile } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as ServerConfig from "./config.ts";
10import * as UiSettings from "./uiSettings.ts";
11
12const encodeProfile = Schema.encodeEffect(Schema.fromJsonString(UiSettingsProfile));
13const decodeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(UiSettingsProfile));
14
15it.layer(NodeServices.layer)("personal UI settings", (it) => {
16 const service = UiSettings.layer.pipe(
17 Layer.provideMerge(
18 ServerConfig.layerTest(process.cwd(), {
19 prefix: "knife-ui-settings-",
20 }),
21 ),
22 );
23
24 it.effect("restores a saved profile and refuses to replace unreadable saved preferences", () =>
25 Effect.gen(function* () {
26 const fs = yield* FileSystem.FileSystem;
27 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "knife-ui-settings-restart-" });
28 const config = ServerConfig.layerTest(process.cwd(), directory);
29 yield* Effect.gen(function* () {
30 const { stateDir } = yield* ServerConfig.ServerConfig;
31 yield* fs.makeDirectory(stateDir, { recursive: true });
32 const saved: UiSettingsProfile = {
33 revision: 8,
34 values: { fontSizeCode: 18 },
35 fieldRevisions: { fontSizeCode: 8 },
36 };
37 const encoded = yield* encodeProfile(saved);
38 yield* fs.writeFileString(`${stateDir}/ui-settings.json`, encoded);
39 const read = UiSettings.UiSettings.pipe(Effect.flatMap((service) => service.current));
40 assert.deepEqual(yield* read.pipe(Effect.provide(Layer.fresh(UiSettings.layer))), saved);
41 yield* fs.writeFileString(`${stateDir}/ui-settings.json`, "broken");
42 const failed = yield* UiSettings.UiSettings.pipe(
43 Effect.flatMap((service) =>
44 service.patch({ patch: { chatWidth: "full" }, baseRevisions: { chatWidth: 0 } }),
45 ),
46 Effect.flip,
47 Effect.provide(Layer.fresh(UiSettings.layer)),
48 );
49 assert.strictEqual(failed.operation, "read");
50 assert.strictEqual(yield* fs.readFileString(`${stateDir}/ui-settings.json`), "broken");
51 }).pipe(Effect.provide(config));
52 }).pipe(Effect.scoped),
53 );
54
55 it.effect("merges independent client edits and rejects stale edits to the same field", () =>
56 Effect.gen(function* () {
57 const settings = yield* UiSettings.UiSettings;
58 const first = yield* settings.current;
59 const second = yield* settings.current;
60 const font = yield* settings.patch({
61 patch: { fontSizeCode: 15 },
62 baseRevisions: { fontSizeCode: first.fieldRevisions.fontSizeCode ?? 0 },
63 });
64 const chat = yield* settings.patch({
65 patch: { chatWidth: "full" },
66 baseRevisions: { chatWidth: second.fieldRevisions.chatWidth ?? 0 },
67 });
68 assert.deepEqual(chat.profile.values, { fontSizeCode: 15, chatWidth: "full" });
69 const stale = yield* settings.patch({
70 patch: { fontSizeCode: 13, timestampFormat: "12-hour" },
71 baseRevisions: { fontSizeCode: 0, timestampFormat: 0 },
72 });
73 assert.deepEqual(stale.conflicts, ["fontSizeCode"]);
74 assert.strictEqual(stale.profile.values.fontSizeCode, 15);
75 assert.strictEqual(stale.profile.values.timestampFormat, "12-hour");
76 assert.strictEqual(stale.profile.fieldRevisions.fontSizeCode, font.profile.revision);
77 const { stateDir } = yield* ServerConfig.ServerConfig;
78 const fs = yield* FileSystem.FileSystem;
79 const persisted = yield* fs.readFileString(`${stateDir}/ui-settings.json`);
80 const decoded = yield* decodeProfile(persisted);
81 assert.deepEqual(decoded, stale.profile);
82 }).pipe(Effect.provide(service)),
83 );
84
85 it.effect("streams the initial profile and delivers an update to a second client", () =>
86 Effect.gen(function* () {
87 const settings = yield* UiSettings.UiSettings;
88 const received = yield* settings.changes.pipe(
89 Stream.take(2),
90 Stream.tap((profile) =>
91 profile.revision === 0
92 ? settings.patch({ patch: { chatWidth: "wide" }, baseRevisions: { chatWidth: 0 } })
93 : Effect.void,
94 ),
95 Stream.runCollect,
96 );
97 assert.strictEqual(received.length, 2);
98 assert.strictEqual(received[1]?.values.chatWidth, "wide");
99 }).pipe(Effect.provide(service)),
100 );
101
102 it.effect("treats a retried committed patch as a no-op and requires a read revision", () =>
103 Effect.gen(function* () {
104 const settings = yield* UiSettings.UiSettings;
105 const input = { patch: { fontSizeCode: 16 }, baseRevisions: { fontSizeCode: 0 } };
106 const committed = yield* settings.patch(input);
107 assert.deepEqual(yield* settings.patch(input), committed);
108 const missingBase = yield* settings.patch({ patch: { fontSizeCode: 18 }, baseRevisions: {} });
109 assert.deepEqual(missingBase.conflicts, ["fontSizeCode"]);
110 assert.deepEqual(missingBase.profile, committed.profile);
111 }).pipe(Effect.provide(service)),
112 );
113});