@bongos/core 1.21.106 → 1.21.108
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 +72 -52
- package/.claude/skills/goal-create/SKILL.md +8 -3
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +6 -4
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +25 -19
- package/docs/copy-registry.json +92 -37
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +189 -188
- package/migrations/core_275_claims_auto_pickup.sql +24 -0
- package/modules/copy-desk/docx.js +27 -7
- package/modules/copy-desk/page-status.js +3 -1
- package/modules/copy-desk/pages.js +44 -5
- package/modules/copy-desk/routes/copy-desk.js +4 -3
- package/modules/copy-desk/tests/copy_docx.mjs +38 -0
- package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/paintbrush.js +152 -12
- package/modules/hall-ui/public/studio.js +3 -1
- package/modules/hall-ui/public/style.css +16 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
- package/modules/hall-ui/records/craft-mode.md +5 -0
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/modules/lifecycle/autopickup.js +94 -0
- package/modules/lifecycle/db-claims.js +18 -4
- package/modules/lifecycle/routes/claims.js +12 -1
- package/modules/lifecycle/routes/project-settings.js +59 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/government-walk.js +1 -0
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
- package/scripts/gds/seed-verify-criteria.js +73 -18
- package/src/bongos/project-settings.js +79 -0
- package/src/module-api.js +8 -1
- package/tests/autopickup_cap.mjs +236 -0
- package/tests/autopickup_cap_db.mjs +115 -0
- package/tests/copy_desk_page_submit.mjs +36 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/hall_tweak_editor.mjs +19 -3
- package/tests/paintbrush.mjs +151 -2
- package/tests/planning_session_skill.mjs +40 -0
- package/tests/seed_verify_criteria.mjs +130 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/autopickup_cap.mjs — tweak auto-pickup's setting, monthly cap and claim mark
|
|
2
|
+
// (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
|
|
3
|
+
//
|
|
4
|
+
// Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
|
|
5
|
+
// 1. the two parsers and the resolver: off by default, cap 20 by default, an
|
|
6
|
+
// unusable stored value reported and never thrown, a dead DB answers defaults;
|
|
7
|
+
// 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
|
|
8
|
+
// lock is taken BEFORE either count;
|
|
9
|
+
// 3. claimTask: a marked claim runs the gate before the insert and names the
|
|
10
|
+
// column; an unmarked claim's insert is unchanged;
|
|
11
|
+
// 4. the routes: any builder reads (with this month's count), only
|
|
12
|
+
// project.settings.manage writes, and a half-valid body writes nothing.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/autopickup_cap.mjs
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
|
|
25
|
+
const {
|
|
26
|
+
DEFAULT_AUTOPICKUP, DEFAULT_AUTOPICKUP_CAP, parseAutopickup, parseAutopickupCap,
|
|
27
|
+
resolveTweakAutopickup, describeTweakAutopickup, SETTING_KEYS, AUTOPICKUP_MODES, MAX_AUTOPICKUP_CAP,
|
|
28
|
+
} = require('../src/bongos/project-settings.js');
|
|
29
|
+
const { autoPickupRefusal, LOCK_KEY, MAX_AUTO_APPLIES_PER_ROUND } = require('../modules/lifecycle/autopickup.js');
|
|
30
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
31
|
+
|
|
32
|
+
async function withPool(query, fn) {
|
|
33
|
+
const pool = poolMod.pool;
|
|
34
|
+
const saved = pool.query;
|
|
35
|
+
pool.query = query;
|
|
36
|
+
try { return await fn(); } finally { pool.query = saved; }
|
|
37
|
+
}
|
|
38
|
+
const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
|
|
39
|
+
|
|
40
|
+
// ---- 1. the setting ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
test('the parsers accept exactly off|on and a whole positive cap', () => {
|
|
43
|
+
assert.deepEqual([...AUTOPICKUP_MODES], ['off', 'on']);
|
|
44
|
+
assert.equal(api.projectSettings.MAX_AUTOPICKUP_CAP, MAX_AUTOPICKUP_CAP, 'the doorway hands out the same bound');
|
|
45
|
+
assert.deepEqual(parseAutopickup(' On '), { mode: 'on' });
|
|
46
|
+
assert.match(parseAutopickup('maybe').reason, /off \| on/);
|
|
47
|
+
assert.deepEqual(parseAutopickupCap('5'), { cap: 5 });
|
|
48
|
+
assert.match(parseAutopickupCap('0').reason, /positive/);
|
|
49
|
+
assert.match(parseAutopickupCap('2.5').reason, /whole/);
|
|
50
|
+
assert.match(parseAutopickupCap('100000').reason, /out of range/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an untouched project is off with a cap of 20', async () => {
|
|
54
|
+
assert.equal(DEFAULT_AUTOPICKUP, 'off');
|
|
55
|
+
assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
|
|
56
|
+
const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
|
|
57
|
+
assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
58
|
+
assert.match(describeTweakAutopickup(r), /waits until a person/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('stored values are read; an unusable one is reported and falls back', async () => {
|
|
62
|
+
const r = await withPool(settingsRows({ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on', [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: 'lots' }),
|
|
63
|
+
() => resolveTweakAutopickup());
|
|
64
|
+
assert.equal(r.mode, 'on');
|
|
65
|
+
assert.equal(r.source.mode, 'project_settings');
|
|
66
|
+
assert.equal(r.cap, 20);
|
|
67
|
+
assert.equal(r.invalid.length, 1);
|
|
68
|
+
assert.equal(r.invalid[0].raw, 'lots');
|
|
69
|
+
assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a database that cannot be read answers the defaults', async () => {
|
|
73
|
+
const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
|
|
74
|
+
assert.equal(r.mode, 'off');
|
|
75
|
+
assert.equal(r.cap, 20);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- 2. the refusal ladder --------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function fakeClient({ round = 0, month = 0 } = {}) {
|
|
81
|
+
const calls = [];
|
|
82
|
+
return {
|
|
83
|
+
calls,
|
|
84
|
+
async query(sql, params) {
|
|
85
|
+
calls.push(sql.includes('pg_advisory_xact_lock') ? `lock:${params[0]}` : sql.includes('date_trunc') ? 'month' : 'round');
|
|
86
|
+
if (sql.includes('pg_advisory_xact_lock')) return { rows: [] };
|
|
87
|
+
return { rows: [{ n: sql.includes('date_trunc') ? month : round }] };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const tweak = { id: 9, source: 'page-tweak' };
|
|
92
|
+
const on = { mode: 'on', cap: 3 };
|
|
93
|
+
|
|
94
|
+
test('a marked claim on anything but a page tweak is refused, before any lock', async () => {
|
|
95
|
+
const c = fakeClient();
|
|
96
|
+
assert.equal((await autoPickupRefusal(c, { id: 9, source: 'manual' }, on)).code, 'AUTO_PICKUP_NOT_A_TWEAK');
|
|
97
|
+
assert.deepEqual(c.calls, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the switch off refuses a marked claim', async () => {
|
|
101
|
+
assert.equal((await autoPickupRefusal(fakeClient(), tweak, { mode: 'off', cap: 3 })).code, 'AUTO_PICKUP_OFF');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the lock is taken before either count, on the one project-wide key', async () => {
|
|
105
|
+
const c = fakeClient();
|
|
106
|
+
assert.equal(await autoPickupRefusal(c, tweak, on), null);
|
|
107
|
+
assert.equal(LOCK_KEY, 'page-tweak/autopickup');
|
|
108
|
+
assert.deepEqual(c.calls, ['lock:page-tweak/autopickup', 'round', 'month']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a third automatic apply of one round is refused', async () => {
|
|
112
|
+
assert.equal(MAX_AUTO_APPLIES_PER_ROUND, 2);
|
|
113
|
+
assert.equal(await autoPickupRefusal(fakeClient({ round: 1 }), tweak, on), null);
|
|
114
|
+
const r = await autoPickupRefusal(fakeClient({ round: 2 }), tweak, on);
|
|
115
|
+
assert.equal(r.code, 'AUTO_PICKUP_ROUND_LIMIT');
|
|
116
|
+
assert.match(r.message, /waits for a person/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the monthly cap refuses at the cap and admits below it', async () => {
|
|
120
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 2 }), tweak, on), null);
|
|
121
|
+
const r = await autoPickupRefusal(fakeClient({ month: 3 }), tweak, on);
|
|
122
|
+
assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
|
|
123
|
+
assert.equal(r.cap, 3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- 3. claimTask wiring ----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
|
|
129
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
|
|
130
|
+
const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
|
|
131
|
+
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
|
|
132
|
+
const insert = body.indexOf('INSERT INTO claims');
|
|
133
|
+
assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
|
|
134
|
+
assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
|
|
135
|
+
assert.match(body, /\$\{autoPickup \? ', auto_pickup' : ''\}/);
|
|
136
|
+
assert.match(body, /goal_id, source FROM tasks WHERE id = \$1 FOR UPDATE/, 'the task row carries its source');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('POST /claims accepts auto_pickup and maps every refusal to a 409', () => {
|
|
140
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
|
|
141
|
+
assert.match(src, /auto_pickup: \{ type: 'boolean' \}/);
|
|
142
|
+
assert.match(src, /autoPickup: autoPickup === true/);
|
|
143
|
+
for (const code of ['AUTO_PICKUP_NOT_A_TWEAK', 'AUTO_PICKUP_OFF', 'AUTO_PICKUP_CAP_REACHED', 'AUTO_PICKUP_ROUND_LIMIT']) {
|
|
144
|
+
assert.match(src, new RegExp(`err.code === '${code}' \\? 409`));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- 4. the routes ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const fakeRes = () => ({
|
|
151
|
+
code: null, statusCode: 200, payload: null, headers: {},
|
|
152
|
+
fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
|
|
153
|
+
status(c) { this.statusCode = c; return this; },
|
|
154
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
155
|
+
json(p) { this.payload = p; return this; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function buildRouter({ used = 4 } = {}) {
|
|
159
|
+
const ps = api.projectSettings;
|
|
160
|
+
const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
|
|
161
|
+
const writes = [];
|
|
162
|
+
const state = { mode: 'off', cap: 20 };
|
|
163
|
+
ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
164
|
+
ps.set = async (key, value, by) => {
|
|
165
|
+
writes.push({ key, value, by });
|
|
166
|
+
if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
|
|
167
|
+
};
|
|
168
|
+
const autopickup = require('../modules/lifecycle/autopickup.js');
|
|
169
|
+
const savedCount = autopickup.countThisMonth;
|
|
170
|
+
autopickup.countThisMonth = async () => used;
|
|
171
|
+
const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
172
|
+
delete require.cache[require.resolve(file)];
|
|
173
|
+
const router = require(file)();
|
|
174
|
+
const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
|
|
175
|
+
const restore = () => { ps.resolveTweakAutopickup = saved.resolve; ps.set = saved.set; autopickup.countThisMonth = savedCount; };
|
|
176
|
+
return { route, writes, restore };
|
|
177
|
+
}
|
|
178
|
+
const handler = (r) => r.stack[r.stack.length - 1].handle;
|
|
179
|
+
|
|
180
|
+
test('the read is open to any builder and reports this month\'s count against the cap', async () => {
|
|
181
|
+
const { route, restore } = buildRouter({ used: 4 });
|
|
182
|
+
try {
|
|
183
|
+
const get = route('get', '/project-settings/tweak-autopickup');
|
|
184
|
+
assert.equal(get.stack.length, 2, 'requireBuilder + the handler');
|
|
185
|
+
const res = fakeRes();
|
|
186
|
+
await handler(get)({}, res);
|
|
187
|
+
assert.equal(res.payload.mode, 'off');
|
|
188
|
+
assert.equal(res.payload.used_this_month, 4);
|
|
189
|
+
assert.equal(res.payload.remaining_this_month, 16);
|
|
190
|
+
assert.equal(res.payload.per_round_limit, 2);
|
|
191
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
192
|
+
} finally { restore(); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the write is gated on project.settings.manage and the Rules decision rule', () => {
|
|
196
|
+
const { route, restore } = buildRouter();
|
|
197
|
+
try {
|
|
198
|
+
const write = route('patch', '/project-settings/tweak-autopickup').stack;
|
|
199
|
+
assert.equal(write.length, 4);
|
|
200
|
+
assert.equal(write[2].handle.name, 'actAloneGate');
|
|
201
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'), 'utf8');
|
|
202
|
+
assert.match(src, /router\.patch\('\/project-settings\/tweak-autopickup', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
|
|
203
|
+
} finally { restore(); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a half-valid body writes nothing; a good one writes both and echoes the new read', async () => {
|
|
207
|
+
const { route, writes, restore } = buildRouter({ used: 0 });
|
|
208
|
+
try {
|
|
209
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
210
|
+
const bad = fakeRes();
|
|
211
|
+
await patch({ body: { mode: 'on', cap: 0 }, builder: { id: 7 } }, bad);
|
|
212
|
+
assert.equal(bad.code, 'bad_tweak_autopickup');
|
|
213
|
+
assert.equal(writes.length, 0);
|
|
214
|
+
|
|
215
|
+
const empty = fakeRes();
|
|
216
|
+
await patch({ body: {}, builder: { id: 7 } }, empty);
|
|
217
|
+
assert.equal(empty.code, 'bad_tweak_autopickup');
|
|
218
|
+
|
|
219
|
+
const good = fakeRes();
|
|
220
|
+
await patch({ body: { mode: 'ON', cap: 5 }, builder: { id: 7 } }, good);
|
|
221
|
+
assert.equal(good.code, null);
|
|
222
|
+
assert.deepEqual(writes, [
|
|
223
|
+
{ key: 'tweak.autopickup', value: 'on', by: 7 },
|
|
224
|
+
{ key: 'tweak.autopickup_monthly_cap', value: 5, by: 7 },
|
|
225
|
+
]);
|
|
226
|
+
assert.equal(good.payload.mode, 'on');
|
|
227
|
+
assert.equal(good.payload.cap, 5);
|
|
228
|
+
} finally { restore(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('core_275 is additive: one nullable column, recorded in schema_migrations', () => {
|
|
232
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8');
|
|
233
|
+
assert.match(sql, /ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;/);
|
|
234
|
+
assert.doesNotMatch(sql, /NOT NULL|DROP /);
|
|
235
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
|
|
236
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/autopickup_cap_db.mjs — the auto-pickup cap holds under a race, against a
|
|
2
|
+
// REAL Postgres (task 1004677 / BV2.TW19a, ADR 0371 D5).
|
|
3
|
+
//
|
|
4
|
+
// tests/autopickup_cap.mjs proves the refusal ladder DB-free. What it cannot prove
|
|
5
|
+
// is the claim this file exists for: two runners racing for the LAST slot cannot
|
|
6
|
+
// both get it. That is a property of pg_advisory_xact_lock plus the count running
|
|
7
|
+
// inside the claim's transaction, so it needs a real database and two real
|
|
8
|
+
// connections.
|
|
9
|
+
//
|
|
10
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
11
|
+
// run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it. It builds
|
|
12
|
+
// throwaway `tasks` and `claims` tables, so point it at a scratch database.
|
|
13
|
+
//
|
|
14
|
+
// Run: DATABASE_URL=postgres://... node tests/autopickup_cap_db.mjs
|
|
15
|
+
|
|
16
|
+
import pg from 'pg';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import url from 'node:url';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await pool.query('SELECT 1');
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.log(`autopickup_cap_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
30
|
+
await pool.end().catch(() => {});
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const { rows: realRows } = await pool.query(`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
34
|
+
if (realRows[0].has_builders) {
|
|
35
|
+
console.log('autopickup_cap_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB.');
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
async function t(name, fn) {
|
|
42
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await pool.query(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS tasks (id bigint PRIMARY KEY, source text);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
50
|
+
id bigserial PRIMARY KEY, task_id bigint, builder_id bigint,
|
|
51
|
+
claimed_at timestamptz DEFAULT now(), released_at timestamptz);
|
|
52
|
+
`);
|
|
53
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
54
|
+
await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8'));
|
|
55
|
+
|
|
56
|
+
const { autoPickupRefusal, countThisMonth } = require('../modules/lifecycle/autopickup.js');
|
|
57
|
+
|
|
58
|
+
async function reset() {
|
|
59
|
+
await pool.query('TRUNCATE claims, tasks');
|
|
60
|
+
await pool.query(`INSERT INTO tasks (id, source) VALUES (1,'page-tweak'),(2,'page-tweak'),(3,'page-tweak'),(4,'manual')`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One marked claim the way claimTask makes it: BEGIN, the gate (which takes the
|
|
64
|
+
// lock and counts), the insert, COMMIT. `pause` holds the transaction open after
|
|
65
|
+
// the count so the other racer's count is forced to happen while it is in flight.
|
|
66
|
+
async function markedClaim(taskId, settings, pause = 0) {
|
|
67
|
+
const client = await pool.connect();
|
|
68
|
+
try {
|
|
69
|
+
await client.query('BEGIN');
|
|
70
|
+
const refusal = await autoPickupRefusal(client, { id: taskId, source: 'page-tweak' }, settings);
|
|
71
|
+
if (refusal) { await client.query('ROLLBACK'); return refusal.code; }
|
|
72
|
+
if (pause) await new Promise((r) => setTimeout(r, pause));
|
|
73
|
+
await client.query('INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES ($1, 1, TRUE)', [taskId]);
|
|
74
|
+
await client.query('COMMIT');
|
|
75
|
+
return 'claimed';
|
|
76
|
+
} catch (e) {
|
|
77
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
78
|
+
throw e;
|
|
79
|
+
} finally { client.release(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await t('core_275 adds a nullable auto_pickup column', async () => {
|
|
83
|
+
const { rows } = await pool.query(`SELECT is_nullable, data_type FROM information_schema.columns WHERE table_name='claims' AND column_name='auto_pickup'`);
|
|
84
|
+
assert.equal(rows[0].data_type, 'boolean');
|
|
85
|
+
assert.equal(rows[0].is_nullable, 'YES');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('two runners racing for the last slot: exactly one wins', async () => {
|
|
89
|
+
await reset();
|
|
90
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES (3, 1, TRUE)`); // cap 2, one used
|
|
91
|
+
const settings = { mode: 'on', cap: 2 };
|
|
92
|
+
const results = await Promise.all([markedClaim(1, settings, 300), markedClaim(2, settings, 300)]);
|
|
93
|
+
assert.deepEqual(results.sort(), ['AUTO_PICKUP_CAP_REACHED', 'claimed'], JSON.stringify(results));
|
|
94
|
+
assert.equal(await countThisMonth(pool), 2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('a round applied twice automatically refuses a third marked claim', async () => {
|
|
98
|
+
await reset();
|
|
99
|
+
const settings = { mode: 'on', cap: 20 };
|
|
100
|
+
assert.equal(await markedClaim(1, settings), 'claimed');
|
|
101
|
+
assert.equal(await markedClaim(1, settings), 'claimed');
|
|
102
|
+
assert.equal(await markedClaim(1, settings), 'AUTO_PICKUP_ROUND_LIMIT');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('unmarked claims and last month\'s marked claims do not count', async () => {
|
|
106
|
+
await reset();
|
|
107
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup, claimed_at) VALUES
|
|
108
|
+
(1, 1, NULL, now()), (2, 1, FALSE, now()), (3, 1, TRUE, date_trunc('month', now()) - interval '1 day'),
|
|
109
|
+
(4, 1, TRUE, now())`); // task 4 is not a page tweak
|
|
110
|
+
assert.equal(await countThisMonth(pool), 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await pool.end();
|
|
114
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
115
|
+
process.exit(failed ? 1 : 0); // the doorway's own pool would otherwise hold the process open
|
|
@@ -282,6 +282,42 @@ try {
|
|
|
282
282
|
assert.equal((t.description.match(/```page-tweak\n/g) || []).length, 1);
|
|
283
283
|
assert.equal(t.status, 'ready');
|
|
284
284
|
});
|
|
285
|
+
// Task 1004696: the artist recommends a line be removed. The real routes:
|
|
286
|
+
// the save stores it, the editor read returns it, and the submit hands it to
|
|
287
|
+
// an engineer as a removal, never into the applier's batch.
|
|
288
|
+
await test('a removal is saved, read back, and submitted as an engineer task to take the line out', async () => {
|
|
289
|
+
const PROJECTS = '/copy-desk/pages/landing:projects';
|
|
290
|
+
const PROJECT_LINES = [
|
|
291
|
+
L('L0001', 'hero', 'Projects', 'placed', { file: 'modules/public-landing/public/projects.html', line: 5, string_id: 'pj0001' }),
|
|
292
|
+
L('L0002', 'hero', 'Every project on this instance, newest first', 'placed', { file: 'modules/public-landing/public/projects.html', line: 6, string_id: 'pj0002' }),
|
|
293
|
+
];
|
|
294
|
+
const r0 = readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h2', lines: INDEX_LINES });
|
|
295
|
+
r0.pages.push({ id: 'landing:projects', reading_hash: 'projects-h1', lines: PROJECT_LINES });
|
|
296
|
+
writeReadings(r0);
|
|
297
|
+
assert.equal((await call('POST', `${PROJECTS}/claim`, ALICE)).status, 201);
|
|
298
|
+
|
|
299
|
+
const empty = await call('PUT', `${PROJECTS}/draft`, ALICE, { reading_hash: 'projects-h1', lines: [{ key: 'L0002', after: '' }] });
|
|
300
|
+
assert.equal(errCode(empty.body), 'line_empty', 'empty words alone never remove a line');
|
|
301
|
+
assert.match(errDetail(empty.body).what_this_means, /remove: true/, 'the refusal says how to ask for a removal');
|
|
302
|
+
|
|
303
|
+
const saved = await call('PUT', `${PROJECTS}/draft`, ALICE, { reading_hash: 'projects-h1', lines: [{ key: 'L0001', after: 'All projects' }, { key: 'L0002', remove: true }] });
|
|
304
|
+
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
305
|
+
assert.deepEqual(saved.body.counter, { changed: 2, total: 2 });
|
|
306
|
+
const read = await call('GET', `${PROJECTS}/draft`, ALICE);
|
|
307
|
+
assert.deepEqual(read.body.draft.lines, [{ key: 'L0001', after: 'All projects' }, { key: 'L0002', after: '', remove: true }]);
|
|
308
|
+
|
|
309
|
+
const r = await call('POST', `${PROJECTS}/submit`, ALICE);
|
|
310
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
311
|
+
assert.equal(r.body.batch.line_count, 1, 'only the rewrite is the applier\'s');
|
|
312
|
+
const round = db.tasks.find((t) => t.source_ref === 'page-tweak/landing:projects/r1');
|
|
313
|
+
assert.deepEqual(pages.parseBatchBlock(round.description).batch.lines.map((l) => l.key), ['L0001']);
|
|
314
|
+
const eng = db.tasks.find((t) => t.source_ref === `page-tweak-unplaced/${round.id}/L0002`);
|
|
315
|
+
assert.ok(eng, 'the removal is an engineer task, keyed like any engineer line');
|
|
316
|
+
assert.equal(eng.title, 'Remove "Every project on this instance, newest first" (landing:projects)');
|
|
317
|
+
assert.match(eng.description, /stop showing a line/);
|
|
318
|
+
assert.match(eng.description, /take it out/);
|
|
319
|
+
assert.deepEqual([eng.status, eng.discipline], ['backlog', 'engineer']);
|
|
320
|
+
});
|
|
285
321
|
} finally {
|
|
286
322
|
server.close();
|
|
287
323
|
pageData._setRoot(null);
|
|
@@ -242,6 +242,7 @@ const BAD = 'x';
|
|
|
242
242
|
const REQUESTS = {
|
|
243
243
|
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
244
244
|
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
245
|
+
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
245
246
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
246
247
|
'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
|
|
247
248
|
'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
|
|
@@ -188,21 +188,37 @@ try {
|
|
|
188
188
|
assert.deepEqual(lib.noteFor(byKey.L0002, 'Projects', ed.notes), { kind: 'engineer', text: 'goes to an engineer' }, 'shared with no registry row goes to an engineer, the submit\'s rule');
|
|
189
189
|
assert.deepEqual(lib.noteFor(byKey.L0004, 'Agents write the code.', ed.notes), { kind: 'was', text: 'was: AI agents claim tasks and ship the code.' });
|
|
190
190
|
assert.equal(lib.noteFor(byKey.L0003, 'Building Digitally,', ed.notes), null);
|
|
191
|
+
// An emptied line says it will be removed, ahead of any other note (task 1004696).
|
|
192
|
+
assert.deepEqual(lib.noteFor(byKey.L0004, ' ', ed.notes), { kind: 'removed', text: 'to be removed · goes to an engineer · was: AI agents claim tasks and ship the code.' });
|
|
193
|
+
assert.equal(lib.noteFor(byKey.L0001, '', ed.notes).kind, 'removed');
|
|
191
194
|
});
|
|
192
195
|
|
|
193
|
-
await test('the autosave body is the WHOLE draft: every changed,
|
|
196
|
+
await test('the autosave body is the WHOLE draft: every changed line, an emptied one as a removal, and nothing else', () => {
|
|
194
197
|
const values = lib.initialValues(READING.lines, { lines: [{ key: 'L0004', after: 'Agents write the code.' }] });
|
|
195
|
-
values.L0003 = '';
|
|
198
|
+
values.L0003 = ' '; // emptied: the artist recommends removing it (task 1004696)
|
|
196
199
|
values.L0006 = 'Built by agents.'; // back to the page's words: not a rewrite
|
|
197
200
|
values.L0007 = 'Log in';
|
|
198
201
|
assert.deepEqual(lib.draftBody('rh-1', READING.lines, values), {
|
|
199
202
|
reading_hash: 'rh-1',
|
|
200
|
-
lines: [{ key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }],
|
|
203
|
+
lines: [{ key: 'L0003', remove: true }, { key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }],
|
|
201
204
|
});
|
|
205
|
+
assert.equal(lib.changedCount(READING.lines, values), 3, 'a removal counts as a change');
|
|
202
206
|
assert.equal(lib.counterText(2, 7, 'saving'), '2 of 7 rewritten · saving…');
|
|
203
207
|
assert.equal(lib.counterText(2, 7, 'saved'), '2 of 7 rewritten · saved');
|
|
204
208
|
});
|
|
205
209
|
|
|
210
|
+
await test('a removal the server holds reads back as an empty value, so the next save from ANY editor keeps it (task 1004696)', () => {
|
|
211
|
+
const draft = { lines: [{ key: 'L0003', after: '', remove: true }, { key: 'L0004', after: 'Agents write the code.' }] };
|
|
212
|
+
const values = lib.initialValues(READING.lines, draft);
|
|
213
|
+
assert.equal(values.L0003, '');
|
|
214
|
+
assert.equal(lib.isRemoved(READING.lines.find((l) => l.key === 'L0003'), values.L0003), true);
|
|
215
|
+
values.L0007 = 'Log in'; // the tweak editor or the studio saves something else
|
|
216
|
+
assert.deepEqual(lib.draftBody('rh-1', READING.lines, values).lines,
|
|
217
|
+
[{ key: 'L0003', remove: true }, { key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }]);
|
|
218
|
+
values.L0003 = 'Building, digitally'; // typed over: the rewrite replaces the removal
|
|
219
|
+
assert.deepEqual(lib.draftBody('rh-1', READING.lines, values).lines[0], { key: 'L0003', after: 'Building, digitally' });
|
|
220
|
+
});
|
|
221
|
+
|
|
206
222
|
await test('names, addresses and the page id', () => {
|
|
207
223
|
assert.equal(lib.displayTitle(PAGE), 'Home', 'a templated title on "/" is the page called Home');
|
|
208
224
|
assert.equal(lib.displayTitle(NEXT.recommended), 'Projects');
|
package/tests/paintbrush.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// else's view changes — the page's words come back when editing ends, and the
|
|
10
10
|
// only writes are the copy desk's own page-round routes (owner, 2026-10-04: the
|
|
11
11
|
// edits are real wording changes, kept apart from the palette's feedback docs).
|
|
12
|
+
// Task 1004696 adds removing a line (saved as { key, remove: true }, sent to an
|
|
13
|
+
// engineer) and the artist review one press away, while editing and once sent.
|
|
12
14
|
import assert from 'node:assert/strict';
|
|
13
15
|
import { test } from 'node:test';
|
|
14
16
|
import fs from 'node:fs';
|
|
@@ -176,7 +178,16 @@ function boot({ round = null, draft = null, claim = { status: 200, body: { ok: t
|
|
|
176
178
|
};
|
|
177
179
|
const type = (el, text) => { el.textContent = text; fire('input', { target: el }); };
|
|
178
180
|
const editable = () => p.body.querySelectorAll('*').filter((n) => n.hasAttribute('contenteditable'));
|
|
179
|
-
|
|
181
|
+
// The review sheet's script, as the page would load it: answer the tag the
|
|
182
|
+
// paintbrush appended with a stand-in OTBPageNotes that records its opening.
|
|
183
|
+
const notesOpened = [];
|
|
184
|
+
const loadNotes = () => {
|
|
185
|
+
const tag = head.children.find((c) => /\/builders\/page-notes\.js\?v=2026-10-04-test$/.test(c.src || ''));
|
|
186
|
+
assert.ok(tag, 'the review sheet\'s own script was asked for, at the paintbrush\'s ?v');
|
|
187
|
+
window.OTBPageNotes = { open: () => notesOpened.push('open') };
|
|
188
|
+
tag.onload();
|
|
189
|
+
};
|
|
190
|
+
return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, head, loadNotes, notesOpened, writes: () => calls.filter((c) => c.method !== 'GET') };
|
|
180
191
|
}
|
|
181
192
|
const settle = () => new Promise((r) => setTimeout(r, 5));
|
|
182
193
|
|
|
@@ -310,7 +321,10 @@ test('the only routes it writes are the copy desk\'s page round — never page-n
|
|
|
310
321
|
const routes = [...BRUSH.matchAll(/request\('(POST|PUT|PATCH|DELETE)', [^,]*?'\/([a-z-]+)'/g)].map((m) => m[1] + ' ' + m[2]);
|
|
311
322
|
assert.deepEqual(routes.sort(), ['POST claim', 'POST submit', 'PUT draft']);
|
|
312
323
|
assert.match(BRUSH, /var API = '\/api\/bongos\/copy-desk\/pages\/';/);
|
|
313
|
-
assert.doesNotMatch(BRUSH,
|
|
324
|
+
assert.doesNotMatch(BRUSH, /\/api\/bongos\/page-notes|\/page-notes\//, 'no feedback-doc route');
|
|
325
|
+
// Task 1004696: it may OPEN the artist review sheet (which writes its own
|
|
326
|
+
// doc); that script is the only page-notes thing it names.
|
|
327
|
+
assert.deepEqual([...BRUSH.matchAll(/page-notes[\w.-]*/g)].map((m) => m[0]).filter((w) => w !== 'page-notes.js'), []);
|
|
314
328
|
});
|
|
315
329
|
|
|
316
330
|
test('its styles live in style.css, on the token tier', () => {
|
|
@@ -342,6 +356,141 @@ test('words only: bold, italic and underline keys do nothing in the words; elsew
|
|
|
342
356
|
assert.equal(prevented, 3, 'a key outside the edited words is not touched');
|
|
343
357
|
});
|
|
344
358
|
|
|
359
|
+
// ---------- removing a line (task 1004696) ----------
|
|
360
|
+
const held = () => boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
361
|
+
|
|
362
|
+
test('REMOVE THIS LINE acts on the line the artist is in: saved as a removal, struck through, and KEEP puts it back', async () => {
|
|
363
|
+
const p = held();
|
|
364
|
+
p.brush.open();
|
|
365
|
+
await settle();
|
|
366
|
+
assert.match(p.bar().innerHTML, /Where the build is going<\/span><button[^>]*data-pb-remove>/, 'the first line is offered from the start, even when the window fires no focus');
|
|
367
|
+
p.fire('focusin', { target: p.lede });
|
|
368
|
+
assert.match(p.bar().innerHTML, /Every version, from first idea to shipped\.<\/span><button[^>]*data-pb-remove>Remove this line/);
|
|
369
|
+
p.press('data-pb-remove');
|
|
370
|
+
await settle();
|
|
371
|
+
const put = p.writes().filter((c) => c.method === 'PUT').pop();
|
|
372
|
+
assert.deepEqual(JSON.parse(JSON.stringify(put.body.lines)), [{ key: 'L0003', remove: true }], 'saved as a removal, never as empty words');
|
|
373
|
+
assert.ok(p.lede.classList.contains('pb-word--removed'));
|
|
374
|
+
assert.ok(!p.lede.hasAttribute('contenteditable'), 'a removed line is not typed into');
|
|
375
|
+
assert.equal(p.lede.getAttribute('tabindex'), '0', 'but it can still be reached, to keep it');
|
|
376
|
+
assert.equal(p.lede.textContent, 'Every version, from first idea to shipped.', 'it shows the page\'s words, struck through');
|
|
377
|
+
assert.match(p.bar().innerHTML, /<strong>1 change<\/strong>/);
|
|
378
|
+
assert.match(p.bar().innerHTML, /data-pb-keep>Keep this line/);
|
|
379
|
+
p.press('data-pb-keep');
|
|
380
|
+
await settle();
|
|
381
|
+
assert.ok(!p.lede.classList.contains('pb-word--removed'));
|
|
382
|
+
assert.equal(p.lede.getAttribute('contenteditable'), 'plaintext-only', 'editable again');
|
|
383
|
+
assert.deepEqual(JSON.parse(JSON.stringify(p.writes().filter((c) => c.method === 'PUT').pop().body.lines)), [], 'and the removal leaves the draft');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('emptying a line\'s words and leaving it marks it to be removed, instead of silently dropping it', async () => {
|
|
387
|
+
const p = held();
|
|
388
|
+
p.brush.open();
|
|
389
|
+
await settle();
|
|
390
|
+
p.type(p.h1, ' ');
|
|
391
|
+
assert.equal(p.h1.getAttribute('contenteditable'), 'plaintext-only', 'still editable while the artist is in it: they may be retyping');
|
|
392
|
+
p.fire('focusout', { target: p.h1 });
|
|
393
|
+
await settle();
|
|
394
|
+
assert.ok(p.h1.classList.contains('pb-word--removed'));
|
|
395
|
+
assert.equal(p.h1.textContent, 'Where the build is going');
|
|
396
|
+
const put = p.writes().filter((c) => c.method === 'PUT').pop();
|
|
397
|
+
assert.deepEqual(JSON.parse(JSON.stringify(put.body.lines)), [{ key: 'L0002', remove: true }]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('REVIEW lists a removal as its own row, says it goes to an engineer, and SEND submits it with the rewrites', async () => {
|
|
401
|
+
const p = held();
|
|
402
|
+
p.brush.open();
|
|
403
|
+
await settle();
|
|
404
|
+
p.type(p.h1, 'Where we are going');
|
|
405
|
+
p.fire('focusin', { target: p.linkWords });
|
|
406
|
+
p.press('data-pb-remove');
|
|
407
|
+
p.press('data-pb-review');
|
|
408
|
+
await settle();
|
|
409
|
+
const html = p.bar().innerHTML;
|
|
410
|
+
assert.match(html, /pb__change--remove[\s\S]*Remove<\/span> See every goal/);
|
|
411
|
+
assert.match(html, /A line you remove goes to an engineer to take out\./);
|
|
412
|
+
p.press('data-pb-send');
|
|
413
|
+
await settle();
|
|
414
|
+
const lastPut = p.writes().filter((c) => c.method === 'PUT').pop();
|
|
415
|
+
assert.deepEqual(JSON.parse(JSON.stringify(lastPut.body.lines)), [{ key: 'L0002', after: 'Where we are going' }, { key: 'L0004', remove: true }]);
|
|
416
|
+
assert.match(p.bar().innerHTML, /Sent for approval: 2 changes/);
|
|
417
|
+
assert.equal(p.linkWords.textContent, 'See every goal', 'the page shows its own words again');
|
|
418
|
+
assert.ok(!p.linkWords.classList.contains('pb-word--removed') && !p.linkWords.hasAttribute('tabindex'), 'and nothing of the mark is left behind');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('a removal saved earlier comes back struck through when editing resumes', async () => {
|
|
422
|
+
const p = boot({
|
|
423
|
+
round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } },
|
|
424
|
+
draft: { reading_hash: 'abc123', lines: [{ key: 'L0003', after: '', remove: true }] },
|
|
425
|
+
});
|
|
426
|
+
p.brush.open();
|
|
427
|
+
await settle();
|
|
428
|
+
assert.ok(p.lede.classList.contains('pb-word--removed'));
|
|
429
|
+
assert.equal(p.lede.textContent, 'Every version, from first idea to shipped.');
|
|
430
|
+
assert.match(p.bar().innerHTML, /1 change/);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ---------- the artist review alongside (task 1004696) ----------
|
|
434
|
+
test('ARTIST REVIEW in the editing bar saves the words, opens the review sheet for this page, and editing carries on', async () => {
|
|
435
|
+
const p = held();
|
|
436
|
+
p.brush.open();
|
|
437
|
+
await settle();
|
|
438
|
+
assert.match(p.bar().innerHTML, /data-pb-notes[^>]*>Artist review</);
|
|
439
|
+
p.type(p.h1, 'A heading, mid-edit');
|
|
440
|
+
p.press('data-pb-notes');
|
|
441
|
+
await settle();
|
|
442
|
+
assert.ok(p.writes().some((c) => c.method === 'PUT'), 'the words were saved before the sheet opened');
|
|
443
|
+
p.loadNotes();
|
|
444
|
+
await settle();
|
|
445
|
+
assert.deepEqual(p.notesOpened, ['open']);
|
|
446
|
+
assert.ok(p.bar(), 'the paintbrush stays');
|
|
447
|
+
assert.equal(p.h1.getAttribute('contenteditable'), 'plaintext-only', 'and the words are still being edited');
|
|
448
|
+
assert.ok(!p.writes().some((c) => /page-notes/.test(c.url)), 'the paintbrush itself writes no feedback doc');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('once SENT, the bar asks whether to review how the page looks too: one press opens the sheet, Not now closes', async () => {
|
|
452
|
+
const p = held();
|
|
453
|
+
p.brush.open();
|
|
454
|
+
await settle();
|
|
455
|
+
p.type(p.h1, 'Where we are going');
|
|
456
|
+
p.press('data-pb-review');
|
|
457
|
+
p.press('data-pb-send');
|
|
458
|
+
await settle();
|
|
459
|
+
const html = p.bar().innerHTML;
|
|
460
|
+
assert.match(html, /Review how this page looks too\?/);
|
|
461
|
+
assert.match(html, /each point becomes an idea under your name/);
|
|
462
|
+
assert.match(html, /data-pb-notes data-pb-focus>Start an artist review/);
|
|
463
|
+
p.press('data-pb-notes');
|
|
464
|
+
await settle();
|
|
465
|
+
p.loadNotes();
|
|
466
|
+
await settle();
|
|
467
|
+
assert.deepEqual(p.notesOpened, ['open']);
|
|
468
|
+
assert.equal(p.bar(), null, 'the sent bar\'s work is done, so it closes');
|
|
469
|
+
|
|
470
|
+
const q = held();
|
|
471
|
+
q.brush.open();
|
|
472
|
+
await settle();
|
|
473
|
+
q.type(q.h1, 'Another heading');
|
|
474
|
+
q.press('data-pb-review');
|
|
475
|
+
q.press('data-pb-send');
|
|
476
|
+
await settle();
|
|
477
|
+
q.press('data-pb-close');
|
|
478
|
+
assert.equal(q.bar(), null);
|
|
479
|
+
assert.equal(q.head.children.filter((c) => /page-notes/.test(c.src || '')).length, 0, 'Not now loads nothing');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('a review sheet that cannot load says so in the bar and points to the top bar\'s Review', async () => {
|
|
483
|
+
const p = held();
|
|
484
|
+
p.brush.open();
|
|
485
|
+
await settle();
|
|
486
|
+
p.press('data-pb-notes');
|
|
487
|
+
await settle();
|
|
488
|
+
const tag = p.head.children.find((c) => /page-notes\.js/.test(c.src || ''));
|
|
489
|
+
tag.onerror();
|
|
490
|
+
await settle();
|
|
491
|
+
assert.match(p.bar().innerHTML, /The artist review could not open\. Press Review in the top bar instead\./);
|
|
492
|
+
});
|
|
493
|
+
|
|
345
494
|
test('a word on the page matches its line however its spaces wrap, and one table serves every element', async () => {
|
|
346
495
|
const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
|
|
347
496
|
p.lede.textContent = ' Every version,\n from first idea to shipped. ';
|