@bongos/core 1.19.296 → 1.19.298
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 +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/economy/credits.js +156 -31
- package/modules/ideas/inbox.js +86 -2
- package/modules/ideas/migrations/ideas_005_grade_developed_by.sql +85 -0
- package/modules/ideas/routes/inbox.js +175 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +119 -5
- package/src/module-api.js +5 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +436 -0
- package/tests/idea_full_pass_credit.mjs +50 -18
- package/tests/idea_spark_queue.mjs +11 -3
package/scripts/gds/spark.js
CHANGED
|
@@ -19,7 +19,17 @@
|
|
|
19
19
|
// are one mental model, and a queue nobody can read is not a pickup surface. The
|
|
20
20
|
// queue is readable by ANY builder: the author's mark is already the consent.
|
|
21
21
|
//
|
|
22
|
+
// --develop is the PICKUP (task 1002922 / BV1.R146): fill in the five template
|
|
23
|
+
// fields and the spark becomes a Full Idea, in place, with the original author
|
|
24
|
+
// preserved and you recorded as its developer. Open to any builder — the author's
|
|
25
|
+
// mark was the consent. The five fields are prose, so they come from a JSON file
|
|
26
|
+
// rather than five shell flags; --template prints the shape to fill in. The hall
|
|
27
|
+
// form (R152) and conversational authoring (R155) are the richer surfaces; this
|
|
28
|
+
// is the terminal path, and all three hit the same route.
|
|
29
|
+
//
|
|
22
30
|
// Usage:
|
|
31
|
+
// node scripts/gds/spark.js --template # print the five fields to fill in
|
|
32
|
+
// node scripts/gds/spark.js --develop 123 --fields-file idea.json
|
|
23
33
|
// node scripts/gds/spark.js --list # the spark queue: what awaits development
|
|
24
34
|
// node scripts/gds/spark.js --list --limit 10
|
|
25
35
|
// node scripts/gds/spark.js 123 # mark idea 123 as a spark
|
|
@@ -30,7 +40,12 @@
|
|
|
30
40
|
// 1 api / network error, or the server refused (not yours / not a quick idea)
|
|
31
41
|
// 2 bad invocation
|
|
32
42
|
|
|
43
|
+
const fs = require('node:fs/promises');
|
|
33
44
|
const { cliClient, requireSession, hasFlag, arg, hallBase } = require('./cli-lib');
|
|
45
|
+
// A CLI may reach into its own domain module — the capture.js / autonomy-gate.js
|
|
46
|
+
// precedent. Pure and DB-free: this only reads the R141 template declaration so
|
|
47
|
+
// the prompt below cannot drift from what the server validates against.
|
|
48
|
+
const { BASE_TEMPLATE } = require('../../modules/ideas/templates');
|
|
34
49
|
|
|
35
50
|
// The server's refusals, in the ideator's own words. Anything unmapped falls
|
|
36
51
|
// through to the raw envelope — a new server-side rule should be visible, not
|
|
@@ -39,6 +54,24 @@ const REFUSALS = {
|
|
|
39
54
|
not_your_idea: (d) =>
|
|
40
55
|
'That idea is not yours to mark. The spark mark is the author\'s consent to have their\n'
|
|
41
56
|
+ 'idea developed, so only the builder who filed it can give or withdraw it.',
|
|
57
|
+
not_a_spark: (d) =>
|
|
58
|
+
d.grade === 'full'
|
|
59
|
+
? `That idea has already been developed into a Full Idea${d.developed_by ? ` (by builder ${d.developed_by})` : ''}.\n`
|
|
60
|
+
+ 'A spark can only be developed once.'
|
|
61
|
+
: 'That idea is not a Big Idea Spark, so it is not available to develop.\n'
|
|
62
|
+
+ 'Its author has to mark it first — that mark is what makes developing it fair.',
|
|
63
|
+
spark_already_developed: () =>
|
|
64
|
+
'Another builder developed this spark first, moments ago. Their version stands.\n'
|
|
65
|
+
+ 'Run --list to see what else is waiting.',
|
|
66
|
+
invalid_field_values: (d) => {
|
|
67
|
+
const errs = Array.isArray(d) ? d : (d.errors || []);
|
|
68
|
+
const lines = ['That is not a complete Full Idea yet:'];
|
|
69
|
+
for (const e of errs) {
|
|
70
|
+
lines.push(` - ${e.field || '(the whole set)'}: ${String(e.reason || '').replace(/_/g, ' ')}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push('Run --template to see the five fields and re-run with the file fixed.');
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
},
|
|
42
75
|
not_a_quick_idea: (d) =>
|
|
43
76
|
`That idea is already a Full Idea (grade=${d.grade || 'full'}). It carries the developed\n`
|
|
44
77
|
+ 'picture the spark mark asks for — there is nothing to hand off.',
|
|
@@ -104,10 +137,89 @@ async function listQueue() {
|
|
|
104
137
|
console.log(renderQueue(r.data || {}));
|
|
105
138
|
}
|
|
106
139
|
|
|
140
|
+
// The five fields, printed as a fill-in-the-blanks JSON skeleton. Read from the
|
|
141
|
+
// R141 declaration rather than restated, so it cannot drift from what the server
|
|
142
|
+
// validates — and it names the substance floor, because "present but three words"
|
|
143
|
+
// is the failure the scorer actually punishes.
|
|
144
|
+
function templateSkeleton() {
|
|
145
|
+
const lines = ['The five fields of a Full Idea (template ' + BASE_TEMPLATE.key + '):', ''];
|
|
146
|
+
for (const f of BASE_TEMPLATE.fields) {
|
|
147
|
+
lines.push(` ${f.key}`);
|
|
148
|
+
lines.push(` ${f.label}`);
|
|
149
|
+
if (f.substanceMin) lines.push(` at least ~${f.substanceMin} characters of real content — padding scores nothing`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push('Write them into a JSON file, then develop a spark with it:');
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(' ' + JSON.stringify(Object.fromEntries(BASE_TEMPLATE.fields.map((f) => [f.key, '...'])), null, 2).replace(/\n/g, '\n '));
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push(' node scripts/gds/spark.js --develop <idea-id> --fields-file <that-file.json>');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// What the developer is told once it lands. The completeness score is the number
|
|
161
|
+
// the R147 credit split and the board bar both read, so it is the headline.
|
|
162
|
+
function renderDeveloped(data) {
|
|
163
|
+
const { idea, author_id, developer_id, self_developed, board_window } = data || {};
|
|
164
|
+
const lines = [];
|
|
165
|
+
lines.push(`Idea ${idea.id} is now a Full Idea — completeness ${idea.completeness_score ?? 'unscored'}/100.`);
|
|
166
|
+
lines.push(` ${hallBase()}/#/idea/${idea.id}`);
|
|
167
|
+
lines.push(self_developed
|
|
168
|
+
? ' You had the thought and you developed it.'
|
|
169
|
+
: ` The thought stays credited to builder ${author_id}; you are recorded as its developer (${developer_id}).`);
|
|
170
|
+
if (board_window) {
|
|
171
|
+
lines.push(` A board ratification window is open until ${board_window.closes_at}.`);
|
|
172
|
+
} else if (idea.rejection) {
|
|
173
|
+
lines.push(` Not yet at the bar — thinnest section: ${idea.rejection.section_key}.`);
|
|
174
|
+
if (idea.rejection.reason_md) lines.push(` ${idea.rejection.reason_md}`);
|
|
175
|
+
}
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function developSpark(ideaId) {
|
|
180
|
+
const file = arg('--fields-file');
|
|
181
|
+
if (!file) {
|
|
182
|
+
console.error('--develop needs --fields-file <path.json> (the five fields).');
|
|
183
|
+
console.error('Run: node scripts/gds/spark.js --template');
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
let fieldValues;
|
|
187
|
+
try {
|
|
188
|
+
fieldValues = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`could not read ${file} as JSON: ${err.message}`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
const api = await cliClient();
|
|
194
|
+
const templateKey = arg('--template-key');
|
|
195
|
+
const r = await api.inbox.postInboxIdDevelop({
|
|
196
|
+
id: ideaId,
|
|
197
|
+
body: { field_values: fieldValues, ...(templateKey ? { template_key: templateKey } : {}) },
|
|
198
|
+
});
|
|
199
|
+
if (!r.ok) {
|
|
200
|
+
console.error(explain(r.status, r.data));
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
console.log(renderDeveloped(r.data));
|
|
204
|
+
}
|
|
205
|
+
|
|
107
206
|
async function main() {
|
|
108
207
|
await requireSession();
|
|
109
208
|
|
|
209
|
+
if (hasFlag('--template')) {
|
|
210
|
+
console.log(templateSkeleton());
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
110
213
|
if (hasFlag('--list')) return listQueue();
|
|
214
|
+
const developIdRaw = arg('--develop');
|
|
215
|
+
if (developIdRaw != null) {
|
|
216
|
+
const devId = Number(developIdRaw);
|
|
217
|
+
if (!Number.isInteger(devId) || devId <= 0) {
|
|
218
|
+
console.error('usage: node scripts/gds/spark.js --develop <idea-id> --fields-file <path.json>');
|
|
219
|
+
process.exit(2);
|
|
220
|
+
}
|
|
221
|
+
return developSpark(devId);
|
|
222
|
+
}
|
|
111
223
|
|
|
112
224
|
const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
|
113
225
|
const id = Number(positional[0]);
|
|
@@ -116,6 +228,8 @@ async function main() {
|
|
|
116
228
|
if (!Number.isInteger(id) || id <= 0) {
|
|
117
229
|
console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
|
|
118
230
|
console.error(' node scripts/gds/spark.js --list [--limit N]');
|
|
231
|
+
console.error(' node scripts/gds/spark.js --develop <idea-id> --fields-file <path.json>');
|
|
232
|
+
console.error(' node scripts/gds/spark.js --template');
|
|
119
233
|
process.exit(2);
|
|
120
234
|
}
|
|
121
235
|
|
|
@@ -141,11 +255,11 @@ async function main() {
|
|
|
141
255
|
}
|
|
142
256
|
}
|
|
143
257
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// through explain(), and a
|
|
148
|
-
module.exports = { explain, renderQueue };
|
|
258
|
+
// The four renderers are exported because each is pinned by a test — explain
|
|
259
|
+
// against the real envelope shape, and the three renderers for the shapes a
|
|
260
|
+
// human actually reads. REFUSALS stays private: it is the message table,
|
|
261
|
+
// reachable through explain(), and a fifth export would be dead surface.
|
|
262
|
+
module.exports = { explain, renderQueue, renderDeveloped, templateSkeleton };
|
|
149
263
|
|
|
150
264
|
if (require.main === module) {
|
|
151
265
|
main().catch((err) => {
|
package/src/module-api.js
CHANGED
|
@@ -1019,8 +1019,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1019
1019
|
// landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
|
|
1020
1020
|
// 1.19.296 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1021
1021
|
// landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
|
|
1022
|
+
// 1.19.297 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1023
|
+
// landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
|
|
1024
|
+
// 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1025
|
+
// landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
|
|
1022
1026
|
// ---------------------------------------------------------------------------
|
|
1023
|
-
const CORE_VERSION = '1.19.
|
|
1027
|
+
const CORE_VERSION = '1.19.298'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1024
1028
|
|
|
1025
1029
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1026
1030
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// tests/idea_credit_split.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002923 (BV1.R147, goal 1000062; ADR 0185) — the spark-handoff credit
|
|
4
|
+
// split: 70% developer / 30% spark author, on both ideator streams.
|
|
5
|
+
//
|
|
6
|
+
// The three properties that are REAL BUGS if missed, asserted first:
|
|
7
|
+
// 1. the total is CONSERVED under rounding (round(3.5)+round(1.5) = 6 from a
|
|
8
|
+
// 5-credit payout is the naive mistake);
|
|
9
|
+
// 2. a SELF-DEVELOPED idea collapses to ONE payee — two rows for one builder
|
|
10
|
+
// would be silently UNDER-paid, because the idempotency predicate is
|
|
11
|
+
// (builder_id, reason) and the second insert is refused;
|
|
12
|
+
// 3. zero-value rows are DROPPED, not booked as 0-credit ledger noise.
|
|
13
|
+
//
|
|
14
|
+
// Then the two streams end-to-end against a stub client, and the develop route
|
|
15
|
+
// actually paying (the gap R146 deliberately left open).
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/idea_credit_split.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const credits = require('../modules/economy/credits.js');
|
|
24
|
+
const { splitIdeaCredit, IDEA_DEVELOPER_SHARE, IDEA_FULL_PASS_CREDIT, IDEA_TASK_SHIP_BASE } = credits;
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
async function test(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sum = (ps) => ps.reduce((n, p) => n + p.delta, 0);
|
|
34
|
+
|
|
35
|
+
// --- property 1: the total is conserved ---------------------------------
|
|
36
|
+
|
|
37
|
+
await test('the split CONSERVES the total at every payout size', () => {
|
|
38
|
+
for (let total = 1; total <= 120; total += 1) {
|
|
39
|
+
const ps = splitIdeaCredit(total, { authorId: 9, developedBy: 42 });
|
|
40
|
+
assert.equal(sum(ps), total, `total ${total} must not gain or lose a credit`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('a 5-credit payout splits 4/1, not 4/2 — the rounding trap', () => {
|
|
45
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 42 });
|
|
46
|
+
assert.equal(sum(ps), 5);
|
|
47
|
+
const dev = ps.find((p) => p.role === 'developer');
|
|
48
|
+
const author = ps.find((p) => p.role === 'spark_author');
|
|
49
|
+
assert.equal(dev.delta, 4, 'round(5 * 0.70) = 4');
|
|
50
|
+
assert.equal(author.delta, 1, 'the author gets the REMAINDER, not round(1.5)=2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('the developer gets the larger share, and it tracks the constant', () => {
|
|
54
|
+
assert.equal(IDEA_DEVELOPER_SHARE, 0.70, 'the owner decision, 2026-08-22');
|
|
55
|
+
const ps = splitIdeaCredit(100, { authorId: 9, developedBy: 42 });
|
|
56
|
+
assert.equal(ps.find((p) => p.role === 'developer').delta, 70);
|
|
57
|
+
assert.equal(ps.find((p) => p.role === 'spark_author').delta, 30);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- property 2: self-development collapses -----------------------------
|
|
61
|
+
|
|
62
|
+
await test('a SELF-developed idea is ONE payee at the full amount', () => {
|
|
63
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 9 });
|
|
64
|
+
assert.equal(ps.length, 1, 'two rows for one builder would be silently under-paid');
|
|
65
|
+
assert.equal(ps[0].delta, 5);
|
|
66
|
+
assert.equal(ps[0].builderId, 9);
|
|
67
|
+
assert.equal(ps[0].role, 'author_developer');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('string/number id mismatch still counts as the same person', () => {
|
|
71
|
+
// captured_by and developed_by come back from pg as strings on a bigint column.
|
|
72
|
+
const ps = splitIdeaCredit(5, { authorId: '9', developedBy: 9 });
|
|
73
|
+
assert.equal(ps.length, 1, 'a type difference must not become a phantom second payee');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('an UNDEVELOPED idea pays its author everything', () => {
|
|
77
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: null });
|
|
78
|
+
assert.deepEqual(ps, [{ builderId: 9, delta: 5, role: 'author' }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- property 3: no zero rows -------------------------------------------
|
|
82
|
+
|
|
83
|
+
await test('a 1-credit payout pays the developer 1 and books NOTHING for the author', () => {
|
|
84
|
+
const ps = splitIdeaCredit(1, { authorId: 9, developedBy: 42 });
|
|
85
|
+
assert.equal(sum(ps), 1);
|
|
86
|
+
assert.equal(ps.length, 1, 'a 0-credit ledger row reads like a payment that was not made');
|
|
87
|
+
assert.equal(ps[0].role, 'developer');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('a zero or negative payout pays nobody', () => {
|
|
91
|
+
assert.deepEqual(splitIdeaCredit(0, { authorId: 9, developedBy: 42 }), []);
|
|
92
|
+
assert.deepEqual(splitIdeaCredit(-5, { authorId: 9, developedBy: 42 }), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('no author means no payment at all', () => {
|
|
96
|
+
assert.deepEqual(splitIdeaCredit(5, { authorId: null, developedBy: 42 }), []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- stream 2, end to end ----------------------------------------------
|
|
100
|
+
|
|
101
|
+
// A stub client that records every INSERT and answers the parties lookup.
|
|
102
|
+
function stubClient({ authorId, developedBy, alreadyPaid = [] }) {
|
|
103
|
+
const inserts = [];
|
|
104
|
+
return {
|
|
105
|
+
inserts,
|
|
106
|
+
query: async (sql, params = []) => {
|
|
107
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
108
|
+
return { rows: [{ captured_by: authorId, developed_by: developedBy }] };
|
|
109
|
+
}
|
|
110
|
+
if (/FROM ideas_task_lineage/i.test(sql)) {
|
|
111
|
+
return { rows: [{ idea_id: '77', captured_by: authorId, grade: 'full', completeness: 100, developed_by: developedBy }] };
|
|
112
|
+
}
|
|
113
|
+
if (/INSERT INTO credit_log/i.test(sql)) {
|
|
114
|
+
const [builderId, , delta, reason] = params;
|
|
115
|
+
if (alreadyPaid.some((a) => String(a.builderId) === String(builderId) && a.reason === reason)) {
|
|
116
|
+
return { rowCount: 0 };
|
|
117
|
+
}
|
|
118
|
+
inserts.push({ builderId, delta, reason, description: params[4] });
|
|
119
|
+
return { rowCount: 1 };
|
|
120
|
+
}
|
|
121
|
+
return { rows: [], rowCount: 0 };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ON = { ideatorCredit: true };
|
|
127
|
+
|
|
128
|
+
await test('stream 2: a developed Full Idea pays BOTH parties under one reason key', async () => {
|
|
129
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
130
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
131
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT, 'the whole payout is booked, split across two rows');
|
|
132
|
+
assert.equal(c.inserts.length, 2);
|
|
133
|
+
// One reason key for both — which is exactly why the idempotency predicate
|
|
134
|
+
// must include builder_id, or the second payee would be refused.
|
|
135
|
+
assert.equal(new Set(c.inserts.map((i) => i.reason)).size, 1);
|
|
136
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
137
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
138
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
139
|
+
assert.match(c.inserts.find((i) => i.builderId === '9').description, /spark author/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('stream 2: the PARTIES come from the DB, not from the caller', async () => {
|
|
143
|
+
// A caller naming the wrong author cannot redirect the money — the whole
|
|
144
|
+
// reason R146 could not safely pay.
|
|
145
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
146
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '999', completenessScore: 100, config: ON });
|
|
147
|
+
assert.ok(!c.inserts.some((i) => i.builderId === '999'), 'the caller-supplied author is ignored when the row is readable');
|
|
148
|
+
assert.deepEqual(c.inserts.map((i) => i.builderId).sort(), ['42', '9']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('stream 2: an undeveloped Full Idea still pays its author the full 5', async () => {
|
|
152
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
153
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
154
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT);
|
|
155
|
+
assert.equal(c.inserts.length, 1);
|
|
156
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
157
|
+
assert.equal(c.inserts[0].delta, IDEA_FULL_PASS_CREDIT);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('stream 2: a PARTIALLY booked split completes on a re-run, never double-pays', async () => {
|
|
161
|
+
const reason = 'idea.credit.full_pass:77';
|
|
162
|
+
// The developer's row landed; the author's did not (a crash between inserts).
|
|
163
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }] });
|
|
164
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
165
|
+
assert.equal(c.inserts.length, 1, 'only the missing row is written');
|
|
166
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
167
|
+
assert.equal(r.credited, 1, 'credited reports only what was NEWLY booked');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('stream 2: a fully booked split reports already_paid and writes nothing', async () => {
|
|
171
|
+
const reason = 'idea.credit.full_pass:77';
|
|
172
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }, { builderId: '9', reason }] });
|
|
173
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
174
|
+
assert.equal(c.inserts.length, 0);
|
|
175
|
+
assert.equal(r.credited, 0);
|
|
176
|
+
assert.equal(r.reason, 'already_paid');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('stream 2: below the bar nobody is paid, developed or not', async () => {
|
|
180
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
181
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 10, config: ON });
|
|
182
|
+
assert.equal(r.credited, 0);
|
|
183
|
+
assert.equal(r.reason, 'below_bar');
|
|
184
|
+
assert.equal(c.inserts.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('stream 2: the lane can still be switched off entirely', async () => {
|
|
188
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
189
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: { ideatorCredit: false } });
|
|
190
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
191
|
+
assert.equal(c.inserts.length, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- stream 1, end to end ----------------------------------------------
|
|
195
|
+
|
|
196
|
+
await test('stream 1: a task shipped under a DEVELOPED idea splits the same way', async () => {
|
|
197
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
198
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
199
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE, 'a 100% full idea pays the full base, split');
|
|
200
|
+
assert.equal(c.inserts.length, 2);
|
|
201
|
+
assert.equal(sum(c.inserts.map((i) => ({ delta: i.delta }))), IDEA_TASK_SHIP_BASE);
|
|
202
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
203
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
204
|
+
// Same rule, so the same wording — one rule to explain, not two.
|
|
205
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /stream 1/);
|
|
206
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('stream 1: an undeveloped idea is unchanged — the author gets it all', async () => {
|
|
210
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
211
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
212
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE);
|
|
213
|
+
assert.equal(c.inserts.length, 1);
|
|
214
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('stream 1: the per-(idea,task) reason key still scopes idempotency', async () => {
|
|
218
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
219
|
+
await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
220
|
+
for (const i of c.inserts) assert.equal(i.reason, 'idea.credit.task_ship:77:100');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
console.log(`\nidea_credit_split: ${passed} passed, ${failed} failed`);
|
|
224
|
+
if (failed > 0) process.exit(1);
|