home / client / test / profile.test.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
// The ardegazu-id:v1:pts key. Replays the recorded op script step for step,
// including the `||`-over-numbers sites where CLJS `or` would be wrong.
//
// Then the two writers that own it: the identity card's own profile write, and
// the profile half of the multi-device self-sync. The invariant both share is
// that the stamp and the record move TOGETHER β€” a stamp set beside a write that
// did not happen claims the remote edit was adopted while the fields were not
// written, and the next fold reads local.ts == folded.ts as "already adopted"
// and drops that edit for good. It is never re-fetched.
import test from "node:test";
import assert from "node:assert/strict";
import { boot, vectors } from "./harness.mjs";
import { BRIDGE_STORE_KEY, readBridgeRecord, writeBridgeRecord } from "ardegazu-id-kit";

const { env, lib } = await boot();
const v = vectors("profile");

const SEED = "a".repeat(43);
const OTHER_SEED = "b".repeat(43);
const entry = (state, ts) => ({ state, ts });

function seed(rec) {
  env.localStorage.clear();
  writeBridgeRecord(rec);
}
const rec = () => readBridgeRecord().rec;

test("the pts op script replays exactly", () => {
  const realNow = Date.now;
  let clock = 1_000_000;
  Date.now = () => clock;
  env.localStorage.clear();
  try {
    v.script.forEach(([op, arg], i) => {
      let got = null;
      switch (op) {
        case "tick": clock = arg; break;
        case "storedProfileTs": got = lib.storedProfileTs(); break;
        case "profileTs": got = lib.profileTs(arg); break;
        case "setProfileTs": lib.setProfileTs(arg); break;
        case "bumpProfileTs": lib.bumpProfileTs(); break;
        case "clearProfileTs": lib.clearProfileTs(); break;
        case "raw": got = env.localStorage.getItem(v.key); break;
        default: throw new Error("unknown op " + op);
      }
      assert.deepEqual(got, v.out[i], `step ${i}: ${op} ${JSON.stringify(arg)}`);
    });
  } finally {
    Date.now = realNow;
  }
});

// ---- the identity card's own write ------------------------------------------
//
// The card's handlers live exactly as long as the card's DOM, and the render
// key hashes only what the card PAINTS β€” ephemeral, the four profile fields,
// the pending seed, the sync line, whether the details panel is open. An `apps`
// section write from another app's /id/ iframe moves none of it, and neither
// does a `soc` mirror write, so the render returns early and the record the
// handlers captured goes stale and STAYS stale. Minutes, not microseconds.

test("a profile edit cannot overwrite what landed since the card rendered", () => {
  seed(lib.newRecord(SEED));
  lib.curRecord(); // the card renders and closes over the record it read

  // chat is opened in another tab: its /id/ iframe writes chat's section, and
  // the hub's social module mirrors the friends list beside it
  const mid = rec();
  mid.apps = { chat: entry({ rooms: 4 }, 5000) };
  mid.soc = { f: ["ana"] };
  writeBridgeRecord(mid);

  // …and only now does the user drag the hue slider
  lib.writeProfile({ hue: 200 });

  assert.equal(rec().hue, 200, "the edit landed");
  assert.deepEqual(rec().apps, { chat: { state: { rooms: 4 }, ts: 5000 } }, "chat's section survived");
  assert.deepEqual(rec().soc, { f: ["ana"] }, "and so did the friends mirror");
});

test("the edit stamp moves only behind a write that happened", () => {
  seed(lib.newRecord(SEED));
  lib.clearProfileTs();

  // an edit that changes nothing is an echo: no write, no stamp, no sync wake
  assert.equal(lib.writeProfile({ name: "" }), false);
  assert.equal(lib.storedProfileTs(), 0);

  // a real edit stamps
  assert.ok(lib.wroteRecord(lib.writeProfile({ name: "ana" })));
  const stamped = lib.storedProfileTs();
  assert.ok(stamped > 0);

  // and an edit onto a record that was forgotten under it does neither
  env.localStorage.removeItem(BRIDGE_STORE_KEY);
  assert.equal(lib.writeProfile({ name: "maria" }), null, "no record at all");
  assert.equal(lib.storedProfileTs(), stamped, "the stamp did not move");
});

test("a profile edit never touches the sections or the friends mirror", () => {
  const r = lib.newRecord(SEED);
  r.apps = { chat: entry({ v: 1 }, 1000) };
  r.soc = { f: ["ana"] };
  seed(r);

  lib.writeProfile({ glyph: "🐒" });

  assert.equal(rec().glyph, "🐒");
  assert.deepEqual(rec().apps, { chat: { state: { v: 1 }, ts: 1000 } });
  assert.deepEqual(rec().soc, { f: ["ana"] });
  assert.deepEqual(Object.keys(rec()), [...lib.recordFields()]);
});

// ---- the name field's commit seam --------------------------------------------
//
// The card once committed the name on blur ALONE, so a user who typed their
// name and tapped straight to an app (the phone-keyboard path β€” likely a real
// user report) left with `name: ""`. The card now commits on Enter, on a pause
// in typing (~800ms), and still on blur β€” and all three drive this ONE seam.
// Three triggers only work as one seam because they re-fire each other's value:
// Enter blurs to dismiss the keyboard (which re-fires change), and a queued
// debounce can land after either. The echo answer is the contract under test.

test("writeName commits the clamped name and moves the stamp β€” Enter's path, driven directly", () => {
  seed(lib.newRecord(SEED));
  lib.clearProfileTs();

  // the commit: clamped like every profile write (trimmed), stamp moved
  assert.ok(lib.wroteRecord(lib.writeName("  Ana  ")));
  assert.equal(rec().name, "Ana");
  const stamped = lib.storedProfileTs();
  assert.ok(stamped > 0);

  // the blur that follows Enter re-commits the same keystrokes, and the
  // debounce that queued behind them lands too: echoes β€” no write, no stamp
  // move, no sync wake
  assert.equal(lib.writeName("  Ana  "), false);
  assert.equal(lib.writeName("Ana"), false);
  assert.equal(lib.storedProfileTs(), stamped);

  // clearing the field is a real edit (an unnamed identity is a choice)…
  assert.ok(lib.wroteRecord(lib.writeName("")));
  assert.equal(rec().name, "");
  // …and whitespace clamps to that same choice, as an echo of it
  assert.equal(lib.writeName("   "), false);
});

test("writeName clamps against the record stored NOW and touches nothing else", () => {
  const r = lib.newRecord(SEED);
  r.hue = 200;
  r.glyph = "🐒";
  r.apps = { chat: entry({ v: 1 }, 1000) };
  r.soc = { f: ["ana"] };
  seed(r);

  assert.ok(lib.wroteRecord(lib.writeName("Maria")));
  assert.equal(rec().name, "Maria");
  assert.equal(rec().hue, 200, "clampProfile saw the record's own hue, not a default");
  assert.equal(rec().glyph, "🐒");
  assert.deepEqual(rec().apps, { chat: { state: { v: 1 }, ts: 1000 } });
  assert.deepEqual(rec().soc, { f: ["ana"] });
  assert.deepEqual(Object.keys(rec()), [...lib.recordFields()]);

  // no record at all: nothing to name, nothing written
  env.localStorage.removeItem(BRIDGE_STORE_KEY);
  assert.equal(lib.writeName("Ana"), null);
});

// ---- the profile half of the self-sync --------------------------------------

test("profileSync.get refuses once the identity has swapped under it", () => {
  const hooks = lib.profileSyncHooks(SEED, () => {}); // the agent booted on SEED

  // another tab imports a different identity. That tab reloads; THIS one does
  // not β€” its agent's channel, sealing key and mailbox room are all still
  // SEED's, so answering here deposits the NEW identity's name, hue, glyph and
  // language into the OLD identity's self-inbox, for its other devices to adopt.
  const other = lib.newRecord(OTHER_SEED);
  other.name = "the other identity";
  other.hue = 42;
  seed(other);

  assert.equal(hooks.get(), null, "not this agent's profile to read");
  assert.equal(rec().name, "the other identity", "and get must not write, either");

  // the guard is the seed, not "any record": the right one still reads
  const ours = lib.newRecord(SEED);
  ours.name = "ana";
  seed(ours);
  assert.equal(hooks.get().name, "ana");
});

test("profileSync.apply refuses once the identity has swapped, stamp included", () => {
  const hooks = lib.profileSyncHooks(SEED, () => {});
  const other = lib.newRecord(OTHER_SEED);
  other.name = "the other identity";
  seed(other);
  lib.setProfileTs(9999); // the NEW identity's edit time

  hooks.apply({ name: "a peer of the old identity", hue: 1, glyph: null, lang: null, ts: Date.now() });

  assert.equal(rec().name, "the other identity", "the fold belongs to the record that asked for it");
  assert.equal(lib.storedProfileTs(), 9999, "and it must not corrupt the new identity's edit time");
});

test("profileSync.apply folds a newer profile in and stamps it", () => {
  const r = lib.newRecord(SEED);
  r.name = "ana";
  r.apps = { chat: entry({ v: 1 }, 1000) };
  seed(r);
  lib.setProfileTs(1000);

  let woke = 0;
  const hooks = lib.profileSyncHooks(SEED, () => woke++);
  hooks.apply({ name: "maria", hue: 210, glyph: null, lang: null, ts: 5000 });

  assert.equal(rec().name, "maria");
  assert.equal(rec().hue, 210);
  assert.equal(lib.storedProfileTs(), 5000, "the folded edit time, not now()");
  assert.equal(woke, 1);
  assert.deepEqual(rec().apps, { chat: { state: { v: 1 }, ts: 1000 } }, "sections untouched");
  assert.deepEqual(Object.keys(rec()), [...lib.recordFields()]);
});

test("an older remote profile is an echo: no write, no stamp, no wake", () => {
  const r = lib.newRecord(SEED);
  r.name = "ana";
  seed(r);
  lib.setProfileTs(9000);
  const before = env.localStorage.getItem(BRIDGE_STORE_KEY);

  let woke = 0;
  lib.profileSyncHooks(SEED, () => woke++)
    .apply({ name: "ana", hue: null, glyph: null, lang: null, ts: 1000 });

  assert.equal(env.localStorage.getItem(BRIDGE_STORE_KEY), before);
  assert.equal(lib.storedProfileTs(), 9000);
  assert.equal(woke, 0);
});

test("a fold whose write is refused must not stamp the edit as adopted", () => {
  // the stamp used to be set BEFORE the guarded write. If the write then did
  // nothing, the next fold saw local.ts == the remote's ts, called it unchanged,
  // and the rename was gone for good β€” never re-fetched, because the sender has
  // no reason to send it again.
  const r = lib.newRecord(SEED);
  r.name = "ana";
  seed(r);
  lib.setProfileTs(1000);

  let woke = 0;
  const hooks = lib.profileSyncHooks(SEED, () => woke++);

  // the identity swaps in the window between the fold's read and its store
  const realGet = env.localStorage.getItem;
  let reads = 0;
  env.localStorage.getItem = (k) => {
    if (k === BRIDGE_STORE_KEY && ++reads === 2) {
      writeBridgeRecord(lib.newRecord(OTHER_SEED));
    }
    return realGet(k);
  };
  try {
    hooks.apply({ name: "maria", hue: null, glyph: null, lang: null, ts: 5000 });
  } finally {
    env.localStorage.getItem = realGet;
  }

  assert.equal(rec().seed, OTHER_SEED, "the swap stands");
  assert.equal(rec().name, "", "the fold did not land");
  assert.equal(lib.storedProfileTs(), 1000, "so the edit time must not claim it did");
  assert.equal(woke, 0);
});

static mirror of HEAD Β· about Β· clone: git clone https://git.ardegazu.ro/home.git