Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/modelswitch.test.mjs

9.1 KiB, 1 run

created by r2519314175:1516, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Test — switching a daimon's model is seamless (proposal #1).
3 ------------------------------------------------------------
4 The daimon settings dialog no longer switches the moment the
5 pulldown moves. A "Change" button beside it carries the switch,
6 and it shows ONLY while the picked model differs from the one the
7 daimon runs on now. Pressing it reuses the existing conversation:
8 it re-meters the context against the new model's window and lets
9 the engine fold on the next turn; a fresh daimon is never raised.
10
11 The decision this turns on lives in ONE shipped function,
12 `DaimondModels.planModelSwitch`, which daimond.js delegates to for
13 the button's label and for the fold-vs-fresh question. This drives
14 the real www/js/models.js in a minimal browser sandbox and asserts
15 the four things the design turns on:
16
17 (a) SAME → NO CHANGE. Picking the model already in force reports
18 `changed:false`, so the button stays hidden and no popup can
19 be raised — the whole complaint the proposal fixes.
20
21 (b) DIFFERENT → RELABEL. A genuinely different model (by model
22 name OR by provider) reports `changed:true`, so the button
23 reads "Change".
24
25 (c) RE-METER. The window returned is the NEW model's, never the
26 old one's, so the meter is recomputed against the window the
27 next turn will actually fold against.
28
29 (d) REUSE, NOT FRESH. A switch whose held context still fits the
30 new window reports `needsFresh:false` — the conversation is
31 carried across. `needsFresh` trips true only when the context
32 exceeds a PUBLISHED new window outright, and never for an
33 unknown window, so a fresh daimon is the rare exception the
34 proposal describes, not the default.
35
36 Each check is proven able to fail: the four --break modes damage
37 the shipped `planModelSwitch` the four ways it could plausibly be
38 got wrong, and the checks that guard each defect go red under it.
39
40 node www/js/modelswitch.test.mjs --break neverchange # the button never relabels
41 node www/js/modelswitch.test.mjs --break alwayschange # it relabels for the same model too
42 node www/js/modelswitch.test.mjs --break oldwindow # the meter keeps the old window
43 node www/js/modelswitch.test.mjs --break alwaysfresh # every switch demands a fresh daimon
44 node www/js/modelswitch.test.mjs # and then, clean
45 ============================================================ */
46import { readFileSync } from 'node:fs';
47import { fileURLToPath } from 'node:url';
48import { dirname, join } from 'node:path';
49
50const HERE = dirname(fileURLToPath(import.meta.url));
51
52let failures = 0, checks = 0;
53function check(name, cond, detail) {
54 checks++;
55 if (cond) console.log(' ok ' + name + (detail ? ' — ' + detail : ''));
56 else { console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); failures++; }
57}
58
59const BREAK = (() => {
60 const i = process.argv.indexOf('--break');
61 return i > 0 ? String(process.argv[i + 1] || '') : '';
62})();
63
64// ── The patches that prove the checks can fail ─────────────────
65// Each damages the ONE shipped function this feature lives in, the
66// four ways it could plausibly be got wrong.
67//
68// neverchange the pick is never seen as a change — the button
69// stays hidden and (b) goes red.
70// alwayschange every pick is a change, even the current model —
71// the button relabels when it must not and (a) goes red.
72// oldwindow the window is read for the OLD model, so the meter
73// measures against the window being left — (c) goes red.
74// alwaysfresh every switch demands a fresh daimon, throwing away
75// the conversation the proposal exists to keep — (d) red.
76function patchModels(src) {
77 if (BREAK === 'neverchange') {
78 return src.replace(
79 "var changed = String(after.model || '') !== String(before.model || '')\n\t\t\t|| String(after.provider || '') !== String(before.provider || '');",
80 'var changed = false;');
81 }
82 if (BREAK === 'alwayschange') {
83 return src.replace(
84 "var changed = String(after.model || '') !== String(before.model || '')\n\t\t\t|| String(after.provider || '') !== String(before.provider || '');",
85 'var changed = true;');
86 }
87 if (BREAK === 'oldwindow') {
88 return src.replace(
89 "(DaimondPricing.contextWindow(after.model || '', after.provider || '') || 0)",
90 "(DaimondPricing.contextWindow(before.model || '', before.provider || '') || 0)");
91 }
92 if (BREAK === 'alwaysfresh') {
93 return src.replace(
94 'needsFresh: changed && win > 0 && (used || 0) > win,',
95 'needsFresh: changed,');
96 }
97 return src;
98}
99
100// ── A minimal browser sandbox ──────────────────────────────────
101// models.js is a classic-script IIFE reading the bare globals
102// `window`, `document`, `localStorage` and (guarded) the timers. It
103// attaches `DaimondModels` onto window.
104const store = new Map();
105const localStorage = {
106 getItem: (k) => (store.has(k) ? store.get(k) : null),
107 setItem: (k, v) => store.set(k, String(v)),
108 removeItem: (k) => store.delete(k),
109};
110const win = globalThis;
111win.dispatchEvent = () => true;
112// `visibilityState: 'hidden'` keeps the credit heartbeat from starting — it beats
113// only while the tab is in front, and there is no tab here.
114const documentShim = {
115 addEventListener: () => {},
116 getElementById: () => null,
117 visibilityState: 'hidden',
118};
119function loadScript(rel, transform) {
120 let body = readFileSync(join(HERE, rel), 'utf8');
121 if (transform) body = transform(body);
122 const fn = new Function('window', 'document', 'localStorage', 'console', 'globalThis', body);
123 fn(win, documentShim, localStorage, console, globalThis);
124}
125
126loadScript('models.js', patchModels);
127
128const M = win.DaimondModels;
129if (!M || !M.planModelSwitch) { console.error('ABORT: DaimondModels.planModelSwitch missing'); process.exit(2); }
130
131// A pricing stub keyed by model, so a switch's window differs by which model it
132// lands on — the only way to show the meter followed the NEW model rather than
133// keeping the old one. `model-x` is deliberately absent: an unknown window is 0.
134const WINDOW = { 'model-a': 200000, 'model-b': 32000 };
135win.DaimondPricing = {
136 contextWindow: (model /*, provider */) => (WINDOW[model] != null ? WINDOW[model] : null),
137};
138
139const A = { provider: 'fw', model: 'model-a' }; // 200k window, the model in force
140const B = { provider: 'fw', model: 'model-b' }; // 32k window, a smaller one
141const A2 = { provider: 'other', model: 'model-a' }; // same name, a different provider
142const X = { provider: 'fw', model: 'model-x' }; // no published window
143
144function main() {
145 // ── (a) SAME → NO CHANGE ───────────────────────────────────
146 console.log('(a) picking the model already in force is not a change');
147 const same = M.planModelSwitch(A, A, 5000);
148 check('same model reports changed:false', same.changed === false, 'changed=' + same.changed);
149 check('same model needs no fresh daimon', same.needsFresh === false);
150 check('same model still reports its own window', same.window === WINDOW['model-a'],
151 'window=' + same.window);
152
153 // ── (b) DIFFERENT → RELABEL ────────────────────────────────
154 console.log('(b) a genuinely different model relabels the button to "Change"');
155 const diff = M.planModelSwitch(A, B, 5000);
156 check('a different model reports changed:true', diff.changed === true);
157 const prov = M.planModelSwitch(A, A2, 5000);
158 check('the same model name on a different provider is still a change',
159 prov.changed === true);
160
161 // ── (c) RE-METER ───────────────────────────────────────────
162 console.log('(c) the meter is recomputed against the NEW model’s window');
163 check('the window returned is the new model’s, not the old one’s',
164 diff.window === WINDOW['model-b'] && diff.window !== WINDOW['model-a'],
165 'window=' + diff.window);
166
167 // ── (d) REUSE, NOT FRESH ───────────────────────────────────
168 console.log('(d) the conversation is reused; a fresh daimon is the rare exception');
169 check('a switch whose context fits the new window reuses it (needsFresh:false)',
170 diff.needsFresh === false, 'used 5000 <= window ' + diff.window);
171 const tight = M.planModelSwitch(A, B, 50000);
172 check('a context that exceeds the new window is the one case fresh is needed',
173 tight.needsFresh === true, 'used 50000 > window ' + tight.window);
174 const unknown = M.planModelSwitch(A, X, 999999);
175 check('an unknown new window never forces a fresh daimon',
176 unknown.changed === true && unknown.window === 0 && unknown.needsFresh === false,
177 'window=' + unknown.window + ' needsFresh=' + unknown.needsFresh);
178
179 console.log('\n' + (failures ? 'FAIL' : 'PASS') + ' — ' + (checks - failures) + '/' + checks
180 + ' checks' + (BREAK ? ' (--break ' + BREAK + ')' : ''));
181 process.exit(failures ? 1 : 0);
182}
183
184main();