|
2 | 2 |
|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
4 | 4 | import { SchemaRegistry } from './registry'; |
| 5 | +import { ObjectQL } from './engine'; |
5 | 6 |
|
6 | 7 | /** |
7 | 8 | * ADR-0029 D7 — app navigation contributions. |
@@ -104,3 +105,131 @@ describe('SchemaRegistry navigation contributions (ADR-0029 D7)', () => { |
104 | 105 | expect(group.children).toHaveLength(1); |
105 | 106 | }); |
106 | 107 | }); |
| 108 | + |
| 109 | +/** |
| 110 | + * [#30581] A package BODY's contributions are derived from its manifest, never |
| 111 | + * appended to: registering the same body again folds each entry once. |
| 112 | + * |
| 113 | + * An artifact reload (`metadata:reloaded`) re-registers every package of the |
| 114 | + * artifact through `ObjectQL.registerApp`, and the manifest step used to push |
| 115 | + * each contribution once more per registration, so a single reload listed |
| 116 | + * every module's entries twice until a restart. Each pin below compares the |
| 117 | + * served app against the snapshot a fresh registration of the same bodies |
| 118 | + * gives, in the same test. |
| 119 | + */ |
| 120 | +describe('[#30581] a package body re-registered folds its contributions once', () => { |
| 121 | + const APP = 'com.qa.nav.app'; |
| 122 | + const ORDERS = 'com.qa.nav.orders'; |
| 123 | + const SERVICE = 'com.qa.nav.service'; |
| 124 | + const LOOSE = 'com.qa.nav.loose'; |
| 125 | + |
| 126 | + const appBody = () => ({ |
| 127 | + id: APP, name: APP, version: '1.0.0', type: 'app', namespace: 'qn', |
| 128 | + apps: [{ |
| 129 | + name: 'qn_app', label: 'Suite', |
| 130 | + navigation: [ |
| 131 | + { id: 'g_sales', type: 'group', label: 'Sales', children: [{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'qn_account' }] }, |
| 132 | + { id: 'g_service', type: 'group', label: 'Service', children: [] }, |
| 133 | + ], |
| 134 | + }], |
| 135 | + }); |
| 136 | + // Equal priorities on purpose: the default 200 makes ties the common case, |
| 137 | + // so the order a re-registration leaves is a real question. |
| 138 | + const ordersBody = (items = [{ id: 'nav_orders', type: 'object', label: 'Orders', objectName: 'qn_order' }]) => ({ |
| 139 | + id: ORDERS, name: ORDERS, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' }, |
| 140 | + navigationContributions: [ |
| 141 | + { app: 'qn_app', group: 'g_sales', items }, |
| 142 | + { app: 'qn_app', group: 'g_service', items: [{ id: 'nav_returns', type: 'object', label: 'Returns', objectName: 'qn_return' }] }, |
| 143 | + ], |
| 144 | + }); |
| 145 | + const serviceBody = () => ({ |
| 146 | + id: SERVICE, name: SERVICE, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' }, |
| 147 | + navigationContributions: [ |
| 148 | + { app: 'qn_app', group: 'g_service', items: [{ id: 'nav_cases', type: 'object', label: 'Cases', objectName: 'qn_case' }] }, |
| 149 | + { app: 'qn_app', group: 'g_sales', items: [{ id: 'nav_quotes', type: 'object', label: 'Quotes', objectName: 'qn_quote' }] }, |
| 150 | + ], |
| 151 | + }); |
| 152 | + // A module that contributes nothing: toggling or re-registering it moves nothing. |
| 153 | + const looseBody = () => ({ id: LOOSE, name: LOOSE, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' } }); |
| 154 | + |
| 155 | + /** Every navigation entry of the served app, `group/id`, in served order. */ |
| 156 | + const served = (engine: ObjectQL): string[] => |
| 157 | + (engine.registry.getApp('qn_app')?.navigation ?? []).flatMap((n: any) => |
| 158 | + n.type === 'group' ? (n.children ?? []).map((c: any) => `${n.id}/${c.id}`) : [n.id]); |
| 159 | + |
| 160 | + const boot = (): ObjectQL => { |
| 161 | + const engine = new ObjectQL(); |
| 162 | + (engine.registry as any).logLevel = 'silent'; |
| 163 | + for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any); |
| 164 | + return engine; |
| 165 | + }; |
| 166 | + |
| 167 | + let engine: ObjectQL; |
| 168 | + let fresh: string[]; |
| 169 | + beforeEach(() => { |
| 170 | + engine = boot(); |
| 171 | + fresh = served(boot()); |
| 172 | + }); |
| 173 | + |
| 174 | + it('serves each entry once on a fresh registration (the control)', () => { |
| 175 | + expect(fresh).toEqual([ |
| 176 | + 'g_sales/nav_accounts', 'g_sales/nav_orders', 'g_sales/nav_quotes', |
| 177 | + 'g_service/nav_returns', 'g_service/nav_cases', |
| 178 | + ]); |
| 179 | + expect(served(engine)).toEqual(fresh); |
| 180 | + }); |
| 181 | + |
| 182 | + it('re-registering every body twice (two artifact reloads) serves the fresh registration, in its order', () => { |
| 183 | + for (let reload = 0; reload < 2; reload++) { |
| 184 | + for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any); |
| 185 | + } |
| 186 | + expect(served(engine)).toEqual(fresh); |
| 187 | + expect(engine.registry.getAppNavContributions('qn_app')).toHaveLength(4); |
| 188 | + }); |
| 189 | + |
| 190 | + it('re-registering ONE module keeps its entries where a fresh registration puts them', () => { |
| 191 | + engine.registerApp(ordersBody() as any); |
| 192 | + engine.registerApp(ordersBody() as any); |
| 193 | + expect(served(engine)).toEqual(fresh); |
| 194 | + }); |
| 195 | + |
| 196 | + it('two disable/enable cycles of a module, each followed by a reload, serve the fresh registration', () => { |
| 197 | + // The enable announcement the roll-up recompute rides on (#30377) still |
| 198 | + // fires once per enable of a disabled package: a reload is no enable. |
| 199 | + const heard: string[] = []; |
| 200 | + const stop = engine.registry.onPackageEnabled((id) => { heard.push(id); }); |
| 201 | + for (let cycle = 0; cycle < 2; cycle++) { |
| 202 | + engine.registry.disablePackage(ORDERS); |
| 203 | + expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']); |
| 204 | + for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any); |
| 205 | + engine.registry.enablePackage(ORDERS); |
| 206 | + expect(served(engine)).toEqual(fresh); |
| 207 | + } |
| 208 | + stop(); |
| 209 | + expect(heard).toEqual([ORDERS, ORDERS]); |
| 210 | + }); |
| 211 | + |
| 212 | + it('toggling and re-registering a module with no contributions leaves the navigation unchanged', () => { |
| 213 | + engine.registry.disablePackage(LOOSE); |
| 214 | + engine.registerApp(looseBody() as any); |
| 215 | + engine.registry.enablePackage(LOOSE); |
| 216 | + expect(served(engine)).toEqual(fresh); |
| 217 | + }); |
| 218 | + |
| 219 | + it('a re-registered body replaces its contributions: an entry it dropped leaves, one it added appears once', () => { |
| 220 | + engine.registerApp(ordersBody([{ id: 'nav_invoices', type: 'object', label: 'Invoices', objectName: 'qn_invoice' }]) as any); |
| 221 | + expect(served(engine)).toEqual([ |
| 222 | + 'g_sales/nav_accounts', 'g_sales/nav_invoices', 'g_sales/nav_quotes', |
| 223 | + 'g_service/nav_returns', 'g_service/nav_cases', |
| 224 | + ]); |
| 225 | + const { navigationContributions: _dropped, ...withNone } = ordersBody(); |
| 226 | + engine.registerApp(withNone as any); |
| 227 | + expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']); |
| 228 | + }); |
| 229 | + |
| 230 | + it('uninstalling a module (not disabling it) drops its contributions from the app', () => { |
| 231 | + expect(engine.registry.uninstallPackage(ORDERS), 'precondition: uninstalled').toBe(true); |
| 232 | + expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']); |
| 233 | + expect(engine.registry.getAppNavContributions('qn_app').map((c) => c.packageId)).toEqual([SERVICE, SERVICE]); |
| 234 | + }); |
| 235 | +}); |
0 commit comments