xref: /aosp_15_r20/external/perfetto/ui/src/base/store_unittest.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2023 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {Draft} from 'immer';
16import {createStore} from './store';
17import {exists} from './utils';
18
19interface Bar {
20  value: number;
21}
22
23interface Foo {
24  counter: number;
25  nested: Bar;
26}
27
28function migrateFoo(init: unknown): Foo {
29  const migrated: Foo = {
30    counter: 123,
31    nested: {
32      value: 456,
33    },
34  };
35  if (exists(init) && typeof init === 'object') {
36    if ('counter' in init && typeof init.counter === 'number') {
37      migrated.counter = init.counter;
38    }
39    if ('nested' in init && typeof init.nested === 'object' && init.nested) {
40      if ('value' in init.nested && typeof init.nested.value === 'number') {
41        migrated.nested.value = init.nested.value;
42      }
43    }
44  }
45
46  console.log('migrating', init);
47
48  return migrated;
49}
50
51interface State {
52  foo: Foo;
53}
54
55const initialState: State = {
56  foo: {
57    counter: 0,
58    nested: {
59      value: 42,
60    },
61  },
62};
63
64describe('root store', () => {
65  test('edit', () => {
66    const store = createStore(initialState);
67    store.edit((draft) => {
68      draft.foo.counter += 123;
69    });
70
71    expect(store.state).toEqual({
72      foo: {
73        counter: 123,
74        nested: {
75          value: 42,
76        },
77      },
78    });
79  });
80
81  test('state [in]equality', () => {
82    const store = createStore(initialState);
83    store.edit((draft) => {
84      draft.foo.counter = 88;
85    });
86    expect(store.state).not.toBe(initialState);
87    expect(store.state.foo).not.toBe(initialState.foo);
88    expect(store.state.foo.nested).toBe(initialState.foo.nested);
89  });
90
91  it('can take multiple edits at once', () => {
92    const store = createStore(initialState);
93    const callback = jest.fn();
94
95    store.subscribe(callback);
96
97    store.edit([
98      (draft) => {
99        draft.foo.counter += 10;
100      },
101      (draft) => {
102        draft.foo.counter += 10;
103      },
104    ]);
105
106    expect(callback).toHaveBeenCalledTimes(1);
107    expect(callback).toHaveBeenCalledWith(store, initialState);
108    expect(store.state).toEqual({
109      foo: {
110        counter: 20,
111        nested: {
112          value: 42,
113        },
114      },
115    });
116  });
117
118  it('can support a huge number of edits', () => {
119    const store = createStore(initialState);
120    const N = 100_000;
121    const edits = Array(N).fill((draft: Draft<State>) => {
122      draft.foo.counter++;
123    });
124    store.edit(edits);
125    expect(store.state.foo.counter).toEqual(N);
126  });
127
128  it('notifies subscribers', () => {
129    const store = createStore(initialState);
130    const callback = jest.fn();
131
132    store.subscribe(callback);
133
134    store.edit((draft) => {
135      draft.foo.counter += 1;
136    });
137
138    expect(callback).toHaveBeenCalledTimes(1);
139    expect(callback).toHaveBeenCalledWith(store, initialState);
140  });
141
142  it('does not notify unsubscribed subscribers', () => {
143    const store = createStore(initialState);
144    const callback = jest.fn();
145
146    // Subscribe then immediately unsubscribe
147    store.subscribe(callback)[Symbol.dispose]();
148
149    // Make an arbitrary edit
150    store.edit((draft) => {
151      draft.foo.counter += 1;
152    });
153
154    expect(callback).not.toHaveBeenCalled();
155  });
156});
157
158describe('sub-store', () => {
159  test('edit', () => {
160    const store = createStore(initialState);
161    const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
162
163    subStore.edit((draft) => {
164      draft.counter += 1;
165    });
166
167    expect(subStore.state).toEqual({
168      counter: 1,
169      nested: {
170        value: 42,
171      },
172    });
173
174    expect(store.state).toEqual({
175      foo: {
176        counter: 1,
177        nested: {
178          value: 42,
179        },
180      },
181    });
182  });
183
184  test('edit from root store', () => {
185    const store = createStore(initialState);
186    const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
187
188    store.edit((draft) => {
189      draft.foo.counter += 1;
190    });
191
192    expect(subStore.state).toEqual({
193      counter: 1,
194      nested: {
195        value: 42,
196      },
197    });
198  });
199
200  it('can create more substores and edit', () => {
201    const store = createStore(initialState);
202    const fooState = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
203    const nestedStore = fooState.createSubStore<Bar>(
204      ['nested'],
205      (x) => x as Bar,
206    );
207
208    nestedStore.edit((draft) => {
209      draft.value += 1;
210    });
211
212    expect(nestedStore.state).toEqual({
213      value: 43,
214    });
215  });
216
217  it('notifies subscribers', () => {
218    const store = createStore(initialState);
219    const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
220    const callback = jest.fn();
221
222    subStore.subscribe(callback);
223
224    subStore.edit((draft) => {
225      draft.counter += 1;
226    });
227
228    expect(callback).toHaveBeenCalledTimes(1);
229    expect(callback).toHaveBeenCalledWith(subStore, initialState.foo);
230  });
231
232  it('does not notify unsubscribed subscribers', () => {
233    const store = createStore(initialState);
234    const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
235    const callback = jest.fn();
236
237    // Subscribe then immediately unsubscribe
238    subStore.subscribe(callback)[Symbol.dispose]();
239
240    // Make an arbitrary edit
241    subStore.edit((draft) => {
242      draft.counter += 1;
243    });
244
245    expect(callback).not.toHaveBeenCalled();
246  });
247
248  it('handles reading when path doesn\t exist in root store', () => {
249    const store = createStore(initialState);
250
251    // This target node is missing - baz doesn't exist in State
252    const subStore = store.createSubStore<Foo>(['baz'], (x) => x as Foo);
253    expect(subStore.state).toBe(undefined);
254  });
255
256  it("handles edit when path doesn't exist in root store", () => {
257    const store = createStore(initialState);
258    const value: Foo = {
259      counter: 123,
260      nested: {
261        value: 456,
262      },
263    };
264
265    // This target node is missing - baz doesn't exist in State
266    const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
267
268    // Edits should work just fine, but the root store will not be modified.
269    subStore.edit((draft) => {
270      draft.counter += 1;
271    });
272  });
273
274  it('check subscriber only called once when edits made to undefined root path', () => {
275    const store = createStore(initialState);
276    const value: Foo = {
277      counter: 123,
278      nested: {
279        value: 456,
280      },
281    };
282
283    const callback = jest.fn();
284
285    // This target node is missing - baz doesn't exist in State
286    const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
287    subStore.subscribe(callback);
288
289    // Edits should work just fine, but the root store will not be modified.
290    subStore.edit((draft) => {
291      draft.counter += 1;
292    });
293
294    expect(callback).toHaveBeenCalledTimes(1);
295    expect(callback).toHaveBeenCalledWith(subStore, value);
296  });
297
298  it("notifies subscribers even when path doesn't exist in root store", () => {
299    const store = createStore(initialState);
300    const value: Foo = {
301      counter: 123,
302      nested: {
303        value: 456,
304      },
305    };
306    const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
307
308    const callback = jest.fn();
309    subStore.subscribe(callback);
310
311    subStore.edit((draft) => {
312      draft.counter += 1;
313    });
314
315    expect(callback).toHaveBeenCalledTimes(1);
316    expect(callback).toHaveBeenCalledWith(subStore, value);
317  });
318
319  it('notifies when relevant edits are made from root store', () => {
320    const store = createStore(initialState);
321    const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
322    const callback = jest.fn();
323
324    // Subscribe on the proxy store
325    subStore.subscribe(callback);
326
327    // Edit the subtree from the root store
328    store.edit((draft) => {
329      draft.foo.counter++;
330    });
331
332    // Expect proxy callback called with correct subtree
333    expect(callback).toHaveBeenCalledTimes(1);
334    expect(callback).toHaveBeenCalledWith(subStore, initialState.foo);
335  });
336
337  it('ignores irrelevant edits from the root store', () => {
338    const store = createStore(initialState);
339    const nestedStore = store.createSubStore<Bar>(
340      ['foo', 'nested'],
341      (x) => x as Bar,
342    );
343    const callback = jest.fn();
344
345    // Subscribe on the proxy store
346    nestedStore.subscribe(callback);
347
348    // Edit an irrelevant subtree on the root store
349    store.edit((draft) => {
350      draft.foo.counter++;
351    });
352
353    // Ensure proxy callback hasn't been called
354    expect(callback).not.toHaveBeenCalled();
355  });
356
357  it('immutable [in]equality works', () => {
358    const store = createStore(initialState);
359    const subStore = store.createSubStore<Foo>(['foo'], migrateFoo);
360    const before = subStore.state;
361
362    subStore.edit((draft) => {
363      draft.counter += 1;
364    });
365
366    const after = subStore.state;
367
368    // something has changed so root should not equal
369    expect(before).not.toBe(after);
370
371    // nested has not changed and so should be the before version.
372    expect(before.nested).toBe(after.nested);
373  });
374
375  // This test depends on the migrate function - if it attempts to preserve
376  // equality then we might have a chance, but our migrate function here does
377  // not, and I'm not sure we can expect people do provide one that does.
378  // TODO(stevegolton): See if we can get this working, regardless of migrate
379  // function implementation.
380  it.skip('unrelated state refs are still equal when modified from root store', () => {
381    const store = createStore(initialState);
382    const subStore = store.createSubStore<Foo>(['foo'], migrateFoo);
383    const before = subStore.state;
384
385    // Check that unrelated state is still the same even though subtree is
386    // modified from the root store
387    store.edit((draft) => {
388      draft.foo.counter = 1234;
389    });
390
391    expect(before.nested).toBe(subStore.state.nested);
392    expect(subStore.state.counter).toBe(1234);
393  });
394
395  it('works when underlying state is undefined', () => {
396    interface RootState {
397      dict: {[key: string]: unknown};
398    }
399    interface ProxyState {
400      bar: string;
401    }
402
403    const store = createStore<RootState>({dict: {}});
404    const migrate = (init: unknown) => (init ?? {bar: 'bar'}) as ProxyState;
405    const subStore = store.createSubStore(['dict', 'foo'], migrate);
406
407    // Check initial migration works, yet underlying store is untouched
408    expect(subStore.state.bar).toBe('bar');
409    expect(store.state.dict['foo']).toBe(undefined);
410
411    // Check updates work
412    subStore.edit((draft) => {
413      draft.bar = 'baz';
414    });
415    expect(subStore.state.bar).toBe('baz');
416    expect((store.state.dict['foo'] as ProxyState).bar).toBe('baz');
417  });
418
419  test('chained substores', () => {
420    interface State {
421      dict: {[key: string]: unknown};
422    }
423
424    interface FooState {
425      bar: {
426        baz: string;
427      };
428    }
429
430    const store = createStore<State>({dict: {}});
431
432    const DEFAULT_FOO_STATE: FooState = {bar: {baz: 'abc'}};
433    const fooStore = store.createSubStore(
434      ['dict', 'foo'],
435      (init) => init ?? DEFAULT_FOO_STATE,
436    );
437
438    const subFooStore = fooStore.createSubStore(
439      ['bar'],
440      (x) => x as FooState['bar'],
441    );
442
443    // Since the entry for 'foo' will be undefined in the dict, we expect the
444    // migrate function on fooStore to return DEFAULT_FOO_STATE, and thus the
445    // state of the subFooStore will be DEFAULT_FOO_STATE.bar.
446    expect(subFooStore.state).toEqual({baz: 'abc'});
447  });
448});
449