slicejs-web-framework 3.3.5 → 3.3.7
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/.opencode/opencode.json +0 -1
- package/Slice/Components/Structural/ContextManager/ContextManager.js +54 -0
- package/Slice/Components/Structural/Controller/Controller.js +5 -0
- package/Slice/Components/Structural/Router/Router.js +18 -10
- package/Slice/Components/Structural/StylesManager/StylesManager.js +4 -0
- package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +25 -3
- package/Slice/Slice.js +44 -2
- package/Slice/tests/build-singleton.test.js +24 -1
- package/Slice/tests/context-patch-use.test.js +95 -0
- package/Slice/tests/fixtures/real-runtime-loader.mjs +3 -1
- package/Slice/tests/public-env-typed-accessors.test.js +66 -0
- package/package.json +1 -1
package/.opencode/opencode.json
CHANGED
|
@@ -154,6 +154,60 @@ export default class ContextManager {
|
|
|
154
154
|
slice.logger.logInfo('ContextManager', `Contexto "${name}" actualizado`);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Merge a partial object into the context's state (first-level merge).
|
|
159
|
+
* `setState` REPLACES the whole state; `patch` keeps the existing fields and
|
|
160
|
+
* overrides only the keys you pass — the common "update one field" case.
|
|
161
|
+
* @param {string} name - Nombre del contexto
|
|
162
|
+
* @param {Object} partial - Campos a fusionar sobre el estado actual
|
|
163
|
+
* @returns {void}
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* slice.context.patch('cart', { discount: 0.1 }); // items/total se conservan
|
|
167
|
+
*/
|
|
168
|
+
patch(name, partial) {
|
|
169
|
+
if (!this.contexts.has(name)) {
|
|
170
|
+
slice.logger.logError('ContextManager', `El contexto "${name}" no existe`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
|
|
174
|
+
slice.logger.logError('ContextManager', `patch("${name}") requiere un objeto parcial`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
return this.setState(name, (prev) => ({ ...prev, ...partial }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Return a handle bound to a single context so you call its methods without
|
|
182
|
+
* repeating the name on every call.
|
|
183
|
+
* @param {string} name - Nombre del contexto
|
|
184
|
+
* @returns {{ get: Function, set: Function, patch: Function, watch: Function, bind: Function, has: Function, destroy: Function }}
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* const cart = slice.context.use('cart');
|
|
188
|
+
* cart.get(); // estado actual
|
|
189
|
+
* cart.patch({ discount: 0.1 }); // merge (conserva el resto)
|
|
190
|
+
* cart.watch(this, (s) => this.render(s));
|
|
191
|
+
* // watch + render inicial en una línea:
|
|
192
|
+
* cart.bind(this, (count) => { this.$badge.textContent = count; }, (s) => s.items.length);
|
|
193
|
+
*/
|
|
194
|
+
use(name) {
|
|
195
|
+
return {
|
|
196
|
+
get: () => this.getState(name),
|
|
197
|
+
set: (updater) => this.setState(name, updater),
|
|
198
|
+
patch: (partial) => this.patch(name, partial),
|
|
199
|
+
watch: (component, callback, selector = null) => this.watch(name, component, callback, selector),
|
|
200
|
+
// watch + an immediate call with the current value (initial render in one line)
|
|
201
|
+
bind: (component, callback, selector = null) => {
|
|
202
|
+
const state = this.getState(name);
|
|
203
|
+
callback(selector ? selector(state) : state);
|
|
204
|
+
return this.watch(name, component, callback, selector);
|
|
205
|
+
},
|
|
206
|
+
has: () => this.has(name),
|
|
207
|
+
destroy: () => this.destroy(name),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
157
211
|
// ============================================
|
|
158
212
|
// WATCH (OBSERVAR CAMBIOS)
|
|
159
213
|
// ============================================
|
|
@@ -716,6 +716,11 @@ export default class Controller {
|
|
|
716
716
|
// Registrar en activeComponents
|
|
717
717
|
this.activeComponents.set(component.sliceId, component);
|
|
718
718
|
|
|
719
|
+
// Exponer sliceId como atributo HTML para búsqueda por DOM (destroyByContainer, etc.)
|
|
720
|
+
if (typeof component.setAttribute === 'function') {
|
|
721
|
+
component.setAttribute('slice-id', component.sliceId);
|
|
722
|
+
}
|
|
723
|
+
|
|
719
724
|
// 🚀 OPTIMIZACIÓN: Actualizar índice inverso de hijos
|
|
720
725
|
if (parent) {
|
|
721
726
|
if (!this.childrenIndex.has(parent.sliceId)) {
|
|
@@ -46,6 +46,14 @@ export default class Router {
|
|
|
46
46
|
this.activeRoute = null;
|
|
47
47
|
this.pathToRouteMap = this.createPathToRouteMap(routes);
|
|
48
48
|
|
|
49
|
+
// O(1) case-insensitive lookup for static routes, so a miss on the exact map
|
|
50
|
+
// doesn't scan every route on each navigation. Param routes (which carry a
|
|
51
|
+
// precompiled `compiled` regex) are excluded — they go through the param loop.
|
|
52
|
+
this._staticLowerIndex = new Map();
|
|
53
|
+
for (const [pattern, route] of this.pathToRouteMap.entries()) {
|
|
54
|
+
if (!route.compiled) this._staticLowerIndex.set(pattern.toLowerCase(), route);
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
// Navigation Guards
|
|
50
58
|
this._beforeEachGuard = null;
|
|
51
59
|
this._afterEachGuard = null;
|
|
@@ -581,6 +589,12 @@ export default class Router {
|
|
|
581
589
|
parentRoute: parentRoute,
|
|
582
590
|
};
|
|
583
591
|
|
|
592
|
+
// Compile parameterized patterns once at map-build time instead of on every
|
|
593
|
+
// navigation. Static routes leave `compiled` undefined.
|
|
594
|
+
if (fullPath.includes('${')) {
|
|
595
|
+
routeWithParent.compiled = this.compilePathPattern(fullPath);
|
|
596
|
+
}
|
|
597
|
+
|
|
584
598
|
pathToRouteMap.set(fullPath, routeWithParent);
|
|
585
599
|
|
|
586
600
|
if (route.children) {
|
|
@@ -685,13 +699,7 @@ export default class Router {
|
|
|
685
699
|
// so '/About' resolves to a route declared as '/about'.
|
|
686
700
|
let exactMatch = this.pathToRouteMap.get(path);
|
|
687
701
|
if (!exactMatch) {
|
|
688
|
-
|
|
689
|
-
for (const [routePattern, route] of this.pathToRouteMap.entries()) {
|
|
690
|
-
if (!routePattern.includes('${') && routePattern.toLowerCase() === lowerPath) {
|
|
691
|
-
exactMatch = route;
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
702
|
+
exactMatch = this._staticLowerIndex.get(path.toLowerCase());
|
|
695
703
|
}
|
|
696
704
|
if (exactMatch) {
|
|
697
705
|
if (exactMatch.parentRoute) {
|
|
@@ -704,9 +712,9 @@ export default class Router {
|
|
|
704
712
|
return { route: exactMatch, params: {} };
|
|
705
713
|
}
|
|
706
714
|
|
|
707
|
-
for (const
|
|
708
|
-
if (
|
|
709
|
-
const { regex, paramNames } =
|
|
715
|
+
for (const route of this.pathToRouteMap.values()) {
|
|
716
|
+
if (route.compiled) {
|
|
717
|
+
const { regex, paramNames } = route.compiled;
|
|
710
718
|
const match = path.match(regex);
|
|
711
719
|
if (match) {
|
|
712
720
|
const params = {};
|
|
@@ -72,6 +72,10 @@ export default class StylesManager {
|
|
|
72
72
|
* @returns {void}
|
|
73
73
|
*/
|
|
74
74
|
registerComponentStyles(componentName, cssText) {
|
|
75
|
+
// Idempotent: a component's CSS is injected at most once, no matter how many
|
|
76
|
+
// times it is built or how a caller reaches here (Controller guards upstream,
|
|
77
|
+
// but the debuggers and the resource loader do not).
|
|
78
|
+
if (slice.controller.requestedStyles.has(componentName)) return;
|
|
75
79
|
slice.controller.requestedStyles.add(componentName);
|
|
76
80
|
this.appendComponentStyles(cssText);
|
|
77
81
|
}
|
|
@@ -5,6 +5,9 @@ export default class ThemeManager {
|
|
|
5
5
|
constructor() {
|
|
6
6
|
this.themeStyles = new Map();
|
|
7
7
|
this.currentTheme = null;
|
|
8
|
+
// In-flight loads, keyed by theme name, so concurrent applyTheme() calls for
|
|
9
|
+
// the same theme share one fetch instead of racing and double-fetching.
|
|
10
|
+
this._loadingThemes = new Map();
|
|
8
11
|
this.themeStyle = document.createElement('style');
|
|
9
12
|
document.head.appendChild(this.themeStyle);
|
|
10
13
|
}
|
|
@@ -20,12 +23,25 @@ export default class ThemeManager {
|
|
|
20
23
|
return;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// Already the active theme — nothing to re-inject.
|
|
27
|
+
if (themeName === this.currentTheme) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (this.themeStyles.has(themeName)) {
|
|
26
32
|
this.setThemeStyle(themeName);
|
|
27
33
|
this.saveThemeLocally(themeName, this.themeStyles.get(themeName));
|
|
34
|
+
return;
|
|
28
35
|
}
|
|
36
|
+
|
|
37
|
+
// Coalesce concurrent loads of the same theme into a single fetch.
|
|
38
|
+
if (!this._loadingThemes.has(themeName)) {
|
|
39
|
+
this._loadingThemes.set(
|
|
40
|
+
themeName,
|
|
41
|
+
this.loadThemeCSS(themeName).finally(() => this._loadingThemes.delete(themeName))
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
await this._loadingThemes.get(themeName);
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
/**
|
|
@@ -79,6 +95,12 @@ export default class ThemeManager {
|
|
|
79
95
|
setThemeStyle(themeName) {
|
|
80
96
|
this.themeStyle.textContent = this.themeStyles.get(themeName);
|
|
81
97
|
this.currentTheme = themeName;
|
|
98
|
+
// Expose the active theme on the root element so CSS can react per theme
|
|
99
|
+
// without any JS — e.g. [data-slice-theme="Dark"] .logo { filter: ... }.
|
|
100
|
+
// Non-breaking: apps opt in only if they reference the attribute.
|
|
101
|
+
if (typeof document !== 'undefined' && document.documentElement) {
|
|
102
|
+
document.documentElement.setAttribute('data-slice-theme', themeName);
|
|
103
|
+
}
|
|
82
104
|
slice.logger.logInfo('ThemeManager', `Theme ${themeName} applied`);
|
|
83
105
|
}
|
|
84
106
|
}
|
package/Slice/Slice.js
CHANGED
|
@@ -79,6 +79,44 @@ export default class Slice {
|
|
|
79
79
|
return { ...this._publicEnv };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Typed accessors over the public env, so callers stop re-parsing strings.
|
|
84
|
+
* slice.env.get('SLICE_PUBLIC_API_URL', '')
|
|
85
|
+
* slice.env.bool('SLICE_PUBLIC_AUTH_ENABLED') // '1'|'true'|'yes'|'on' → true
|
|
86
|
+
* slice.env.int('SLICE_PUBLIC_TIMEOUT', 5000)
|
|
87
|
+
* slice.env.list('SLICE_PUBLIC_MODELS') // 'a, b' → ['a','b']
|
|
88
|
+
* slice.env.has('X') / slice.env.all()
|
|
89
|
+
* @returns {{ get: Function, has: Function, all: Function, bool: Function, int: Function, list: Function }}
|
|
90
|
+
*/
|
|
91
|
+
get env() {
|
|
92
|
+
const read = (name) =>
|
|
93
|
+
Object.prototype.hasOwnProperty.call(this._publicEnv, name) ? this._publicEnv[name] : undefined;
|
|
94
|
+
const present = (v) => v !== undefined && String(v).trim() !== '';
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
get: (name, fallback = undefined) => this.getEnv(name, fallback),
|
|
98
|
+
has: (name) => Object.prototype.hasOwnProperty.call(this._publicEnv, name),
|
|
99
|
+
all: () => this.getPublicEnv(),
|
|
100
|
+
bool: (name, fallback = false) => {
|
|
101
|
+
const v = read(name);
|
|
102
|
+
return present(v) ? ['1', 'true', 'yes', 'on'].includes(String(v).trim().toLowerCase()) : fallback;
|
|
103
|
+
},
|
|
104
|
+
int: (name, fallback = 0) => {
|
|
105
|
+
const v = read(name);
|
|
106
|
+
const n = parseInt(v, 10);
|
|
107
|
+
return Number.isNaN(n) ? fallback : n;
|
|
108
|
+
},
|
|
109
|
+
list: (name, fallback = []) => {
|
|
110
|
+
const v = read(name);
|
|
111
|
+
if (!present(v)) return fallback;
|
|
112
|
+
return String(v)
|
|
113
|
+
.split(',')
|
|
114
|
+
.map((s) => s.trim())
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
/**
|
|
83
121
|
* Get a component instance by sliceId.
|
|
84
122
|
* @param {string} componentSliceId
|
|
@@ -114,11 +152,15 @@ export default class Slice {
|
|
|
114
152
|
const { singleton, ...rest } = props;
|
|
115
153
|
const sliceId = rest.sliceId || componentName;
|
|
116
154
|
|
|
155
|
+
// Singletons are allowed for any category whose *type* is 'Service' — not
|
|
156
|
+
// only the built-in 'Service' category. Custom categories declared in
|
|
157
|
+
// sliceConfig with `"type": "Service"` (e.g. AppServices) are services too.
|
|
117
158
|
const category = this.controller.componentCategories.get(componentName);
|
|
118
|
-
|
|
159
|
+
const categoryType = slice.paths?.components?.[category]?.type;
|
|
160
|
+
if (categoryType !== 'Service') {
|
|
119
161
|
this.logger.logError(
|
|
120
162
|
'Slice',
|
|
121
|
-
`singleton:true is only supported for Service components ('${componentName}' is ${category || 'unknown'}). ` +
|
|
163
|
+
`singleton:true is only supported for Service-type components ('${componentName}' is in category '${category || 'unknown'}', type '${categoryType || 'unknown'}'). ` +
|
|
122
164
|
`For app-wide UI build a Provider Service that manages the Visual (see ToastProvider/ToolTipProvider).`
|
|
123
165
|
);
|
|
124
166
|
return null;
|
|
@@ -43,7 +43,9 @@ function createSlice() {
|
|
|
43
43
|
paths: {
|
|
44
44
|
components: {
|
|
45
45
|
Service: { path: '/Components/Service', type: 'Service' },
|
|
46
|
-
Visual: { path: '/Components/Visual', type: 'Visual' }
|
|
46
|
+
Visual: { path: '/Components/Visual', type: 'Visual' },
|
|
47
|
+
// Custom category whose type is Service (e.g. an app's own services).
|
|
48
|
+
AppServices: { path: '/Components/AppServices', type: 'Service' }
|
|
47
49
|
}
|
|
48
50
|
},
|
|
49
51
|
themeManager: {},
|
|
@@ -60,6 +62,9 @@ function createSlice() {
|
|
|
60
62
|
// Pre-seed the class so _build skips the network fetch (the real path for a
|
|
61
63
|
// class already cached by the controller). Everything else is real.
|
|
62
64
|
instance.controller.classes.set('Probe', Probe);
|
|
65
|
+
// AppProbe is a Service-shaped class registered under the custom 'AppServices'
|
|
66
|
+
// category; reuse the Probe class (same shape).
|
|
67
|
+
instance.controller.classes.set('AppProbe', Probe);
|
|
63
68
|
return instance;
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -157,6 +162,24 @@ test(
|
|
|
157
162
|
})
|
|
158
163
|
);
|
|
159
164
|
|
|
165
|
+
test(
|
|
166
|
+
'singleton:true is allowed for a custom Service-type category (AppServices)',
|
|
167
|
+
withSlice(async (slice) => {
|
|
168
|
+
let errored = false;
|
|
169
|
+
slice.logger.logError = () => {
|
|
170
|
+
errored = true;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const a = await slice.build('AppProbe', { singleton: true });
|
|
174
|
+
const b = await slice.build('AppProbe', { singleton: true });
|
|
175
|
+
|
|
176
|
+
assert.ok(a, 'builds the instance');
|
|
177
|
+
assert.equal(a, b, 'returns the same singleton instance');
|
|
178
|
+
assert.equal(errored, false, 'does not log an error for a Service-type category');
|
|
179
|
+
assert.equal(constructCount, 1, 'constructed exactly once');
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
|
|
160
183
|
test(
|
|
161
184
|
'default (non-singleton) build path is unchanged',
|
|
162
185
|
withSlice(async (slice) => {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
globalThis.alert = () => {};
|
|
5
|
+
|
|
6
|
+
// Minimal global `slice` so ContextManager's slice.logger / slice.events work in
|
|
7
|
+
// isolation (a tiny in-memory event bus drives the watch notifications).
|
|
8
|
+
const subscribers = new Map(); // eventName -> Set<fn>
|
|
9
|
+
let subId = 0;
|
|
10
|
+
globalThis.slice = {
|
|
11
|
+
logger: { logError() {}, logInfo() {}, logWarning() {} },
|
|
12
|
+
events: {
|
|
13
|
+
subscribe(name, cb) {
|
|
14
|
+
if (!subscribers.has(name)) subscribers.set(name, new Set());
|
|
15
|
+
subscribers.get(name).add(cb);
|
|
16
|
+
return `sub-${++subId}`;
|
|
17
|
+
},
|
|
18
|
+
emit(name, ...args) {
|
|
19
|
+
for (const cb of subscribers.get(name) ?? []) cb(...args);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const { default: ContextManager } = await import(
|
|
25
|
+
'../Components/Structural/ContextManager/ContextManager.js'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function freshContext(name, initial) {
|
|
29
|
+
const ctx = new ContextManager();
|
|
30
|
+
ctx.create(name, initial);
|
|
31
|
+
return ctx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('patch merges into existing state (does not replace)', () => {
|
|
35
|
+
const ctx = freshContext('cart', { items: 2, total: 47, discount: 0 });
|
|
36
|
+
ctx.patch('cart', { discount: 0.1 });
|
|
37
|
+
assert.deepEqual(ctx.getState('cart'), { items: 2, total: 47, discount: 0.1 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('setState replaces, patch keeps the rest', () => {
|
|
41
|
+
const ctx = freshContext('s', { a: 1, b: 2 });
|
|
42
|
+
ctx.setState('s', { a: 9 }); // replace → drops b
|
|
43
|
+
assert.deepEqual(ctx.getState('s'), { a: 9 });
|
|
44
|
+
ctx.patch('s', { b: 5 }); // merge onto { a: 9 }
|
|
45
|
+
assert.deepEqual(ctx.getState('s'), { a: 9, b: 5 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('patch rejects non-object partials and missing contexts (no throw)', () => {
|
|
49
|
+
const ctx = freshContext('s', { a: 1 });
|
|
50
|
+
ctx.patch('s', null);
|
|
51
|
+
ctx.patch('s', [1, 2]);
|
|
52
|
+
ctx.patch('nope', { x: 1 });
|
|
53
|
+
assert.deepEqual(ctx.getState('s'), { a: 1 });
|
|
54
|
+
assert.equal(ctx.has('nope'), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('use(name) returns a handle bound to that context', () => {
|
|
58
|
+
const ctx = freshContext('settings', { model: 'a' });
|
|
59
|
+
const settings = ctx.use('settings');
|
|
60
|
+
|
|
61
|
+
assert.equal(settings.get().model, 'a');
|
|
62
|
+
settings.set({ model: 'b' });
|
|
63
|
+
assert.equal(ctx.getState('settings').model, 'b');
|
|
64
|
+
settings.patch({ theme: 'dark' });
|
|
65
|
+
assert.deepEqual(ctx.getState('settings'), { model: 'b', theme: 'dark' });
|
|
66
|
+
assert.equal(settings.has(), true);
|
|
67
|
+
settings.destroy();
|
|
68
|
+
assert.equal(ctx.has('settings'), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('use(name).bind calls back immediately and on every change', () => {
|
|
72
|
+
const ctx = freshContext('cart', { items: [], n: 0 });
|
|
73
|
+
const calls = [];
|
|
74
|
+
ctx.use('cart').bind({ sliceId: 'c1' }, (state) => calls.push(state.n));
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(calls, [0]); // immediate initial call
|
|
77
|
+
ctx.patch('cart', { n: 1 });
|
|
78
|
+
assert.deepEqual(calls, [0, 1]); // fired on change
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('use(name).bind with a selector only fires when the selected value changes', () => {
|
|
82
|
+
const ctx = freshContext('cart', { items: ['x'], note: 'a' });
|
|
83
|
+
const counts = [];
|
|
84
|
+
ctx.use('cart').bind(
|
|
85
|
+
{ sliceId: 'c2' },
|
|
86
|
+
(count) => counts.push(count),
|
|
87
|
+
(s) => s.items.length
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
assert.deepEqual(counts, [1]); // immediate, selected value = length
|
|
91
|
+
ctx.patch('cart', { note: 'b' }); // length unchanged → no fire
|
|
92
|
+
assert.deepEqual(counts, [1]);
|
|
93
|
+
ctx.patch('cart', { items: ['x', 'y'] }); // length changed → fire
|
|
94
|
+
assert.deepEqual(counts, [1, 2]);
|
|
95
|
+
});
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// map only that exact specifier to a small fixture components map (just data,
|
|
6
6
|
// the same shape the real components.js exports). Everything else resolves
|
|
7
7
|
// normally, so the tests exercise the real Slice + Controller code, not mocks.
|
|
8
|
-
|
|
8
|
+
// AppProbe lives in a CUSTOM category ('AppServices') whose type is 'Service' —
|
|
9
|
+
// it must be allowed as a singleton (the type, not the category name, decides).
|
|
10
|
+
const COMPONENTS = { Probe: 'Service', ModalProbe: 'Visual', AppProbe: 'AppServices' };
|
|
9
11
|
const STUB = `export default ${JSON.stringify(COMPONENTS)};`;
|
|
10
12
|
|
|
11
13
|
export async function resolve(specifier, context, nextResolve) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
globalThis.alert = () => {};
|
|
5
|
+
|
|
6
|
+
const { default: Slice } = await import('../Slice.js');
|
|
7
|
+
|
|
8
|
+
function createSliceInstance() {
|
|
9
|
+
return new Slice({
|
|
10
|
+
paths: {},
|
|
11
|
+
themeManager: {},
|
|
12
|
+
stylesManager: {},
|
|
13
|
+
logger: {},
|
|
14
|
+
debugger: {},
|
|
15
|
+
loading: {},
|
|
16
|
+
events: {}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('slice.env.get / has / all', () => {
|
|
21
|
+
const s = createSliceInstance();
|
|
22
|
+
s.setPublicEnv({ SLICE_PUBLIC_API_URL: 'https://api.example.com', INTERNAL: 'hidden' });
|
|
23
|
+
|
|
24
|
+
assert.equal(s.env.get('SLICE_PUBLIC_API_URL'), 'https://api.example.com');
|
|
25
|
+
assert.equal(s.env.get('SLICE_PUBLIC_MISSING', 'fb'), 'fb');
|
|
26
|
+
assert.equal(s.env.has('SLICE_PUBLIC_API_URL'), true);
|
|
27
|
+
assert.equal(s.env.has('SLICE_PUBLIC_MISSING'), false);
|
|
28
|
+
assert.deepEqual(s.env.all(), { SLICE_PUBLIC_API_URL: 'https://api.example.com' }); // only SLICE_PUBLIC_*
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('slice.env.bool parses truthy strings and falls back', () => {
|
|
32
|
+
const s = createSliceInstance();
|
|
33
|
+
s.setPublicEnv({
|
|
34
|
+
SLICE_PUBLIC_ON: 'true',
|
|
35
|
+
SLICE_PUBLIC_ON2: 'YES',
|
|
36
|
+
SLICE_PUBLIC_ON3: '1',
|
|
37
|
+
SLICE_PUBLIC_OFF: 'false',
|
|
38
|
+
SLICE_PUBLIC_EMPTY: ''
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_ON'), true);
|
|
42
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_ON2'), true);
|
|
43
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_ON3'), true);
|
|
44
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_OFF'), false);
|
|
45
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_EMPTY', true), true); // empty → fallback
|
|
46
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_MISSING'), false); // missing → default false
|
|
47
|
+
assert.equal(s.env.bool('SLICE_PUBLIC_MISSING', true), true); // missing → custom fallback
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('slice.env.int parses integers and falls back', () => {
|
|
51
|
+
const s = createSliceInstance();
|
|
52
|
+
s.setPublicEnv({ SLICE_PUBLIC_TIMEOUT: '5000', SLICE_PUBLIC_BAD: 'nope' });
|
|
53
|
+
|
|
54
|
+
assert.equal(s.env.int('SLICE_PUBLIC_TIMEOUT'), 5000);
|
|
55
|
+
assert.equal(s.env.int('SLICE_PUBLIC_BAD', 10), 10); // non-numeric → fallback
|
|
56
|
+
assert.equal(s.env.int('SLICE_PUBLIC_MISSING', 42), 42); // missing → fallback
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('slice.env.list splits CSV, trims, drops empties', () => {
|
|
60
|
+
const s = createSliceInstance();
|
|
61
|
+
s.setPublicEnv({ SLICE_PUBLIC_MODELS: ' a , b ,, c ', SLICE_PUBLIC_EMPTY: '' });
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(s.env.list('SLICE_PUBLIC_MODELS'), ['a', 'b', 'c']);
|
|
64
|
+
assert.deepEqual(s.env.list('SLICE_PUBLIC_EMPTY', ['x']), ['x']); // empty → fallback
|
|
65
|
+
assert.deepEqual(s.env.list('SLICE_PUBLIC_MISSING'), []); // missing → default []
|
|
66
|
+
});
|