@bongos/core 1.24.6 → 1.24.7
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.
- package/.bongos-core.json +25 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +4 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +17 -12
- package/docs/copy-registry.json +79 -33
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +9 -9
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/project-settings.js +59 -15
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/project_settings_page.mjs +8 -5
package/tests/autopickup_cap.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
|
|
3
3
|
//
|
|
4
4
|
// Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
|
|
5
|
-
// 1. the
|
|
5
|
+
// 1. the parsers and the resolver: off by default, cap 200 by default (task
|
|
6
|
+
// 1005003; it was 20), per-rank caps read from one JSON row, an
|
|
6
7
|
// unusable stored value reported and never thrown, a dead DB answers defaults;
|
|
7
8
|
// 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
|
|
8
9
|
// lock is taken BEFORE either count;
|
|
@@ -35,7 +36,12 @@ async function withPool(query, fn) {
|
|
|
35
36
|
pool.query = query;
|
|
36
37
|
try { return await fn(); } finally { pool.query = saved; }
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
// Answers both the single-key read (getSetting) and the one-query batch read
|
|
40
|
+
// (key = ANY($1)) that resolveTweakAutopickup makes.
|
|
41
|
+
const settingsRows = (map) => async (_sql, [key]) => {
|
|
42
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
43
|
+
return { rows: keys.filter((k) => k in map).map((k) => ({ key: k, value: map[k] })) };
|
|
44
|
+
};
|
|
39
45
|
|
|
40
46
|
// ---- 1. the setting ---------------------------------------------------------
|
|
41
47
|
|
|
@@ -50,11 +56,11 @@ test('the parsers accept exactly off|on and a whole positive cap', () => {
|
|
|
50
56
|
assert.match(parseAutopickupCap('100000').reason, /out of range/);
|
|
51
57
|
});
|
|
52
58
|
|
|
53
|
-
test('an untouched project is off with a cap of
|
|
59
|
+
test('an untouched project is off with a cap of 200 and no rank caps', async () => {
|
|
54
60
|
assert.equal(DEFAULT_AUTOPICKUP, 'off');
|
|
55
|
-
assert.equal(DEFAULT_AUTOPICKUP_CAP,
|
|
61
|
+
assert.equal(DEFAULT_AUTOPICKUP_CAP, 200);
|
|
56
62
|
const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
|
|
57
|
-
assert.deepEqual(r, { mode: 'off', cap:
|
|
63
|
+
assert.deepEqual(r, { mode: 'off', cap: 200, rank_caps: {}, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
58
64
|
assert.match(describeTweakAutopickup(r), /waits until a person/);
|
|
59
65
|
});
|
|
60
66
|
|
|
@@ -63,7 +69,7 @@ test('stored values are read; an unusable one is reported and falls back', async
|
|
|
63
69
|
() => resolveTweakAutopickup());
|
|
64
70
|
assert.equal(r.mode, 'on');
|
|
65
71
|
assert.equal(r.source.mode, 'project_settings');
|
|
66
|
-
assert.equal(r.cap,
|
|
72
|
+
assert.equal(r.cap, 200);
|
|
67
73
|
assert.equal(r.invalid.length, 1);
|
|
68
74
|
assert.equal(r.invalid[0].raw, 'lots');
|
|
69
75
|
assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
|
|
@@ -72,7 +78,7 @@ test('stored values are read; an unusable one is reported and falls back', async
|
|
|
72
78
|
test('a database that cannot be read answers the defaults', async () => {
|
|
73
79
|
const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
|
|
74
80
|
assert.equal(r.mode, 'off');
|
|
75
|
-
assert.equal(r.cap,
|
|
81
|
+
assert.equal(r.cap, 200);
|
|
76
82
|
});
|
|
77
83
|
|
|
78
84
|
// ---- 2. the refusal ladder --------------------------------------------------
|
|
@@ -128,7 +134,7 @@ test('the monthly cap refuses at the cap and admits below it', async () => {
|
|
|
128
134
|
test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
|
|
129
135
|
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
|
|
130
136
|
const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
|
|
131
|
-
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
|
|
137
|
+
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings, builderRank)');
|
|
132
138
|
const insert = body.indexOf('INSERT INTO claims');
|
|
133
139
|
assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
|
|
134
140
|
assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
|
|
@@ -155,15 +161,19 @@ const fakeRes = () => ({
|
|
|
155
161
|
json(p) { this.payload = p; return this; },
|
|
156
162
|
});
|
|
157
163
|
|
|
158
|
-
function buildRouter({ used = 4 } = {}) {
|
|
164
|
+
function buildRouter({ used = 4, rankCaps = {} } = {}) {
|
|
159
165
|
const ps = api.projectSettings;
|
|
160
166
|
const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
|
|
161
167
|
const writes = [];
|
|
162
|
-
const state = { mode: 'off', cap: 20 };
|
|
168
|
+
const state = { mode: 'off', cap: 20, rank_caps: { ...rankCaps } };
|
|
163
169
|
ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
164
170
|
ps.set = async (key, value, by) => {
|
|
165
171
|
writes.push({ key, value, by });
|
|
166
|
-
if (key === 'tweak.autopickup') state.mode = value;
|
|
172
|
+
if (key === 'tweak.autopickup') state.mode = value;
|
|
173
|
+
else if (key.startsWith('tweak.autopickup_rank_cap.')) {
|
|
174
|
+
const rank = key.slice('tweak.autopickup_rank_cap.'.length);
|
|
175
|
+
if (value === '') delete state.rank_caps[rank]; else state.rank_caps[rank] = value;
|
|
176
|
+
} else state.cap = value;
|
|
167
177
|
};
|
|
168
178
|
const autopickup = require('../modules/lifecycle/autopickup.js');
|
|
169
179
|
const savedCount = autopickup.countThisMonth;
|
|
@@ -234,3 +244,185 @@ test('core_275 is additive: one nullable column, recorded in schema_migrations',
|
|
|
234
244
|
assert.doesNotMatch(sql, /NOT NULL|DROP /);
|
|
235
245
|
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
|
|
236
246
|
});
|
|
247
|
+
|
|
248
|
+
// ---- 5. the cap per project and per builder rank (task 1005003) ------------
|
|
249
|
+
|
|
250
|
+
const { rankCapKey } = require('../src/bongos/project-settings.js');
|
|
251
|
+
const { effectiveCap } = require('../modules/lifecycle/autopickup.js');
|
|
252
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
253
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
254
|
+
|
|
255
|
+
test('a project override and a rank override are both read, in ONE query', async () => {
|
|
256
|
+
assert.equal(rankCapKey('xenos'), 'tweak.autopickup_rank_cap.xenos');
|
|
257
|
+
let queries = 0;
|
|
258
|
+
const rows = settingsRows({
|
|
259
|
+
[SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on',
|
|
260
|
+
[SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: '40',
|
|
261
|
+
'tweak.autopickup_rank_cap.xenos': '5',
|
|
262
|
+
'tweak.autopickup_rank_cap.archon': '80',
|
|
263
|
+
'tweak.autopickup_rank_cap.metic': '', // cleared: follows the project cap
|
|
264
|
+
'tweak.autopickup_rank_cap.olympian': '9', // off the live ladder: never read
|
|
265
|
+
});
|
|
266
|
+
const r = await withPool(async (sql, params) => { queries += 1; return rows(sql, params); }, () => resolveTweakAutopickup());
|
|
267
|
+
assert.equal(queries, 1, 'mode, cap and every rank cap come back in one round trip');
|
|
268
|
+
assert.equal(r.cap, 40);
|
|
269
|
+
assert.deepEqual(r.rank_caps, { xenos: 5, archon: 80 });
|
|
270
|
+
assert.deepEqual(r.invalid, []);
|
|
271
|
+
assert.equal(describeTweakAutopickup(r),
|
|
272
|
+
'On: a running Autobongos runner applies submitted pages on its own, up to 40 a month. Some builder ranks have their own limit: xenos 5, archon 80. The artist still approves every one.');
|
|
273
|
+
|
|
274
|
+
const bad = await withPool(settingsRows({ 'tweak.autopickup_rank_cap.xenos': '-1' }), () => resolveTweakAutopickup());
|
|
275
|
+
assert.deepEqual(bad.rank_caps, {}, 'an unusable rank row falls back to the project cap');
|
|
276
|
+
assert.equal(bad.invalid.length, 1);
|
|
277
|
+
assert.equal(bad.invalid[0].where, "project_settings['tweak.autopickup_rank_cap.xenos']");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the effective cap is the rank\'s own cap, else the project cap', () => {
|
|
281
|
+
const s = { mode: 'on', cap: 200, rank_caps: { xenos: 5 } };
|
|
282
|
+
assert.deepEqual(effectiveCap(s, 'xenos'), { cap: 5, from: 'rank' });
|
|
283
|
+
assert.deepEqual(effectiveCap(s, 'metic'), { cap: 200, from: 'project' });
|
|
284
|
+
assert.deepEqual(effectiveCap(s, null), { cap: 200, from: 'project' });
|
|
285
|
+
assert.deepEqual(effectiveCap({ cap: 200 }, 'xenos'), { cap: 200, from: 'project' }, 'no rank caps at all');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('the default cap of 200 admits round 199 and refuses at 200', async () => {
|
|
289
|
+
const s = { mode: 'on', cap: DEFAULT_AUTOPICKUP_CAP, rank_caps: {} };
|
|
290
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 199 }), tweak, s, 'metic'), null);
|
|
291
|
+
const r = await autoPickupRefusal(fakeClient({ month: 200 }), tweak, s, 'metic');
|
|
292
|
+
assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
|
|
293
|
+
assert.equal(r.cap, 200);
|
|
294
|
+
assert.equal(r.cap_from, 'project');
|
|
295
|
+
assert.equal(r.message, 'Auto-pickup has used this month\'s 200 rounds, so submitted pages wait for a person until next month.');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('a rank override replaces the project cap for that rank only, both ways', async () => {
|
|
299
|
+
const s = { mode: 'on', cap: 50, rank_caps: { xenos: 10, archon: 80 } };
|
|
300
|
+
// lower than the project cap: refuses a xenos runner where a metic one is admitted
|
|
301
|
+
const lower = await autoPickupRefusal(fakeClient({ month: 10 }), tweak, s, 'xenos');
|
|
302
|
+
assert.equal(lower.code, 'AUTO_PICKUP_CAP_REACHED');
|
|
303
|
+
assert.equal(lower.cap, 10);
|
|
304
|
+
assert.equal(lower.cap_from, 'rank');
|
|
305
|
+
assert.match(lower.message, /reached 10, the limit this project sets for a xenos builder's runner, so submitted pages wait for a person/);
|
|
306
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 10 }), tweak, s, 'metic'), null);
|
|
307
|
+
// higher than the project cap: admits an archon runner past the project's number
|
|
308
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 60 }), tweak, s, 'archon'), null);
|
|
309
|
+
assert.equal((await autoPickupRefusal(fakeClient({ month: 60 }), tweak, s, 'metic')).code, 'AUTO_PICKUP_CAP_REACHED');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('claimTask passes the claimer\'s rank, read inside the transaction, to the gate', () => {
|
|
313
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
|
|
314
|
+
const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
|
|
315
|
+
const rankRead = body.indexOf('const builderRank = builderRows[0] ? builderRows[0].rank : null;');
|
|
316
|
+
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings, builderRank)');
|
|
317
|
+
assert.ok(rankRead > 0 && gate > rankRead);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('the read reports the caller\'s own cap and what is left of it', async () => {
|
|
321
|
+
const { route, restore } = buildRouter({ used: 4, rankCaps: { xenos: 6 } });
|
|
322
|
+
try {
|
|
323
|
+
const get = handler(route('get', '/project-settings/tweak-autopickup'));
|
|
324
|
+
const x = fakeRes();
|
|
325
|
+
await get({ builder: { id: 1, rank: 'xenos' } }, x);
|
|
326
|
+
assert.deepEqual([x.payload.my_cap, x.payload.my_cap_from, x.payload.remaining_this_month], [6, 'rank', 2]);
|
|
327
|
+
assert.deepEqual(x.payload.rank_caps, { xenos: 6 });
|
|
328
|
+
const m = fakeRes();
|
|
329
|
+
await get({ builder: { id: 2, rank: 'metic' } }, m);
|
|
330
|
+
assert.deepEqual([m.payload.my_cap, m.payload.my_cap_from, m.payload.remaining_this_month], [20, 'project', 16]);
|
|
331
|
+
} finally { restore(); }
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('rank_caps is a partial update: a number sets, null clears, an unnamed rank is kept', async () => {
|
|
335
|
+
const { route, writes, restore } = buildRouter({ rankCaps: { xenos: 5, thetes: 9 } });
|
|
336
|
+
try {
|
|
337
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
338
|
+
const res = fakeRes();
|
|
339
|
+
await patch({ body: { rank_caps: { metic: 30, xenos: null } }, builder: { id: 7, rank: 'metic' } }, res);
|
|
340
|
+
assert.equal(res.code, null);
|
|
341
|
+
// One single-row upsert per named rank — never a read-modify-write of a shared
|
|
342
|
+
// object, so it cannot overwrite a rank someone else is editing at the same time.
|
|
343
|
+
assert.deepEqual(writes, [
|
|
344
|
+
{ key: 'tweak.autopickup_rank_cap.metic', value: 30, by: 7 },
|
|
345
|
+
{ key: 'tweak.autopickup_rank_cap.xenos', value: '', by: 7 },
|
|
346
|
+
]);
|
|
347
|
+
assert.deepEqual(res.payload.rank_caps, { thetes: 9, metic: 30 });
|
|
348
|
+
assert.equal(res.payload.my_cap, 30);
|
|
349
|
+
} finally { restore(); }
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('two people saving two different ranks at once both keep their edit', async () => {
|
|
353
|
+
const { route, restore } = buildRouter();
|
|
354
|
+
try {
|
|
355
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
356
|
+
const a = fakeRes();
|
|
357
|
+
const b = fakeRes();
|
|
358
|
+
await Promise.all([
|
|
359
|
+
patch({ body: { rank_caps: { xenos: 5 } }, builder: { id: 7 } }, a),
|
|
360
|
+
patch({ body: { rank_caps: { archon: 80 } }, builder: { id: 8 } }, b),
|
|
361
|
+
]);
|
|
362
|
+
const after = fakeRes();
|
|
363
|
+
await handler(route('get', '/project-settings/tweak-autopickup'))({ builder: { id: 9, rank: 'metic' } }, after);
|
|
364
|
+
assert.deepEqual(after.payload.rank_caps, { xenos: 5, archon: 80 });
|
|
365
|
+
} finally { restore(); }
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('an unknown rank or a bad rank number writes nothing, the project cap included', async () => {
|
|
369
|
+
const { route, writes, restore } = buildRouter();
|
|
370
|
+
try {
|
|
371
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
372
|
+
const unknown = fakeRes();
|
|
373
|
+
await patch({ body: { cap: 100, rank_caps: { olympian: 5 } }, builder: { id: 7 } }, unknown);
|
|
374
|
+
assert.equal(unknown.code, 'bad_tweak_autopickup');
|
|
375
|
+
assert.match(unknown.payload.message, /unknown rank 'olympian'/);
|
|
376
|
+
const zero = fakeRes();
|
|
377
|
+
await patch({ body: { cap: 100, rank_caps: { xenos: 0 } }, builder: { id: 7 } }, zero);
|
|
378
|
+
assert.match(zero.payload.message, /rank_caps\.xenos: must be positive/);
|
|
379
|
+
assert.equal(writes.length, 0);
|
|
380
|
+
} finally { restore(); }
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// The server's permission check on the write, run for real: the route's own
|
|
384
|
+
// requirePermission layer, against a stub government port (the
|
|
385
|
+
// tests/government_require_permission.mjs double). Whoever holds
|
|
386
|
+
// project.settings.manage — the project owner, or anyone granted it — passes; a
|
|
387
|
+
// builder without it is refused with 403 before the handler runs.
|
|
388
|
+
async function runWriteGate(heldKeys) {
|
|
389
|
+
const { route, restore } = buildRouter();
|
|
390
|
+
try {
|
|
391
|
+
const stack = route('patch', '/project-settings/tweak-autopickup').stack;
|
|
392
|
+
const gate = stack[1].handle;
|
|
393
|
+
assert.equal(gate.name, 'permissionGate', 'the second layer is the kernel permission gate');
|
|
394
|
+
seams._reset();
|
|
395
|
+
seams.registerProvider('government', { resolveBuilderPermissions: async () => heldKeys });
|
|
396
|
+
const res = { _status: null, _json: null, status(c) { this._status = c; return this; }, json(b) { this._json = b; return this; } };
|
|
397
|
+
attachFail({}, res, () => {});
|
|
398
|
+
let passed = false;
|
|
399
|
+
await gate({ builder: { id: 7, rank: 'metic' }, body: { rank_caps: { xenos: 5 } } }, res, () => { passed = true; });
|
|
400
|
+
return { passed, res };
|
|
401
|
+
} finally { seams._reset(); restore(); }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
test('a builder holding project.settings.manage may set the caps', async () => {
|
|
405
|
+
const { passed, res } = await runWriteGate(['project.settings.manage']);
|
|
406
|
+
assert.equal(passed, true);
|
|
407
|
+
assert.equal(res._status, null);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('a builder without project.settings.manage is refused', async () => {
|
|
411
|
+
const { passed, res } = await runWriteGate(['idea.triage']);
|
|
412
|
+
assert.equal(passed, false);
|
|
413
|
+
assert.equal(res._status, 403);
|
|
414
|
+
assert.equal(res._json.error.code, 'permission_forbidden');
|
|
415
|
+
assert.deepEqual(res._json.error.details.required, ['project.settings.manage']);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('the settings page row offers the project cap and one box per live rank', () => {
|
|
419
|
+
const { tweakCapRow } = require('../modules/lifecycle/routes/project-settings.js');
|
|
420
|
+
const row = tweakCapRow({ mode: 'off', cap: 200, rank_caps: { xenos: 5 }, source: { cap: 'default' }, invalid: [] });
|
|
421
|
+
assert.equal(row.key, 'tweak_round_cap');
|
|
422
|
+
assert.equal(row.editable, true);
|
|
423
|
+
assert.equal(row.value.cap, 200);
|
|
424
|
+
assert.deepEqual(row.value.ranks, api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: rank === 'xenos' ? 5 : null })));
|
|
425
|
+
const page = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'project-settings.js'), 'utf8');
|
|
426
|
+
assert.match(page, /row\.key === 'tweak_round_cap'/);
|
|
427
|
+
assert.match(page, /sendJson\('PATCH', '\/project-settings\/tweak-autopickup', \{ cap: Number\(\$\('ps-tweak-cap'\)\.value\), rank_caps: rankCaps \}\)/);
|
|
428
|
+
});
|
|
@@ -65,9 +65,11 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
65
65
|
list: gc.listCategories,
|
|
66
66
|
set: api.projectSettings.set,
|
|
67
67
|
resolveShipMode: api.projectSettings.resolveShipMode,
|
|
68
|
+
resolveTweak: api.projectSettings.resolveTweakAutopickup,
|
|
68
69
|
};
|
|
69
70
|
api.resolveRotDays = async () => timer;
|
|
70
71
|
api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
72
|
+
api.projectSettings.resolveTweakAutopickup = async () => ({ mode: 'off', cap: 200, rank_caps: {}, source: { mode: 'default', cap: 'default', rank_caps: 'default' }, invalid: [] });
|
|
71
73
|
// reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
|
|
72
74
|
const rewardPort = reward === 'throws'
|
|
73
75
|
? { rewardConfig: () => { throw new Error('reward boom'); } }
|
|
@@ -84,6 +86,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
84
86
|
gc.listCategories = saved.list;
|
|
85
87
|
api.projectSettings.set = saved.set;
|
|
86
88
|
api.projectSettings.resolveShipMode = saved.resolveShipMode;
|
|
89
|
+
api.projectSettings.resolveTweakAutopickup = saved.resolveTweak;
|
|
87
90
|
};
|
|
88
91
|
// The handler is the LAST layer on the matching route (requireBuilder and the
|
|
89
92
|
// permission gate sit in front); calling it directly is what keeps this a unit test
|
|
@@ -103,22 +106,22 @@ const CATS = [
|
|
|
103
106
|
{ id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
|
|
104
107
|
];
|
|
105
108
|
|
|
106
|
-
test('one read returns all
|
|
109
|
+
test('one read returns all seven fundamentals, each naming where its value came from', async () => {
|
|
107
110
|
const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
|
|
108
111
|
try {
|
|
109
112
|
const res = fakeRes();
|
|
110
113
|
await handlerFor('get', '/project-settings')({ query: {} }, res);
|
|
111
114
|
const rows = res.payload.rows;
|
|
112
|
-
assert.equal(rows.length,
|
|
115
|
+
assert.equal(rows.length, 7, 'the page draws seven rows, so the read returns seven');
|
|
113
116
|
assert.deepEqual(rows.map((r) => r.key),
|
|
114
|
-
['ship_mode', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
117
|
+
['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
115
118
|
// Provenance is the point of the page: every row says where its value came from.
|
|
116
119
|
for (const r of rows) {
|
|
117
120
|
assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
|
|
118
121
|
assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
|
|
119
122
|
}
|
|
120
|
-
//
|
|
121
|
-
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'rot_timer', 'work_categories']);
|
|
123
|
+
// Four knobs: the criterion's two, ship mode (task 1004628) and the monthly tweak-round cap (task 1005003).
|
|
124
|
+
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories']);
|
|
122
125
|
// A no-store receipt: a cached settings read is wrong the moment a knob turns.
|
|
123
126
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
124
127
|
} finally { restore(); }
|