@symbiote-native/splash-screen 3.0.3-canary-20260724095331 → 3.0.3
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 +36 -0
- package/package.json +7 -4
- package/src/angular/services/hide-animation.service/hide-animation.service.test.ts +0 -205
- package/src/react/hooks/use-hide-animation/use-hide-animation.test.tsx +0 -114
- package/src/vue/composables/use-hide-animation/use-hide-animation.test.ts +0 -199
package/README.md
CHANGED
|
@@ -130,6 +130,42 @@ const { container, logo } = useHideAnimation({
|
|
|
130
130
|
See the docs-site package page (`docs/packages/splash-screen`) for the full config surface and
|
|
131
131
|
the Vue/Angular equivalents.
|
|
132
132
|
|
|
133
|
+
## API
|
|
134
|
+
|
|
135
|
+
The core entry point (`@symbiote-native/splash-screen`) is framework-agnostic — `hide`/`isVisible`
|
|
136
|
+
re-exported verbatim from `react-native-bootsplash`, plus the readiness gate and style computation
|
|
137
|
+
each adapter's lifecycle wrapper drives:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
hide(config?: IHideConfig): Promise<void> // dismisses the native splash, optional fade
|
|
141
|
+
isVisible(): boolean // is the native splash still up
|
|
142
|
+
getHideAnimationConstants(): IHideAnimationConstants // darkModeEnabled + logo/status-bar/nav-bar metrics,
|
|
143
|
+
// read off the RNBootSplash native module
|
|
144
|
+
new HideAnimationController(config: IHideAnimationConfig)
|
|
145
|
+
// readiness gate — onContainerLayout / onLogoLoadEnd / onBrandLoadEnd / updateConfig(config);
|
|
146
|
+
// hide() fires exactly once, after layout + both images + the caller's own `ready` all report in.
|
|
147
|
+
computeHideAnimationStyles(config, constants, controller): IHideAnimationResult
|
|
148
|
+
// the { container, logo, brand } prop bags, recomputed from the current config.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Plus `IHideConfig`, `IManifest`, `IHideAnimationConfig`, `IHideAnimationContainerProps`,
|
|
152
|
+
`IHideAnimationImageProps`, `IHideAnimationResult`, `IHideAnimationConstants`.
|
|
153
|
+
|
|
154
|
+
Each adapter entry point re-exports `hide`/`isVisible` (and the `IHideConfig`/`IManifest`/
|
|
155
|
+
`IHideAnimationConfig`/`IHideAnimationResult` types) unchanged, and adds one lifecycle wrapper
|
|
156
|
+
over the controller — the only per-framework difference is how the config is read:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// @symbiote-native/splash-screen/react — re-runs every render, so a plain value
|
|
160
|
+
useHideAnimation(config: IHideAnimationConfig): IHideAnimationResult
|
|
161
|
+
|
|
162
|
+
// @symbiote-native/splash-screen/vue — setup runs once, so a getter
|
|
163
|
+
useHideAnimation(getConfig: () => IHideAnimationConfig): ComputedRef<IHideAnimationResult>
|
|
164
|
+
|
|
165
|
+
// @symbiote-native/splash-screen/angular — inject(HideAnimationService), connect() once
|
|
166
|
+
connect(getConfig: () => IHideAnimationConfig): Signal<IHideAnimationResult>
|
|
167
|
+
```
|
|
168
|
+
|
|
133
169
|
## Test it
|
|
134
170
|
|
|
135
171
|
Headless hook/composable tests live next to each adapter entry
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/splash-screen",
|
|
3
|
-
"version": "3.0.3
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Third-party splash-screen native module wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,10 @@
|
|
|
46
46
|
"codegen-specs",
|
|
47
47
|
"cli.cjs",
|
|
48
48
|
"react-native.config.cjs",
|
|
49
|
-
"symbiote-splash-screen.podspec"
|
|
49
|
+
"symbiote-splash-screen.podspec",
|
|
50
|
+
"!src/**/*.test.*",
|
|
51
|
+
"!src/**/*.spec.*",
|
|
52
|
+
"!src/**/*.detox.*"
|
|
50
53
|
],
|
|
51
54
|
"publishConfig": {
|
|
52
55
|
"access": "public"
|
|
@@ -71,7 +74,7 @@
|
|
|
71
74
|
"react": ">=19.0.0",
|
|
72
75
|
"react-native": ">=0.86",
|
|
73
76
|
"vue": ">=3.5.0",
|
|
74
|
-
"@symbiote-native/angular": "0.6.
|
|
77
|
+
"@symbiote-native/angular": "0.6.2",
|
|
75
78
|
"@symbiote-native/react": "0.2.8",
|
|
76
79
|
"@symbiote-native/vue": "0.3.8"
|
|
77
80
|
},
|
|
@@ -107,7 +110,7 @@
|
|
|
107
110
|
"@vue/runtime-core": "^3.5.13",
|
|
108
111
|
"react": "19.2.3",
|
|
109
112
|
"typescript": "~6.0.0",
|
|
110
|
-
"@symbiote-native/angular": "0.6.
|
|
113
|
+
"@symbiote-native/angular": "0.6.2",
|
|
111
114
|
"@symbiote-native/engine": "0.1.7",
|
|
112
115
|
"@symbiote-native/react": "0.2.8",
|
|
113
116
|
"@symbiote-native/test-utils": "0.1.6",
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
// Co-located Angular-driven test (ADR 0025) for HideAnimationService. Mounts a real host
|
|
2
|
-
// component through @symbiote-native/angular so `connect()` runs the same way an app would call
|
|
3
|
-
// it — inside the component's own injection context — and drives the returned signal through a
|
|
4
|
-
// full mount/unmount lifecycle rather than poking the service in isolation.
|
|
5
|
-
|
|
6
|
-
import '@angular/compiler';
|
|
7
|
-
import { Component, inject, signal, type Signal } from '@angular/core';
|
|
8
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import { mount, unmount } from '@symbiote-native/angular';
|
|
10
|
-
import { flattenStyle } from '@symbiote-native/engine';
|
|
11
|
-
import { installFabric } from '@symbiote-native/test-utils';
|
|
12
|
-
import type { IImageSourceProp } from '@symbiote-native/components';
|
|
13
|
-
import { hide as mockedHide } from 'react-native-bootsplash';
|
|
14
|
-
import type { IHideAnimationResult } from '../../../core';
|
|
15
|
-
import { HideAnimationService } from './index';
|
|
16
|
-
|
|
17
|
-
vi.mock('react-native-bootsplash', () => ({
|
|
18
|
-
hide: vi.fn(() => Promise.resolve()),
|
|
19
|
-
isVisible: vi.fn(() => true),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
function isPresent<T>(value: unknown): value is T {
|
|
23
|
-
return value !== null && value !== undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const FAKE_NATIVE_MODULE = {
|
|
27
|
-
getConstants: () => ({ darkModeEnabled: false }),
|
|
28
|
-
hide: vi.fn(() => Promise.resolve()),
|
|
29
|
-
isVisible: () => true,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const registeredNativeModules: Record<string, unknown> = { RNBootSplash: FAKE_NATIVE_MODULE };
|
|
33
|
-
|
|
34
|
-
const ROOT_TAG = 940;
|
|
35
|
-
const fabric = installFabric();
|
|
36
|
-
const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
|
|
37
|
-
const settle = async (): Promise<void> => {
|
|
38
|
-
await tick();
|
|
39
|
-
await tick();
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const MANIFEST = {
|
|
43
|
-
background: '#ffffff',
|
|
44
|
-
logo: { width: 100, height: 100 },
|
|
45
|
-
brand: { bottom: 40, width: 80, height: 20 },
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const LOGO_SOURCE: IImageSourceProp = { uri: 'logo.png' };
|
|
49
|
-
const BRAND_SOURCE: IImageSourceProp = { uri: 'brand.png' };
|
|
50
|
-
|
|
51
|
-
let capturedResult: Signal<IHideAnimationResult> | undefined;
|
|
52
|
-
let capturedAnimate: ReturnType<typeof vi.fn> | undefined;
|
|
53
|
-
let capturedReady: ReturnType<typeof signal<boolean>> | undefined;
|
|
54
|
-
|
|
55
|
-
@Component({
|
|
56
|
-
selector: 'symbiote-hide-animation-host',
|
|
57
|
-
standalone: true,
|
|
58
|
-
template: '',
|
|
59
|
-
})
|
|
60
|
-
class HideAnimationHost {
|
|
61
|
-
readonly ready = signal(true);
|
|
62
|
-
readonly animate = vi.fn();
|
|
63
|
-
|
|
64
|
-
readonly hideAnimation = inject(HideAnimationService).connect(() => ({
|
|
65
|
-
manifest: MANIFEST,
|
|
66
|
-
ready: this.ready(),
|
|
67
|
-
logo: LOGO_SOURCE,
|
|
68
|
-
brand: BRAND_SOURCE,
|
|
69
|
-
animate: this.animate,
|
|
70
|
-
}));
|
|
71
|
-
|
|
72
|
-
constructor() {
|
|
73
|
-
capturedResult = this.hideAnimation;
|
|
74
|
-
capturedAnimate = this.animate;
|
|
75
|
-
capturedReady = this.ready;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
@Component({
|
|
80
|
-
selector: 'symbiote-hide-animation-no-logo-host',
|
|
81
|
-
standalone: true,
|
|
82
|
-
template: '',
|
|
83
|
-
})
|
|
84
|
-
class HideAnimationNoLogoHost {
|
|
85
|
-
readonly animate = vi.fn();
|
|
86
|
-
|
|
87
|
-
readonly hideAnimation = inject(HideAnimationService).connect(() => ({
|
|
88
|
-
manifest: MANIFEST,
|
|
89
|
-
logo: undefined,
|
|
90
|
-
brand: undefined,
|
|
91
|
-
animate: this.animate,
|
|
92
|
-
}));
|
|
93
|
-
|
|
94
|
-
constructor() {
|
|
95
|
-
capturedResult = this.hideAnimation;
|
|
96
|
-
capturedAnimate = this.animate;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
beforeEach(() => {
|
|
101
|
-
globalThis.__turboModuleProxy = <T>(name: string): T | null => {
|
|
102
|
-
const module = registeredNativeModules[name];
|
|
103
|
-
return isPresent<T>(module) ? module : null;
|
|
104
|
-
};
|
|
105
|
-
capturedResult = undefined;
|
|
106
|
-
capturedAnimate = undefined;
|
|
107
|
-
capturedReady = undefined;
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
afterEach(() => {
|
|
111
|
-
unmount(ROOT_TAG);
|
|
112
|
-
fabric.reset();
|
|
113
|
-
globalThis.__turboModuleProxy = undefined;
|
|
114
|
-
vi.clearAllMocks();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe('HideAnimationService.connect', () => {
|
|
118
|
-
it('reports the container/logo/brand shapes for a config with both logo and brand', async () => {
|
|
119
|
-
mount(ROOT_TAG, HideAnimationHost);
|
|
120
|
-
await settle();
|
|
121
|
-
|
|
122
|
-
const result = capturedResult?.();
|
|
123
|
-
if (result === undefined) throw new Error('hideAnimation signal was not captured');
|
|
124
|
-
|
|
125
|
-
expect(flattenStyle(result.container.style).backgroundColor).toBe(MANIFEST.background);
|
|
126
|
-
expect(result.container.onLayout).toBeTypeOf('function');
|
|
127
|
-
|
|
128
|
-
expect(result.logo.source).toBe(LOGO_SOURCE);
|
|
129
|
-
expect(flattenStyle(result.logo.style)).toEqual({
|
|
130
|
-
width: MANIFEST.logo.width,
|
|
131
|
-
height: MANIFEST.logo.height,
|
|
132
|
-
});
|
|
133
|
-
expect(result.logo.onLoadEnd).toBeTypeOf('function');
|
|
134
|
-
|
|
135
|
-
expect(result.brand.source).toBe(BRAND_SOURCE);
|
|
136
|
-
expect(flattenStyle(result.brand.style)).toMatchObject({
|
|
137
|
-
bottom: MANIFEST.brand.bottom,
|
|
138
|
-
width: MANIFEST.brand.width,
|
|
139
|
-
height: MANIFEST.brand.height,
|
|
140
|
-
});
|
|
141
|
-
expect(result.brand.onLoadEnd).toBeTypeOf('function');
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('fires hide() exactly once, only after layout + logo + brand + ready are all satisfied', async () => {
|
|
145
|
-
mount(ROOT_TAG, HideAnimationHost);
|
|
146
|
-
await settle();
|
|
147
|
-
|
|
148
|
-
const result = capturedResult;
|
|
149
|
-
if (result === undefined || capturedAnimate === undefined) {
|
|
150
|
-
throw new Error('hideAnimation signal was not captured');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
result().container.onLayout();
|
|
154
|
-
expect(mockedHide).not.toHaveBeenCalled();
|
|
155
|
-
|
|
156
|
-
result().logo.onLoadEnd?.();
|
|
157
|
-
expect(mockedHide).not.toHaveBeenCalled();
|
|
158
|
-
|
|
159
|
-
// Brand is the last gate: only once it reports load-end does hide() fire.
|
|
160
|
-
result().brand.onLoadEnd?.();
|
|
161
|
-
await settle();
|
|
162
|
-
|
|
163
|
-
expect(mockedHide).toHaveBeenCalledOnce();
|
|
164
|
-
expect(mockedHide).toHaveBeenCalledWith({ fade: false });
|
|
165
|
-
expect(capturedAnimate).toHaveBeenCalledOnce();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('does not fire hide() again after it has already fired', async () => {
|
|
169
|
-
mount(ROOT_TAG, HideAnimationHost);
|
|
170
|
-
await settle();
|
|
171
|
-
|
|
172
|
-
const result = capturedResult;
|
|
173
|
-
if (result === undefined || capturedReady === undefined) {
|
|
174
|
-
throw new Error('hideAnimation signal was not captured');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
result().container.onLayout();
|
|
178
|
-
result().logo.onLoadEnd?.();
|
|
179
|
-
result().brand.onLoadEnd?.();
|
|
180
|
-
await settle();
|
|
181
|
-
expect(mockedHide).toHaveBeenCalledOnce();
|
|
182
|
-
|
|
183
|
-
// A later config change (ready flips off then back on) re-runs the effect's updateConfig,
|
|
184
|
-
// but the controller's own animateHasBeenCalled guard keeps hide() from firing twice.
|
|
185
|
-
capturedReady.set(false);
|
|
186
|
-
await settle();
|
|
187
|
-
capturedReady.set(true);
|
|
188
|
-
await settle();
|
|
189
|
-
|
|
190
|
-
expect(mockedHide).toHaveBeenCalledOnce();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('reports source -1 and no onLoadEnd when config.logo is omitted', async () => {
|
|
194
|
-
mount(ROOT_TAG, HideAnimationNoLogoHost);
|
|
195
|
-
await settle();
|
|
196
|
-
|
|
197
|
-
const result = capturedResult?.();
|
|
198
|
-
if (result === undefined) throw new Error('hideAnimation signal was not captured');
|
|
199
|
-
|
|
200
|
-
expect(result.logo.source).toBe(-1);
|
|
201
|
-
expect(result.logo.onLoadEnd).toBeUndefined();
|
|
202
|
-
expect(result.brand.source).toBe(-1);
|
|
203
|
-
expect(result.brand.onLoadEnd).toBeUndefined();
|
|
204
|
-
});
|
|
205
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// Co-located React-driven test (ADR 0025) for useHideAnimation. Mocks react-native-bootsplash's
|
|
2
|
-
// hide()/isVisible() (imported straight from the npm package by core/hide.ts) and installs a
|
|
3
|
-
// fake __turboModuleProxy so getHideAnimationConstants() resolves without a real native host.
|
|
4
|
-
|
|
5
|
-
import { createElement, type ReactElement } from 'react';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import { mount, unmount, View } from '@symbiote-native/react';
|
|
8
|
-
import { installFabric } from '@symbiote-native/test-utils';
|
|
9
|
-
import { useHideAnimation } from './index';
|
|
10
|
-
import type { IHideAnimationConfig, IHideAnimationResult, IManifest } from '../../../core';
|
|
11
|
-
|
|
12
|
-
vi.mock('react-native-bootsplash', () => ({
|
|
13
|
-
hide: vi.fn(() => Promise.resolve()),
|
|
14
|
-
isVisible: vi.fn(() => true),
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
const ROOT_TAG = 900;
|
|
18
|
-
|
|
19
|
-
const FAKE_NATIVE_MODULE = { getConstants: () => ({ darkModeEnabled: false }) };
|
|
20
|
-
|
|
21
|
-
function isPresent<T>(value: unknown): value is T {
|
|
22
|
-
return value !== null && value !== undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
Object.assign(globalThis, {
|
|
26
|
-
__turboModuleProxy: <T,>(name: string): T | null =>
|
|
27
|
-
name === 'RNBootSplash' && isPresent<T>(FAKE_NATIVE_MODULE) ? FAKE_NATIVE_MODULE : null,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const MANIFEST: IManifest = { background: '#fff', logo: { width: 100, height: 100 } };
|
|
31
|
-
|
|
32
|
-
const results: IHideAnimationResult[] = [];
|
|
33
|
-
|
|
34
|
-
function Probe(props: { config: IHideAnimationConfig }): ReactElement {
|
|
35
|
-
results.push(useHideAnimation(props.config));
|
|
36
|
-
return createElement(View);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const fabric = installFabric();
|
|
40
|
-
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
fabric.reset();
|
|
43
|
-
results.length = 0;
|
|
44
|
-
vi.clearAllMocks();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
afterEach(() => unmount(ROOT_TAG));
|
|
48
|
-
|
|
49
|
-
describe('useHideAnimation', () => {
|
|
50
|
-
it('reports the skip sentinel and no onLoadEnd when logo is omitted', () => {
|
|
51
|
-
mount(ROOT_TAG, createElement(Probe, { config: { manifest: MANIFEST, animate: () => {} } }));
|
|
52
|
-
|
|
53
|
-
const last = results[results.length - 1];
|
|
54
|
-
expect(last.logo.source).toBe(-1);
|
|
55
|
-
expect(last.logo.onLoadEnd).toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('returns the full logo/brand shape when both are provided', () => {
|
|
59
|
-
const config: IHideAnimationConfig = {
|
|
60
|
-
manifest: { ...MANIFEST, brand: { bottom: 20, width: 60, height: 20 } },
|
|
61
|
-
logo: 123,
|
|
62
|
-
brand: 456,
|
|
63
|
-
animate: () => {},
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
mount(ROOT_TAG, createElement(Probe, { config }));
|
|
67
|
-
|
|
68
|
-
const last = results[results.length - 1];
|
|
69
|
-
expect(last.logo.source).toBe(123);
|
|
70
|
-
expect(last.logo.resizeMode).toBe('contain');
|
|
71
|
-
expect(last.brand.source).toBe(456);
|
|
72
|
-
expect(last.brand.resizeMode).toBe('contain');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('hides exactly once, only after layout + logo load-end both resolve', async () => {
|
|
76
|
-
const { hide } = await import('react-native-bootsplash');
|
|
77
|
-
let animateCalls = 0;
|
|
78
|
-
const config: IHideAnimationConfig = {
|
|
79
|
-
manifest: MANIFEST,
|
|
80
|
-
logo: 1,
|
|
81
|
-
ready: true,
|
|
82
|
-
animate: () => {
|
|
83
|
-
animateCalls += 1;
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
mount(ROOT_TAG, createElement(Probe, { config }));
|
|
88
|
-
const result = results[results.length - 1];
|
|
89
|
-
|
|
90
|
-
expect(hide).not.toHaveBeenCalled();
|
|
91
|
-
result.container.onLayout();
|
|
92
|
-
expect(hide).not.toHaveBeenCalled();
|
|
93
|
-
|
|
94
|
-
result.logo.onLoadEnd?.();
|
|
95
|
-
|
|
96
|
-
expect(hide).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(hide).toHaveBeenCalledWith({ fade: false });
|
|
98
|
-
await vi.waitFor(() => expect(animateCalls).toBe(1));
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('does not hide again once already triggered', async () => {
|
|
102
|
-
const { hide } = await import('react-native-bootsplash');
|
|
103
|
-
const config: IHideAnimationConfig = { manifest: MANIFEST, ready: true, animate: () => {} };
|
|
104
|
-
|
|
105
|
-
mount(ROOT_TAG, createElement(Probe, { config }));
|
|
106
|
-
const result = results[results.length - 1];
|
|
107
|
-
|
|
108
|
-
result.container.onLayout();
|
|
109
|
-
await vi.waitFor(() => expect(hide).toHaveBeenCalledTimes(1));
|
|
110
|
-
|
|
111
|
-
result.container.onLayout();
|
|
112
|
-
expect(hide).toHaveBeenCalledTimes(1);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
// Co-located Vue-driven test (ADR 0025) for useHideAnimation. react-native-bootsplash's
|
|
2
|
-
// TurboModule is faked (both its public JS API and the RNBootSplash native constants
|
|
3
|
-
// module HideAnimationController/computeHideAnimationStyles reach through), so no real
|
|
4
|
-
// native call fires. Proves the composable wires the framework-agnostic core's readiness
|
|
5
|
-
// gate + style computation onto Vue's own reactivity: a getter-driven `ready` ref reactively
|
|
6
|
-
// re-runs the watchEffect, hide() fires exactly once, and the returned computed reflects the
|
|
7
|
-
// core's container/logo/brand shapes faithfully — including the "no logo" skip case.
|
|
8
|
-
|
|
9
|
-
import { effectScope, nextTick, ref, type EffectScope, type Ref } from '@vue/runtime-core';
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
-
import { hide } from 'react-native-bootsplash';
|
|
12
|
-
import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
|
|
13
|
-
import { useHideAnimation } from './index';
|
|
14
|
-
import type { IHideAnimationConfig, IManifest } from '../../../core';
|
|
15
|
-
|
|
16
|
-
vi.mock('react-native-bootsplash', () => ({
|
|
17
|
-
hide: vi.fn(() => Promise.resolve()),
|
|
18
|
-
isVisible: vi.fn(() => true),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const FAKE_NATIVE_MODULE = {
|
|
22
|
-
getConstants: vi.fn(() => ({ darkModeEnabled: false })),
|
|
23
|
-
hide: vi.fn(() => Promise.resolve()),
|
|
24
|
-
isVisible: () => true,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const registeredNativeModules: Record<string, unknown> = { RNBootSplash: FAKE_NATIVE_MODULE };
|
|
28
|
-
|
|
29
|
-
function isPresent<T>(value: unknown): value is T {
|
|
30
|
-
return value !== null && value !== undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
globalThis.__turboModuleProxy = <T>(name: string): T | null => {
|
|
35
|
-
const module = registeredNativeModules[name];
|
|
36
|
-
return isPresent<T>(module) ? module : null;
|
|
37
|
-
};
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
afterEach(() => {
|
|
41
|
-
globalThis.__turboModuleProxy = undefined;
|
|
42
|
-
vi.clearAllMocks();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const MANIFEST: IManifest = {
|
|
46
|
-
background: '#ffffff',
|
|
47
|
-
logo: { width: 100, height: 100 },
|
|
48
|
-
brand: { bottom: 40, width: 80, height: 20 },
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// container.style is IStyleProp<IViewStyle> (union with the falsy/array style-composition
|
|
52
|
-
// shapes), but computeHideAnimationStyles always hands back a plain object here — narrow it
|
|
53
|
-
// once so the assertions can read .backgroundColor without an `as` cast.
|
|
54
|
-
function isPlainViewStyle(style: IStyleProp<IViewStyle>): style is IViewStyle {
|
|
55
|
-
return typeof style === 'object' && style !== null && !Array.isArray(style);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function asViewStyle(style: IStyleProp<IViewStyle>): IViewStyle {
|
|
59
|
-
if (!isPlainViewStyle(style)) {
|
|
60
|
-
throw new Error('expected a plain view-style object');
|
|
61
|
-
}
|
|
62
|
-
return style;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function runInScope<T>(fn: () => T): { value: T; stop: () => void } {
|
|
66
|
-
const scope: EffectScope = effectScope();
|
|
67
|
-
const value = scope.run(fn);
|
|
68
|
-
if (value === undefined) {
|
|
69
|
-
throw new Error('effectScope.run() returned undefined');
|
|
70
|
-
}
|
|
71
|
-
return { value, stop: () => scope.stop() };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
describe('useHideAnimation (Vue)', () => {
|
|
75
|
-
it('fires hide exactly once, only after layout + logo load + ready all become true', async () => {
|
|
76
|
-
const readyRef: Ref<boolean> = ref(false);
|
|
77
|
-
const animate = vi.fn();
|
|
78
|
-
const getConfig = (): IHideAnimationConfig => ({
|
|
79
|
-
manifest: MANIFEST,
|
|
80
|
-
logo: 1,
|
|
81
|
-
animate,
|
|
82
|
-
ready: readyRef.value,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const { value: result, stop } = runInScope(() => useHideAnimation(getConfig));
|
|
86
|
-
|
|
87
|
-
expect(hide).not.toHaveBeenCalled();
|
|
88
|
-
|
|
89
|
-
result.value.container.onLayout();
|
|
90
|
-
expect(hide, 'layout alone must not hide').not.toHaveBeenCalled();
|
|
91
|
-
|
|
92
|
-
expect(typeof result.value.logo.onLoadEnd).toBe('function');
|
|
93
|
-
result.value.logo.onLoadEnd?.();
|
|
94
|
-
expect(hide, 'layout + logo alone must not hide (ready still false)').not.toHaveBeenCalled();
|
|
95
|
-
|
|
96
|
-
readyRef.value = true;
|
|
97
|
-
await nextTick();
|
|
98
|
-
expect(hide).toHaveBeenCalledTimes(1);
|
|
99
|
-
expect(hide).toHaveBeenCalledWith({ fade: false });
|
|
100
|
-
|
|
101
|
-
stop();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('does not fire hide again once it already fired', async () => {
|
|
105
|
-
const readyRef: Ref<boolean> = ref(true);
|
|
106
|
-
const getConfig = (): IHideAnimationConfig => ({
|
|
107
|
-
manifest: MANIFEST,
|
|
108
|
-
logo: 1,
|
|
109
|
-
animate: vi.fn(),
|
|
110
|
-
ready: readyRef.value,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const { value: result, stop } = runInScope(() => useHideAnimation(getConfig));
|
|
114
|
-
|
|
115
|
-
result.value.container.onLayout();
|
|
116
|
-
result.value.logo.onLoadEnd?.();
|
|
117
|
-
await nextTick();
|
|
118
|
-
expect(hide).toHaveBeenCalledTimes(1);
|
|
119
|
-
|
|
120
|
-
// Toggling ready off and back on must not re-run the already-fired animation.
|
|
121
|
-
readyRef.value = false;
|
|
122
|
-
await nextTick();
|
|
123
|
-
readyRef.value = true;
|
|
124
|
-
await nextTick();
|
|
125
|
-
expect(hide).toHaveBeenCalledTimes(1);
|
|
126
|
-
|
|
127
|
-
stop();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('returns the container/logo/brand shapes for a config with both logo and brand', () => {
|
|
131
|
-
const getConfig = (): IHideAnimationConfig => ({
|
|
132
|
-
manifest: MANIFEST,
|
|
133
|
-
logo: 1,
|
|
134
|
-
brand: 2,
|
|
135
|
-
animate: vi.fn(),
|
|
136
|
-
ready: true,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const { value: result, stop } = runInScope(() => useHideAnimation(getConfig));
|
|
140
|
-
const { container, logo, brand } = result.value;
|
|
141
|
-
|
|
142
|
-
expect(asViewStyle(container.style).backgroundColor).toBe('#ffffff');
|
|
143
|
-
expect(typeof container.onLayout).toBe('function');
|
|
144
|
-
|
|
145
|
-
expect(logo.source).toBe(1);
|
|
146
|
-
expect(logo.style).toEqual({ width: 100, height: 100 });
|
|
147
|
-
expect(typeof logo.onLoadEnd).toBe('function');
|
|
148
|
-
|
|
149
|
-
expect(brand.source).toBe(2);
|
|
150
|
-
expect(brand.style).toEqual({
|
|
151
|
-
position: 'absolute',
|
|
152
|
-
bottom: 40,
|
|
153
|
-
width: 80,
|
|
154
|
-
height: 20,
|
|
155
|
-
});
|
|
156
|
-
expect(typeof brand.onLoadEnd).toBe('function');
|
|
157
|
-
|
|
158
|
-
stop();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('skips the logo (source -1, no onLoadEnd) when config.logo is omitted', () => {
|
|
162
|
-
const getConfig = (): IHideAnimationConfig => ({
|
|
163
|
-
manifest: MANIFEST,
|
|
164
|
-
animate: vi.fn(),
|
|
165
|
-
ready: true,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const { value: result, stop } = runInScope(() => useHideAnimation(getConfig));
|
|
169
|
-
const { logo } = result.value;
|
|
170
|
-
|
|
171
|
-
expect(logo.source).toBe(-1);
|
|
172
|
-
expect('onLoadEnd' in logo).toBe(false);
|
|
173
|
-
|
|
174
|
-
stop();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('reads the native constants exactly once, not on every recompute', () => {
|
|
178
|
-
const readyRef: Ref<boolean> = ref(false);
|
|
179
|
-
const getConfig = (): IHideAnimationConfig => ({
|
|
180
|
-
manifest: MANIFEST,
|
|
181
|
-
logo: 1,
|
|
182
|
-
animate: vi.fn(),
|
|
183
|
-
ready: readyRef.value,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const { value: result, stop } = runInScope(() => useHideAnimation(getConfig));
|
|
187
|
-
|
|
188
|
-
// Force several recomputes of the returned computed.
|
|
189
|
-
void result.value;
|
|
190
|
-
readyRef.value = true;
|
|
191
|
-
void result.value;
|
|
192
|
-
readyRef.value = false;
|
|
193
|
-
void result.value;
|
|
194
|
-
|
|
195
|
-
expect(FAKE_NATIVE_MODULE.getConstants).toHaveBeenCalledTimes(1);
|
|
196
|
-
|
|
197
|
-
stop();
|
|
198
|
-
});
|
|
199
|
-
});
|