|
17 | 17 | */ |
18 | 18 |
|
19 | 19 | import { describe, it, expect } from 'vitest'; |
20 | | -import { ObjectStackDefinitionSchema, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; |
| 20 | +import { ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; |
21 | 21 | import { |
22 | 22 | applyArtifactForwardConversions, |
23 | 23 | parseRangeFloor, |
@@ -401,6 +401,108 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub |
401 | 401 | }); |
402 | 402 | }); |
403 | 403 |
|
| 404 | +/** |
| 405 | + * #15429 — the second member of the DEFAULT-FLIP class this door refuses. |
| 406 | + * |
| 407 | + * `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto an |
| 408 | + * edge-branched `decision` with two or more conditioned out-edges, so a flow |
| 409 | + * written while every true branch ran keeps that behaviour now that the |
| 410 | + * traversal is exclusive. An omitted `mode` IS the exclusive gateway by the |
| 411 | + * contract on `DecisionConfigSchema`, so the rewrite reinterprets a legal |
| 412 | + * shape — sound only where the source's age is a fact (`os migrate meta |
| 413 | + * --from 17`, the operator's assertion). At this door the trigger is the |
| 414 | + * artifact's declared `engines.protocol` floor, and `^17.0.0` is what |
| 415 | + * `create-objectstack` stamps: an app scaffolded today, authored against the |
| 416 | + * exclusive contract, lands inside the window. Replaying the entry here would |
| 417 | + * hand it an inclusive gateway it never asked for — the #17885 shape, on a |
| 418 | + * key whose omission is the ruled default. |
| 419 | + * |
| 420 | + * Same four legs as the block above: SUBJECT (window open, no `mode` |
| 421 | + * written) · the strict parse the door feeds · NEGATIVE (window shut) · |
| 422 | + * FIRING CONTROL (the entry WOULD rewrite this very fixture with the window |
| 423 | + * open and no refusal, so the subject leg cannot pass vacuously). |
| 424 | + */ |
| 425 | +describe('the artifact door never writes `mode: inclusive` onto an authored exclusive decision (#15429)', () => { |
| 426 | + /** One edge-branched decision with two conditioned out-edges and no `mode` — the shape the entry rewrites. */ |
| 427 | + const twoBranchDecisionDefinition = (protocolRange: string) => ({ |
| 428 | + manifest: { |
| 429 | + id: 'app.example.leads', name: 'leads', version: '1.0.0', type: 'app', |
| 430 | + engines: { protocol: protocolRange }, |
| 431 | + }, |
| 432 | + flows: [{ |
| 433 | + name: 'lead_verdict', |
| 434 | + label: 'Lead verdict', |
| 435 | + type: 'autolaunched', |
| 436 | + nodes: [ |
| 437 | + { id: 'start', type: 'start', label: 'Start' }, |
| 438 | + { id: 'verdict', type: 'decision', label: 'Verdict?' }, |
| 439 | + { id: 'refuse', type: 'end', label: 'Refuse' }, |
| 440 | + { id: 'convert', type: 'end', label: 'Convert' }, |
| 441 | + ], |
| 442 | + edges: [ |
| 443 | + { id: 'e1', source: 'start', target: 'verdict' }, |
| 444 | + { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'", label: 'Refuse' }, |
| 445 | + { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'", label: 'Convert' }, |
| 446 | + ], |
| 447 | + }], |
| 448 | + }); |
| 449 | + const ID = 'flow-decision-mode-inclusive-explicit'; |
| 450 | + const verdictNodeOf = (definition: unknown) => |
| 451 | + (definition as { flows: { nodes: { id: string; config?: Record<string, unknown> }[] }[] }) |
| 452 | + .flows[0]!.nodes.find((n) => n.id === 'verdict')!; |
| 453 | + |
| 454 | + it('leaves the decision without `mode` on an artifact the retired window IS open for', () => { |
| 455 | + const def = twoBranchDecisionDefinition('^17.0.0'); |
| 456 | + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); |
| 457 | + |
| 458 | + // ⭐ ANTI-VACUITY: the window really is open on this input. |
| 459 | + expect(result.verdict).toBe('converted-forward'); |
| 460 | + expect(result.authoredFloor).toBe('17.0.0'); |
| 461 | + |
| 462 | + const verdict = verdictNodeOf(result.definition); |
| 463 | + expect(verdict.config, 'the authored exclusive gateway is untouched').toBeUndefined(); |
| 464 | + expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); |
| 465 | + // Copy-on-write: nothing was recognized, so the same reference comes back. |
| 466 | + expect(result.definition).toBe(def); |
| 467 | + }); |
| 468 | + |
| 469 | + it('registers the decision without `mode` — asserted AFTER the strict parse the door feeds', () => { |
| 470 | + const def = twoBranchDecisionDefinition('^17.0.0'); |
| 471 | + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); |
| 472 | + expect(result.verdict).toBe('converted-forward'); |
| 473 | + |
| 474 | + const parsed = ObjectStackDefinitionSchema.parse(result.definition); |
| 475 | + const registered = verdictNodeOf(parsed); |
| 476 | + expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode'); |
| 477 | + }); |
| 478 | + |
| 479 | + it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => { |
| 480 | + const def = twoBranchDecisionDefinition('^99.0.0'); |
| 481 | + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); |
| 482 | + expect(result.verdict).toBe('authored-current'); |
| 483 | + expect(result.notices).toEqual([]); |
| 484 | + expect(verdictNodeOf(result.definition).config).toBeUndefined(); |
| 485 | + }); |
| 486 | + |
| 487 | + /** |
| 488 | + * ⭐ FIRING CONTROL — the entry WOULD rewrite this exact fixture: the same |
| 489 | + * bytes through the primitive with the retired window open and no refusal |
| 490 | + * come back inclusive, with the entry's own notice. A door that had merely |
| 491 | + * stopped recognizing the shape, or a fixture the entry never matched, would |
| 492 | + * go green above and prove nothing; this leg is what makes the subject a |
| 493 | + * reading of the refusal. |
| 494 | + */ |
| 495 | + it('FIRING CONTROL — without the refusal, the same fixture IS rewritten to `mode: inclusive`', () => { |
| 496 | + const notices: ConversionNotice[] = []; |
| 497 | + const rewritten = applyConversions(twoBranchDecisionDefinition('^17.0.0'), { |
| 498 | + includeRetired: true, |
| 499 | + onNotice: (n) => notices.push(n), |
| 500 | + }); |
| 501 | + expect(verdictNodeOf(rewritten).config).toEqual({ mode: 'inclusive' }); |
| 502 | + expect(notices.map((n) => n.conversionId)).toContain(ID); |
| 503 | + }); |
| 504 | +}); |
| 505 | + |
404 | 506 | describe('parseRangeFloor — the range spellings artifacts actually carry', () => { |
405 | 507 | it.each([ |
406 | 508 | ['^17.1.0', [17, 1, 0]], |
|
0 commit comments