@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
279 lines • 13.8 kB
JavaScript
import { Cache } from '@furystack/cache';
import { Shade, createComponent, flushUpdates } from '@furystack/shades';
import { using, usingAsync } from '@furystack/utils';
import { describe, expect, it, vi } from 'vitest';
import { CacheView } from './cache-view.js';
const TestContent = Shade({
customElementName: 'test-cache-content',
render: ({ props }) => createComponent("span", { className: "content-value" }, props.data.value),
});
const TestContentWithLabel = Shade({
customElementName: 'test-cache-content-with-label',
render: ({ props }) => (createComponent("span", { className: "content-value" },
props.label,
": ",
props.data.value)),
});
const renderCacheView = async (cache, args, options) => {
const el = (createComponent("div", null,
createComponent(CacheView, { cache: cache, args: args, content: TestContent, loader: options?.loader, error: options?.error })));
const cacheView = el.firstElementChild;
cacheView.updateComponent();
await flushUpdates();
return { container: el, cacheView };
};
describe('CacheView', () => {
it('should be defined', () => {
expect(CacheView).toBeDefined();
expect(typeof CacheView).toBe('function');
});
it('should create a cache-view element', () => {
using(new Cache({ load: async (key) => key }), (cache) => {
const el = (createComponent(CacheView, { cache: cache, args: ['test'], content: TestContent }));
expect(el).toBeDefined();
expect(el.tagName?.toLowerCase()).toBe('shade-cache-view');
});
});
describe('loading state', () => {
it('should render null by default when loading', async () => {
await usingAsync(new Cache({
load: () => new Promise(() => { }),
}), async (cache) => {
const { cacheView } = await renderCacheView(cache, ['test']);
expect(cacheView.querySelector('test-cache-content')).toBeNull();
expect(cacheView.querySelector('shade-result')).toBeNull();
});
});
it('should render custom loader when provided', async () => {
await usingAsync(new Cache({
load: () => new Promise(() => { }),
}), async (cache) => {
const { cacheView } = await renderCacheView(cache, ['test'], {
loader: (createComponent("span", { className: "custom-loader" }, "Loading...")),
});
const loader = cacheView.querySelector('.custom-loader');
expect(loader).not.toBeNull();
expect(loader?.textContent).toBe('Loading...');
});
});
});
describe('loaded state', () => {
it('should render content when cache has loaded value', async () => {
await usingAsync(new Cache({ load: async (key) => `Hello ${key}` }), async (cache) => {
await cache.get('world');
const { cacheView } = await renderCacheView(cache, ['world']);
const contentEl = cacheView.querySelector('test-cache-content');
expect(contentEl).not.toBeNull();
const contentComponent = contentEl;
contentComponent.updateComponent();
await flushUpdates();
const valueEl = contentComponent.querySelector('.content-value');
expect(valueEl?.textContent).toBe('Hello world');
});
});
});
describe('failed state', () => {
it('should render default error UI when cache has failed', async () => {
await usingAsync(new Cache({
load: async () => {
throw new Error('Test failure');
},
}), async (cache) => {
try {
await cache.get('test');
}
catch {
// expected
}
const { cacheView } = await renderCacheView(cache, ['test']);
const resultEl = cacheView.querySelector('shade-result');
expect(resultEl).not.toBeNull();
const resultComponent = resultEl;
resultComponent.updateComponent();
await flushUpdates();
const titleEl = resultComponent.querySelector('.result-title');
titleEl.updateComponent();
await flushUpdates();
expect(titleEl?.textContent).toBe('Something went wrong');
});
});
it('should render custom error UI when error prop is provided', async () => {
await usingAsync(new Cache({
load: async () => {
throw new Error('Custom failure');
},
}), async (cache) => {
try {
await cache.get('test');
}
catch {
// expected
}
const customError = vi.fn((err, _retry) => (createComponent("span", { className: "custom-error" }, String(err))));
const { cacheView } = await renderCacheView(cache, ['test'], { error: customError });
expect(customError).toHaveBeenCalledOnce();
expect(customError.mock.calls[0][0]).toBeInstanceOf(Error);
const customErrorEl = cacheView.querySelector('.custom-error');
expect(customErrorEl).not.toBeNull();
});
});
it('should not render content when failed even if stale value exists', async () => {
await usingAsync(new Cache({ load: async (key) => key }), async (cache) => {
await cache.get('test');
cache.setExplicitValue({
loadArgs: ['test'],
value: { status: 'failed', error: new Error('fail'), value: 'stale', updatedAt: new Date() },
});
const { cacheView } = await renderCacheView(cache, ['test']);
expect(cacheView.querySelector('test-cache-content')).toBeNull();
expect(cacheView.querySelector('shade-result')).not.toBeNull();
});
});
it('should call cache.reload when retry is invoked', async () => {
const loadFn = vi.fn(async () => {
throw new Error('fail');
});
await usingAsync(new Cache({ load: loadFn }), async (cache) => {
try {
await cache.get('test');
}
catch {
// expected
}
let capturedRetry;
const customError = (_err, retry) => {
capturedRetry = retry;
return (createComponent("span", { className: "custom-error" }, "Error"));
};
await renderCacheView(cache, ['test'], { error: customError });
expect(capturedRetry).toBeDefined();
loadFn.mockResolvedValueOnce('recovered');
capturedRetry();
await flushUpdates();
const observable = cache.getObservable('test');
const result = observable.getValue();
expect(result.status).toBe('loaded');
expect(result.value).toBe('recovered');
});
});
});
describe('obsolete state', () => {
it('should render content when obsolete and trigger reload', async () => {
const loadFn = vi.fn(async (key) => `Hello ${key}`);
await usingAsync(new Cache({ load: loadFn }), async (cache) => {
await cache.get('test');
cache.setObsolete('test');
const { cacheView } = await renderCacheView(cache, ['test']);
const contentEl = cacheView.querySelector('test-cache-content');
expect(contentEl).not.toBeNull();
await flushUpdates();
// reload should have been called (initial load + obsolete reload)
expect(loadFn).toHaveBeenCalledTimes(2);
});
});
});
describe('error takes priority over value', () => {
it('should show error when failed with value, not content', async () => {
await usingAsync(new Cache({ load: async (key) => key }), async (cache) => {
await cache.get('test');
const failedWithValue = {
status: 'failed',
error: new Error('whoops'),
value: 'stale-data',
updatedAt: new Date(),
};
cache.setExplicitValue({ loadArgs: ['test'], value: failedWithValue });
const { cacheView } = await renderCacheView(cache, ['test']);
expect(cacheView.querySelector('test-cache-content')).toBeNull();
expect(cacheView.querySelector('shade-result')).not.toBeNull();
});
});
});
describe('contentProps', () => {
it('should forward contentProps to the content component', async () => {
await usingAsync(new Cache({ load: async (key) => `Hello ${key}` }), async (cache) => {
await cache.get('world');
const el = (createComponent("div", null,
createComponent(CacheView, { cache: cache, args: ['world'], content: TestContentWithLabel, contentProps: { label: 'Greeting' } })));
const cacheView = el.firstElementChild;
cacheView.updateComponent();
await flushUpdates();
const contentEl = cacheView.querySelector('test-cache-content-with-label');
expect(contentEl).not.toBeNull();
contentEl.updateComponent();
await flushUpdates();
const valueEl = contentEl.querySelector('.content-value');
expect(valueEl?.textContent).toBe('Greeting: Hello world');
});
});
it('should forward contentProps when cache entry is obsolete', async () => {
const loadFn = vi.fn(async (key) => `Hello ${key}`);
await usingAsync(new Cache({ load: loadFn }), async (cache) => {
await cache.get('world');
cache.setObsolete('world');
const el = (createComponent("div", null,
createComponent(CacheView, { cache: cache, args: ['world'], content: TestContentWithLabel, contentProps: { label: 'Stale' } })));
const cacheView = el.firstElementChild;
cacheView.updateComponent();
await flushUpdates();
const contentEl = cacheView.querySelector('test-cache-content-with-label');
expect(contentEl).not.toBeNull();
contentEl.updateComponent();
await flushUpdates();
const valueEl = contentEl.querySelector('.content-value');
expect(valueEl?.textContent).toBe('Stale: Hello world');
expect(loadFn).toHaveBeenCalledTimes(2);
});
});
});
describe('view transitions', () => {
const mockStartViewTransition = () => {
const spy = vi.fn((optionsOrCallback) => {
const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update;
update?.();
return {
finished: Promise.resolve(),
ready: Promise.resolve(),
updateCallbackDone: Promise.resolve(),
skipTransition: vi.fn(),
};
});
document.startViewTransition = spy;
return spy;
};
it('should call startViewTransition when viewTransition is enabled and cache state category changes', async () => {
const spy = mockStartViewTransition();
await usingAsync(new Cache({ load: async (key) => `loaded-${key}` }), async (cache) => {
const el = (createComponent("div", null,
createComponent(CacheView, { cache: cache, args: ['test'], content: TestContent, loader: createComponent("span", { className: "loader" }, "Loading"), viewTransition: true })));
const cacheView = el.firstElementChild;
cacheView.updateComponent();
await flushUpdates();
expect(cacheView.querySelector('.loader')).toBeTruthy();
spy.mockClear();
await cache.get('test');
cacheView.updateComponent();
await flushUpdates();
expect(spy).toHaveBeenCalled();
});
delete document.startViewTransition;
});
it('should not call startViewTransition when viewTransition is not set', async () => {
const spy = mockStartViewTransition();
await usingAsync(new Cache({ load: async (key) => `loaded-${key}` }), async (cache) => {
const el = (createComponent("div", null,
createComponent(CacheView, { cache: cache, args: ['test'], content: TestContent, loader: createComponent("span", { className: "loader" }, "Loading") })));
const cacheView = el.firstElementChild;
cacheView.updateComponent();
await flushUpdates();
spy.mockClear();
await cache.get('test');
cacheView.updateComponent();
await flushUpdates();
expect(spy).not.toHaveBeenCalled();
});
delete document.startViewTransition;
});
});
});
//# sourceMappingURL=cache-view.spec.js.map