diff --git a/src/cache.js b/src/cache.js index 7ab72c90..afa4a5cb 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,4 +1,3 @@ -import { stringifyElement } from "./utils.js"; import * as emitter from "./emitter.js"; const entries = new WeakMap(); @@ -28,6 +27,17 @@ export function getEntry(target, key) { return entry; } +export function getEntries(target) { + const result = []; + const targetMap = entries.get(target); + if (targetMap) { + targetMap.forEach(entry => { + result.push(entry); + }); + } + return result; +} + function calculateChecksum(entry) { let checksum = entry.state; if (entry.deps) { @@ -48,6 +58,7 @@ function restoreObservedDeps(entry, deps) { if (deps) { deps.forEach(depEntry => { entry.deps.add(depEntry); + /* istanbul ignore if */ if (!depEntry.contexts) depEntry.contexts = new Set(); depEntry.contexts.add(entry); restoreObservedDeps(entry, depEntry.deps); @@ -56,15 +67,11 @@ function restoreObservedDeps(entry, deps) { } const contextStack = new Set(); -export function get(target, key, getter) { +export function get(target, key, getter, validate) { const entry = getEntry(target, key); if (contextStack.size && contextStack.has(entry)) { - throw Error( - `Circular get invocation of the '${key}' property in '${stringifyElement( - target, - )}'`, - ); + throw Error(`Circular get invocation is forbidden: '${key}'`); } contextStack.forEach(context => { @@ -77,7 +84,11 @@ export function get(target, key, getter) { } }); - if (entry.checksum && entry.checksum === calculateChecksum(entry)) { + if ( + ((validate && validate(entry.value)) || !validate) && + entry.checksum && + entry.checksum === calculateChecksum(entry) + ) { return entry.value; } @@ -86,6 +97,7 @@ export function get(target, key, getter) { if (entry.observed && entry.deps && entry.deps.size) { entry.deps.forEach(depEntry => { + /* istanbul ignore else */ if (depEntry.contexts) depEntry.contexts.delete(entry); }); } @@ -126,7 +138,7 @@ export function get(target, key, getter) { export function set(target, key, setter, value, force) { if (contextStack.size && !force) { throw Error( - `Try to set '${key}' of '${stringifyElement(target)}' in get call`, + `Setting property in chain of get calls is forbidden: '${key}'`, ); } @@ -142,22 +154,40 @@ export function set(target, key, setter, value, force) { } } +function invalidateEntry(entry, clearValue) { + entry.checksum = 0; + entry.state += 1; + + dispatchDeep(entry); + + if (clearValue) { + entry.value = undefined; + } +} + export function invalidate(target, key, clearValue) { if (contextStack.size) { throw Error( - `Try to invalidate '${key}' in '${stringifyElement(target)}' get call`, + `Invalidating property in chain of get calls is forbidden: '${key}'`, ); } const entry = getEntry(target, key); + invalidateEntry(entry, clearValue); +} - entry.checksum = 0; - entry.state += 1; - - dispatchDeep(entry); +export function invalidateAll(target, clearValue) { + if (contextStack.size) { + throw Error( + "Invalidating all properties in chain of get calls is forbidden", + ); + } - if (clearValue) { - entry.value = undefined; + const targetMap = entries.get(target); + if (targetMap) { + targetMap.forEach(entry => { + invalidateEntry(entry, clearValue); + }); } } @@ -176,6 +206,7 @@ export function observe(target, key, getter, fn) { if (entry.deps) { entry.deps.forEach(depEntry => { + /* istanbul ignore else */ if (!depEntry.contexts) depEntry.contexts = new Set(); depEntry.contexts.add(entry); }); @@ -186,6 +217,7 @@ export function observe(target, key, getter, fn) { entry.observed = false; if (entry.deps && entry.deps.size) { entry.deps.forEach(depEntry => { + /* istanbul ignore else */ if (depEntry.contexts) depEntry.contexts.delete(entry); }); } diff --git a/src/index.js b/src/index.js index cdf411c9..b78b281f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,12 @@ +import * as store from "./store/index.js"; + export { default as define } from "./define.js"; export { default as property } from "./property.js"; export { default as parent } from "./parent.js"; export { default as children } from "./children.js"; export { default as render } from "./render.js"; -export { dispatch } from "./utils.js"; - export { html, svg } from "./template/index.js"; +export { store }; + +export { dispatch } from "./utils.js"; diff --git a/src/store/bootstrap.js b/src/store/bootstrap.js new file mode 100644 index 00000000..d12662fd --- /dev/null +++ b/src/store/bootstrap.js @@ -0,0 +1,382 @@ +/* eslint-disable no-use-before-define, import/no-cycle, no-console */ +import * as cache from "../cache.js"; + +import { setupStorage, memoryStorage } from "./storage.js"; +import { sync } from "./utils.js"; +import { pending } from "./guards.js"; +import get from "./get.js"; + +function getTypeConstructor(type, key) { + switch (type) { + case "string": + return String; + case "number": + return Number; + case "boolean": + return Boolean; + default: + throw TypeError( + `The value for the '${key}' array must be a string, number or boolean: ${type}`, + ); + } +} + +// UUID v4 generator thanks to https://gist.github.com/jed/982883 +function uuid(temp) { + return temp + ? // eslint-disable-next-line no-bitwise, no-mixed-operators + (temp ^ ((Math.random() * 16) >> (temp / 4))).toString(16) + : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid); +} + +function mapError(error, proxyKeys) { + /* istanbul ignore next */ + if (process.env.NODE_ENV !== "production" && console.error) { + console.error(error); + } + + if (!(error instanceof Error)) { + error = Error(`Error instance must be thrown: ${error}`); + } + + proxyKeys.forEach(key => { + Object.defineProperty(error, key, { + get: () => { + throw Error( + `Try to get '${key}' in error state - use store.error() guard`, + ); + }, + enumerable: true, + }); + }); + + return error; +} + +export const connect = `__store__connect__${Date.now()}__`; +export const definitions = new WeakMap(); +export const placeholders = new WeakSet(); + +export function bootstrap(Model, options) { + if (Array.isArray(Model)) return setupListModel(Model[0], options); + return setupModel(Model); +} + +const configs = new WeakMap(); +function setupModel(Model) { + if (typeof Model !== "object" || Model === null) { + throw TypeError(`Model definition must be an object: ${typeof Model}`); + } + let config = configs.get(Model); + + if (!config) { + const storage = Model[connect]; + if (storage) delete Model[connect]; + + const errorKeys = []; + let invalidatePromise; + + config = { + enumerable: hasOwnProperty.call(Model, "id"), + placeholder: {}, + mapError: error => mapError(error, errorKeys), + invalidate: () => { + if (!invalidatePromise) { + invalidatePromise = Promise.resolve().then(() => { + cache.invalidate(config, undefined, true); + invalidatePromise = null; + }); + } + }, + }; + + config.storage = setupStorage(storage || memoryStorage(config, Model)); + + const transform = Object.keys(Object.freeze(Model)).map(key => { + Object.defineProperty(config.placeholder, key, { + get: () => { + throw Error( + `Try to get '${key}' in pending state - use store.pending() or store.ready() guards`, + ); + }, + enumerable: true, + }); + + if (!(key in Error.prototype)) errorKeys.push(key); + + if (key === "id") { + if (Model[key] !== true) { + throw TypeError( + `The 'id' property must be true or undefined: ${typeof Model[key]}`, + ); + } + return (model, data, lastModel) => { + const id = lastModel + ? lastModel.id + : (hasOwnProperty.call(data, "id") && String(data.id)) || + String(uuid()); + Object.defineProperty(model, "id", { value: id }); + }; + } + + const type = typeof Model[key]; + const defaultValue = Model[key]; + + switch (type) { + case "function": + return model => { + Object.defineProperty(model, key, { + get() { + return cache.get(this, key, defaultValue); + }, + }); + }; + case "object": { + if (defaultValue === null) { + throw TypeError( + `The value for the '${key}' must be an object instance: ${defaultValue}`, + ); + } + + const isArray = Array.isArray(defaultValue); + + if (isArray) { + const nestedType = typeof defaultValue[0]; + + if (nestedType !== "object") { + const Constructor = getTypeConstructor(nestedType, key); + const defaultArray = Object.freeze(defaultValue.map(Constructor)); + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + if (!Array.isArray(data[key])) { + throw TypeError( + `The value for '${key}' property must be an array: ${typeof data[ + key + ]}`, + ); + } + model[key] = data[key].map(Constructor); + } else if (lastModel && hasOwnProperty.call(lastModel, key)) { + model[key] = lastModel[key]; + } else { + model[key] = defaultArray; + } + }; + } + + const localConfig = bootstrap(defaultValue, { nested: true }); + + if (localConfig.enumerable && defaultValue[1]) { + const nestedOptions = defaultValue[1]; + if (typeof nestedOptions !== "object") { + throw TypeError( + `Options for '${key}' array property must be an object instance: ${typeof nestedOptions}`, + ); + } + if (nestedOptions.loose) { + config.contexts = config.contexts || new Set(); + config.contexts.add(bootstrap(defaultValue[0])); + } + } + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + if (!Array.isArray(data[key])) { + throw TypeError( + `The value for '${key}' property must be an array: ${typeof data[ + key + ]}`, + ); + } + model[key] = localConfig.create(data[key]); + } else { + model[key] = + (lastModel && lastModel[key]) || + (localConfig.enumerable + ? [] + : localConfig.create(defaultValue)); + } + }; + } + + const nestedConfig = bootstrap(defaultValue); + if (nestedConfig.enumerable) { + return (model, data, lastModel) => { + let resultModel; + + if (hasOwnProperty.call(data, key)) { + const nestedData = data[key]; + + if (typeof nestedData !== "object" || nestedData === null) { + if (nestedData !== undefined && nestedData !== null) { + resultModel = { id: nestedData }; + } + } else { + const dataModel = definitions.get(nestedData); + if (dataModel) { + if (dataModel && dataModel !== defaultValue) { + throw TypeError( + "Model instance must match the definition", + ); + } + resultModel = nestedData; + } else { + resultModel = nestedConfig.create(nestedData); + sync(nestedConfig, resultModel.id, resultModel); + } + } + } else { + resultModel = lastModel && lastModel[key]; + } + + if (resultModel) { + const id = resultModel.id; + Object.defineProperty(model, key, { + get() { + return cache.get(this, key, (host, cachedModel) => { + if (pending(host)) return cachedModel; + return get(defaultValue, id); + }); + }, + enumerable: true, + }); + } else { + model[key] = undefined; + } + }; + } + + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + model[key] = nestedConfig.create(data[key], lastModel[key]); + } else { + model[key] = lastModel ? lastModel[key] : nestedConfig.create({}); + } + }; + } + // eslint-disable-next-line no-fallthrough + default: { + const Constructor = getTypeConstructor(type); + return (model, data, lastModel) => { + if (hasOwnProperty.call(data, key)) { + model[key] = Constructor(data[key]); + } else if (lastModel && hasOwnProperty.call(lastModel, key)) { + model[key] = lastModel[key]; + } else { + model[key] = defaultValue; + } + }; + } + } + }); + + config.create = function create(data, lastModel) { + if (lastModel) definitions.delete(lastModel); + if (data === null) return undefined; + + if (typeof data !== "object") { + throw TypeError(`Model values must be an object: ${data}`); + } + + const model = transform.reduce((acc, fn) => { + fn(acc, data, lastModel); + return acc; + }, {}); + + definitions.set(model, Model); + + return Object.freeze(model); + }; + + placeholders.add(Object.freeze(config.placeholder)); + configs.set(Model, Object.freeze(config)); + } + + return config; +} + +const listErrorKeys = Object.getOwnPropertyNames(Array.prototype).filter( + key => !(key in Error.prototype), +); + +const lists = new WeakMap(); +function setupListModel(Model, options = { nested: false }) { + let config = lists.get(Model); + + if (!config) { + const modelConfig = setupModel(Model); + + const contexts = new Set(); + contexts.add(modelConfig); + + if (!options.nested) { + if (!modelConfig.enumerable) { + throw TypeError("Model definition must have 'id' key set to `true`"); + } + if (!modelConfig.storage.list) { + throw TypeError("Model definition storage must support `list` action"); + } + } + + config = { + contexts, + enumerable: modelConfig.enumerable, + storage: setupStorage({ + cache: modelConfig.storage.cache, + get: + !options.nested && + (parameters => { + return modelConfig.storage.list(parameters); + }), + }), + placeholder: Object.freeze([]), + mapError: error => mapError(error, listErrorKeys), + create(items) { + const result = items + .filter(i => i) + .reduce((acc, data) => { + let id = data; + if (typeof data === "object" && data !== null) { + id = data.id; + const dataModel = definitions.get(data); + if (dataModel) { + if (dataModel && dataModel !== Model) { + throw TypeError("Model instance must match the definition"); + } + } else { + const model = modelConfig.create(data); + id = model.id; + if (modelConfig.enumerable) { + sync(modelConfig, id, model); + } else { + acc.push(model); + } + } + } else if (!modelConfig.enumerable) { + throw TypeError( + `Model instance must be an object: ${typeof data}`, + ); + } + if (modelConfig.enumerable) { + const key = acc.length; + Object.defineProperty(acc, key, { + get() { + return cache.get(this, key, (list, cachedModel) => { + if (pending(list)) return cachedModel; + return get(Model, id); + }); + }, + enumerable: true, + }); + } + return acc; + }, []); + + return Object.freeze(result); + }, + }; + + lists.set(Model, Object.freeze(config)); + } + + return config; +} diff --git a/src/store/clear.js b/src/store/clear.js new file mode 100644 index 00000000..6a4b4dfe --- /dev/null +++ b/src/store/clear.js @@ -0,0 +1,25 @@ +import * as cache from "../cache.js"; +import { bootstrap, definitions } from "./bootstrap.js"; +import { stringifyParameters } from "./utils.js"; + +export default function clear(model, parameters) { + if (typeof model !== "object" || model === null || model instanceof Error) { + throw TypeError( + `The first argument must be model instance or model definition: ${model}`, + ); + } + + const Model = definitions.get(model); + + if (Model) { + cache.invalidate(bootstrap(Model), model.id, true); + } else { + const config = bootstrap(model); + + if (config.enumerable && parameters === undefined) { + cache.invalidateAll(config, true); + } else { + cache.invalidate(config, stringifyParameters(parameters), true); + } + } +} diff --git a/src/store/get.js b/src/store/get.js new file mode 100644 index 00000000..fef93e67 --- /dev/null +++ b/src/store/get.js @@ -0,0 +1,102 @@ +/* eslint-disable import/no-cycle */ +import * as cache from "../cache.js"; + +import { bootstrap } from "./bootstrap.js"; +import { pending } from "./guards.js"; +import { + setPendingState, + sync, + stringifyParameters, + getCurrentTimestamp, + setTimestamp, +} from "./utils.js"; + +function resolveTimestamp(h, v) { + return v || getCurrentTimestamp(); +} + +export default function get(Model, parameters) { + const config = bootstrap(Model); + let id; + + if (!config.storage.get) { + throw TypeError("Model definition storage must support 'get' method"); + } + + if (config.enumerable) { + id = stringifyParameters(parameters); + } else if (parameters !== undefined) { + throw TypeError( + "Model definition must have 'id' key to support parameters", + ); + } + + return cache.get( + config, + id, + (h, cachedModel) => { + if ( + cachedModel === config.placeholder || + (cachedModel && pending(cachedModel)) + ) { + return cachedModel; + } + + let validContexts = true; + if (config.contexts) { + config.contexts.forEach(context => { + if ( + cache.get(context, undefined, resolveTimestamp) === + getCurrentTimestamp() + ) { + validContexts = false; + } + }); + } + + if ( + validContexts && + cachedModel && + cachedModel !== config.placeholder && + (config.storage.cache === true || config.storage.validate(cachedModel)) + ) { + return cachedModel; + } + + try { + const result = config.storage.get(parameters); + + if (typeof result !== "object" || result === null) { + throw Error( + `Model instance for '${id}' parameters not found: ${result}`, + ); + } + + if (result instanceof Promise) { + result + .then(data => { + if (typeof data !== "object" || data === null) { + throw Error( + `Model instance for '${id}' parameters not found: ${data}`, + ); + } + + data.id = id; + sync(config, id, config.create(data)); + }) + .catch(e => { + sync(config, id, config.mapError(e)); + }); + + if (cachedModel) return setPendingState(cachedModel, true); + return config.placeholder; + } + + return setTimestamp(config.create(result)); + } catch (e) { + return setTimestamp(config.mapError(e)); + } + }, + config.storage.validate, + ); +} diff --git a/src/store/guards.js b/src/store/guards.js new file mode 100644 index 00000000..3e8a4872 --- /dev/null +++ b/src/store/guards.js @@ -0,0 +1,15 @@ +/* eslint-disable import/no-cycle */ +import { placeholders } from "./bootstrap.js"; +import { getPendingState } from "./utils.js"; + +export function error(model) { + return model instanceof Error; +} + +export function ready(model) { + return model && !error(model) && !placeholders.has(model); +} + +export function pending(model) { + return placeholders.has(model) || getPendingState(model); +} diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 00000000..4162ba61 --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,7 @@ +export { connect } from "./bootstrap.js"; + +export { default as get } from "./get.js"; +export { default as set } from "./set.js"; +export { default as clear } from "./clear.js"; + +export * from "./guards"; diff --git a/src/store/set.js b/src/store/set.js new file mode 100644 index 00000000..035c6af0 --- /dev/null +++ b/src/store/set.js @@ -0,0 +1,53 @@ +import { setPendingState, sync } from "./utils.js"; +import { bootstrap, definitions } from "./bootstrap.js"; + +export default function set(model, values = {}) { + let Model = definitions.get(model); + const isModelInstance = !!Model; + + Model = Model || model; + + const config = bootstrap(Model); + + if (!config.storage.set) { + throw TypeError("Model definition storage must support 'set' method"); + } + + try { + if (values && hasOwnProperty.call(values, "id")) { + throw TypeError("Values must not have 'id' property"); + } + + const localModel = config.create( + values, + Model === model ? undefined : model, + ); + const id = (localModel && localModel.id) || model.id; + + const result = config.storage.set( + Model === model ? undefined : id, + localModel, + ); + + if (isModelInstance) setPendingState(model, true); + + return Promise.resolve(result).then(data => { + const resultModel = data ? config.create(data) : localModel; + + if (isModelInstance && resultModel && id !== resultModel.id) { + throw TypeError( + `Local and storage data must have the same id: '${id}', '${resultModel.id}'`, + ); + } + + return sync( + config, + (resultModel && resultModel.id) || id, + resultModel, + true, + ); + }); + } catch (e) { + return Promise.reject(config.mapError(e)); + } +} diff --git a/src/store/storage.js b/src/store/storage.js new file mode 100644 index 00000000..2be92221 --- /dev/null +++ b/src/store/storage.js @@ -0,0 +1,47 @@ +/* eslint-disable import/no-cycle */ +import * as cache from "../cache.js"; + +import { getCurrentTimestamp, getTimestamp } from "./utils.js"; +import { error } from "./guards.js"; + +export function setupStorage(storage) { + if (typeof storage === "function") storage = { get: storage }; + + const result = { cache: true, ...storage }; + + if (result.cache === false || result.cache === 0) { + result.validate = cachedModel => + !cachedModel || getTimestamp(cachedModel) === getCurrentTimestamp(); + } else if (typeof result.cache === "number") { + result.validate = cachedModel => + !cachedModel || + getTimestamp(cachedModel) + result.cache > getCurrentTimestamp(); + } else if (result.cache !== true) { + throw TypeError( + `Storage cache property must be a boolean or number: ${typeof result.cache}`, + ); + } + + return Object.freeze(result); +} + +export function memoryStorage(config) { + return { + get: config.enumerable ? () => {} : () => config.create({}), + set: () => {}, + list: + config.enumerable && + function list(parameters) { + if (parameters) { + throw TypeError( + `Memory-based model definition does not support parameters`, + ); + } + + return cache.getEntries(config).reduce((acc, { key, value }) => { + if (value && !error(value)) acc.push(key); + return acc; + }, []); + }, + }; +} diff --git a/src/store/utils.js b/src/store/utils.js new file mode 100644 index 00000000..a08af821 --- /dev/null +++ b/src/store/utils.js @@ -0,0 +1,81 @@ +import * as cache from "../cache.js"; + +export const _ = (h, v) => v; + +export function setPendingState(model, value) { + if (model) cache.set(model, "pending", _, value, true); + return model; +} + +export function getPendingState(model) { + return cache.get(model, "pending", _) || false; +} + +export function stringifyParameters(parameters) { + switch (typeof parameters) { + case "object": + return JSON.stringify( + Object.keys(parameters) + .sort() + .reduce((acc, key) => { + if ( + typeof parameters[key] === "object" && + parameters[key] !== null + ) { + throw TypeError( + `You must use primitive value for '${key}' key: ${typeof parameters[ + key + ]}`, + ); + } + acc[key] = parameters[key]; + return acc; + }, {}), + ); + case "undefined": + return undefined; + default: + return String(parameters); + } +} + +function resolveWithInvalidate(config, model, lastModel) { + if (model === undefined || !lastModel) { + config.invalidate(); + } + return model; +} + +export function sync(config, id, model, invalidate) { + cache.set(config, id, invalidate ? resolveWithInvalidate : _, model, true); + return setPendingState(model, false); +} + +let currentTimestamp; +export function getCurrentTimestamp() { + if (!currentTimestamp) { + currentTimestamp = Date.now(); + requestAnimationFrame(() => { + currentTimestamp = undefined; + }); + } + return currentTimestamp; +} + +const timestamps = new WeakMap(); + +export function getTimestamp(model) { + let timestamp = timestamps.get(model); + + if (!timestamp) { + timestamp = getCurrentTimestamp(); + timestamps.set(model, timestamp); + } + + return timestamp; +} + +export function setTimestamp(model) { + timestamps.set(model, getCurrentTimestamp()); + return model; +} diff --git a/src/utils.js b/src/utils.js index c9ffeb73..1cdb13eb 100644 --- a/src/utils.js +++ b/src/utils.js @@ -29,9 +29,8 @@ export function shadyCSS(fn, fallback) { return fallback; } -export function stringifyElement(element) { - const tagName = String(element.tagName).toLowerCase(); - return `<${tagName}>`; +export function stringifyElement(target) { + return `<${String(target.tagName).toLowerCase()}>`; } export const IS_IE = "ActiveXObject" in window; diff --git a/test/spec/cache.js b/test/spec/cache.js index 8ba08d5b..c9d83f45 100644 --- a/test/spec/cache.js +++ b/test/spec/cache.js @@ -1,4 +1,11 @@ -import { get, set, invalidate, observe } from "../../src/cache.js"; +import { + get, + set, + getEntries, + invalidate, + invalidateAll, + observe, +} from "../../src/cache.js"; describe("cache:", () => { let target; @@ -52,6 +59,13 @@ describe("cache:", () => { expect(spy).not.toHaveBeenCalled(); }); + + it("forces getter to be called if validation fails", () => { + get(target, "key", () => get(target, "otherKey", () => "value")); + get(target, "key", spy, () => false); + + expect(spy).toHaveBeenCalledTimes(1); + }); }); describe("set()", () => { @@ -91,6 +105,23 @@ describe("cache:", () => { }); }); + describe("getEntries()", () => { + it("returns empty array for new object", () => { + expect(getEntries({})).toEqual([]); + }); + + it("returns an array with entries", () => { + const host = {}; + get(host, "key", () => "value"); + expect(getEntries(host)).toEqual([ + jasmine.objectContaining({ + value: "value", + key: "key", + }), + ]); + }); + }); + describe("invalidate()", () => { it("throws if called inside of the get()", () => { expect(() => @@ -118,6 +149,30 @@ describe("cache:", () => { }); }); + describe("invalidateAll()", () => { + it("throws if called inside of the get()", () => { + expect(() => get(target, "key", () => invalidateAll(target))).toThrow(); + }); + + it("does nothing if target has no entries", () => { + expect(() => invalidateAll({})).not.toThrow(); + }); + + it("clears all entries", () => { + get(target, "key", () => "value"); + + expect(getEntries(target).length).toBe(1); + + invalidateAll(target, true); + expect(getEntries(target)).toEqual([ + jasmine.objectContaining({ + value: undefined, + key: "key", + }), + ]); + }); + }); + describe("observe()", () => { const _ = (t, v) => v; diff --git a/test/spec/store.js b/test/spec/store.js new file mode 100644 index 00000000..f44d646c --- /dev/null +++ b/test/spec/store.js @@ -0,0 +1,1107 @@ +import { store } from "../../src/index.js"; +import * as cache from "../../src/cache.js"; +import { resolveTimeout } from "../helpers.js"; + +describe("store:", () => { + let Model; + + beforeAll(() => { + window.env = "production"; + }); + + afterAll(() => { + window.env = "development"; + }); + + beforeEach(() => { + Model = { + id: true, + string: "value", + number: 1, + bool: false, + computed: ({ string }) => `This is the string: ${string}`, + nestedObject: { + value: "test", + }, + nestedExternalObject: { + id: true, + value: "test", + }, + nestedArrayOfPrimitives: ["one", "two"], + nestedArrayOfObjects: [{ one: "two" }], + nestedArrayOfExternalObjects: [{ id: true, value: "test" }], + }; + }); + + describe("not connected (memory based) -", () => { + describe("get()", () => { + it("throws for wrong arguments", () => { + expect(() => store.get()).toThrow(); + }); + + it('throws for model definition with wrongly set "id" key', () => { + expect(() => store.get({ id: 1 })).toThrow(); + }); + + it("throws if property value is not a string, number or boolean", () => { + expect(() => store.get({ value: undefined })).toThrow(); + }); + + it("throws when called with parameters for singleton type", () => { + expect(() => store.get({}, "1")).toThrow(); + }); + + it("throws when property is set as null", () => { + expect(() => store.get({ value: null })).toThrow(); + }); + + it("returns an error for not defined model", () => { + expect(store.get({ id: true }, "1")).toBeInstanceOf(Error); + }); + + it("returns an error with guarded properties", () => { + const model = store.get({ id: true, testValue: "", message: "" }, 1); + + expect(() => model.testValue).toThrow(); + expect(() => model.message).not.toThrow(); + }); + + it("returns default model for singleton", () => { + Model = { value: "test" }; + expect(store.get(Model)).toEqual({ value: "test" }); + }); + + describe("for created instance", () => { + let promise; + beforeEach(() => { + promise = store.set(Model, {}); + }); + + it("returns default values", done => + promise.then(model => { + expect(model).toEqual({ + string: "value", + number: 1, + bool: false, + nestedObject: { + value: "test", + }, + nestedExternalObject: undefined, + nestedArrayOfPrimitives: ["one", "two"], + nestedArrayOfObjects: [{ one: "two" }], + nestedArrayOfExternalObjects: [], + }); + expect(model.id).toBeDefined(); + expect(model.computed).toEqual("This is the string: value"); + + done(); + })); + + it("returns cached model", done => + promise.then(model => { + expect(store.get(Model, model.id)).toBe(model); + done(); + })); + }); + + describe("for listing models", () => { + let promise; + beforeEach(() => { + Model = { id: true, value: "" }; + promise = Promise.all([ + store.set(Model, { value: "one" }), + store.set(Model, { value: "two" }), + ]); + }); + + it("throws an error for singleton definition (without 'id' key)", () => { + expect(() => store.get([{}])).toThrow(); + }); + + it("throws an error for nested parameters", () => { + expect(() => + store.get([Model], { id: "", other: { value: "test" } }), + ).toThrow(); + }); + + it("returns an error when called with parameters", () => { + expect(store.get([Model], { a: "b" })).toBeInstanceOf(Error); + }); + + it("returns an error with guarded properties", () => { + const model = store.get([Model], { a: "b" }); + + expect(() => model.map).toThrow(); + expect(() => model.message).not.toThrow(); + }); + + it("returns an array with updated models", done => { + expect(store.get([Model])).toEqual([]); + + promise.then(() => { + expect(store.get([Model])).toEqual([ + { value: "one" }, + { value: "two" }, + ]); + done(); + }); + }); + + it("returns the same array", () => { + expect(store.get([Model])).toBe(store.get([Model])); + }); + + it("returns an array without deleted model", done => + promise + .then(([model]) => store.set(model, null)) + .then(() => { + expect(store.get([Model])).toEqual([{ value: "two" }]); + done(); + })); + }); + }); + + describe("set()", () => { + let promise; + beforeEach(() => { + promise = store.set(Model); + }); + + it("rejects an error when values are not an object or null", done => { + store + .set(Model, false) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("throws an error when set method is not supported", () => { + expect(() => store.set([Model])).toThrow(); + }); + + it("rejects an error when values contain 'id' property", done => + promise + .then(model => store.set(model, model)) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done)); + + it("rejects an error when array with primitives is set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfPrimitives: "test", + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("rejects an error when array with objects is set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfObjects: "test", + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("rejects an error when array with external objects is set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfExternalObjects: "test", + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("rejects an error when array with nested objects are set with wrong type", done => { + promise + .then(model => + store.set(model, { + nestedArrayOfObjects: [{}, "test"], + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it('creates uuid for objects with "id" key', done => + promise.then(model => { + expect(model.id).toBeDefined(); + expect(model.nestedObject.id).not.toBeDefined(); + expect(model.nestedArrayOfObjects[0].id).not.toBeDefined(); + done(); + })); + + it("updates single property", done => + promise.then(model => + store.set(model, { string: "new value" }).then(newModel => { + expect(newModel.string).toBe("new value"); + expect(newModel.number).toBe(1); + expect(newModel.bool).toBe(false); + expect(newModel.nestedObject).toBe(model.nestedObject); + expect(newModel.nestedArrayOfObjects).toBe( + newModel.nestedArrayOfObjects, + ); + expect(newModel.nestedArrayOfPrimitives).toBe( + newModel.nestedArrayOfPrimitives, + ); + done(); + }), + )); + + it("updates nested object", done => + promise.then(model => + store + .set(model, { nestedObject: { value: "other" } }) + .then(newModel => { + expect(newModel.nestedObject).toEqual({ value: "other" }); + done(); + }), + )); + + it("rejects an error when updates nested object with different model", done => + promise.then(model => + store + .set({ test: "value" }) + .then(otherModel => + store.set(model, { nestedExternalObject: otherModel }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done), + )); + + it("updates nested external object with proper model", done => + promise.then(model => + store.set(Model.nestedExternalObject, {}).then(newExternal => + store + .set(model, { nestedExternalObject: newExternal }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(newExternal); + done(); + }), + ), + )); + + it("updates nested external object with data", done => + promise.then(model => + store + .set(model, { nestedExternalObject: { value: "one", a: "b" } }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toEqual({ value: "one" }); + done(); + }), + )); + + it("updates nested external object with model id", done => + promise.then(model => + store.set(Model.nestedExternalObject, {}).then(newExternal => + store + .set(model, { nestedExternalObject: newExternal.id }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(newExternal); + done(); + }), + ), + )); + + it("clears nested external object", done => + promise.then(model => + store + .set(model, { nestedExternalObject: null }) + .then(newModel => { + expect(newModel).not.toBe(model); + expect(newModel.nestedExternalObject).toBe(undefined); + }) + .then(done), + )); + + it("updates nested array of primitives", done => + promise.then(model => + store + .set(model, { nestedArrayOfPrimitives: [1, 2, 3] }) + .then(newModel => { + expect(newModel.nestedArrayOfPrimitives).toEqual(["1", "2", "3"]); + done(); + }), + )); + + it("create model with nested array of objects", done => { + store + .set(Model, { + nestedArrayOfObjects: [ + { one: "two" }, + { two: "three", one: "four" }, + ], + }) + .then(model => { + expect(model.nestedArrayOfObjects).toEqual([ + { one: "two" }, + { one: "four" }, + ]); + done(); + }); + }); + + it("updates nested array of objects", done => + promise.then(model => + store + .set(model, { nestedArrayOfObjects: [{ one: "three" }] }) + .then(newModel => { + expect(newModel.nestedArrayOfObjects).toEqual([{ one: "three" }]); + done(); + }), + )); + + it("rejects an error when model in nested array does not match model", done => { + store + .set({ myValue: "text" }) + .then(model => + store.set(Model, { + nestedArrayOfExternalObjects: [model], + }), + ) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("creates model with nested external object from raw data", done => { + store + .set(Model, { + nestedArrayOfExternalObjects: [{ id: "1", value: "1" }], + }) + .then(model => { + expect(model.nestedArrayOfExternalObjects[0].id).toEqual("1"); + expect(model.nestedArrayOfExternalObjects).toEqual([ + { value: "1" }, + ]); + done(); + }); + }); + + it("creates model with nested external object from model instance", done => { + store.set(Model.nestedArrayOfExternalObjects[0]).then(nestedModel => + store + .set(Model, { + nestedArrayOfExternalObjects: [nestedModel], + }) + .then(model => { + expect(model.nestedArrayOfExternalObjects[0]).toBe(nestedModel); + done(); + }), + ); + }); + + it("deletes model", done => + promise.then(model => + store.set(model, null).then(() => { + expect(store.get(Model, model.id)).toBeInstanceOf(Error); + done(); + }), + )); + }); + + describe("clear()", () => { + let promise; + beforeEach(() => { + promise = store.set(Model, { string: "test" }); + }); + + it("throws when clear not a model instance or model definition", () => { + expect(() => store.clear()).toThrow(); + expect(() => store.clear("string")).toThrow(); + expect(() => store.clear(store.get(Model, 100))).toThrow(); + }); + + it("removes model instance by reference", done => { + promise + .then(model => { + store.clear(model); + expect(store.get(Model, 1)).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("removes model instance by id", done => { + promise + .then(() => { + store.clear(Model, 1); + expect(store.get(Model, 1)).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("removes all model instances by definition", done => { + promise + .then(() => { + store.clear(Model); + expect(store.get(Model, 1)).toBeInstanceOf(Error); + }) + .then(done); + }); + }); + }); + + describe("connected to sync storage -", () => { + let storage; + beforeEach(() => { + storage = { + 1: { id: "1", value: "test" }, + 2: { id: "2", value: "other" }, + }; + + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + storage[id || values.id] = values; + }, + list: () => Object.values(storage), + }, + }; + }); + + it("throws an error when get method is not defined", () => { + Model = { id: true, [store.connect]: {} }; + expect(() => store.get(Model, "1")).toThrow(); + }); + + it("throws an error for listing model when list method is not defined", () => { + Model = { id: true, [store.connect]: { get: () => {} } }; + expect(() => store.get([Model])).toThrow(); + }); + + it("throws when cache is set with wrong type", () => { + expect(() => + store.get({ value: "test", [store.connect]: { cache: "lifetime" } }), + ).toThrow(); + }); + + it("returns an error when id does not match", done => { + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + return { ...values, id: parseInt(id, 10) + 1 }; + }, + }, + }; + + const model = store.get(Model, 1); + store + .set(model, { value: "test" }) + .catch(e => e) + .then(e => expect(e).toBeInstanceOf(Error)) + .then(done); + }); + + it("returns an error instance when get action throws", () => { + storage = null; + const model = store.get(Model, 1); + expect(model).toBeInstanceOf(Error); + expect(store.get(Model, 1)).toBe(model); + }); + + it("does not cache set action when it rejects an error", done => { + const origStorage = storage; + storage = null; + store + .set(Model, { value: "other" }) + .catch(() => { + storage = origStorage; + expect(store.get(Model, 1)).toEqual({ value: "test" }); + }) + .then(done); + }); + + it("returns a promise rejecting an error instance when set throws", done => { + storage = null; + store + .set(Model, { value: "test" }) + .catch(e => { + expect(e).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("returns an error instance when get throws primitive value", () => { + Model = { + id: true, + [store.connect]: () => { + throw Promise.resolve(); + }, + }; + expect(store.get(Model, 1)).toBeInstanceOf(Error); + }); + + it("returns an error for not existing model", () => { + expect(store.get(Model, 0)).toBeInstanceOf(Error); + }); + + it("returns model from the storage", () => { + expect(store.get(Model, 1)).toEqual({ value: "test" }); + }); + + it("returns the same model for string or number id", () => { + expect(store.get(Model, "1")).toBe(store.get(Model, 1)); + }); + + it("returns a list of models", () => { + expect(store.get([Model])).toEqual([ + { value: "test" }, + { value: "other" }, + ]); + }); + + it("adds item to list of models", done => { + expect(store.get([Model]).length).toBe(2); + store.set(Model, { value: "new value" }).then(model => { + const list = store.get([Model]); + expect(list.length).toBe(3); + expect(list[2]).toBe(model); + done(); + }); + }); + + it("removes item form list of models", done => { + store.set(store.get([Model])[0], null).then(() => { + const list = store.get([Model]); + expect(list.length).toBe(1); + done(); + }); + }); + + it("returns the same list when modifies already existing item", done => { + const list = store.get([Model]); + store.set(list[0], { value: "new value" }).then(() => { + expect(store.get([Model])).toBe(list); + done(); + }); + }); + + it("calls observed properties once", done => { + const spy = jasmine.createSpy("observe callback"); + const getter = () => store.get([Model]); + const unobserve = cache.observe({}, "key", getter, spy); + + resolveTimeout(() => { + expect(spy).toHaveBeenCalledTimes(1); + unobserve(); + }).then(done); + }); + + it("set states for model instance", () => { + const model = store.get(Model, 1); + expect(store.pending(model)).toBe(false); + expect(store.ready(model)).toBe(true); + expect(store.error(model)).toBe(false); + }); + + it("for cache set to 'false' calls storage each time", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => storage[id], + }, + }; + + const model = store.get(Model, 1); + expect(model).toEqual({ value: "test" }); + + expect(model).toBe(store.get(Model, 1)); + expect(model).toBe(store.get(Model, 1)); + + resolveTimeout(() => { + expect(model).not.toBe(store.get(Model, 1)); + done(); + }); + }); + + it("for cache set to 'false' does not call get for single item", done => { + const spy = jasmine.createSpy("get"); + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => { + spy(id); + return storage[id]; + }, + list: () => Object.values(storage), + }, + }; + + const model = store.get([Model]); + requestAnimationFrame(() => { + expect(model[0]).toEqual({ value: "test" }); + expect(spy).toHaveBeenCalledTimes(0); + done(); + }); + }); + + it("for cache set to number get calls storage after timeout", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: 100, + get: id => storage[id], + }, + }; + + const model = store.get(Model, 1); + expect(model).toEqual({ value: "test" }); + expect(model).toBe(store.get(Model, 1)); + + resolveTimeout(() => { + expect(model).not.toBe(store.get(Model, 1)); + }).then(done); + }); + + it("uses id returned from set action", done => { + let count = 2; + Model = { + id: true, + value: "", + [store.connect]: { + get: id => storage[id], + set: (id, values) => { + if (!id) { + id = count + 1; + count += 1; + values = { id, ...values }; + } + storage[id] = values; + return values; + }, + }, + }; + + store + .set(Model, { value: "test" }) + .then(model => { + expect(store.get(Model, "3")).toBe(model); + }) + .then(done); + }); + + it("clear forces call for model again", done => { + const model = store.get(Model, 1); + store.clear(model); + requestAnimationFrame(() => { + expect(store.get(Model, 1)).not.toBe(model); + done(); + }); + }); + + describe("with nested array options", () => { + const setupDep = options => { + return { + items: [Model, options], + [store.connect]: () => ({ items: Object.values(storage) }), + }; + }; + + it("throws an error when options are set with wrong type", () => { + expect(() => store.get({ items: [Model, true] })).toThrow(); + }); + + it("returns updated list when loose option is set", done => { + const DepModel = setupDep({ loose: true }); + const model = store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(model, null) + .then(() => { + const newList = store.get(DepModel); + expect(newList.items.length).toBe(1); + }) + .then(done); + }); + + it("returns the same list if loose options are not set", done => { + const DepModel = setupDep(); + const model = store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(model, null) + .then(() => { + const newList = store.get(DepModel); + expect(newList.items[0]).toBeInstanceOf(Error); + expect(newList.items.length).toBe(2); + }) + .then(done); + }); + + it("returns the same list if loose options are not set", done => { + const DepModel = setupDep({ loose: false }); + const model = store.get(Model, 1); + + const list = store.get(DepModel); + expect(list.items.length).toBe(2); + + store + .set(model, null) + .then(() => { + const newList = store.get(DepModel); + expect(newList.items[0]).toBeInstanceOf(Error); + expect(newList.items.length).toBe(2); + }) + .then(done); + }); + + it("returns updated list if one of many loose arrays changes", done => { + const otherStorage = { + "1": { id: "1", value: "test" }, + }; + const NewModel = { + id: true, + value: "", + [store.connect]: { + get: id => otherStorage[id], + set: (id, values) => { + if (values === null) { + delete otherStorage[id]; + } else { + otherStorage[id] = values; + } + }, + }, + }; + + const DepModel = { + items: [Model, { loose: true }], + otherItems: [NewModel, { loose: true }], + [store.connect]: () => ({ + items: Object.values(storage), + otherItems: Object.values(otherStorage), + }), + }; + + const list = store.get(DepModel); + store.set(list.otherItems[0], null); + + requestAnimationFrame(() => { + const newList = store.get(DepModel); + expect(newList.otherItems.length).toBe(0); + done(); + }); + }); + }); + }); + + describe("connected to async storage -", () => { + let fn; + beforeEach(() => { + fn = id => Promise.resolve({ id, value: "true" }); + Model = { + id: true, + value: "", + [store.connect]: id => fn(id), + }; + }); + + it("rejects an error when promise resolves with other type than object", done => { + fn = () => { + return Promise.resolve("value"); + }; + + store.get(Model, 1); + + Promise.resolve() + .then(() => {}) + .then(() => { + const model = store.get(Model, 1); + expect(model).toBeInstanceOf(Error); + }) + .then(done); + }); + + it("returns placeholder object in pending state", () => { + const placeholder = store.get(Model, 1); + expect(placeholder).toBeInstanceOf(Object); + expect(() => placeholder.value).toThrow(); + }); + + it("calls storage get action once for permanent cache", () => { + const spy = jasmine.createSpy(); + fn = id => { + spy(id); + return Promise.resolve({ id, value: "test" }); + }; + store.get(Model, 1); + store.get(Model, 1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("calls storage get action once for time-based cache", () => { + const spy = jasmine.createSpy(); + Model = { + id: true, + value: "", + [store.connect]: { + cache: 100, + get: id => { + spy(id); + return Promise.resolve({ id, value: "test" }); + }, + }, + }; + + store.get(Model, 1); + store.get(Model, 1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("calls observe method twice (pending & ready states)", done => { + const spy = jasmine.createSpy(); + cache.observe({}, "key", () => store.get(Model, "1"), spy); + + resolveTimeout(() => { + expect(spy).toHaveBeenCalledTimes(2); + }).then(done); + }); + + it("returns cached external nested object in pending state", done => { + Model = { + id: true, + value: "", + nestedExternalObject: { + id: true, + value: "test", + [store.connect]: { + cache: false, + get: id => Promise.resolve({ id, value: "one" }), + }, + }, + [store.connect]: { + cache: false, + get: id => + Promise.resolve({ + id, + value: "test", + nestedExternalObject: "1", + }), + }, + }; + + store.get(Model, 1); + + setTimeout(() => { + const model = store.get(Model, 1); + const nestedModel = model.nestedExternalObject; + setTimeout(() => { + const resolvedNestedModel = model.nestedExternalObject; + expect(resolvedNestedModel).not.toBe(nestedModel); + + setTimeout(() => { + const newModel = store.get(Model, 1); + expect(newModel).toBe(model); + expect(newModel.nestedExternalObject).toBe(resolvedNestedModel); + done(); + }); + }); + }); + }); + + it("returns cached external nested object in pending state", done => { + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => + Promise.resolve({ + id, + value: "test", + }), + list: () => Promise.resolve(["1"]), + }, + }; + + store.get([Model]); + + requestAnimationFrame(() => { + const models = store.get([Model]); + const model = models[0]; + requestAnimationFrame(() => { + const resolvedModel = models[0]; + expect(resolvedModel).not.toBe(model); + + requestAnimationFrame(() => { + const newModels = store.get([Model]); + expect(newModels).toBe(models); + expect(newModels[0]).toBe(resolvedModel); + done(); + }); + }); + }); + }); + + it("returns placeholder in async calls for long fetching model", done => { + let resolvePromise; + Model = { + id: true, + value: "", + [store.connect]: { + cache: false, + get: id => + new Promise(resolve => { + resolvePromise = () => resolve({ id, value: "test" }); + }), + }, + }; + + const pendingModel = store.get(Model, 1); + expect(store.pending(pendingModel)).toBe(true); + expect(() => pendingModel.value).toThrow(); + + let resolvedModel; + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBe(true); + + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBe(true); + + resolvePromise(); + Promise.resolve().then(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBe(false); + + requestAnimationFrame(() => { + resolvedModel = store.get(Model, 1); + expect(store.pending(resolvedModel)).toBe(true); + done(); + }); + }); + }); + + // setTimeout(() => { + // resolvedModel = store.get(Model, 1); + // expect(store.pending(resolvedModel)).toBe(true); + + // // requestAnimationFrame(() => { + // // resolvedModel = store.get(Model, 1); + // // const isPending = store.pending(resolvedModel); + // // console.log(isPending); + // // // expect(isPending).toBe(true); + // // done(); + // // }); + // done(); + // }, 1000); + }); + }); + + describe("for success", () => { + it("sets pending state", done => { + expect(store.pending(store.get(Model, 1))).toBe(true); + + Promise.resolve() + .then(() => { + expect(store.pending(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets ready state", done => { + expect(store.ready(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => { + expect(store.ready(store.get(Model, 1))).toBe(true); + }) + .then(done); + }); + + it("sets error state", done => { + expect(store.error(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => { + expect(store.error(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + }); + + describe("for error", () => { + beforeEach(() => { + fn = () => Promise.reject(Error("some error")); + }); + + it("caches an error result", done => { + store.get(Model, 1); + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.get(Model, 1)).toBe(store.get(Model, 1)); + }) + .then(done); + }); + + it("sets pending state", done => { + expect(store.pending(store.get(Model, 1))).toBe(true); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.pending(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets ready state", done => { + expect(store.ready(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.ready(store.get(Model, 1))).toBe(false); + }) + .then(done); + }); + + it("sets error state", done => { + expect(store.error(store.get(Model, 1))).toBe(false); + + Promise.resolve() + .then(() => {}) + .then(() => { + expect(store.error(store.get(Model, 1))).toBe(true); + }) + .then(done); + }); + }); + }); +});