@bongos/core 1.19.1064 → 1.19.1066
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 +96 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +11 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +1 -0
- package/docs/api/openapi.json +237 -4
- package/docs/api-reference.md +5 -2
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +60 -41
- package/docs/copy-registry.json +228 -47
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +11 -2
- package/docs/page-readings.json +45 -22
- package/modules/copy-desk/page-decisions.js +71 -0
- package/modules/copy-desk/page-status.js +100 -2
- package/modules/copy-desk/pages.js +30 -0
- package/modules/copy-desk/routes/copy-desk.js +156 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
- package/modules/economy/credits.js +77 -0
- package/modules/economy/reward-policy.js +7 -1
- package/modules/economy/reward.js +4 -0
- package/modules/hall-ui/public/approval-queue-lib.js +156 -0
- package/modules/hall-ui/public/approval-queue.css +286 -0
- package/modules/hall-ui/public/approval-queue.js +338 -0
- package/modules/hall-ui/public/studio.css +33 -0
- package/modules/hall-ui/public/studio.html +18 -1
- package/modules/hall-ui/public/studio.js +30 -0
- package/modules/hall-ui/public/studio.states.json +198 -0
- package/modules/hall-ui/records/approval-queue.md +32 -0
- package/modules/lifecycle/db-grade.js +80 -58
- package/modules/lifecycle/db-ship.js +14 -5
- package/modules/lifecycle/github-pr-close.js +41 -0
- package/modules/lifecycle/github-push.js +5 -2
- package/modules/lifecycle/lifecycle.js +12 -0
- package/modules/lifecycle/page-tweak-approve.js +242 -0
- package/modules/lifecycle/routes/gate-approvals.js +17 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +18 -0
- package/src/module-api.js +1 -1
- package/tests/artist_page_credit.mjs +272 -0
- package/tests/copy_desk_page_approve.mjs +307 -0
- package/tests/fixtures/page-ledger.mjs +41 -1
- package/tests/gate_approvals.mjs +93 -0
- package/tests/hall_approval_queue.mjs +327 -0
- package/tests/hall_audit.mjs +4 -0
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1066",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1066",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1066",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7685,5 +7685,17 @@
|
|
|
7685
7685
|
"id": "1004322",
|
|
7686
7686
|
"text": "A session can now apply an artist's rewritten page. The new /tweak skill takes the next submitted page, applies every line in one pass, and photographs the page before and after, on a desktop and a phone, in light and dark, at"
|
|
7687
7687
|
}
|
|
7688
|
+
],
|
|
7689
|
+
"1.19.1065": [
|
|
7690
|
+
{
|
|
7691
|
+
"id": "1004323",
|
|
7692
|
+
"text": "An artist can now approve a tweaked page from the studio. A new Approval queue button opens a glass panel that shows the applied page as real pictures, before and after, on desktop and phone, in light and dark, with the change"
|
|
7693
|
+
}
|
|
7694
|
+
],
|
|
7695
|
+
"1.19.1066": [
|
|
7696
|
+
{
|
|
7697
|
+
"id": "1004324",
|
|
7698
|
+
"text": "When an artist's page rewrite is approved and goes live, the artist is now paid 30 credits for it, the same value an artist review used to carry. The payment lands at the moment the page ships, never at approval, and a page th"
|
|
7699
|
+
}
|
|
7688
7700
|
]
|
|
7689
7701
|
}
|
|
@@ -136,6 +136,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
136
136
|
// page id, since the editor shows one page. A page id carries a colon, which a
|
|
137
137
|
// Windows file name cannot, so there is no per-id file to find first.
|
|
138
138
|
[/^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft'],
|
|
139
|
+
// the Approval queue's renders (task 1004323): a round's named visuals, one
|
|
140
|
+
// fixture for any task id, as the editor read above serves any page.
|
|
141
|
+
[/^tasks\/\d+\/visuals$/, 'tasks__visuals'],
|
|
139
142
|
];
|
|
140
143
|
|
|
141
144
|
// CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
|
|
@@ -148,6 +151,10 @@ const CANNED_WRITES = [
|
|
|
148
151
|
['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
|
|
149
152
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
|
|
150
153
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
|
|
154
|
+
// the Approval queue's two decisions (task 1004323), so the approved and
|
|
155
|
+
// sent-back lines the artboard draws render too.
|
|
156
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/approve$/, 'copy-desk__pages__approve.post'],
|
|
157
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/send-back$/, 'copy-desk__pages__send-back.post'],
|
|
151
158
|
];
|
|
152
159
|
|
|
153
160
|
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
|
@@ -252,6 +259,17 @@ app.use((req, res, next) => {
|
|
|
252
259
|
// frame, so the one thing being reviewed is the one thing you cannot see.
|
|
253
260
|
// A generated stand-in; see scripts/hall-preview/task-visual.js.
|
|
254
261
|
if (/^task-visuals\/[A-Za-z0-9._-]+$/.test(apiPath) && req.method === 'GET') {
|
|
262
|
+
// HALL_PREVIEW_VISUALS=<dir> serves a REAL image of that name from <dir>
|
|
263
|
+
// instead (task 1004323): the Approval queue's review sheet shows the renders
|
|
264
|
+
// /tweak really attached, copied under the fixture's names into a scratch
|
|
265
|
+
// dir. Nothing is committed; unset, every visual is the stand-in as before.
|
|
266
|
+
const realDir = process.env.HALL_PREVIEW_VISUALS;
|
|
267
|
+
const name = apiPath.slice('task-visuals/'.length);
|
|
268
|
+
const real = realDir && /^task-\d+-[0-9a-f]{16}\.(png|jpg)$/.test(name) ? path.join(realDir, name) : null;
|
|
269
|
+
if (real && fs.existsSync(real)) {
|
|
270
|
+
res.set({ 'content-type': name.endsWith('.png') ? 'image/png' : 'image/jpeg', 'cache-control': 'no-store' });
|
|
271
|
+
return res.end(fs.readFileSync(real));
|
|
272
|
+
}
|
|
255
273
|
const png = require('./task-visual').placeholderPng();
|
|
256
274
|
res.set({ 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
257
275
|
return res.end(png);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1066'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/artist_page_credit.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004324 / BV2.TW13, ADR 0341 D10: an approved page pays its ARTIST at
|
|
4
|
+
// the land, as the artist reviews it replaced paid.
|
|
5
|
+
//
|
|
6
|
+
// The contract this pins, through the REAL db.shipTask and the REAL economy
|
|
7
|
+
// writer over a small stateful ledger (tasks, claims, credit_log):
|
|
8
|
+
// * a shipped page tweak books ONE credit_log row, reason
|
|
9
|
+
// tweak.page_approved:<task id>, to the builder of the round's latest WEB
|
|
10
|
+
// claim, worth credits_reward x KIND_MULTIPLIERS[kind] (30 for a feature
|
|
11
|
+
// round at 30);
|
|
12
|
+
// * the shipper's per-task estimate (task.shipped) is NOT paid on a page
|
|
13
|
+
// tweak, so the per-task stream's total is the same as an ordinary ship's;
|
|
14
|
+
// * a self-applied round (the artist also ran /tweak) is one artist row;
|
|
15
|
+
// * rounding is the one multiplier rule, once;
|
|
16
|
+
// * a re-ship or a reconciler re-sweep books nothing a second time;
|
|
17
|
+
// * a sent-back round is never paid, and a re-applied round pays its artist,
|
|
18
|
+
// never either applier;
|
|
19
|
+
// * the lane is not withheld by cost-plus-only, and only an explicit
|
|
20
|
+
// artistCredit:false turns it off;
|
|
21
|
+
// * the reason prefix is the one the studio tally sums, and the tally reads
|
|
22
|
+
// the booked row as 30 credits.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/artist_page_credit.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
34
|
+
achievements.evaluateAfterShip = async () => [];
|
|
35
|
+
achievements.evaluateAfterCreditAward = async () => [];
|
|
36
|
+
achievements.reconcileBuilderAchievements = async () => [];
|
|
37
|
+
const seams = require('../src/module-seams.js');
|
|
38
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
39
|
+
const credits = require('../modules/economy/credits.js');
|
|
40
|
+
const db = require('../modules/lifecycle/db.js');
|
|
41
|
+
const pageStatus = require('../modules/copy-desk/page-status.js');
|
|
42
|
+
|
|
43
|
+
const { test, summary } = makeRunner();
|
|
44
|
+
|
|
45
|
+
const ARTIST = '41';
|
|
46
|
+
const APPLIER = '42';
|
|
47
|
+
const APPLIER_2 = '43';
|
|
48
|
+
const OTHER_ARTIST = '44';
|
|
49
|
+
const RECONCILER = '999';
|
|
50
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
51
|
+
|
|
52
|
+
// The claims a round collects, in order. A web claim has no worktree and no
|
|
53
|
+
// session; the applier's CLI claim has a worktree.
|
|
54
|
+
const web = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: null, creator_session_id: null });
|
|
55
|
+
const cli = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: `tw-${builder}-${at}`, creator_session_id: `s-${builder}` });
|
|
56
|
+
|
|
57
|
+
// A stateful ledger for one task. It answers the statements shipTask and the
|
|
58
|
+
// artist writer send; credit_log is a real list, so the NOT EXISTS guards are
|
|
59
|
+
// evaluated against what was actually booked.
|
|
60
|
+
function ledger({ source = 'page-tweak', status = 'confirmed', creditsReward = 30, kind = 'feature', claims = [] } = {}) {
|
|
61
|
+
const state = {
|
|
62
|
+
task: { id: 7001, title: 'Tweak: Home (landing:index)', source, status, credits_reward: creditsReward, kind, shipped_by: null },
|
|
63
|
+
claims: claims.map((c, i) => ({ id: 9000 + i, released_at: 'earlier', ...c })),
|
|
64
|
+
credit_log: [],
|
|
65
|
+
};
|
|
66
|
+
const latest = (list) => list.slice().sort((a, b) => (a.claimed_at - b.claimed_at) || (a.id - b.id)).slice(-1)[0] || null;
|
|
67
|
+
const pool = makeSqlAwareClient((s, p) => {
|
|
68
|
+
const t = state.task;
|
|
69
|
+
if (/^UPDATE tasks SET status = 'shipped'/.test(s)) {
|
|
70
|
+
if (t.status !== 'confirmed') return { rows: [] };
|
|
71
|
+
t.status = 'shipped';
|
|
72
|
+
t.shipped_by = t.shipped_by ?? p[1];
|
|
73
|
+
return { rows: [{ id: t.id, title: t.title, status: 'shipped', shipped_at: new Date().toISOString(), shipped_by: t.shipped_by,
|
|
74
|
+
security_sensitive: false, value_summary: null, credits_reward: t.credits_reward, kind: t.kind, source: t.source }] };
|
|
75
|
+
}
|
|
76
|
+
if (/^UPDATE claims/.test(s)) return { rows: [] };
|
|
77
|
+
if (/^SELECT builder_id FROM claims WHERE task_id = \$1/.test(s)) {
|
|
78
|
+
const c = latest(state.claims);
|
|
79
|
+
return { rows: c ? [{ builder_id: c.builder_id }] : [] };
|
|
80
|
+
}
|
|
81
|
+
if (/^SELECT 1 FROM credit_log WHERE task_id = \$1 AND reason = 'task\.shipped'/.test(s)) {
|
|
82
|
+
return { rows: state.credit_log.filter((r) => r.task_id === p[0] && r.reason === 'task.shipped').map(() => ({ one: 1 })) };
|
|
83
|
+
}
|
|
84
|
+
if (/^SELECT t\.source, t\.status, t\.credits_reward, t\.kind/.test(s)) {
|
|
85
|
+
if (p[0] !== t.id) return { rows: [] };
|
|
86
|
+
assert.match(s, /c\.worktree_name IS NULL AND c\.creator_session_id IS NULL/, 'the payee is read from WEB claims only');
|
|
87
|
+
const w = latest(state.claims.filter((c) => c.worktree_name == null && c.creator_session_id == null));
|
|
88
|
+
return { rows: [{ source: t.source, status: t.status, credits_reward: t.credits_reward, kind: t.kind, artist_id: w ? w.builder_id : null }] };
|
|
89
|
+
}
|
|
90
|
+
if (/^INSERT INTO credit_log/.test(s)) {
|
|
91
|
+
const [builder_id, task_id, delta, reason] = p;
|
|
92
|
+
if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE task_id = \$2 AND reason = \$4\)/.test(s)
|
|
93
|
+
&& state.credit_log.some((r) => r.task_id === task_id && r.reason === reason)) return { rows: [], rowCount: 0 };
|
|
94
|
+
if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE builder_id = \$1 AND reason = \$4\)/.test(s)
|
|
95
|
+
&& state.credit_log.some((r) => r.builder_id === builder_id && r.reason === reason)) return { rows: [], rowCount: 0 };
|
|
96
|
+
state.credit_log.push({ builder_id, task_id, delta, reason, recorded_at: new Date().toISOString() });
|
|
97
|
+
return { rows: [], rowCount: 1 };
|
|
98
|
+
}
|
|
99
|
+
return { rows: [] };
|
|
100
|
+
});
|
|
101
|
+
return { pool, state };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ship = (pool) => db.shipTask({ taskId: 7001, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
105
|
+
const sent = (pool) => pool._client.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
|
|
106
|
+
const perTaskRows = (state) => state.credit_log.filter((r) => r.reason === 'task.shipped' || r.reason.startsWith('tweak.page_approved:'));
|
|
107
|
+
|
|
108
|
+
// =========================================================================
|
|
109
|
+
await test('a shipped page tweak pays its artist 30, on tweak.page_approved:<id>, and the shipper no estimate', async () => {
|
|
110
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
111
|
+
const out = await ship(pool);
|
|
112
|
+
assert.equal(out.status, 'shipped');
|
|
113
|
+
assert.equal(out.creditsAwarded, 0, 'the shipper is paid no per-task estimate on a page tweak');
|
|
114
|
+
assert.equal(String(out.creditedBuilderId), APPLIER, 'the applier is still the ship-credited builder (achievements, session reward)');
|
|
115
|
+
assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.task_id, r.delta, r.reason]),
|
|
116
|
+
[[ARTIST, 7001, 30, 'tweak.page_approved:7001']], 'exactly one row: the artist, the priced value, the tally reason');
|
|
117
|
+
assert.equal(out.artistCredit.reason, 'paid');
|
|
118
|
+
assert.equal(out.artistCredit.credited, 30);
|
|
119
|
+
assert.ok(!sent(pool).some((q) => /'task\.shipped'/.test(q)), 'the estimate path is never entered for a page tweak');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('self-applied: the artist also ran /tweak, and is paid one artist row, nothing doubled', async () => {
|
|
123
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(ARTIST, 2)] });
|
|
124
|
+
const out = await ship(pool);
|
|
125
|
+
assert.equal(String(out.creditedBuilderId), ARTIST);
|
|
126
|
+
assert.equal(state.credit_log.length, 1, 'one row, not an artist row plus an estimate row');
|
|
127
|
+
assert.equal(String(state.credit_log[0].builder_id), ARTIST);
|
|
128
|
+
assert.equal(state.credit_log[0].delta, 30);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('rounding: the priced value is credits_reward x the kind multiplier, rounded once', async () => {
|
|
132
|
+
for (const [reward, kind, want] of [[30, 'feature', 30], [25, 'bug', 30], [7, 'refactor', 6], [5, 'spike', 6], [3, 'cleanup', 2], [11, 'mystery', 11]]) {
|
|
133
|
+
const { pool, state } = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
134
|
+
await ship(pool);
|
|
135
|
+
assert.equal(state.credit_log.length, 1, `${reward} x ${kind}: one row`);
|
|
136
|
+
assert.equal(state.credit_log[0].delta, want, `${reward} x ${kind} pays ${want}`);
|
|
137
|
+
assert.equal(state.credit_log[0].delta, credits.applyKindMultiplier(reward, kind).effective, 'the same rule as the estimate');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('a zero-reward round books nothing (no zero-value row that reads like a payment)', async () => {
|
|
142
|
+
const { pool, state } = ledger({ creditsReward: 0, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
143
|
+
const out = await ship(pool);
|
|
144
|
+
assert.equal(state.credit_log.length, 0);
|
|
145
|
+
assert.equal(out.artistCredit.reason, 'zero_value');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('the per-task stream total is conserved: a page tweak pays the artist what the ordinary ship paid the shipper', async () => {
|
|
149
|
+
for (const [reward, kind] of [[30, 'feature'], [25, 'bug'], [7, 'refactor']]) {
|
|
150
|
+
const ordinary = ledger({ source: null, creditsReward: reward, kind, claims: [cli(APPLIER, 2)] });
|
|
151
|
+
await ship(ordinary.pool);
|
|
152
|
+
const tweak = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
153
|
+
await ship(tweak.pool);
|
|
154
|
+
const sum = (st) => perTaskRows(st).reduce((a, r) => a + r.delta, 0);
|
|
155
|
+
const ordinaryRows = perTaskRows(ordinary.state);
|
|
156
|
+
// An instance in cost-plus-only books no ordinary estimate at all; the
|
|
157
|
+
// equality then holds against what the estimate WOULD have been.
|
|
158
|
+
const estimate = ordinaryRows.length ? sum(ordinary.state) : credits.applyKindMultiplier(reward, kind).effective;
|
|
159
|
+
assert.equal(sum(tweak.state), estimate, `${reward} x ${kind}: the same total, moved to the artist`);
|
|
160
|
+
assert.ok(!perTaskRows(tweak.state).some((r) => String(r.builder_id) === APPLIER), 'the applier gets no per-task row');
|
|
161
|
+
assert.ok(!sent(ordinary.pool).some((q) => /^SELECT t\.source, t\.status/.test(q)), 'an ordinary ship never asks the artist lane');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('idempotent: a re-ship or a reconciler re-sweep books nothing a second time', async () => {
|
|
166
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
167
|
+
await ship(pool);
|
|
168
|
+
// The shipped flip is terminal, so a second shipTask throws before paying.
|
|
169
|
+
await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
|
|
170
|
+
// And the writer itself, asked again inside some later transaction, refuses.
|
|
171
|
+
const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
172
|
+
assert.equal(again.reason, 'already_paid');
|
|
173
|
+
assert.equal(again.credited, 0);
|
|
174
|
+
assert.equal(state.credit_log.length, 1, 'still one row');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('one payment per ROUND: a payee that reads differently on a re-sweep is still refused', async () => {
|
|
178
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
179
|
+
await ship(pool);
|
|
180
|
+
state.claims.push({ id: 9100, released_at: 'later', ...web(OTHER_ARTIST, 3) });
|
|
181
|
+
const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
182
|
+
assert.equal(again.reason, 'already_paid', 'keyed on (task, reason), not (builder, reason)');
|
|
183
|
+
assert.equal(state.credit_log.length, 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('a sent-back round is never paid: it sits at ready, its ship is refused, and the writer refuses too', async () => {
|
|
187
|
+
const { pool, state } = ledger({ status: 'ready', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
188
|
+
await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
|
|
189
|
+
const direct = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
190
|
+
assert.equal(direct.reason, 'not_shipped', 'a round must read shipped in the transaction to be paid');
|
|
191
|
+
for (const s of ['completed', 'confirmed', 'active', 'abandoned']) {
|
|
192
|
+
state.task.status = s;
|
|
193
|
+
const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
194
|
+
assert.equal(r.reason, 'not_shipped', `${s} is not paid`);
|
|
195
|
+
}
|
|
196
|
+
assert.equal(state.credit_log.length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await test('sent back, re-applied by someone else, approved and landed: the artist is paid once, neither applier', async () => {
|
|
200
|
+
// web (artist writes) -> CLI (applier 1, sent back, released) -> CLI (applier 2)
|
|
201
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), cli(APPLIER_2, 3)] });
|
|
202
|
+
const out = await ship(pool);
|
|
203
|
+
assert.equal(String(out.creditedBuilderId), APPLIER_2, 'the landing applier is the ship-credited builder');
|
|
204
|
+
assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.delta]), [[ARTIST, 30]]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('the payee is the LATEST web claim: an artist who resumed the draft after a send-back is the one paid', async () => {
|
|
208
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), web(OTHER_ARTIST, 3), cli(APPLIER, 4)] });
|
|
209
|
+
await ship(pool);
|
|
210
|
+
assert.deepEqual(state.credit_log.map((r) => String(r.builder_id)), [OTHER_ARTIST],
|
|
211
|
+
'the TW05 reads name the same builder as the round\'s artist');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('a page tweak with no web claim pays nobody rather than guessing', async () => {
|
|
215
|
+
const { pool, state } = ledger({ claims: [cli(APPLIER, 2)] });
|
|
216
|
+
const out = await ship(pool);
|
|
217
|
+
assert.equal(out.artistCredit.reason, 'no_artist');
|
|
218
|
+
assert.equal(state.credit_log.length, 0, 'and the applier is not paid the estimate in its place');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('the writer reads the task itself: an ordinary task handed to it is refused', async () => {
|
|
222
|
+
const { pool, state } = ledger({ source: 'cascade', status: 'shipped', claims: [web(ARTIST, 1)] });
|
|
223
|
+
const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
224
|
+
assert.equal(r.reason, 'not_a_page_tweak');
|
|
225
|
+
const missing = await credits.awardArtistPageCredit(pool._client, { taskId: 1, config: credits.rewardPolicy({}) });
|
|
226
|
+
assert.equal(missing.reason, 'not_found');
|
|
227
|
+
assert.equal(state.credit_log.length, 0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test('the artist lane: cost-plus-only does not withhold it; only an explicit artistCredit:false does', async () => {
|
|
231
|
+
const p = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
232
|
+
assert.equal(p.perTaskCredit, false);
|
|
233
|
+
assert.equal(p.artistCredit, true, 'a different craft\'s pay, not the builder estimate');
|
|
234
|
+
assert.equal(credits.rewardPolicy({}).artistCredit, true, 'absent -> on');
|
|
235
|
+
for (const v of [true, 1, 'yes', undefined, null]) assert.equal(credits.rewardPolicy({ artistCredit: v }).artistCredit, true);
|
|
236
|
+
assert.equal(credits.rewardPolicy({ artistCredit: false }).artistCredit, false, 'explicit opt-out');
|
|
237
|
+
|
|
238
|
+
const { pool, state } = ledger({ status: 'shipped', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
239
|
+
const paid = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: p });
|
|
240
|
+
assert.equal(paid.credited, 30, 'paid under cost-plus-only');
|
|
241
|
+
const off = ledger({ status: 'shipped', claims: [web(ARTIST, 1)] });
|
|
242
|
+
const r = await credits.awardArtistPageCredit(off.pool._client, { taskId: 7001, config: credits.rewardPolicy({ artistCredit: false }) });
|
|
243
|
+
assert.equal(r.reason, 'artist_lane_off');
|
|
244
|
+
assert.equal(off.state.credit_log.length, 0);
|
|
245
|
+
assert.equal(state.credit_log.length, 1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('the booked row is what the studio tally sums: same prefix, and it reads as 30 credits', async () => {
|
|
249
|
+
assert.equal(credits.ARTIST_PAGE_REASON_PREFIX, pageStatus.TALLY_REASON_PREFIX,
|
|
250
|
+
'economy writes and copy-desk reads the same prefix (restated in each, since a module cannot require another)');
|
|
251
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
252
|
+
await ship(pool);
|
|
253
|
+
const tally = pageStatus.composeTally({ builderId: ARTIST, tasks: [], creditRows: state.credit_log, now: new Date() });
|
|
254
|
+
assert.equal(tally.credits, 30);
|
|
255
|
+
assert.equal(tally.this_week, 30);
|
|
256
|
+
const applierTally = pageStatus.composeTally({ builderId: APPLIER, tasks: [], creditRows: state.credit_log.filter((r) => String(r.builder_id) === APPLIER), now: new Date() });
|
|
257
|
+
assert.equal(applierTally.credits, 0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await test('the reward port exposes the writer, delegated so a stub bites at call time', async () => {
|
|
261
|
+
const port = require('../modules/economy/reward.js');
|
|
262
|
+
assert.equal(typeof port.awardArtistPageCredit, 'function');
|
|
263
|
+
const real = credits.awardArtistPageCredit;
|
|
264
|
+
credits.awardArtistPageCredit = async () => ({ credited: 0, reason: 'stubbed' });
|
|
265
|
+
try {
|
|
266
|
+
assert.equal((await port.awardArtistPageCredit({}, { taskId: 1 })).reason, 'stubbed');
|
|
267
|
+
} finally {
|
|
268
|
+
credits.awardArtistPageCredit = real;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
summary();
|