UNPKG

@traxjs/trax

Version:

Reactive state management

595 lines (498 loc) 22.4 kB
import { beforeEach, describe, expect, it, test } from 'vitest'; import { createTraxEnv } from '../core'; import { Store, Trax, TraxObjectType, TraxProcessor } from '../index'; import { TraxComputeContext } from '../types'; describe('Doc examples', () => { let trax: Trax; beforeEach(() => { trax = createTraxEnv(); }); it('simple createStore', async () => { // Simple data store // trax.log.consoleOutput = "All"; const greetingStore = trax.createStore("Greeting", { message: "Hellow World" }); expect(greetingStore.data.message).toBe("Hellow World"); expect(greetingStore.id).toBe("Greeting"); const gs = trax.getStore("Greeting"); expect(gs).toBe(greetingStore); const subGreetingStore = greetingStore.createStore("Misc", { miscInfo: "Blah blah" }); expect(subGreetingStore.id).toBe("Greeting>Misc"); expect(greetingStore.disposed).toBe(false); greetingStore.dispose(); expect(greetingStore.disposed).toBe(true); // trax.log.consoleOutput = ""; expect(trax.log.maxSize).toBe(1000); // default value trax.log.maxSize = 5000; // increase log buffer size }); it('should support createStore for a simple todo list', async () => { interface TodoData { todos: TodoItem[], completedCount: number; itemsLeft: number; } interface TodoItem { description: string; completed: boolean; } let tdsStore: any; const todoStore = trax.createStore("Todos", (store: Store<TodoData>) => { const data = store.init({ // initial root data todos: [], completedCount: 0, itemsLeft: 0 }); tdsStore = store; // count processor (eager) store.compute("count", () => { const completedCount = data.todos.filter((todo) => todo.completed).length; data.completedCount = completedCount; data.itemsLeft = data.todos.length - completedCount; }); // store API return { data, // expose the root graph as "data" addTodo(desc: string, completed = false) { data.todos.push({ description: desc, completed }); }, deleteTodo(todo: TodoItem) { const idx = data.todos.indexOf(todo); idx > -1 && data.todos.splice(idx, 1); } } }); // usage const data = todoStore.data; expect(data.todos.length).toBe(0); todoStore.addTodo("First"); todoStore.addTodo("Second"); todoStore.addTodo("Third"); expect(data.itemsLeft).toBe(0); // still 0 because changes weren't propagated await trax.reconciliation(); expect(data.itemsLeft).toBe(3); // changes have been propagated todoStore.deleteTodo(data.todos[0]); data.todos[0].completed = true; await trax.reconciliation(); expect(data.itemsLeft).toBe(1); expect(data.todos[0].description).toBe("Second"); const tds = trax.getStore("Todos"); expect(tds).toBe(tdsStore); }); it('should support createStore for a simple todo list', async () => { interface TodoData { todos: TodoItem[], completedCount: number; itemsLeft: number; } interface TodoItem { description: string; completed: boolean; } const todoStore = trax.createStore("TodoStore", (store: Store<TodoData>) => { const data = store.init({ // initial root data todos: [], completedCount: 0, itemsLeft: 0 }, { count: (data, cc: TraxComputeContext) => { // processor to compute the 2 counters const completedCount = data.todos.filter((todo) => todo.completed).length; data.completedCount = completedCount; data.itemsLeft = data.todos.length - completedCount; // cc = compute context object // cc.computeCount - here 1, then 2, 3, etc. // cc.maxComputeCount - can be changed to automatically dispose a processor // after a certain number of executions (e.g. cc.maxComputeCount=1) // cc.processorId - here: "TodoStore#data[count]" // cc.processorName - here: "count" } }) }); const data = todoStore.data; data.todos.push({ description: "Do something", completed: false }); expect(data.itemsLeft).toBe(0); // changes not propagated await trax.reconciliation(); expect(data.itemsLeft).toBe(1); expect(trax.isTraxObject(data.todos[0])).toBe(true); }); it('should support getProcessor and getActiveProcessor', async () => { interface Person { firstName: string; lastName: string; age: number; isAdult?: boolean; prettyName: string; } let processorId1 = "", active1 = "", processor2: any = null, active2 = "", active3 = ""; const personStore = trax.createStore("PersonStore", (store: Store<Person>) => { const data = store.init({ // initial root data firstName: "Homer", lastName: "Simpson", age: 39, prettyName: "" // computed }, { adult: (data, cc) => { data.isAdult = data.age >= 18; processorId1 = cc.processorId; active1 = trax.getActiveProcessor()?.id || ""; } }); processor2 = store.compute("prettyName", () => { data.prettyName = data.firstName + " " + data.lastName active2 = trax.getActiveProcessor()?.id || ""; }); active3 = trax.getActiveProcessor()?.id || ""; }); expect(processorId1).toBe("PersonStore#data[adult]"); expect(active1).toBe("PersonStore#data[adult]"); expect(processor2.id).toBe("PersonStore#prettyName"); expect(trax.getProcessor("PersonStore#prettyName")).toBe(processor2); expect(trax.getProcessor(processorId1)!.id).toBe(processorId1); expect(active2).toBe("PersonStore#prettyName"); expect(active3).toBe(""); const data = personStore.data; expect(data.prettyName).toBe("Homer Simpson"); data.firstName = "Marge"; expect(data.prettyName).toBe("Homer Simpson"); // change not yet propagated await trax.reconciliation(); expect(data.prettyName).toBe("Marge Simpson"); // change propagated expect(data.prettyName).toBe("Marge Simpson"); expect(trax.pendingChanges).toBe(false); data.firstName = "Bart"; expect(trax.pendingChanges).toBe(true); data.lastName = "SIMPSON"; expect(trax.pendingChanges).toBe(true); await trax.reconciliation(); expect(data.prettyName).toBe("Bart SIMPSON"); expect(trax.pendingChanges).toBe(false); expect(data.prettyName).toBe("Bart SIMPSON"); data.lastName = "Simpson"; data.firstName = "Lisa"; expect(data.prettyName).toBe("Bart SIMPSON"); // change not yet propagated trax.processChanges(); expect(data.prettyName).toBe("Lisa Simpson"); // change propagated let o = personStore.add(["abc", 123, "def"], { name: "Maggie" }); expect(trax.getTraxId(o)).toBe("PersonStore/abc:123:def"); }); it('should support isTraxObject', async () => { expect(trax.isTraxObject({})).toBe(false); expect(trax.isTraxObject(123)).toBe(false); const testStore = trax.createStore("TestStore", { foo: "bar" }); expect(trax.isTraxObject(testStore)).toBe(true); expect(trax.isTraxObject(testStore.data)).toBe(true); }); it('should support get TraxId', async () => { expect(trax.getTraxId({})).toBe(""); const testStore = trax.createStore("TestStore", { foo: { bar: "baz" } }); expect(trax.getTraxId(testStore)).toBe("TestStore"); expect(trax.getTraxId(testStore.data)).toBe("TestStore/data"); expect(trax.getTraxId(testStore.data.foo)).toBe("TestStore/data*foo"); expect(trax.getTraxId(testStore.data.foo.bar)).toBe(""); // bar is not an object }); it('should support trax object type', async () => { expect(trax.getTraxObjectType({})).toBe(""); // TraxObjectType.NotATraxObject const testStore = trax.createStore("TestStore", { foo: { bar: [1, 2, 3], baz: "abc" } }); expect(trax.getTraxObjectType(testStore)).toBe("S"); // TraxObjectType.Store expect(trax.getTraxObjectType(testStore.data.foo)).toBe("O"); // TraxObjectType.Object expect(trax.getTraxObjectType(testStore.data.foo.bar)).toBe("A"); // TraxObjectType.Array }); it('should support getData', async () => { const testStore = trax.createStore("TestStore", { foo: { bar: [1, 2, 3], baz: "abc" } }); expect(trax.getData("TestStore/data")).toBe(testStore.data); expect(trax.getData("TestStore/data*foo*bar")).toBe(undefined); // because testStore.root.foo.bar has never been accessed const v = testStore.data.foo.bar expect(trax.getData("TestStore/data*foo*bar")).toBe(testStore.data.foo.bar); expect(trax.getData("XYZ")).toBe(undefined); }); it('should support store.add', async () => { interface MessageData { messages: { id: string; text: string; read?: boolean; // true if the message has been read }[]; unread: number; // number of unread messages } const msgStore = trax.createStore("MessageStore", (store: Store<MessageData>) => { const data = store.init({ messages: [], unread: 0 }, { unread: (data) => { const msgs = data.messages; const readCount = msgs.filter((m) => !!m.read).length; data.unread = msgs.length - readCount; } }); return { data, addMsg(id: string, text: string, read = false) { const m = store.add(["Message", id], { id, text, read }); data.messages.push(m); } } }); msgStore.addMsg("M0", "Message 0"); msgStore.addMsg("M1", "Message 1"); msgStore.addMsg("M2", "Message 2", true); await trax.reconciliation(); expect(msgStore.data.unread).toBe(2); const m0 = msgStore.data.messages[0]; expect(trax.getTraxId(m0)).toBe("MessageStore/Message:M0"); // id defined by the application -> easy to retrieve expect(trax.getData("MessageStore/Message:M0")).toBe(m0); const ms = trax.getStore<MessageData>("MessageStore")!; // add message outside the addMsg method ms.data.messages.push({ id: "M3", text: "Message 3" }); const m3 = msgStore.data.messages[3]; expect(trax.getTraxId(m3)).toBe("MessageStore/data*messages*3"); // generated id -> message cannot be easily retrieved by id as its id cannot be easily guessed await trax.reconciliation(); expect(ms.get("Message:M0")).toBe(m0); expect(ms.get(["Message", "M0"])).toBe(m0); expect(ms.data.unread).toBe(3) ms.data.messages.shift(); // remove first array element const ok = ms.remove(m0); expect(ok).toBe(true); await trax.reconciliation(); expect(ms.data.unread).toBe(2); }); it('should support store.compute', async () => { const store = trax.createStore("UserStore", { id: "X1", firstName: "Bart", lastName: "Simpson" }); let output = ""; const r = store.compute("Output", () => { // Note: we could update the DOM instead of processin a string const usr = store.data; output = `User: ${usr.firstName} ${usr.lastName}`; }); expect(output).toBe("User: Bart Simpson"); store.data.firstName = "Homer"; await trax.reconciliation(); expect(output).toBe("User: Homer Simpson"); expect(store.getProcessor("Output")).toBe(r); }); it('should support sub-stores', async () => { const store = trax.createStore("Foo", { value: "ABC" }); const subStore = store.createStore("Bar", { anotherValue: "DEF" }); expect(subStore.id).toBe("Foo>Bar"); expect(trax.getTraxId(subStore.data)).toBe("Foo>Bar/data"); expect(store.getStore("Bar")).toBe(subStore); }); it('should support store.async', async () => { interface Person { firstName: string; lastName: string; prettyName?: string; } const store = trax.createStore("PStore", (store: Store<Person>) => { const data = store.init({ firstName: "Homer", lastName: "Simpson" }, { prettyName: (data) => { data.prettyName = data.firstName + " " + data.lastName; } }); return { person: data, updateName: store.async(function* (firstNameSuffix: string, lastNameSuffix: string) { data.firstName += firstNameSuffix; yield pause(1); // simulate an external async call const r = data.lastName + lastNameSuffix; data.lastName = r; return r; }) } }); async function pause(timeMs = 10) { return new Promise((resolve) => { setTimeout(resolve, timeMs); }); } const data = store.person; expect(store.person.prettyName).toBe("Homer Simpson"); // updateName has an async signature const r = await store.updateName("(FirstName)", "(LastName)"); expect(r).toBe("Simpson(LastName)"); expect(store.person.prettyName).toBe("Homer(FirstName) Simpson(LastName)"); }); it('should support sync and async processors', async () => { async function pause(timeMs = 1) { return new Promise((resolve) => { setTimeout(resolve, timeMs); }); } async function getAvatar(userId: string) { // simulate an async fetch await pause(); return "AVATAR[" + userId + "]"; } interface Person { id: string; firstName: string; lastName: string; prettyName?: string; avatar?: string; } const store1 = trax.createStore("PersonStore", (store: Store<Person>) => { store.init({ id: "U1", firstName: "Homer", lastName: "Simpson" }, { "~prettyName": (data) => { // lazy synchronous processor data.prettyName = data.firstName + " " + data.lastName; }, "~avatar": function* (data) { // lazy asynchronous processor data.avatar = yield getAvatar(data.id); } }); }); const p1 = store1.data; expect(p1.prettyName).toBe(undefined); // unprocessed expect(p1.avatar).toBe(undefined); // unprocessed await pause(10); expect(p1.avatar).toBe(undefined); // still unprocessed let output1 = ""; store1.compute("Output1", () => { output1 = `${p1.id} / ${p1.prettyName} / ${p1.avatar}`; }); expect(output1).toBe('U1 / Homer Simpson / undefined'); // avatar not retrieved yet await pause(10); expect(output1).toBe('U1 / Homer Simpson / AVATAR[U1]'); // avatar retrieved const store2 = trax.createStore("PersonStore2", (store: Store<Person>) => { store.init({ id: "U1", firstName: "Homer", lastName: "Simpson" }, { "prettyName": (d) => { // eager + synchronous processor d.prettyName = d.firstName + " " + d.lastName; }, "avatar": function* (d) { // eager + asynchronous processor d.avatar = yield getAvatar(d.id); } }); }); const p2 = store2.data; expect(p2.prettyName).toBe("Homer Simpson"); expect(p2.avatar).toBe(undefined); // being retrieved await pause(10); expect(p2.avatar).toBe("AVATAR[U1]"); // retrieved const store3 = trax.createStore("PersonStore3", (store: Store<Person>) => { const d = store.init({ id: "U1", firstName: "Homer", lastName: "Simpson" }); store.compute("prettyName", () => { // eager + synchronous processor d.prettyName = d.firstName + " " + d.lastName; }); store.compute("avatar", function* () { // eager + asynchronous processor d.avatar = yield getAvatar(d.id); }); }); const p3 = store3.data; expect(p3.prettyName).toBe("Homer Simpson"); expect(p3.avatar).toBe(undefined); // being retrieved await pause(10); expect(p3.avatar).toBe("AVATAR[U1]"); // retrieved }); it('should demo all processor properties and methods', async () => { async function pause(timeMs = 1) { return new Promise((resolve) => { setTimeout(resolve, timeMs); }); } async function getAvatar(userId: string) { // simulate an async fetch await pause(); return "AVATAR[" + userId + "]"; } interface Person { id: string; firstName: string; lastName: string; prettyName?: string; avatar?: string; } let p2: TraxProcessor; const store = trax.createStore("PersonStore", (store: Store<Person>) => { const data = store.init({ id: "U1", firstName: "Homer", lastName: "Simpson" }, { prettyName: (data) => { // synchronous processor data.prettyName = data.firstName + " " + data.lastName; } }); p2 = store.compute("Avatar", function* () { // asynchronous processor data.avatar = yield getAvatar(data.id); }); }); const p1 = store.getProcessor("data[prettyName]")!; expect(p1.id).toBe("PersonStore#data[prettyName]"); expect(p2!.id).toBe("PersonStore#Avatar"); const person = store.data; expect(p1.dirty).toBe(false); expect(person.prettyName).toBe("Homer Simpson"); person.firstName = "Marge"; expect(p1.dirty).toBe(true); expect(person.prettyName).toBe("Homer Simpson"); // change not propagated await trax.reconciliation(); expect(p1.dirty).toBe(false); expect(person.prettyName).toBe("Marge Simpson"); expect(p1.dependencies).toMatchObject([ "PersonStore/data.firstName", "PersonStore/data.lastName", ]); // autocompute let output = ""; const outputProcessor = store.compute("Output", () => { output = `Person[${person.id}] ${person.prettyName}`; }, true, true); expect(output).toBe("Person[U1] Marge Simpson"); expect(outputProcessor.isRenderer).toBe(true); expect(outputProcessor.computeCount).toBe(1); person.firstName = "BART"; person.lastName = "SIMPSON"; expect(outputProcessor.computeCount).toBe(1); // changes not propagated await trax.reconciliation(); expect(outputProcessor.computeCount).toBe(2); expect(output).toBe("Person[U1] BART SIMPSON"); expect(outputProcessor.disposed).toBe(false); expect(outputProcessor.computeCount).toBe(2); outputProcessor.dispose(); expect(outputProcessor.disposed).toBe(true); // new changes will have no impacts person.firstName = "MAGGIE"; await trax.reconciliation(); expect(outputProcessor.computeCount).toBe(2); // processor didn't run // render let renderResult = ""; const renderProcessor = store.compute("Render", () => { renderResult = `RENDER: ${person.prettyName}`; }, false); // false -> no auto-compute let dirtyCount = 0; renderProcessor.onDirty = () => { dirtyCount++; } expect(renderResult).toBe(""); // not rendered expect(dirtyCount).toBe(0); // onDirty is not called at init expect(renderProcessor.dirty).toBe(true); renderProcessor.compute(); expect(renderResult).toBe("RENDER: MAGGIE SIMPSON"); // rendered expect(dirtyCount).toBe(0); person.firstName = 'LISA'; expect(dirtyCount).toBe(0); // onDirty hasn't been called yet await trax.reconciliation(); expect(dirtyCount).toBe(1); // onDirty was called expect(renderResult).toBe("RENDER: MAGGIE SIMPSON"); // not re-rendered expect(renderProcessor.dirty).toBe(true); renderProcessor.compute(); expect(renderResult).toBe("RENDER: LISA SIMPSON"); expect(renderProcessor.computeCount).toBe(2); expect(renderProcessor.dirty).toBe(false); renderProcessor.compute(); // will be ignored as renderProcessor is not dirty expect(renderProcessor.computeCount).toBe(2); // still 2 renderProcessor.compute(true); // forced re-render expect(renderProcessor.computeCount).toBe(3); // 2 -> 3 }); });