Skip to content

Commit 651f09a

Browse files
committed
test: add useViewModelInstance e2e harness tests
1 parent f0abd7d commit 651f09a

File tree

2 files changed

+342
-1
lines changed

2 files changed

+342
-1
lines changed
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
render,
6+
waitFor,
7+
cleanup,
8+
} from 'react-native-harness';
9+
import { useEffect, useState, useCallback } from 'react';
10+
import { Text, View } from 'react-native';
11+
import {
12+
RiveFileFactory,
13+
useViewModelInstance,
14+
type RiveFile,
15+
type ViewModel,
16+
type ViewModelInstance,
17+
} from '@rive-app/react-native';
18+
19+
const MULTI_AB = require('../assets/rive/arbtboards-models-instances.riv');
20+
const DATABINDING = require('../assets/rive/databinding.riv');
21+
22+
function expectDefined<T>(value: T): asserts value is NonNullable<T> {
23+
expect(value).toBeDefined();
24+
}
25+
26+
async function loadMultiAB() {
27+
return RiveFileFactory.fromSource(MULTI_AB, undefined);
28+
}
29+
30+
async function loadDatabinding() {
31+
return RiveFileFactory.fromSource(DATABINDING, undefined);
32+
}
33+
34+
// ── Helpers ──────────────────────────────────────────────────────────
35+
36+
type VMICtx = {
37+
instance: ViewModelInstance | null;
38+
instanceName: string | undefined;
39+
id: string | undefined;
40+
renderCount: number;
41+
};
42+
43+
function createCtx(): VMICtx {
44+
return {
45+
instance: null,
46+
instanceName: undefined,
47+
id: undefined,
48+
renderCount: 0,
49+
};
50+
}
51+
52+
// ── ViewModel source components ──────────────────────────────────────
53+
54+
function VMIFromViewModel({
55+
viewModel,
56+
name,
57+
useNew,
58+
ctx,
59+
}: {
60+
viewModel: ViewModel | null;
61+
name?: string;
62+
useNew?: boolean;
63+
ctx: VMICtx;
64+
}) {
65+
const instance = useViewModelInstance(viewModel, {
66+
...(name != null && { name }),
67+
...(useNew != null && { useNew }),
68+
});
69+
useEffect(() => {
70+
ctx.instance = instance;
71+
ctx.instanceName = instance?.instanceName;
72+
ctx.id = instance?.stringProperty('_id')?.value;
73+
ctx.renderCount++;
74+
}, [ctx, instance]);
75+
return (
76+
<View>
77+
<Text>{String(!!instance)}</Text>
78+
</View>
79+
);
80+
}
81+
82+
// ── Param-change component (viewModelName changes via external trigger) ─
83+
84+
type ParamChangeCtx = {
85+
instance: ViewModelInstance | null;
86+
id: string | undefined;
87+
setViewModelName: ((name: string) => void) | null;
88+
};
89+
90+
function createParamChangeCtx(): ParamChangeCtx {
91+
return { instance: null, id: undefined, setViewModelName: null };
92+
}
93+
94+
function VMIWithParamChange({
95+
file,
96+
initialViewModelName,
97+
ctx,
98+
}: {
99+
file: RiveFile;
100+
initialViewModelName: string;
101+
ctx: ParamChangeCtx;
102+
}) {
103+
const [vmName, setVmName] = useState(initialViewModelName);
104+
const instance = useViewModelInstance(file, { viewModelName: vmName });
105+
106+
const setViewModelName = useCallback((name: string) => {
107+
setVmName(name);
108+
}, []);
109+
110+
useEffect(() => {
111+
ctx.instance = instance;
112+
ctx.id = instance?.stringProperty('_id')?.value;
113+
ctx.setViewModelName = setViewModelName;
114+
}, [ctx, instance, setViewModelName]);
115+
116+
return (
117+
<View>
118+
<Text>{String(!!instance)}</Text>
119+
</View>
120+
);
121+
}
122+
123+
// ── onInit-on-change component ────────────────────────────────────────
124+
125+
type OnInitChangeCtx = {
126+
instance: ViewModelInstance | null;
127+
initCalls: Array<{ vmName: string; id: string | undefined }>;
128+
setViewModelName: ((name: string) => void) | null;
129+
};
130+
131+
function createOnInitChangeCtx(): OnInitChangeCtx {
132+
return { instance: null, initCalls: [], setViewModelName: null };
133+
}
134+
135+
function VMIWithOnInitAndChange({
136+
file,
137+
initialViewModelName,
138+
ctx,
139+
}: {
140+
file: RiveFile;
141+
initialViewModelName: string;
142+
ctx: OnInitChangeCtx;
143+
}) {
144+
const [vmName, setVmName] = useState(initialViewModelName);
145+
const instance = useViewModelInstance(file, {
146+
viewModelName: vmName,
147+
onInit: (vmi) => {
148+
ctx.initCalls.push({
149+
vmName,
150+
id: vmi.stringProperty('_id')?.value,
151+
});
152+
},
153+
});
154+
155+
const setViewModelName = useCallback((name: string) => {
156+
setVmName(name);
157+
}, []);
158+
159+
useEffect(() => {
160+
ctx.instance = instance;
161+
ctx.setViewModelName = setViewModelName;
162+
}, [ctx, instance, setViewModelName]);
163+
164+
return (
165+
<View>
166+
<Text>{String(!!instance)}</Text>
167+
</View>
168+
);
169+
}
170+
171+
// ── ViewModel source tests ───────────────────────────────────────────
172+
173+
describe('useViewModelInstance from ViewModel source', () => {
174+
it('creates default instance from ViewModel', async () => {
175+
const file = await loadMultiAB();
176+
const vm = file.viewModelByName('viewmodel1');
177+
expectDefined(vm);
178+
179+
const ctx = createCtx();
180+
await render(<VMIFromViewModel viewModel={vm} ctx={ctx} />);
181+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
182+
expectDefined(ctx.id);
183+
expect(ctx.id).toBe('vm1.vmi.id');
184+
cleanup();
185+
});
186+
187+
it('creates named instance from ViewModel', async () => {
188+
const file = await loadMultiAB();
189+
const vm = file.viewModelByName('viewmodel1');
190+
expectDefined(vm);
191+
192+
const ctx = createCtx();
193+
await render(<VMIFromViewModel viewModel={vm} name="vmi2" ctx={ctx} />);
194+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
195+
expect(ctx.instanceName).toBe('vmi2');
196+
expect(ctx.id).toBe('vm1.vmi2.id');
197+
cleanup();
198+
});
199+
200+
it('creates blank instance from ViewModel with useNew', async () => {
201+
const file = await loadMultiAB();
202+
const vm = file.viewModelByName('viewmodel1');
203+
expectDefined(vm);
204+
205+
const ctx = createCtx();
206+
await render(<VMIFromViewModel viewModel={vm} useNew={true} ctx={ctx} />);
207+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
208+
// Blank instance should exist but have empty/default property values
209+
expectDefined(ctx.instance);
210+
cleanup();
211+
});
212+
213+
it('returns null for non-existent named instance from ViewModel', async () => {
214+
const file = await loadMultiAB();
215+
const vm = file.viewModelByName('viewmodel1');
216+
expectDefined(vm);
217+
218+
const ctx = createCtx();
219+
await render(
220+
<VMIFromViewModel viewModel={vm} name="doesNotExist" ctx={ctx} />
221+
);
222+
await new Promise((r) => setTimeout(r, 500));
223+
expect(ctx.instance).toBeNull();
224+
cleanup();
225+
});
226+
227+
it('returns null when ViewModel source is null', async () => {
228+
const ctx = createCtx();
229+
await render(<VMIFromViewModel viewModel={null} ctx={ctx} />);
230+
await new Promise((r) => setTimeout(r, 500));
231+
expect(ctx.instance).toBeNull();
232+
cleanup();
233+
});
234+
});
235+
236+
// ── Param change tests ───────────────────────────────────────────────
237+
238+
describe('useViewModelInstance param changes', () => {
239+
it('switches instance when viewModelName changes', async () => {
240+
const file = await loadMultiAB();
241+
const ctx = createParamChangeCtx();
242+
243+
await render(
244+
<VMIWithParamChange
245+
file={file}
246+
initialViewModelName="viewmodel1"
247+
ctx={ctx}
248+
/>
249+
);
250+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
251+
expect(ctx.id).toBe('vm1.vmi.id');
252+
253+
// Change to viewmodel2
254+
expectDefined(ctx.setViewModelName);
255+
ctx.setViewModelName('viewmodel2');
256+
await waitFor(() => expect(ctx.id).toBe('vm2.vmi1.id'), { timeout: 5000 });
257+
258+
// Change to viewmodel3
259+
ctx.setViewModelName('viewmodel3');
260+
await waitFor(() => expect(ctx.id).toBe('vm3.vmi1.id'), { timeout: 5000 });
261+
262+
cleanup();
263+
});
264+
265+
it('returns null when viewModelName changes to non-existent', async () => {
266+
const file = await loadMultiAB();
267+
const ctx = createParamChangeCtx();
268+
269+
await render(
270+
<VMIWithParamChange
271+
file={file}
272+
initialViewModelName="viewmodel1"
273+
ctx={ctx}
274+
/>
275+
);
276+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
277+
expect(ctx.id).toBe('vm1.vmi.id');
278+
279+
expectDefined(ctx.setViewModelName);
280+
ctx.setViewModelName('nonExistent');
281+
await waitFor(() => expect(ctx.instance).toBeNull(), { timeout: 5000 });
282+
283+
cleanup();
284+
});
285+
});
286+
287+
// ── onInit on param change ───────────────────────────────────────────
288+
289+
describe('useViewModelInstance onInit on param change', () => {
290+
it('calls onInit for each new instance when viewModelName changes', async () => {
291+
const file = await loadMultiAB();
292+
const ctx = createOnInitChangeCtx();
293+
294+
await render(
295+
<VMIWithOnInitAndChange
296+
file={file}
297+
initialViewModelName="viewmodel1"
298+
ctx={ctx}
299+
/>
300+
);
301+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
302+
expect(ctx.initCalls.length).toBeGreaterThanOrEqual(1);
303+
expect(ctx.initCalls[0]!.id).toBe('vm1.vmi.id');
304+
305+
// Change to viewmodel2
306+
expectDefined(ctx.setViewModelName);
307+
const callCountBefore = ctx.initCalls.length;
308+
ctx.setViewModelName('viewmodel2');
309+
await waitFor(
310+
() => expect(ctx.initCalls.length).toBeGreaterThan(callCountBefore),
311+
{ timeout: 5000 }
312+
);
313+
314+
const lastCall = ctx.initCalls[ctx.initCalls.length - 1];
315+
expect(lastCall!.id).toBe('vm2.vmi1.id');
316+
317+
cleanup();
318+
});
319+
});
320+
321+
// ── databinding.riv: ViewModel source with number property ───────────
322+
323+
describe('useViewModelInstance from ViewModel with databinding.riv', () => {
324+
it('default instance has expected age property', async () => {
325+
const file = await loadDatabinding();
326+
const vm = file.defaultArtboardViewModel();
327+
expectDefined(vm);
328+
329+
const ctx = createCtx();
330+
await render(<VMIFromViewModel viewModel={vm} ctx={ctx} />);
331+
await waitFor(() => expect(ctx.instance).not.toBeNull(), { timeout: 5000 });
332+
333+
expectDefined(ctx.instance);
334+
const age = ctx.instance.numberProperty('age')?.value;
335+
expect(age).toBe(30);
336+
cleanup();
337+
});
338+
});

example/__tests__/viewmodel-properties.harness.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ describe('ViewModel Properties', () => {
9999
// Most backends reject invalid enum values; the value should revert to 'cat'
100100
// Android legacy SDK accepts them (reads back 'snakeLizard')
101101
const val = enumProperty.value;
102-
if (Platform.OS === 'android' && RiveFileFactory.getBackend() === 'legacy') {
102+
if (
103+
Platform.OS === 'android' &&
104+
RiveFileFactory.getBackend() === 'legacy'
105+
) {
103106
expect(val === 'cat' || val === 'snakeLizard').toBe(true);
104107
} else {
105108
expect(val).toBe('cat');

0 commit comments

Comments
 (0)