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 | ============================================================ */ |
| 46 | import { readFileSync } from 'node:fs'; |
| 47 | import { fileURLToPath } from 'node:url'; |
| 48 | import { dirname, join } from 'node:path'; |
| 49 | |
| 50 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 51 | |
| 52 | let failures = 0, checks = 0; |
| 53 | function 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 | |
| 59 | const 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. |
| 76 | function 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. |
| 104 | const store = new Map(); |
| 105 | const 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 | }; |
| 110 | const win = globalThis; |
| 111 | win.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. |
| 114 | const documentShim = { |
| 115 | addEventListener: () => {}, |
| 116 | getElementById: () => null, |
| 117 | visibilityState: 'hidden', |
| 118 | }; |
| 119 | function 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 | |
| 126 | loadScript('models.js', patchModels); |
| 127 | |
| 128 | const M = win.DaimondModels; |
| 129 | if (!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. |
| 134 | const WINDOW = { 'model-a': 200000, 'model-b': 32000 }; |
| 135 | win.DaimondPricing = { |
| 136 | contextWindow: (model /*, provider */) => (WINDOW[model] != null ? WINDOW[model] : null), |
| 137 | }; |
| 138 | |
| 139 | const A = { provider: 'fw', model: 'model-a' }; // 200k window, the model in force |
| 140 | const B = { provider: 'fw', model: 'model-b' }; // 32k window, a smaller one |
| 141 | const A2 = { provider: 'other', model: 'model-a' }; // same name, a different provider |
| 142 | const X = { provider: 'fw', model: 'model-x' }; // no published window |
| 143 | |
| 144 | function 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 | |
| 184 | main(); |