@bongos/core 1.19.1067 → 1.19.1069
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 +73 -58
- package/docs/copy-inventory.md +74 -53
- package/docs/copy-registry.json +310 -101
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -2
- package/docs/page-readings.json +566 -536
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +23 -32
- package/modules/hall-ui/public/studio-lib.js +207 -0
- package/modules/hall-ui/public/studio.css +559 -170
- package/modules/hall-ui/public/studio.html +143 -49
- package/modules/hall-ui/public/studio.js +442 -33
- package/modules/hall-ui/public/studio.states.json +164 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/studio.md +47 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +4 -0
- package/tests/hall_nav.mjs +25 -1
- package/tests/hall_page_gate_map.mjs +2 -0
- package/tests/hall_studio_home.mjs +429 -0
- package/tests/hall_studio_world.mjs +7 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// tests/hall_studio_home.mjs — the studio home (task 1003971 / BV2.TW16, ADR
|
|
2
|
+
// 0341 D2, D5, D8 and D11; docs/design/mocks/tweak-mode/Main.dc.html).
|
|
3
|
+
//
|
|
4
|
+
// Two halves. The pure rules (studio-lib.js) run over what the REAL composers
|
|
5
|
+
// (modules/copy-desk/page-status.js) answer for a small ledger, so the words the
|
|
6
|
+
// studio shows are tested on the routes' own shapes, never on a literal of them.
|
|
7
|
+
// Then the REAL page: studio.html's own body in a mini-DOM, with the real
|
|
8
|
+
// dom-utils.js, tweak-editor-lib.js, studio-lib.js and studio.js in a vm over a
|
|
9
|
+
// stub client, driven the way an artist would: the tally, Resume, the three
|
|
10
|
+
// panels, "take it", and the studio's own words typed over in place.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { makeRunner, makeEl, parseInto, walk, settle } from './helpers.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
23
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
24
|
+
const lib = require('../modules/hall-ui/public/studio-lib.js');
|
|
25
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
26
|
+
const ps = require('../modules/copy-desk/page-status.js');
|
|
27
|
+
|
|
28
|
+
const { test, summary } = makeRunner();
|
|
29
|
+
|
|
30
|
+
// ── a small ledger, through the real composers ──────────────────────────────
|
|
31
|
+
const ME = '7';
|
|
32
|
+
const NOW = new Date('2026-09-28T12:00:00.000Z');
|
|
33
|
+
const day = (n) => new Date(NOW.getTime() - n * 86400000).toISOString();
|
|
34
|
+
const INV = {
|
|
35
|
+
surfaces: [{ id: 'landing' }, { id: 'builders' }, { id: 'status' }],
|
|
36
|
+
pages: [
|
|
37
|
+
{ id: 'landing:index', surface: 'landing', title: '{{productName}}: {{tagline}}', path: '/', files: [] },
|
|
38
|
+
{ id: 'landing:projects', surface: 'landing', title: '{{productName}} — projects', path: '/projects', files: [] },
|
|
39
|
+
{ id: 'builders:studio', surface: 'builders', title: 'Studio', path: '/studio', files: [] },
|
|
40
|
+
{ id: 'builders:work', surface: 'builders', title: 'Tasks', path: '/work', files: [] },
|
|
41
|
+
{ id: 'status:index', surface: 'status', title: 'Status', path: '/', files: [] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const page = (id) => INV.pages.find((p) => p.id === id);
|
|
45
|
+
const TASKS = [
|
|
46
|
+
{ id: '11', source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 1), status: 'shipped', shipped_at: day(9), artist_id: ME, description: pages.roundTaskBody(page('landing:index'), 1) },
|
|
47
|
+
{ id: '12', source: 'page-tweak', source_ref: pages.roundSourceRef('status:index', 1), status: 'shipped', shipped_at: day(2), artist_id: ME, description: pages.roundTaskBody(page('status:index'), 1) },
|
|
48
|
+
{ id: '13', source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 2), status: 'active', holder_id: ME, holder_login: 'me', holder_worktree: false, description: pages.roundTaskBody(page('landing:index'), 2) },
|
|
49
|
+
];
|
|
50
|
+
const CREDITS = [
|
|
51
|
+
{ reason: 'tweak.page_approved:11', delta: 30, recorded_at: day(9) },
|
|
52
|
+
{ reason: 'tweak.page_approved:12', delta: 30, recorded_at: day(2) },
|
|
53
|
+
];
|
|
54
|
+
const ASKS = [{ page_id: 'landing:projects', askers: 2, oldest_ask_at: day(3) }];
|
|
55
|
+
const TALLY = ps.composeTally({ builderId: ME, tasks: TASKS, creditRows: CREDITS, now: NOW });
|
|
56
|
+
const PAGES = ps.composePages({ inventory: INV, readings: null, tasks: TASKS });
|
|
57
|
+
const NEXT = ps.composeNext({ inventory: INV, readings: null, tasks: TASKS, asks: ASKS, builderId: ME });
|
|
58
|
+
|
|
59
|
+
// The studio's own reading (the shape page-reader.js writes): the greeting, the
|
|
60
|
+
// sub-line's template, and a generic holed line that must never win.
|
|
61
|
+
const L = (key, section, text, placement) => ({ key, section, text, placement, file: 'modules/hall-ui/public/studio.html', line: 1, string_id: key });
|
|
62
|
+
const STUDIO_READING = {
|
|
63
|
+
id: 'builders:studio',
|
|
64
|
+
reading_hash: 'rs-1',
|
|
65
|
+
lines: [
|
|
66
|
+
L('L0001', 'skip link', 'Skip to content', 'shared'),
|
|
67
|
+
L('L0002', 'Good evening.', 'Good evening.', 'placed'),
|
|
68
|
+
L('L0003', 'Good evening.', '{…}, on the {…} page, is where you left it. {…} of its {…} lines are yours so far.', 'placed'),
|
|
69
|
+
L('L0004', 'Good evening.', '{…} of {…}', 'placed'),
|
|
70
|
+
L('L0005', 'Good evening.', 'Approval queue', 'placed'),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
function studioRead({ held = false, draftLines = null } = {}) {
|
|
74
|
+
let description = pages.roundTaskBody(page('builders:studio'), 1);
|
|
75
|
+
if (draftLines) {
|
|
76
|
+
const r = pages.resolveDraft({ readingPage: STUDIO_READING, body: { reading_hash: 'rs-1', lines: draftLines }, savedAt: day(1) });
|
|
77
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
78
|
+
description += '\n\n' + pages.composeDraftBlock(r.draft);
|
|
79
|
+
}
|
|
80
|
+
const round = held ? { id: 70, status: 'active', holder_id: ME, holder_login: 'me', holder_worktree: false, description } : null;
|
|
81
|
+
return ps.composeEditor({ page: page('builders:studio'), readingPage: STUDIO_READING, round, builderId: ME });
|
|
82
|
+
}
|
|
83
|
+
const RESUME_READ = { ok: true, counter: { changed: 12, total: 38 } };
|
|
84
|
+
|
|
85
|
+
// ── the mini-DOM over the page's own markup ─────────────────────────────────
|
|
86
|
+
const HTML = read('studio.html');
|
|
87
|
+
const BODY = /<body[^>]*>([\s\S]*)<\/body>/.exec(HTML)[1].replace(/<script[\s\S]*?<\/script>/g, '');
|
|
88
|
+
const SOURCES = ['dom-utils.js', 'tweak-editor-lib.js', 'studio-lib.js', 'studio.js'].map((f) => [f, read(f)]);
|
|
89
|
+
let boot = null;
|
|
90
|
+
|
|
91
|
+
function augment(el) {
|
|
92
|
+
if (el._aug) return el;
|
|
93
|
+
el._aug = true;
|
|
94
|
+
const classes = () => String(el.className || '').split(/\s+/).filter(Boolean);
|
|
95
|
+
el.classList = {
|
|
96
|
+
add: (c) => { const s = new Set(classes()); s.add(c); el.className = [...s].join(' '); },
|
|
97
|
+
remove: (c) => { el.className = classes().filter((x) => x !== c).join(' '); },
|
|
98
|
+
toggle: (c, force) => { const on = force === undefined ? !classes().includes(c) : !!force; if (on) el.classList.add(c); else el.classList.remove(c); return on; },
|
|
99
|
+
contains: (c) => classes().includes(c),
|
|
100
|
+
};
|
|
101
|
+
for (const attr of ['hidden', 'disabled']) {
|
|
102
|
+
Object.defineProperty(el, attr, { get() { return attr in el.attrs; }, set(v) { if (v) el.attrs[attr] = ''; else delete el.attrs[attr]; }, configurable: true });
|
|
103
|
+
}
|
|
104
|
+
Object.defineProperty(el, 'href', { get() { return el.attrs.href || ''; }, set(v) { el.attrs.href = String(v); }, configurable: true });
|
|
105
|
+
Object.defineProperty(el, 'innerHTML', {
|
|
106
|
+
get() { return el._html === null ? '' : el._html; },
|
|
107
|
+
set(v) { el._html = String(v); parseInto(el, el._html); walk(el, (c) => { augment(c); return null; }); },
|
|
108
|
+
configurable: true,
|
|
109
|
+
});
|
|
110
|
+
el.focus = () => { boot.focused = el; };
|
|
111
|
+
el.blur = () => el.fire('blur', { target: el });
|
|
112
|
+
return el;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bootStudio({ answers = {}, signedIn = true } = {}) {
|
|
116
|
+
const root = augment(makeEl('body', {}));
|
|
117
|
+
root.innerHTML = BODY;
|
|
118
|
+
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
119
|
+
const docListeners = {};
|
|
120
|
+
const requests = [];
|
|
121
|
+
const htmlEl = augment(makeEl('html', {}));
|
|
122
|
+
const documentObj = {
|
|
123
|
+
body: root,
|
|
124
|
+
documentElement: htmlEl,
|
|
125
|
+
getElementById: byId,
|
|
126
|
+
querySelector: (sel) => root.querySelector(sel),
|
|
127
|
+
querySelectorAll: (sel) => root.querySelectorAll(sel),
|
|
128
|
+
createElement: (tag) => augment(makeEl(tag, {})),
|
|
129
|
+
addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); },
|
|
130
|
+
execCommand() {},
|
|
131
|
+
};
|
|
132
|
+
const client = {
|
|
133
|
+
request: async (method, url, body) => {
|
|
134
|
+
requests.push({ method, url, body });
|
|
135
|
+
const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
|
|
136
|
+
if (key === 'GET /me') return signedIn ? ok({ builder: { id: ME, display_name: 'Artist A', rank: 'metic' } }) : { ok: false, status: 401, data: {} };
|
|
137
|
+
const a = answers[key];
|
|
138
|
+
if (typeof a === 'function') return a(body);
|
|
139
|
+
if (a) return a;
|
|
140
|
+
return { status: 404, ok: false, data: { error: { code: 'no_answer', message: key } } };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const windowObj = {
|
|
144
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
145
|
+
BongosClient: { createClient: () => client },
|
|
146
|
+
location: { host: 'builders.cloudbongos.com', hostname: 'builders.cloudbongos.com', protocol: 'https:', search: '', pathname: '/studio', assign: (u) => { out.navigated = u; } },
|
|
147
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
148
|
+
addEventListener() {},
|
|
149
|
+
scrollTo() {},
|
|
150
|
+
};
|
|
151
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, encodeURIComponent };
|
|
152
|
+
sandbox.globalThis = sandbox;
|
|
153
|
+
vm.createContext(sandbox);
|
|
154
|
+
for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
|
|
155
|
+
const out = {
|
|
156
|
+
requests, navigated: null, focused: null, el: byId, html: htmlEl,
|
|
157
|
+
writes: () => requests.filter((r) => r.method !== 'GET'),
|
|
158
|
+
click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); el.fire('click', { target: el }); await out.settle(); },
|
|
159
|
+
clickIn: async (overlayId, sel) => {
|
|
160
|
+
const overlay = byId(overlayId);
|
|
161
|
+
const target = overlay.querySelector(sel);
|
|
162
|
+
assert.ok(target, `no ${sel} in #${overlayId}`);
|
|
163
|
+
overlay.fire('click', { target });
|
|
164
|
+
await out.settle();
|
|
165
|
+
},
|
|
166
|
+
settle: async () => { for (let i = 0; i < 10; i++) await settle(); },
|
|
167
|
+
};
|
|
168
|
+
boot = out;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
172
|
+
const HOME = {
|
|
173
|
+
'GET /copy-desk/tally': ok(TALLY),
|
|
174
|
+
'GET /copy-desk/next': ok(NEXT),
|
|
175
|
+
'GET /copy-desk/pages': ok(PAGES),
|
|
176
|
+
'GET /copy-desk/pages/landing:index/draft': ok(RESUME_READ),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
// ── the pure half, over the composers' answers ────────────────────────────
|
|
181
|
+
await test('the tally reads the composer: credits, pages tweaked, and this week with its sign', () => {
|
|
182
|
+
assert.deepEqual(lib.tallyParts(TALLY), { credits: '60', pages: '2', week: '+30' });
|
|
183
|
+
assert.deepEqual(lib.tallyParts({ credits: 0, pages_tweaked: 0, this_week: 0 }), { credits: '0', pages: '0', week: '0' }, 'a quiet week is 0, not +0');
|
|
184
|
+
assert.equal(lib.tallyParts(null), null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('Resume and the sub-line name the held page as the artist reads it, in the artboard\'s words', () => {
|
|
188
|
+
assert.equal(NEXT.resume.page_id, 'landing:index', 'the composer reports the held round as resume');
|
|
189
|
+
assert.equal(lib.resumeDetail(NEXT.resume, RESUME_READ.counter), 'Home · landing · 12 of 38 lines');
|
|
190
|
+
assert.equal(lib.resumeDetail(NEXT.resume, null), 'Home · landing');
|
|
191
|
+
assert.deepEqual(lib.subParts(NEXT.resume, RESUME_READ.counter),
|
|
192
|
+
{ title: 'Home', surface: 'landing', counted: true, changed: 'Twelve', total: 'thirty-eight' });
|
|
193
|
+
assert.equal(lib.subParts(null, null), null, 'nothing held: the page\'s own sentence stands');
|
|
194
|
+
assert.equal(lib.numberWords(0, { capital: true }), 'None');
|
|
195
|
+
assert.equal(lib.numberWords(75), 'seventy-five');
|
|
196
|
+
assert.equal(lib.numberWords(120), 'one hundred and twenty');
|
|
197
|
+
assert.equal(lib.numberWords(1200), '1200');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('the three details: next up, the approvals count, and the whole count', () => {
|
|
201
|
+
assert.equal(lib.flowDetail(NEXT), 'next up: Projects — 2 people asked for it');
|
|
202
|
+
assert.equal(lib.flowDetail({ recommended: null }), 'every page is taken for now');
|
|
203
|
+
assert.equal(lib.statusDetail(PAGES), '2 of 5 pages tweaked');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await test('Artist Review Status: landing, builders hall, status, each N of M, the bar never empty', () => {
|
|
207
|
+
const rows = lib.surfaceRows(PAGES, 'cloudbongos.com');
|
|
208
|
+
assert.deepEqual(rows.map((r) => [r.name, r.where, r.count, r.pct]), [
|
|
209
|
+
['Landing', 'cloudbongos.com', '1 of 2 tweaked', 50],
|
|
210
|
+
['Builders hall', 'where the work happens', '0 of 2 tweaked', 3],
|
|
211
|
+
['Status', 'the public dashboard', '1 of 1 tweaked', 100],
|
|
212
|
+
]);
|
|
213
|
+
assert.equal(lib.landingHost('https://cloudbongos.com/', 'builders.x.test'), 'cloudbongos.com', 'the stamped origin wins');
|
|
214
|
+
assert.equal(lib.landingHost('{{publicOrigin}}', 'builders.x.test'), 'x.test', 'an unstamped token is no address');
|
|
215
|
+
assert.equal(lib.askedLine(NEXT.asked_pages), '1 page has been asked for. It comes up first in the Tweak/CopyWrite Flow.');
|
|
216
|
+
assert.equal(lib.askedLine(4), '4 pages have been asked for. They come up first in the Tweak/CopyWrite Flow.');
|
|
217
|
+
assert.equal(lib.askedLine(0), '');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('a word on the page finds its own line: the most specific wins, holes alone never match, shared never', () => {
|
|
221
|
+
const sentence = 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.';
|
|
222
|
+
assert.equal(lib.matchLine(sentence, STUDIO_READING.lines).key, 'L0003', 'not the generic "{…} of {…}"');
|
|
223
|
+
assert.equal(lib.matchLine('Good evening.', STUDIO_READING.lines).key, 'L0002');
|
|
224
|
+
assert.equal(lib.matchLine('Nine of ten', STUDIO_READING.lines), null, 'a line of holes and one word is no match for a sentence');
|
|
225
|
+
assert.equal(lib.matchLine('Skip to content', STUDIO_READING.lines), null, 'shell text is not the studio\'s own');
|
|
226
|
+
assert.equal(lib.matchLine('Something new', STUDIO_READING.lines), null);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('a refused claim, in words', () => {
|
|
230
|
+
assert.equal(lib.claimWords('page_held', { holder: { login: 'bob' } }), 'bob is tweaking this page right now.');
|
|
231
|
+
assert.match(lib.claimWords('not_an_artist', {}), /Only an artist can take a page/);
|
|
232
|
+
assert.equal(lib.claimWords('page_in_flight', { what_this_means: 'The server says so.' }), 'The server says so.');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── the real page ─────────────────────────────────────────────────────────
|
|
236
|
+
await test('the home paints from the four reads: tally, Resume into the editor, the sub-line, the details', async () => {
|
|
237
|
+
const s = bootStudio({ answers: HOME });
|
|
238
|
+
await s.settle();
|
|
239
|
+
assert.equal(s.el('st-tally').hidden, false);
|
|
240
|
+
assert.deepEqual(['st-tally-credits', 'st-tally-pages', 'st-tally-week'].map((id) => s.el(id).textContent), ['60', '2', '+30']);
|
|
241
|
+
assert.equal(s.el('st-resume').hidden, false);
|
|
242
|
+
assert.equal(s.el('st-resume').getAttribute('href'), '/builders/tweak-editor?page=landing:index');
|
|
243
|
+
assert.equal(s.el('st-resume-detail').textContent, 'Home · landing · 12 of 38 lines');
|
|
244
|
+
assert.equal(s.el('studio-sub').textContent, 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.');
|
|
245
|
+
assert.equal(s.el('st-flow-detail').textContent, 'next up: Projects — 2 people asked for it');
|
|
246
|
+
assert.equal(s.el('st-status-detail').textContent, '2 of 5 pages tweaked');
|
|
247
|
+
assert.deepEqual(s.writes(), [], 'opening the studio writes nothing');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await test('nothing held: no Resume, and the page\'s own sub-line stands', async () => {
|
|
251
|
+
const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/next': ok({ ...NEXT, resume: null }) } });
|
|
252
|
+
await s.settle();
|
|
253
|
+
assert.equal(s.el('st-resume').hidden, true);
|
|
254
|
+
assert.match(s.el('studio-sub').textContent, /^Nothing is on your desk yet\./);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('signed out: no tally, no reads of the ledger', async () => {
|
|
258
|
+
const s = bootStudio({ answers: HOME, signedIn: false });
|
|
259
|
+
await s.settle();
|
|
260
|
+
assert.equal(s.el('st-tally').hidden, true);
|
|
261
|
+
assert.equal(s.requests.filter((r) => /copy-desk\/(tally|next|pages)/.test(r.url)).length, 0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('the Tweak/CopyWrite Flow: the recommendation, pick another swaps it, take it claims THAT page and opens it', async () => {
|
|
265
|
+
const s = bootStudio({ answers: { ...HOME, 'POST /copy-desk/pages/builders:studio/claim': ok({ ok: true, outcome: 'created' }) } });
|
|
266
|
+
await s.settle();
|
|
267
|
+
await s.click('st-flow');
|
|
268
|
+
assert.equal(s.el('st-panel').hidden, false);
|
|
269
|
+
assert.ok(s.html.classList.contains('st-open'));
|
|
270
|
+
assert.equal(s.el('st-panel-title').textContent, 'Tweak/CopyWrite Flow');
|
|
271
|
+
const body = () => s.el('st-panel-body').innerHTML;
|
|
272
|
+
assert.match(body(), /st-next__page">Projects</);
|
|
273
|
+
assert.match(body(), /landing · 2 people asked for it/);
|
|
274
|
+
await s.clickIn('st-panel', '[data-pick]');
|
|
275
|
+
assert.doesNotMatch(body(), /id="st-picks" hidden/, 'the chips open');
|
|
276
|
+
assert.match(body(), /data-pick-id="builders:studio">Studio · builders hall</);
|
|
277
|
+
await s.clickIn('st-panel', '[data-pick-id="builders:studio"]');
|
|
278
|
+
assert.match(body(), /st-next__page">Studio</, 'the picked page is now the one to take');
|
|
279
|
+
await s.clickIn('st-panel', '[data-take]');
|
|
280
|
+
assert.deepEqual(s.writes().map((w) => `${w.method} ${w.url}`), ['POST /api/bongos/copy-desk/pages/builders:studio/claim']);
|
|
281
|
+
assert.equal(s.navigated, '/builders/tweak-editor?page=builders:studio');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await test('a refused take stays in the panel and says why', async () => {
|
|
285
|
+
const s = bootStudio({ answers: { ...HOME, 'POST /copy-desk/pages/landing:projects/claim': { ok: false, status: 409, data: { error: { code: 'page_held', message: 'held', details: { holder: { login: 'bob' } } } } } } });
|
|
286
|
+
await s.settle();
|
|
287
|
+
await s.click('st-flow');
|
|
288
|
+
await s.clickIn('st-panel', '[data-take]');
|
|
289
|
+
assert.equal(s.navigated, null);
|
|
290
|
+
const err = s.el('st-take-err');
|
|
291
|
+
assert.equal(err.hidden, false);
|
|
292
|
+
assert.match(err.textContent, /is tweaking this page right now/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await test('Artist Review Status: three surface lines, the instance\'s address on landing, the asked line', async () => {
|
|
296
|
+
const s = bootStudio({ answers: HOME });
|
|
297
|
+
s.el('hall-main').setAttribute('data-apex', 'https://cloudbongos.com');
|
|
298
|
+
await s.settle();
|
|
299
|
+
await s.click('st-status');
|
|
300
|
+
const body = s.el('st-panel-body').innerHTML;
|
|
301
|
+
assert.equal(s.el('st-panel-title').textContent, 'Artist Review Status');
|
|
302
|
+
assert.deepEqual([...body.matchAll(/st-surface__name">([^<]*)</g)].map((m) => m[1]), ['Landing', 'Builders hall', 'Status']);
|
|
303
|
+
assert.match(body, /st-surface__where">cloudbongos\.com</);
|
|
304
|
+
assert.match(body, /1 of 2 tweaked/);
|
|
305
|
+
assert.match(body, /style="width: 50%"/);
|
|
306
|
+
assert.match(body, /1 page has been asked for/);
|
|
307
|
+
await s.clickIn('st-panel', '[data-close-panel]');
|
|
308
|
+
assert.equal(s.el('st-panel').hidden, true);
|
|
309
|
+
assert.ok(!s.html.classList.contains('st-open'));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await test('the studio\'s words: a first click offers the studio page, and nothing is taken until the artist says so', async () => {
|
|
313
|
+
const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: false })) } });
|
|
314
|
+
await s.settle();
|
|
315
|
+
await s.click('studio-greet');
|
|
316
|
+
assert.equal(s.el('st-offer').hidden, false);
|
|
317
|
+
assert.equal(s.el('studio-greet').getAttribute('contenteditable'), null, 'not editable before the page is taken');
|
|
318
|
+
assert.deepEqual(s.writes(), []);
|
|
319
|
+
await s.click('st-offer-no');
|
|
320
|
+
assert.equal(s.el('st-offer').hidden, true);
|
|
321
|
+
assert.deepEqual(s.writes(), [], 'not now takes nothing');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('taken: the words the reading lists are typed over in place, and the WHOLE draft is saved to the studio page', async () => {
|
|
325
|
+
let held = false;
|
|
326
|
+
const saved = [];
|
|
327
|
+
const s = bootStudio({
|
|
328
|
+
answers: {
|
|
329
|
+
...HOME,
|
|
330
|
+
'GET /copy-desk/pages/builders:studio/draft': () => ok(studioRead({ held, draftLines: held ? [{ key: 'L0005', after: 'Waiting for you' }] : null })),
|
|
331
|
+
'POST /copy-desk/pages/builders:studio/claim': () => { held = true; return ok({ ok: true, outcome: 'created' }); },
|
|
332
|
+
'PUT /copy-desk/pages/builders:studio/draft': (b) => { saved.push(b); return ok({ ok: true, outcome: 'saved' }); },
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
await s.settle();
|
|
336
|
+
await s.click('studio-greet');
|
|
337
|
+
await s.click('st-offer-take');
|
|
338
|
+
const greet = s.el('studio-greet');
|
|
339
|
+
const sub = s.el('studio-sub');
|
|
340
|
+
assert.equal(greet.getAttribute('contenteditable'), 'true');
|
|
341
|
+
assert.equal(sub.getAttribute('contenteditable'), 'true', 'the sub-line found its own line, not the generic one');
|
|
342
|
+
assert.equal(sub.textContent, STUDIO_READING.lines[2].text, 'a line with holes is typed over as the reading holds it');
|
|
343
|
+
assert.equal(s.el('st-hint').hidden, true);
|
|
344
|
+
assert.equal(s.el('st-editing').hidden, false);
|
|
345
|
+
assert.match(s.el('st-editing').innerHTML, /The studio page is yours · 1 of 5 rewritten · saved/);
|
|
346
|
+
assert.match(s.el('st-editing').innerHTML, /href="\/builders\/tweak-editor\?page=builders:studio"/, 'submit is the editor\'s');
|
|
347
|
+
|
|
348
|
+
greet.textContent = 'Good night.';
|
|
349
|
+
greet.fire('input', { target: greet });
|
|
350
|
+
greet.fire('blur', { target: greet });
|
|
351
|
+
await s.settle();
|
|
352
|
+
assert.equal(saved.length, 1, 'one save');
|
|
353
|
+
assert.deepEqual(JSON.parse(JSON.stringify(saved[0])), { reading_hash: 'rs-1', lines: [{ key: 'L0002', after: 'Good night.' }, { key: 'L0005', after: 'Waiting for you' }] },
|
|
354
|
+
'the whole draft: the word typed here AND the line the editor saved');
|
|
355
|
+
assert.match(s.el('st-editing').innerHTML, /2 of 5 rewritten · saved/);
|
|
356
|
+
greet.fire('blur', { target: greet });
|
|
357
|
+
await s.settle();
|
|
358
|
+
assert.equal(saved.length, 1, 'an unchanged draft is not saved again');
|
|
359
|
+
const writes = s.writes().map((w) => `${w.method} ${w.url}`);
|
|
360
|
+
assert.deepEqual(writes, ['POST /api/bongos/copy-desk/pages/builders:studio/claim', 'PUT /api/bongos/copy-desk/pages/builders:studio/draft'],
|
|
361
|
+
'the claim and the draft, and never a live string');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await test('already held: a click goes straight to editing; a refused save says so in the server\'s words', async () => {
|
|
365
|
+
const s = bootStudio({
|
|
366
|
+
answers: {
|
|
367
|
+
...HOME,
|
|
368
|
+
'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: true })),
|
|
369
|
+
'PUT /copy-desk/pages/builders:studio/draft': { ok: false, status: 409, data: { error: { code: 'reading_moved', message: 'moved', details: { what_this_means: 'The page changed since it was read.' } } } },
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
await s.settle();
|
|
373
|
+
await s.click('studio-greet');
|
|
374
|
+
assert.equal(s.el('st-offer').hidden, true, 'no offer: the page is already yours');
|
|
375
|
+
const greet = s.el('studio-greet');
|
|
376
|
+
assert.equal(greet.getAttribute('contenteditable'), 'true');
|
|
377
|
+
greet.textContent = 'Evening.';
|
|
378
|
+
greet.fire('input', { target: greet });
|
|
379
|
+
greet.fire('blur', { target: greet });
|
|
380
|
+
await s.settle();
|
|
381
|
+
assert.match(s.el('st-editing').innerHTML, /not saved · The page changed since it was read\./);
|
|
382
|
+
assert.ok(s.el('st-editing').classList.contains('st-editing--failed'));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await test('a refused claim on the studio page keeps the words as they are and says why', async () => {
|
|
386
|
+
const s = bootStudio({
|
|
387
|
+
answers: {
|
|
388
|
+
...HOME,
|
|
389
|
+
'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: false })),
|
|
390
|
+
'POST /copy-desk/pages/builders:studio/claim': { ok: false, status: 403, data: { error: { code: 'not_an_artist', message: 'no' } } },
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
await s.settle();
|
|
394
|
+
await s.click('studio-greet');
|
|
395
|
+
await s.click('st-offer-take');
|
|
396
|
+
assert.equal(s.el('studio-greet').getAttribute('contenteditable'), null);
|
|
397
|
+
assert.equal(s.el('st-offer-err').hidden, false);
|
|
398
|
+
assert.match(s.el('st-offer-err').textContent, /Only an artist can take a page/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await test('the older reviews stay one quiet door away while any still wait', async () => {
|
|
402
|
+
const REVIEW = { id: '1900', title: 'Artist review: the copy and visuals that shipped in task 1899', status: 'backlog', source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1899', created_at: day(20) };
|
|
403
|
+
const s = bootStudio({ answers: { ...HOME, 'GET /tasks?discipline=artist&status=backlog&limit=50': ok({ tasks: [REVIEW] }) } });
|
|
404
|
+
await s.settle();
|
|
405
|
+
assert.equal(s.el('st-older').hidden, false);
|
|
406
|
+
assert.equal(s.el('st-older-label').textContent, '1 review from before Tweak Mode still waits');
|
|
407
|
+
await s.click('st-older');
|
|
408
|
+
assert.equal(s.el('st-reviews').hidden, false);
|
|
409
|
+
assert.match(s.el('studio-focus').innerHTML, /to tend now · 1 of 1/);
|
|
410
|
+
const none = bootStudio({ answers: HOME });
|
|
411
|
+
await none.settle();
|
|
412
|
+
assert.equal(none.el('st-older').hidden, true, 'no review waiting: no door');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await test('the markup: one h1 (the greeting), the tally and four doors, the libs before the page, the full-screen switch', () => {
|
|
416
|
+
const nc = HTML.replace(/<!--[\s\S]*?-->/g, '');
|
|
417
|
+
assert.equal((nc.match(/<h1\b/g) || []).length, 1);
|
|
418
|
+
assert.match(nc, /<h1[^>]*id="studio-greet"[^>]*data-words/);
|
|
419
|
+
assert.match(nc, /<p[^>]*id="studio-sub"[^>]*data-words/);
|
|
420
|
+
for (const id of ['st-tally', 'st-resume', 'st-flow', 'studio-approvals', 'st-status', 'st-older']) assert.match(nc, new RegExp(`id="${id}"`));
|
|
421
|
+
assert.match(nc, /<body[^>]*data-fullscreen-switch/);
|
|
422
|
+
const at = (f) => nc.indexOf(f);
|
|
423
|
+
assert.ok(at('tweak-editor-lib.js') < at('studio-lib.js') && at('studio-lib.js') < at('/studio.js'), 'the libs, then the page');
|
|
424
|
+
assert.ok(at('approval-queue.js') < at('/studio.js'), 'the Approval queue panel before the page that mounts it');
|
|
425
|
+
});
|
|
426
|
+
} finally {
|
|
427
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
428
|
+
summary();
|
|
429
|
+
}
|
|
@@ -55,6 +55,12 @@ test('the Studio writes ONLY the artist\'s verdict on their own review queue', (
|
|
|
55
55
|
/^PATCH \$\{API\}\/tasks\/\$\{reviewId\}$/, // note it → the words, on the review
|
|
56
56
|
/^POST \$\{API\}\/tasks$/, // needs work→ the follow-up task
|
|
57
57
|
/^POST \$\{API\}\/tasks\/\$\{reviewId\}\/merge$/, // …and the review closed against it
|
|
58
|
+
// The home (task 1003971 / BV2.TW16, ADR 0341 D5 and D11): take a page's
|
|
59
|
+
// tweak round, and save the studio page's draft while the artist holds it.
|
|
60
|
+
// Neither writes a live string; the words reach a page only as a shipped,
|
|
61
|
+
// approved tweak.
|
|
62
|
+
/^POST \$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/claim$/,
|
|
63
|
+
/^PUT \$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/draft$/,
|
|
58
64
|
];
|
|
59
65
|
// Every write site, as "<VERB> <route expression>" — the verb from the
|
|
60
66
|
// request call, the route from the WRITE_ROUTES entry it names.
|
|
@@ -69,7 +75,7 @@ test('the Studio writes ONLY the artist\'s verdict on their own review queue', (
|
|
|
69
75
|
const named = /^WRITE_ROUTES\.(\w+)\(/.exec(arg.trim());
|
|
70
76
|
return { verb, arg: arg.trim(), route: named ? routes.get(named[1]) : null };
|
|
71
77
|
});
|
|
72
|
-
assert.equal(sites.length,
|
|
78
|
+
assert.equal(sites.length, 6, `the four verdict writes, one page claim and one draft save; found ${sites.length}`);
|
|
73
79
|
for (const s of sites) {
|
|
74
80
|
assert.ok(s.route, `every write names a WRITE_ROUTES entry, never an inline URL: ${s.verb} ${s.arg}`);
|
|
75
81
|
const site = `${s.verb} ${s.route}`;
|
|
@@ -407,7 +407,7 @@ try {
|
|
|
407
407
|
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
408
408
|
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
409
409
|
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
410
|
-
assert.match(HTML, /shell\.js\?v=2026-09-
|
|
410
|
+
assert.match(HTML, /shell\.js\?v=2026-09-28-archive/);
|
|
411
411
|
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
412
412
|
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks']) {
|
|
413
413
|
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '0c646e5bac9336e8';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|