@dszp/netsapiens-lib 0.1.8 → 0.3.0
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/README.md +95 -1
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +151 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +0 -0
- package/dist/inventory.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js.map +1 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/model.d.ts +18 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js.map +1 -0
- package/dist/nsAuthClient.d.ts.map +1 -0
- package/dist/nsAuthClient.js.map +1 -0
- package/dist/nsClient.d.ts +19 -0
- package/dist/nsClient.d.ts.map +1 -0
- package/dist/nsClient.js +40 -3
- package/dist/nsClient.js.map +1 -0
- package/dist/nsDevice.d.ts.map +1 -0
- package/dist/nsDevice.js.map +1 -0
- package/dist/nsSubscriptions.d.ts.map +1 -0
- package/dist/nsSubscriptions.js.map +1 -0
- package/dist/nsSynchronous.d.ts.map +1 -0
- package/dist/nsSynchronous.js.map +1 -0
- package/dist/nsWriteClient.d.ts.map +1 -0
- package/dist/nsWriteClient.js.map +1 -0
- package/dist/policy.d.ts +8 -2
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +3 -1
- package/dist/policy.js.map +1 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js.map +1 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js.map +1 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js.map +1 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js.map +1 -0
- package/package.json +7 -3
- package/src/eligibility.selftest.ts +95 -0
- package/src/eligibility.ts +118 -0
- package/src/html.ts +407 -0
- package/src/index.ts +120 -0
- package/src/inventory.selftest.ts +198 -0
- package/src/inventory.ts +314 -0
- package/src/jwt.selftest.ts +145 -0
- package/src/jwt.ts +491 -0
- package/src/mermaid.ts +169 -0
- package/src/model.ts +130 -0
- package/src/nsAuthClient.selftest.ts +60 -0
- package/src/nsAuthClient.ts +102 -0
- package/src/nsClient.selftest.ts +173 -0
- package/src/nsClient.ts +323 -0
- package/src/nsDevice.selftest.ts +190 -0
- package/src/nsDevice.ts +167 -0
- package/src/nsSubscriptions.selftest.ts +486 -0
- package/src/nsSubscriptions.ts +638 -0
- package/src/nsSynchronous.selftest.ts +63 -0
- package/src/nsSynchronous.ts +98 -0
- package/src/nsWriteClient.selftest.ts +104 -0
- package/src/nsWriteClient.ts +157 -0
- package/src/policy.ts +123 -0
- package/src/principal.selftest.ts +118 -0
- package/src/principal.ts +101 -0
- package/src/raster.selftest.ts +42 -0
- package/src/raster.ts +79 -0
- package/src/resolver.selftest.ts +225 -0
- package/src/resolver.ts +1115 -0
- package/src/sensitivity.ts +40 -0
- package/src/themes.ts +142 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal + policy engine proof, driven by two ns_t claim sets that mirror the shapes the real
|
|
3
|
+
* platform emits: a reseller acting as self (no mask), and a domain user being impersonated by a
|
|
4
|
+
* reseller (`mask_chain` set). Identities are fictional — the SHAPE is what's under test.
|
|
5
|
+
*
|
|
6
|
+
* The masked case is the interesting one: `sub`/`domain`/`user_scope` describe the MASKED user, while
|
|
7
|
+
* `mask_chain` carries the real operator behind the mask. Getting that backwards is the bug this
|
|
8
|
+
* fixture exists to catch.
|
|
9
|
+
*
|
|
10
|
+
* Run: `pnpm test:principal`.
|
|
11
|
+
*/
|
|
12
|
+
import { extractContext } from './jwt.js';
|
|
13
|
+
import { toPrincipal, isResellerScope, isAdminScope } from './principal.js';
|
|
14
|
+
import { can, isAllowed, type FeaturePolicies } from './policy.js';
|
|
15
|
+
|
|
16
|
+
// Claim sets shaped exactly like the platform's, with fictional identities.
|
|
17
|
+
const RESELLER_SELF = {
|
|
18
|
+
aud: 'ns', iss: 'manage.example.com', sub: 'admin@0000.12345.service',
|
|
19
|
+
domain: '0000.12345.service', territory: '12345.service', user: 'admin',
|
|
20
|
+
user_email: 'alex@acme42.example', user_scope: 'Reseller', displayName: 'Alex Reseller',
|
|
21
|
+
mask_chain: null,
|
|
22
|
+
};
|
|
23
|
+
const RESELLER_MASKED = {
|
|
24
|
+
aud: 'ns', iss: 'manage.example.com', sub: '100@acme',
|
|
25
|
+
domain: 'acme', territory: '12345.service', user: '100',
|
|
26
|
+
user_email: 'jordan@acme.example', user_scope: 'Office Manager', displayName: 'Jordan Manager',
|
|
27
|
+
mask_chain: 'operator@0000.12345.service',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const self = toPrincipal(extractContext(RESELLER_SELF as Record<string, unknown>));
|
|
31
|
+
const masked = toPrincipal(extractContext(RESELLER_MASKED as Record<string, unknown>));
|
|
32
|
+
|
|
33
|
+
let pass = 0, fail = 0;
|
|
34
|
+
const check = (name: string, cond: boolean) => { cond ? (pass++, console.log(' ok ' + name)) : (fail++, console.log(' FAIL ' + name)); };
|
|
35
|
+
|
|
36
|
+
// ---- normalization ----
|
|
37
|
+
check('self: not masking', self.masking === false && self.operator === null);
|
|
38
|
+
check('self: reseller scope + id', self.scope === 'Reseller' && self.id === 'admin@0000.12345.service');
|
|
39
|
+
check('self: domain', self.domain === '0000.12345.service');
|
|
40
|
+
check('masked: masking flag', masked.masking === true);
|
|
41
|
+
check('masked: effective is the masked user (acme/100/Office Manager)',
|
|
42
|
+
masked.domain === 'acme' && masked.user === '100' && masked.scope === 'Office Manager' && masked.id === '100@acme');
|
|
43
|
+
check('masked: operator is the real reseller', masked.operator?.id === 'operator@0000.12345.service');
|
|
44
|
+
check('masked: identity fields surfaced', masked.email === 'jordan@acme.example' && masked.displayName === 'Jordan Manager');
|
|
45
|
+
|
|
46
|
+
// ---- scope helpers ----
|
|
47
|
+
check('isResellerScope(self)', isResellerScope(self.scope) === true);
|
|
48
|
+
check('isResellerScope(masked effective) is FALSE (scope reads Office Manager while masked)', isResellerScope(masked.scope) === false);
|
|
49
|
+
check('isAdminScope(masked) true (Office Manager)', isAdminScope(masked.scope) === true);
|
|
50
|
+
check('isResellerScope case-insensitive', isResellerScope('reseller') && isResellerScope('SUPER USER'));
|
|
51
|
+
|
|
52
|
+
// ---- policy cases ----
|
|
53
|
+
check('all resellers: self yes, masked no', isAllowed(self, [{ scopes: ['Reseller'] }]) && !isAllowed(masked, [{ scopes: ['Reseller'] }]));
|
|
54
|
+
check('my reseller only: self yes',
|
|
55
|
+
isAllowed(self, [{ scopes: ['Reseller'], users: ['admin@0000.12345.service'] }]));
|
|
56
|
+
check('my reseller only: a different reseller no',
|
|
57
|
+
!isAllowed({ ...self, id: 'someoneelse@0000.12345.service' }, [{ scopes: ['Reseller'], users: ['admin@0000.12345.service'] }]));
|
|
58
|
+
check('office managers: masked yes, self no', isAllowed(masked, [{ scopes: ['Office Manager'] }]) && !isAllowed(self, [{ scopes: ['Office Manager'] }]));
|
|
59
|
+
check('all users in acme: masked yes, self no', isAllowed(masked, [{ domains: ['acme'] }]) && !isAllowed(self, [{ domains: ['acme'] }]));
|
|
60
|
+
check('domains with scope filter', isAllowed(masked, [{ domains: ['acme'], scopes: ['Office Manager', 'Basic User'] }]));
|
|
61
|
+
check('specific users: masked yes', isAllowed(masked, [{ users: ['100@acme'] }]));
|
|
62
|
+
check('operator gate (only when my reseller masked-in): masked yes, self no',
|
|
63
|
+
isAllowed(masked, [{ operators: ['operator@0000.12345.service'] }]) && !isAllowed(self, [{ operators: ['operator@0000.12345.service'] }]));
|
|
64
|
+
check('masking:false rule: self yes, masked no', isAllowed(self, [{ masking: false }]) && !isAllowed(masked, [{ masking: false }]));
|
|
65
|
+
check("domain '*' wildcard matches any", isAllowed(self, [{ domains: ['*'] }]) && isAllowed(masked, [{ domains: ['*'] }]));
|
|
66
|
+
check('ANY-rule semantics (union of two rules)',
|
|
67
|
+
isAllowed(masked, [{ scopes: ['Reseller'] }, { domains: ['acme'] }]));
|
|
68
|
+
check('empty policy denies', !isAllowed(self, []));
|
|
69
|
+
check('conditionless rule does NOT match (no accidental allow-all)', !isAllowed(self, [{}]) && !isAllowed(self, [{ description: 'todo' }]));
|
|
70
|
+
|
|
71
|
+
// ---- notUsers: the one negative condition ----
|
|
72
|
+
const otherReseller = { ...self, id: 'other@0000.12345.service' };
|
|
73
|
+
check('notUsers denies a principal the positive fields admit',
|
|
74
|
+
isAllowed(self, [{ scopes: ['Reseller'] }]) &&
|
|
75
|
+
!isAllowed(self, [{ scopes: ['Reseller'], notUsers: ['admin@0000.12345.service'] }]));
|
|
76
|
+
check('notUsers leaves everyone else at that scope alone',
|
|
77
|
+
isAllowed(otherReseller, [{ scopes: ['Reseller'], notUsers: ['admin@0000.12345.service'] }]));
|
|
78
|
+
check('notUsers is case-insensitive, like every other list',
|
|
79
|
+
!isAllowed(self, [{ scopes: ['Reseller'], notUsers: ['ADMIN@0000.12345.SERVICE'] }]));
|
|
80
|
+
check('a negation-only rule never matches (no allow-all-but)',
|
|
81
|
+
!isAllowed(self, [{ notUsers: ['nobody@example.com'] }]) &&
|
|
82
|
+
!isAllowed(masked, [{ notUsers: ['nobody@example.com'] }]));
|
|
83
|
+
// The deny narrows THE RULE IT SITS ON, not the policy — rules still OR. A caller that compiles a
|
|
84
|
+
// "deny this account" intent into a policy must therefore put the negation on EVERY rule it emits;
|
|
85
|
+
// leaving one rule bare re-admits the account through it. Asserted here so the property is pinned at
|
|
86
|
+
// the engine rather than only in whichever consumer got the distribution right.
|
|
87
|
+
// The one asymmetry in this engine: a grant follows the role being performed, a denial follows the
|
|
88
|
+
// person. `masked` is 100@acme with operator@0000.12345.service behind the mask.
|
|
89
|
+
check('notUsers denies the OPERATOR behind a mask, not only the account being acted as',
|
|
90
|
+
isAllowed(masked, [{ scopes: ['Office Manager'] }]) &&
|
|
91
|
+
!isAllowed(masked, [{ scopes: ['Office Manager'], notUsers: ['operator@0000.12345.service'] }]));
|
|
92
|
+
check('notUsers still denies the effective identity while masked',
|
|
93
|
+
!isAllowed(masked, [{ scopes: ['Office Manager'], notUsers: ['100@acme'] }]));
|
|
94
|
+
check('a mask by someone NOT denied is unaffected',
|
|
95
|
+
isAllowed(masked, [{ scopes: ['Office Manager'], notUsers: ['someoneelse@0000.12345.service'] }]));
|
|
96
|
+
// Denying the operator must not leak into the unmasked case: `self` IS operator@… acting as themselves,
|
|
97
|
+
// with no mask, so the deny bites through p.id — but a principal with no operator must never throw or
|
|
98
|
+
// match on a missing field.
|
|
99
|
+
check('an unmasked principal with no operator is judged on its own id alone',
|
|
100
|
+
isAllowed({ ...self, id: 'other@0000.12345.service' }, [{ scopes: ['Reseller'], notUsers: ['operator@0000.12345.service'] }]));
|
|
101
|
+
check('notUsers is per-rule: a bare sibling rule still admits the denied account',
|
|
102
|
+
isAllowed(self, [
|
|
103
|
+
{ scopes: ['Reseller'], notUsers: ['admin@0000.12345.service'] },
|
|
104
|
+
{ users: ['admin@0000.12345.service'] },
|
|
105
|
+
]));
|
|
106
|
+
|
|
107
|
+
// ---- feature registry (fail-closed) ----
|
|
108
|
+
const FEATURES: FeaturePolicies = {
|
|
109
|
+
'callflow.view': [{ scopes: ['Reseller'] }, { operators: ['operator@0000.12345.service'] }], // resellers, or my masked previews
|
|
110
|
+
'admin.publish': [{ scopes: ['Reseller', 'Super User'] }],
|
|
111
|
+
};
|
|
112
|
+
check('feature callflow.view: self(reseller) yes', can(self, 'callflow.view', FEATURES));
|
|
113
|
+
check('feature callflow.view: masked-by-my-reseller yes (via operator rule)', can(masked, 'callflow.view', FEATURES));
|
|
114
|
+
check('feature admin.publish: masked office-manager no', !can(masked, 'admin.publish', FEATURES));
|
|
115
|
+
check('unknown feature denies (fail closed)', !can(self, 'does.not.exist', FEATURES));
|
|
116
|
+
|
|
117
|
+
console.log(`\nprincipal.selftest: ${pass} passed, ${fail} failed`);
|
|
118
|
+
if (fail > 0) process.exit(1);
|
package/src/principal.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an ns_t JWT's claims into a `Principal` — the identity + role the portal authorizes
|
|
3
|
+
* against. Handles NetSapiens **masking** (impersonation): when a reseller/admin is masked-in as a
|
|
4
|
+
* domain user, the token's `domain`/`user`/`scope`/`sub` describe the MASKED user, while `mask_chain`
|
|
5
|
+
* names the real operator. `Principal` keeps both, plus a `masking` flag.
|
|
6
|
+
*
|
|
7
|
+
* Two things the caller must keep straight (see policy.ts):
|
|
8
|
+
* - **Auth scope** (what data is reachable) follows the EFFECTIVE identity: a masked token is
|
|
9
|
+
* NS-scoped to the masked user's domain; only an un-masked reseller/super-user reads cross-domain.
|
|
10
|
+
* - **Role gating** may consider the OPERATOR (mask_chain) too — e.g. "allow my reseller while
|
|
11
|
+
* developing" — since the effective scope reads "Office Manager" while masked.
|
|
12
|
+
*
|
|
13
|
+
* Portable (no Node). Pure functions.
|
|
14
|
+
*/
|
|
15
|
+
import type { JwtContext } from './jwt.js';
|
|
16
|
+
|
|
17
|
+
/** Known NetSapiens user scopes (the string is open — unknown scopes pass through as-is). */
|
|
18
|
+
export type Scope =
|
|
19
|
+
| 'Super User'
|
|
20
|
+
| 'Reseller'
|
|
21
|
+
| 'Office Manager'
|
|
22
|
+
| 'Site Manager'
|
|
23
|
+
| 'Call Center Supervisor'
|
|
24
|
+
| 'Call Center Agent'
|
|
25
|
+
| 'Basic User'
|
|
26
|
+
| (string & {});
|
|
27
|
+
|
|
28
|
+
/** The real operator behind a mask (parsed from `mask_chain`). */
|
|
29
|
+
export interface Operator {
|
|
30
|
+
/** `user@domain` (verbatim mask_chain, lowercased id below). */
|
|
31
|
+
raw: string;
|
|
32
|
+
user: string;
|
|
33
|
+
domain: string;
|
|
34
|
+
/** Lowercased `user@domain` for comparisons. */
|
|
35
|
+
id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Principal {
|
|
39
|
+
/** Effective domain — the masked user's when masking; the token is NS-scoped to it. */
|
|
40
|
+
domain: string;
|
|
41
|
+
/** Effective user / extension. */
|
|
42
|
+
user: string;
|
|
43
|
+
/** Effective identity `user@domain`, lowercased (from sub, or user+domain). */
|
|
44
|
+
id: string;
|
|
45
|
+
/** Effective user_scope (the masked user's scope when masking). */
|
|
46
|
+
scope: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
displayName?: string;
|
|
49
|
+
territory?: string;
|
|
50
|
+
/** True when a mask is in effect (mask_chain present). */
|
|
51
|
+
masking: boolean;
|
|
52
|
+
/** The real operator behind the mask, or null when not masking. */
|
|
53
|
+
operator: Operator | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lc = (s: string | undefined) => (s ?? '').trim().toLowerCase();
|
|
57
|
+
|
|
58
|
+
/** Scopes that can read/act across the whole reseller/fleet (cross-domain). */
|
|
59
|
+
const RESELLER_SCOPES = new Set(['reseller', 'super user', 'superuser', 'super-user']);
|
|
60
|
+
/** Scopes with domain-admin authority (a superset that includes reseller-level). */
|
|
61
|
+
const ADMIN_SCOPES = new Set([...RESELLER_SCOPES, 'office manager', 'site manager', 'call center supervisor']);
|
|
62
|
+
|
|
63
|
+
/** Reseller / super-user — the only scopes that legitimately read cross-domain. */
|
|
64
|
+
export function isResellerScope(scope: string | undefined): boolean {
|
|
65
|
+
return RESELLER_SCOPES.has(lc(scope));
|
|
66
|
+
}
|
|
67
|
+
/** Domain-admin-or-higher (office manager / site manager / supervisor / reseller / super user). */
|
|
68
|
+
export function isAdminScope(scope: string | undefined): boolean {
|
|
69
|
+
return ADMIN_SCOPES.has(lc(scope));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a `mask_chain` value ("user@domain") into an Operator, or null if empty/malformed. */
|
|
73
|
+
export function parseOperator(maskChain: string | undefined): Operator | null {
|
|
74
|
+
const raw = (maskChain ?? '').trim();
|
|
75
|
+
if (!raw || !raw.includes('@')) return null;
|
|
76
|
+
const at = raw.lastIndexOf('@');
|
|
77
|
+
const user = raw.slice(0, at);
|
|
78
|
+
const domain = raw.slice(at + 1);
|
|
79
|
+
if (!user || !domain) return null;
|
|
80
|
+
return { raw, user, domain, id: `${user}@${domain}`.toLowerCase() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build a Principal from decoded ns_t context (a JwtContext / JwtVerdict — both carry the claims). */
|
|
84
|
+
export function toPrincipal(ctx: JwtContext): Principal {
|
|
85
|
+
const domain = (ctx.domain ?? '').trim();
|
|
86
|
+
const user = (ctx.user ?? '').trim();
|
|
87
|
+
const sub = (ctx.sub ?? '').trim();
|
|
88
|
+
const id = (sub.includes('@') ? sub : user && domain ? `${user}@${domain}` : sub || user).toLowerCase();
|
|
89
|
+
const operator = parseOperator(ctx.maskChain);
|
|
90
|
+
return {
|
|
91
|
+
domain,
|
|
92
|
+
user,
|
|
93
|
+
id,
|
|
94
|
+
scope: (ctx.scope ?? '').trim(),
|
|
95
|
+
...(ctx.email ? { email: ctx.email } : {}),
|
|
96
|
+
...(ctx.displayName ? { displayName: ctx.displayName } : {}),
|
|
97
|
+
...(ctx.territory ? { territory: ctx.territory } : {}),
|
|
98
|
+
masking: operator != null,
|
|
99
|
+
operator,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof for the browser PNG rasterizer's portable pieces. The size resolver is pure and unit-tested
|
|
3
|
+
* here; the full Image→canvas→PNG path is browser-only (validated via agent-browser, see the plan).
|
|
4
|
+
* Run: `pnpm test:raster`.
|
|
5
|
+
*/
|
|
6
|
+
import { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
7
|
+
|
|
8
|
+
let pass = 0, fail = 0;
|
|
9
|
+
const check = (name: string, cond: boolean) => { cond ? (pass++, console.log(' ok ' + name)) : (fail++, console.log(' FAIL ' + name)); };
|
|
10
|
+
const size = (s: string) => resolveSvgSize(s);
|
|
11
|
+
|
|
12
|
+
// ---- resolveSvgSize ----
|
|
13
|
+
check('explicit px width/height win', (() => { const r = size('<svg width="800" height="600" viewBox="0 0 10 10"></svg>'); return r.width === 800 && r.height === 600; })());
|
|
14
|
+
check('percentage width falls back to viewBox', (() => { const r = size('<svg width="100%" viewBox="0 0 812 640"></svg>'); return r.width === 812 && r.height === 640; })());
|
|
15
|
+
check('viewBox only', (() => { const r = size('<svg viewBox="0 0 400 300"></svg>'); return r.width === 400 && r.height === 300; })());
|
|
16
|
+
check('unit suffix stripped', (() => { const r = size('<svg width="120px" height="90px"></svg>'); return r.width === 120 && r.height === 90; })());
|
|
17
|
+
check('comma-separated viewBox', (() => { const r = size('<svg viewBox="0,0,50,40"></svg>'); return r.width === 50 && r.height === 40; })());
|
|
18
|
+
check('nothing → default 800x600', (() => { const r = size('<svg></svg>'); return r.width === 800 && r.height === 600; })());
|
|
19
|
+
|
|
20
|
+
// ---- rasterizerScript: must be valid JS + its emitted size logic must actually work ----
|
|
21
|
+
const src = rasterizerScript();
|
|
22
|
+
check('rasterizerScript returns non-empty string', typeof src === 'string' && src.length > 200);
|
|
23
|
+
check('defines svgToPngBlob', /function\s+svgToPngBlob\s*\(/.test(src));
|
|
24
|
+
check('emits PNG via toBlob', src.includes('toBlob') && src.includes('image/png'));
|
|
25
|
+
check('honors scale + background', src.includes('scale') && src.includes('background'));
|
|
26
|
+
check('clamps to 8192', src.includes('8192'));
|
|
27
|
+
check('emitted script is syntactically valid JS', (() => { try { new Function(src + '\nreturn typeof svgToPngBlob === "function";')(); return true; } catch { return false; } })());
|
|
28
|
+
|
|
29
|
+
// Execute the EMITTED __svgSize (pure string logic, no DOM) — this catches regex-escaping bugs in
|
|
30
|
+
// the generated code that the string-`includes` smoke checks above cannot. Mirrors resolveSvgSize.
|
|
31
|
+
const emittedSvgSize = new Function(src + '\nreturn __svgSize;')() as (s: string) => { width: number; height: number };
|
|
32
|
+
check('emitted __svgSize: viewBox-only splits on whitespace', (() => { const r = emittedSvgSize('<svg width="100%" viewBox="0 0 812 640"></svg>'); return r.width === 812 && r.height === 640; })());
|
|
33
|
+
check('emitted __svgSize: comma-separated viewBox', (() => { const r = emittedSvgSize('<svg viewBox="0,0,50,40"></svg>'); return r.width === 50 && r.height === 40; })());
|
|
34
|
+
check('emitted __svgSize: explicit px wins', (() => { const r = emittedSvgSize('<svg width="640" height="480" viewBox="0 0 10 10"></svg>'); return r.width === 640 && r.height === 480; })());
|
|
35
|
+
check('emitted __svgSize matches resolveSvgSize on a Mermaid-shaped SVG', (() => {
|
|
36
|
+
const svg = '<svg id="m1" width="100%" viewBox="0 0 917 523" style="max-width: 917px;"></svg>';
|
|
37
|
+
const a = resolveSvgSize(svg), b = emittedSvgSize(svg);
|
|
38
|
+
return a.width === b.width && a.height === b.height && a.width === 917 && a.height === 523;
|
|
39
|
+
})());
|
|
40
|
+
|
|
41
|
+
console.log(`\nraster.selftest: ${pass} passed, ${fail} failed`);
|
|
42
|
+
if (fail > 0) process.exit(1);
|
package/src/raster.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser PNG rasterizer for call-flow diagrams — Node-free, Web-APIs-only. The library never holds
|
|
3
|
+
* an SVG (Mermaid renders it client-side), so this is a BROWSER helper: `resolveSvgSize` is a pure,
|
|
4
|
+
* testable size reader, and `rasterizerScript()` returns injectable JS defining `svgToPngBlob`.
|
|
5
|
+
* A host page (dia today; the lib's gallery HTML later) injects the script and calls the global.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Intrinsic pixel size of an SVG string: explicit px width/height, else viewBox, else a default. */
|
|
9
|
+
export function resolveSvgSize(svgString: string): { width: number; height: number } {
|
|
10
|
+
const DEFAULT = { width: 800, height: 600 };
|
|
11
|
+
const attr = (name: string): string | undefined => {
|
|
12
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`).exec(svgString);
|
|
13
|
+
return m ? m[1] : undefined;
|
|
14
|
+
};
|
|
15
|
+
const px = (s: string | undefined): number | null => {
|
|
16
|
+
if (!s || s.includes('%')) return null; // reject "100%" etc.
|
|
17
|
+
const n = parseFloat(s); // "120px" → 120
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
19
|
+
};
|
|
20
|
+
const w = px(attr('width'));
|
|
21
|
+
const h = px(attr('height'));
|
|
22
|
+
if (w && h) return { width: w, height: h };
|
|
23
|
+
const vb = attr('viewBox');
|
|
24
|
+
if (vb) {
|
|
25
|
+
const p = vb.trim().split(/[\s,]+/).map(Number);
|
|
26
|
+
if (p.length === 4 && p[2] > 0 && p[3] > 0) return { width: w ?? p[2], height: h ?? p[3] };
|
|
27
|
+
}
|
|
28
|
+
return { width: w ?? DEFAULT.width, height: h ?? DEFAULT.height };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Injectable browser JS (a function declaration) defining `svgToPngBlob(svgEl, { scale, background })`.
|
|
33
|
+
* A host injects this string into an inline <script> (no bundler needed) and calls the function.
|
|
34
|
+
* Rasterizes a LIVE, already-rendered <svg>: clone → force explicit px size (from the same algorithm
|
|
35
|
+
* as resolveSvgSize) → data-URL into an Image → draw onto a scaled canvas (optional opaque background)
|
|
36
|
+
* → PNG Blob. Scale is clamped so the larger dimension stays ≤ 8192px (browser canvas caps). CSP-safe
|
|
37
|
+
* (no eval/new Function; uses a data: image URL — ensure any host CSP allows `img-src data:`).
|
|
38
|
+
*/
|
|
39
|
+
export function rasterizerScript(): string {
|
|
40
|
+
return `
|
|
41
|
+
// size reader mirroring resolveSvgSize (viewBox is authoritative for Mermaid). Top-level so it is
|
|
42
|
+
// unit-testable outside a browser (see raster.selftest.ts) — the DOM path below is browser-only.
|
|
43
|
+
function __svgSize(str) {
|
|
44
|
+
function attr(name){ var m = new RegExp('\\\\b'+name+'\\\\s*=\\\\s*["\\']([^"\\']*)["\\']').exec(str); return m ? m[1] : undefined; }
|
|
45
|
+
function px(s){ if(!s || s.indexOf('%')>=0) return null; var n=parseFloat(s); return (isFinite(n)&&n>0)?n:null; }
|
|
46
|
+
var w=px(attr('width')), h=px(attr('height'));
|
|
47
|
+
if(w&&h) return {width:w,height:h};
|
|
48
|
+
var vb=attr('viewBox');
|
|
49
|
+
if(vb){ var p=vb.trim().split(new RegExp('[\\\\s,]+')).map(Number); if(p.length===4&&p[2]>0&&p[3]>0) return {width:(w||p[2]),height:(h||p[3])}; }
|
|
50
|
+
return {width:(w||800),height:(h||600)};
|
|
51
|
+
}
|
|
52
|
+
async function svgToPngBlob(svg, opts) {
|
|
53
|
+
opts = opts || {};
|
|
54
|
+
var scale = opts.scale || 2;
|
|
55
|
+
var background = (opts.background == null) ? null : opts.background;
|
|
56
|
+
var clone = svg.cloneNode(true);
|
|
57
|
+
if(!clone.getAttribute('xmlns')) clone.setAttribute('xmlns','http://www.w3.org/2000/svg');
|
|
58
|
+
var size = __svgSize(new XMLSerializer().serializeToString(clone));
|
|
59
|
+
var W = size.width, H = size.height;
|
|
60
|
+
clone.setAttribute('width', W);
|
|
61
|
+
clone.setAttribute('height', H);
|
|
62
|
+
var str = new XMLSerializer().serializeToString(clone);
|
|
63
|
+
// --- clamp effective scale to the canvas cap ---
|
|
64
|
+
var eff = scale, MAX = 8192, big = Math.max(W, H);
|
|
65
|
+
if (big * eff > MAX) eff = MAX / big;
|
|
66
|
+
// --- SVG → Image → canvas → PNG ---
|
|
67
|
+
var url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str);
|
|
68
|
+
var img = new Image();
|
|
69
|
+
await new Promise(function(res, rej){ img.onload = res; img.onerror = function(){ rej(new Error('SVG failed to load for rasterization')); }; img.src = url; });
|
|
70
|
+
var canvas = document.createElement('canvas');
|
|
71
|
+
canvas.width = Math.max(1, Math.round(W * eff));
|
|
72
|
+
canvas.height = Math.max(1, Math.round(H * eff));
|
|
73
|
+
var ctx = canvas.getContext('2d');
|
|
74
|
+
if (background) { ctx.fillStyle = background; ctx.fillRect(0, 0, canvas.width, canvas.height); }
|
|
75
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
76
|
+
return await new Promise(function(res, rej){ canvas.toBlob(function(b){ b ? res(b) : rej(new Error('canvas.toBlob returned null')); }, 'image/png'); });
|
|
77
|
+
}
|
|
78
|
+
`.trim();
|
|
79
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade tier + queue-priority rendering proof. Run: `pnpm test:resolver`.
|
|
3
|
+
*
|
|
4
|
+
* Tiers group by RING ORDER (ordinal-order, or the manifest write-key
|
|
5
|
+
* callqueue-agent-dispatch-order-ordinal) — the real Linear Cascade rounds, NOT the
|
|
6
|
+
* cross-queue priority. Queue priority (lower = higher) is shown once when the queue
|
|
7
|
+
* shares one non-default value, per-agent when it varies, and hidden at the default (0).
|
|
8
|
+
*/
|
|
9
|
+
import { resolveFlow } from './resolver.js';
|
|
10
|
+
import type { Snapshot, FlowGraph, NodeKind } from './model.js';
|
|
11
|
+
|
|
12
|
+
let pass = 0,
|
|
13
|
+
fail = 0;
|
|
14
|
+
const check = (name: string, cond: boolean) => {
|
|
15
|
+
cond ? (pass++, console.log(' ok ' + name)) : (fail++, console.log(' FAIL ' + name));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function snap(agents: Record<string, unknown>[]): Snapshot {
|
|
19
|
+
return {
|
|
20
|
+
meta: { domain: 'testco.12345.service' },
|
|
21
|
+
callqueues: [{ callqueue: '9100', description: 'open', 'callqueue-dispatch-type': 'Linear Cascade' }],
|
|
22
|
+
agentsByQueue: { '9100': agents },
|
|
23
|
+
users: [
|
|
24
|
+
{ user: '100', 'name-first-name': 'Debbi', 'name-last-name': 'Smith' },
|
|
25
|
+
{ user: '103', 'name-first-name': 'Emily', 'name-last-name': 'Laugle' },
|
|
26
|
+
{ user: '102', 'name-first-name': 'Elizabeth', 'name-last-name': 'Ross' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const linesOf = (g: FlowGraph, kind: NodeKind) => (g.nodes.find((n) => n.kind === kind)?.lines ?? []).join('\n');
|
|
31
|
+
|
|
32
|
+
// ---- ring-order tiers + uniform non-default priority shown once ----
|
|
33
|
+
{
|
|
34
|
+
const g = resolveFlow(
|
|
35
|
+
snap([
|
|
36
|
+
{ 'callqueue-agent-id': 'sip:100@x', 'ordinal-order': 1, 'callqueue-agent-dispatch-queue-priority-ordinal': 3 },
|
|
37
|
+
{ 'callqueue-agent-id': 'sip:103@x', 'ordinal-order': 1, 'callqueue-agent-dispatch-queue-priority-ordinal': 3 },
|
|
38
|
+
{ 'callqueue-agent-id': 'sip:102@x', 'ordinal-order': 2, 'callqueue-agent-dispatch-queue-priority-ordinal': 3 },
|
|
39
|
+
]),
|
|
40
|
+
{ kind: 'queue', ref: '9100' },
|
|
41
|
+
);
|
|
42
|
+
const a = linesOf(g, 'agents');
|
|
43
|
+
check('tier by ring order: Tier 1 present', a.includes('Tier 1:'));
|
|
44
|
+
check('tier by ring order: Tier 2 present', a.includes('Tier 2:'));
|
|
45
|
+
check('round 1 = Debbi + Emily', /Tier 1:[\s\S]*Debbi[\s\S]*Emily[\s\S]*Tier 2:/.test(a));
|
|
46
|
+
check('round 2 = Elizabeth', /Tier 2:[\s\S]*Elizabeth/.test(a));
|
|
47
|
+
const q = linesOf(g, 'queue');
|
|
48
|
+
check('uniform 2+ priority → compact badge once at queue (P3️⃣)', q.includes('P3️⃣'));
|
|
49
|
+
check('no verbose "priority 3" wording', !/priority 3\b/.test(q));
|
|
50
|
+
check('priority NOT repeated per agent when uniform', !a.includes('P3️⃣'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- concise priority badge: nothing for 0 (blank) / 1 (baseline); P<keycap> for 2+ ----
|
|
54
|
+
{
|
|
55
|
+
const g = resolveFlow(
|
|
56
|
+
snap([
|
|
57
|
+
{ 'callqueue-agent-id': 'sip:100@x', 'ordinal-order': 1, 'callqueue-agent-dispatch-queue-priority-ordinal': 1 }, // baseline
|
|
58
|
+
{ 'callqueue-agent-id': 'sip:103@x', 'ordinal-order': 1, 'callqueue-agent-dispatch-queue-priority-ordinal': 0 }, // blank
|
|
59
|
+
{ 'callqueue-agent-id': 'sip:102@x', 'ordinal-order': 1, 'callqueue-agent-dispatch-queue-priority-ordinal': 2 }, // set → P2️⃣
|
|
60
|
+
]),
|
|
61
|
+
{ kind: 'queue', ref: '9100' },
|
|
62
|
+
);
|
|
63
|
+
const a = linesOf(g, 'agents');
|
|
64
|
+
check('concise: no Tier header (ring order uniform)', !a.includes('Tier 1:'));
|
|
65
|
+
check('concise: priority 2 shows P2️⃣ once', (a.match(/P2️⃣/g) ?? []).length === 1);
|
|
66
|
+
check('concise: baseline priority 1 shows no badge', !a.includes('P1️⃣'));
|
|
67
|
+
check('concise: blank priority 0 shows no badge', !a.includes('P0️⃣'));
|
|
68
|
+
check('concise: no verbose "priority" word on the roster', !/·\s*priority\b/.test(a));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- manifest-preview write-key fallback still tiers ----
|
|
72
|
+
{
|
|
73
|
+
const g = resolveFlow(
|
|
74
|
+
snap([
|
|
75
|
+
{ 'callqueue-agent-id': 'sip:100@x', 'callqueue-agent-dispatch-order-ordinal': 1 },
|
|
76
|
+
{ 'callqueue-agent-id': 'sip:102@x', 'callqueue-agent-dispatch-order-ordinal': 2 },
|
|
77
|
+
]),
|
|
78
|
+
{ kind: 'queue', ref: '9100' },
|
|
79
|
+
);
|
|
80
|
+
check('write-key fallback tiers (manifest preview)', linesOf(g, 'agents').includes('Tier 2:'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- all-default priority (0) → hidden ----
|
|
84
|
+
{
|
|
85
|
+
const g = resolveFlow(
|
|
86
|
+
snap([
|
|
87
|
+
{ 'callqueue-agent-id': 'sip:100@x', 'ordinal-order': 1 },
|
|
88
|
+
{ 'callqueue-agent-id': 'sip:102@x', 'ordinal-order': 2 },
|
|
89
|
+
]),
|
|
90
|
+
{ kind: 'queue', ref: '9100' },
|
|
91
|
+
);
|
|
92
|
+
check('default (0) priority hidden', !/queue priority/.test(linesOf(g, 'queue')));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- unanswered disposition: "Stay in queue" is drawn explicitly, not silently dropped ----
|
|
96
|
+
// forward-no-answer disabled/empty = portal "If unanswered → Stay in queue"; forward-on-busy → vmail.
|
|
97
|
+
{
|
|
98
|
+
const base: Snapshot = {
|
|
99
|
+
meta: { domain: 'testco.12345.service' },
|
|
100
|
+
callqueues: [{ callqueue: '9102', description: 'office', 'callqueue-dispatch-type': 'Linear Cascade' }],
|
|
101
|
+
agentsByQueue: { '9102': [{ 'callqueue-agent-id': 'sip:100@x', 'ordinal-order': 1 }] },
|
|
102
|
+
users: [{ user: '100', 'name-first-name': 'Debbi', 'name-last-name': 'Smith' }],
|
|
103
|
+
};
|
|
104
|
+
const stay = resolveFlow(
|
|
105
|
+
{
|
|
106
|
+
...base,
|
|
107
|
+
answerrulesByUser: {
|
|
108
|
+
'9102': [
|
|
109
|
+
{
|
|
110
|
+
'time-frame': '*',
|
|
111
|
+
enabled: 'yes',
|
|
112
|
+
'forward-no-answer': { parameters: [], enabled: 'no' }, // Stay in queue
|
|
113
|
+
'forward-on-busy': { parameters: ['vmail_500'], enabled: 'yes' },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{ kind: 'queue', ref: '9102' },
|
|
119
|
+
);
|
|
120
|
+
const stayEdge = stay.edges.find((e) => e.label === 'if unanswered · stays in queue');
|
|
121
|
+
check('stay-in-queue: explicit unanswered edge is drawn', !!stayEdge);
|
|
122
|
+
check('stay-in-queue: unanswered edge emanates from the agents node', !!stayEdge && stayEdge.from === 'agents_9102');
|
|
123
|
+
check('stay-in-queue: back-edge leaf points at the queue node', !!stayEdge && stayEdge.to.startsWith('ref_queue_9102'));
|
|
124
|
+
check('stay-in-queue: if-unavailable → voicemail still shown', stay.edges.some((e) => e.label === 'if unavailable' && stay.nodes.find((n) => n.id === e.to)?.kind === 'voicemail'));
|
|
125
|
+
|
|
126
|
+
// When forward-no-answer IS a real target, route there (no "stays in queue" leaf).
|
|
127
|
+
const toVm = resolveFlow(
|
|
128
|
+
{
|
|
129
|
+
...base,
|
|
130
|
+
callqueues: [{ callqueue: '9101', description: 'tech', 'callqueue-dispatch-type': 'Linear Cascade' }],
|
|
131
|
+
agentsByQueue: { '9101': [{ 'callqueue-agent-id': 'sip:100@x', 'ordinal-order': 1 }] },
|
|
132
|
+
answerrulesByUser: {
|
|
133
|
+
'9101': [
|
|
134
|
+
{
|
|
135
|
+
'time-frame': '*',
|
|
136
|
+
enabled: 'yes',
|
|
137
|
+
'forward-no-answer': { parameters: ['vmail_500'], enabled: 'yes' },
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{ kind: 'queue', ref: '9101' },
|
|
143
|
+
);
|
|
144
|
+
check('routed unanswered: no "stays in queue" leaf when a target is set', !toVm.edges.some((e) => e.label === 'if unanswered · stays in queue'));
|
|
145
|
+
check('routed unanswered: no answer / timeout edge present', toVm.edges.some((e) => e.label === 'no answer / timeout'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- AA "Add Tier": a keypress that opens a second-level menu ----
|
|
149
|
+
// Shape (confirmed live): the tier's prompt id (900185) exists ONLY in the dialplan, as a sibling
|
|
150
|
+
// `Prompt_900185.` rule family in the SAME AA dialplan. The /autoattendants detail nests the tier as
|
|
151
|
+
// `option-5.auto-attendant` with no id, so the two join on the keypress digit. Fictional ids here.
|
|
152
|
+
{
|
|
153
|
+
const aaSnap: Snapshot = {
|
|
154
|
+
meta: { domain: 'testco.12345.service' },
|
|
155
|
+
autoattendants: [{ user: '9000', 'attendant-name': 'aa_open', 'starting-prompt': 'Prompt_900001' }],
|
|
156
|
+
users: [{ user: '1043', 'name-first-name': 'Ada', 'name-last-name': 'Byron' }],
|
|
157
|
+
attendantDetails: {
|
|
158
|
+
'9000': {
|
|
159
|
+
'attendant-name': 'aa_open',
|
|
160
|
+
user: '9000',
|
|
161
|
+
'starting-prompt': 'Prompt_900001',
|
|
162
|
+
'time-frame': '*',
|
|
163
|
+
'auto-attendant': {
|
|
164
|
+
'option-2': { 'destination-application': 'to-user-residential', 'destination-user': '1043' },
|
|
165
|
+
'option-5': {
|
|
166
|
+
description: 'AA designer: press 5 for tier More options',
|
|
167
|
+
audio: { 'file-script-text': 'More options' },
|
|
168
|
+
'auto-attendant': {
|
|
169
|
+
'option-1': { 'destination-application': 'sip:start@directory', 'destination-user': '900186' },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
attendantDialrulesByExt: {
|
|
176
|
+
'9000': [
|
|
177
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900001.Case_1', 'dial-rule-application': 'Announce', 'dial-rule-translation-destination-user': '900013' },
|
|
178
|
+
{ 'dial-rule-matching-to-uri': 'Announce_900013.Done', 'dial-rule-application': 'Prompt', 'dial-rule-translation-destination-user': '900001' },
|
|
179
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900001.Case_2', 'dial-rule-application': 'to-user-residential', 'dial-rule-translation-destination-user': '1043' },
|
|
180
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900001.Case_4', 'dial-rule-application': 'Announce', 'dial-rule-translation-destination-user': '900014' },
|
|
181
|
+
{ 'dial-rule-matching-to-uri': 'Announce_900014.Done', 'dial-rule-application': 'hangup', 'dial-rule-translation-destination-user': '' },
|
|
182
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900001.Case_5', 'dial-rule-application': 'Prompt', 'dial-rule-translation-destination-user': '900185' },
|
|
183
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900001.Default', 'dial-rule-application': 'Prompt', 'dial-rule-translation-destination-user': '900001' },
|
|
184
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900185.Case_1', 'dial-rule-application': 'sip:start@directory', 'dial-rule-translation-destination-user': '900186' },
|
|
185
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900185.Case_9', 'dial-rule-application': 'Prompt', 'dial-rule-translation-destination-user': '900001' },
|
|
186
|
+
{ 'dial-rule-matching-to-uri': 'Prompt_900185.Default', 'dial-rule-application': 'Prompt', 'dial-rule-translation-destination-user': '900185' },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const g = resolveFlow(aaSnap, { kind: 'attendant', ref: '9000' });
|
|
191
|
+
const sub = g.nodes.find((n) => n.id === 'aa_9000_p900185');
|
|
192
|
+
|
|
193
|
+
check('tier: press 5 is a submenu node, not a "Play prompt" leaf', !!sub && sub.kind === 'attendant');
|
|
194
|
+
check('tier: no dead-end Play prompt for the tier id', !g.nodes.some((n) => n.id === 'aaprompt_9000_900185'));
|
|
195
|
+
check('tier: submenu is labelled with its keypress', !!sub && sub.label === '🔀 Submenu (press 5)');
|
|
196
|
+
check('tier: submenu carries its own menu-prompt script', !!sub && sub.sub === '“More options”');
|
|
197
|
+
check('tier: menu edge "press 5" lands on the submenu', g.edges.some((e) => e.label === 'press 5' && e.to === 'aa_9000_p900185'));
|
|
198
|
+
check(
|
|
199
|
+
'tier: the tier\'s own options render below it',
|
|
200
|
+
g.edges.some((e) => e.from === 'aa_9000_p900185' && e.label === 'press 1' && e.to === 'directory'),
|
|
201
|
+
);
|
|
202
|
+
check(
|
|
203
|
+
'tier: the tier\'s no-key default repeats ITS greeting, not the top menu\'s',
|
|
204
|
+
g.edges.some((e) => e.from === 'aa_9000_p900185' && e.label === 'no key / timeout' && e.to === 'aarepeat_9000_900185'),
|
|
205
|
+
);
|
|
206
|
+
check(
|
|
207
|
+
'tier: a key back to the parent prompt draws a loops-back leaf, not infinite recursion',
|
|
208
|
+
g.edges.some((e) => e.from === 'aa_9000_p900185' && e.kind === 'ref' && e.label === 'press 9'),
|
|
209
|
+
);
|
|
210
|
+
check('tier: top-level keys still resolve', g.edges.some((e) => e.label === 'press 2' && e.to === 'user_1043'));
|
|
211
|
+
|
|
212
|
+
// A played message is never a dead end: `Announce_<id>.Done` says where the call goes next.
|
|
213
|
+
check(
|
|
214
|
+
'announce: message returning to its menu reuses the shared Repeat greeting node',
|
|
215
|
+
g.edges.some((e) => e.from === 'aaannounce_9000_900013' && e.label === 'then' && e.to === 'aarepeat_9000_900001'),
|
|
216
|
+
);
|
|
217
|
+
check(
|
|
218
|
+
'announce: a non-menu .Done target routes normally (hang up)',
|
|
219
|
+
g.edges.some((e) => e.from === 'aaannounce_9000_900014' && e.label === 'then' && g.nodes.find((n) => n.id === e.to)?.kind === 'hangup'),
|
|
220
|
+
);
|
|
221
|
+
check('announce: no message node is left without an outgoing edge', ['aaannounce_9000_900013', 'aaannounce_9000_900014'].every((id) => g.edges.some((e) => e.from === id)));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
225
|
+
if (fail) throw new Error(`${fail} check(s) failed`);
|