blob: f90cde2ca7df41d4f395c0e95412f40db649490d [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Draft} from 'immer';
import {using} from './disposable';
import {createStore} from './store';
interface Bar {
value: number;
}
interface Foo {
counter: number;
nested: Bar;
}
function migrateFoo(init: unknown): Foo {
const migrated: Foo = {
counter: 123,
nested: {
value: 456,
},
};
if (init && typeof init === 'object') {
if ('counter' in init && typeof init.counter === 'number') {
migrated.counter = init.counter;
}
if ('nested' in init && typeof init.nested === 'object' && init.nested) {
if ('value' in init.nested && typeof init.nested.value === 'number') {
migrated.nested.value = init.nested.value;
}
}
}
console.log('migrating', init);
return migrated;
}
interface State {
foo: Foo;
}
const initialState: State = {
foo: {
counter: 0,
nested: {
value: 42,
},
},
};
describe('root store', () => {
test('edit', () => {
const store = createStore(initialState);
store.edit((draft) => {
draft.foo.counter += 123;
});
expect(store.state).toEqual({
foo: {
counter: 123,
nested: {
value: 42,
},
},
});
});
test('state [in]equality', () => {
const store = createStore(initialState);
store.edit((draft) => {
draft.foo.counter = 88;
});
expect(store.state).not.toBe(initialState);
expect(store.state.foo).not.toBe(initialState.foo);
expect(store.state.foo.nested).toBe(initialState.foo.nested);
});
it('can take multiple edits at once', () => {
const store = createStore(initialState);
const callback = jest.fn();
store.subscribe(callback);
store.edit([
(draft) => {
draft.foo.counter += 10;
},
(draft) => {
draft.foo.counter += 10;
},
]);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(store, initialState);
expect(store.state).toEqual({
foo: {
counter: 20,
nested: {
value: 42,
},
},
});
});
it('can support a huge number of edits', () => {
const store = createStore(initialState);
const N = 100_000;
const edits = Array(N).fill((draft: Draft<State>) => {
draft.foo.counter++;
});
store.edit(edits);
expect(store.state.foo.counter).toEqual(N);
});
it('notifies subscribers', () => {
const store = createStore(initialState);
const callback = jest.fn();
store.subscribe(callback);
store.edit((draft) => {
draft.foo.counter += 1;
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(store, initialState);
});
it('does not notify unsubscribed subscribers', () => {
const store = createStore(initialState);
const callback = jest.fn();
// Subscribe then immediately unsubscribe
using(store.subscribe(callback));
// Make an arbitrary edit
store.edit((draft) => {
draft.foo.counter += 1;
});
expect(callback).not.toHaveBeenCalled();
});
});
describe('sub-store', () => {
test('edit', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
subStore.edit((draft) => {
draft.counter += 1;
});
expect(subStore.state).toEqual({
counter: 1,
nested: {
value: 42,
},
});
expect(store.state).toEqual({
foo: {
counter: 1,
nested: {
value: 42,
},
},
});
});
test('edit from root store', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
store.edit((draft) => {
draft.foo.counter += 1;
});
expect(subStore.state).toEqual({
counter: 1,
nested: {
value: 42,
},
});
});
it('can create more substores and edit', () => {
const store = createStore(initialState);
const fooState = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
const nestedStore = fooState.createSubStore<Bar>(
['nested'],
(x) => x as Bar,
);
nestedStore.edit((draft) => {
draft.value += 1;
});
expect(nestedStore.state).toEqual({
value: 43,
});
});
it('notifies subscribers', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
const callback = jest.fn();
subStore.subscribe(callback);
subStore.edit((draft) => {
draft.counter += 1;
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(subStore, initialState.foo);
});
it('does not notify unsubscribed subscribers', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
const callback = jest.fn();
// Subscribe then immediately unsubscribe
using(subStore.subscribe(callback));
// Make an arbitrary edit
subStore.edit((draft) => {
draft.counter += 1;
});
expect(callback).not.toHaveBeenCalled();
});
it('handles reading when path doesn\t exist in root store', () => {
const store = createStore(initialState);
// This target node is missing - baz doesn't exist in State
const subStore = store.createSubStore<Foo>(['baz'], (x) => x as Foo);
expect(subStore.state).toBe(undefined);
});
it("handles edit when path doesn't exist in root store", () => {
const store = createStore(initialState);
const value: Foo = {
counter: 123,
nested: {
value: 456,
},
};
// This target node is missing - baz doesn't exist in State
const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
// Edits should work just fine, but the root store will not be modified.
subStore.edit((draft) => {
draft.counter += 1;
});
});
it('check subscriber only called once when edits made to undefined root path', () => {
const store = createStore(initialState);
const value: Foo = {
counter: 123,
nested: {
value: 456,
},
};
const callback = jest.fn();
// This target node is missing - baz doesn't exist in State
const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
subStore.subscribe(callback);
// Edits should work just fine, but the root store will not be modified.
subStore.edit((draft) => {
draft.counter += 1;
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(subStore, value);
});
it("notifies subscribers even when path doesn't exist in root store", () => {
const store = createStore(initialState);
const value: Foo = {
counter: 123,
nested: {
value: 456,
},
};
const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value);
const callback = jest.fn();
subStore.subscribe(callback);
subStore.edit((draft) => {
draft.counter += 1;
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(subStore, value);
});
it('notifies when relevant edits are made from root store', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo);
const callback = jest.fn();
// Subscribe on the proxy store
subStore.subscribe(callback);
// Edit the subtree from the root store
store.edit((draft) => {
draft.foo.counter++;
});
// Expect proxy callback called with correct subtree
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(subStore, initialState.foo);
});
it('ignores irrelevant edits from the root store', () => {
const store = createStore(initialState);
const nestedStore = store.createSubStore<Bar>(
['foo', 'nested'],
(x) => x as Bar,
);
const callback = jest.fn();
// Subscribe on the proxy store
nestedStore.subscribe(callback);
// Edit an irrelevant subtree on the root store
store.edit((draft) => {
draft.foo.counter++;
});
// Ensure proxy callback hasn't been called
expect(callback).not.toHaveBeenCalled();
});
it('immutable [in]equality works', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], migrateFoo);
const before = subStore.state;
subStore.edit((draft) => {
draft.counter += 1;
});
const after = subStore.state;
// something has changed so root should not equal
expect(before).not.toBe(after);
// nested has not changed and so should be the before version.
expect(before.nested).toBe(after.nested);
});
// This test depends on the migrate function - if it attempts to preserve
// equality then we might have a chance, but our migrate function here does
// not, and I'm not sure we can expect people do provide one that does.
// TODO(stevegolton): See if we can get this working, regardless of migrate
// function implementation.
it.skip('unrelated state refs are still equal when modified from root store', () => {
const store = createStore(initialState);
const subStore = store.createSubStore<Foo>(['foo'], migrateFoo);
const before = subStore.state;
// Check that unrelated state is still the same even though subtree is
// modified from the root store
store.edit((draft) => {
draft.foo.counter = 1234;
});
expect(before.nested).toBe(subStore.state.nested);
expect(subStore.state.counter).toBe(1234);
});
it('works when underlying state is undefined', () => {
interface RootState {
dict: {[key: string]: unknown};
}
interface ProxyState {
bar: string;
}
const store = createStore<RootState>({dict: {}});
const migrate = (init: unknown) => (init ?? {bar: 'bar'}) as ProxyState;
const subStore = store.createSubStore(['dict', 'foo'], migrate);
// Check initial migration works, yet underlying store is untouched
expect(subStore.state.bar).toBe('bar');
expect(store.state.dict['foo']).toBe(undefined);
// Check updates work
subStore.edit((draft) => {
draft.bar = 'baz';
});
expect(subStore.state.bar).toBe('baz');
expect((store.state.dict['foo'] as ProxyState).bar).toBe('baz');
});
test('chained substores', () => {
interface State {
dict: {[key: string]: unknown};
}
interface FooState {
bar: {
baz: string;
};
}
const store = createStore<State>({dict: {}});
const DEFAULT_FOO_STATE: FooState = {bar: {baz: 'abc'}};
const fooStore = store.createSubStore(
['dict', 'foo'],
(init) => init ?? DEFAULT_FOO_STATE,
);
const subFooStore = fooStore.createSubStore(
['bar'],
(x) => x as FooState['bar'],
);
// Since the entry for 'foo' will be undefined in the dict, we expect the
// migrate function on fooStore to return DEFAULT_FOO_STATE, and thus the
// state of the subFooStore will be DEFAULT_FOO_STATE.bar.
expect(subFooStore.state).toEqual({baz: 'abc'});
});
});