packages/__tests__/src/3-runtime-html/computed-observer.spec.ts
import { ComputedObserver, IDirtyChecker, IObserverLocator } from '@aurelia/runtime';
import {
Constructable,
} from '@aurelia/kernel';
import {
assert,
createFixture,
eachCartesianJoin,
TestContext,
} from '@aurelia/testing';
describe('3-runtime-html/computed-observer.spec.ts', function () {
interface IComputedObserverTestCase<T extends IApp = IApp> {
title: string;
template: string;
ViewModel?: Constructable<T>;
assertFn: AssertionFn<T>;
only?: boolean;
}
interface AssertionFn<T extends IApp> {
// eslint-disable-next-line @typescript-eslint/prefer-function-type
(ctx: TestContext, testHost: HTMLElement, component: T): void | Promise<void>;
}
interface IApp {
[key: string]: any;
items: IAppItem[];
readonly total: number;
}
interface IAppItem {
name: string;
value: number;
isDone?: boolean;
}
const computedObserverTestCases: IComputedObserverTestCase[] = [
{
title: 'works in basic scenario',
template: `\${total}`,
ViewModel: class TestClass implements IApp {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1 };
});
public get total(): number {
return this.items.reduce((total, item) => total + (item.value > 5 ? item.value : 0), 0);
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '40');
component.items[0].value = 100;
assert.strictEqual(host.textContent, '40');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '140');
component.items.splice(1, 1, { name: 'item - 1', value: 100 });
// todo: this scenario
// component.items[1] = { name: 'item - 1', value: 100 };
assert.strictEqual(host.textContent, '140');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '240');
},
},
{
title: 'works with [].filter https://github.com/aurelia/aurelia/issues/534',
template: `\${total}`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get total(): number {
return this.items.filter(item => item.isDone).length;
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '5');
component.items[1].isDone = true;
assert.strictEqual(host.textContent, '5');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '6');
},
},
{
title: 'works with multiple layers of fn call',
template: `\${total}`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get total(): number {
return this
.items
.filter(item => item.isDone)
.filter(item => item.value > 1)
.length;
}
},
assertFn: (ctx, host, component) => {
assert.html.textContent(host, '4');
component.items[1].isDone = true;
assert.html.textContent(host, '4');
ctx.platform.domQueue.flush();
assert.html.textContent(host, '5');
},
},
{
title: 'works with Map.size',
template: `\${total}`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public itemMap: Map<any, any> = new Map([1, 2, 3].map(i => [`item - ${i}`, i]));
public get total(): number {
return this.itemMap.size;
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '3');
component.itemMap.set(`item - 4`, 10);
assert.strictEqual(host.textContent, '3');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '4');
},
},
{
title: 'works with multiple computed dependencies',
template: `\${total}`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get activeItems(): IAppItem[] {
return this.items.filter(i => !i.isDone);
}
public get total(): number {
return this.activeItems.reduce((total, item) => total + item.value, 0);
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '30' /* idx 0, 2, 4, 6, 8 only */);
component.items[0].isDone = false;
assert.strictEqual(component.activeItems.length, 6);
assert.strictEqual(host.textContent, '30');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '31');
},
},
{
title: 'works with array index',
template: `\${total}`,
ViewModel: class AppBase implements IApp {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get total(): number {
return this.items[0].value;
}
},
assertFn: (ctx, host, component) => {
const dirtyChecker = ctx.container.get(IDirtyChecker) as any;
assert.strictEqual((dirtyChecker.tracked as any[]).length, 0, 'Should have had no dirty checking');
assert.html.textContent(host, '1');
component.items.splice(0, 1, { name: 'mock', value: 1000 });
assert.html.textContent(host, '1');
ctx.platform.domQueue.flush();
assert.html.textContent(host, '1000');
},
},
{
title: 'Works with <let/>',
template: `<let real-total.bind="total * 2"></let>\${realTotal}`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get total(): number {
return this.items.reduce((total, item) => total + item.value, 0);
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '110');
component.items[0].value = 100;
assert.strictEqual(host.textContent, '110');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '308');
},
},
{
title: 'Works with [repeat]',
template: `<div repeat.for="item of activeItems">\${item.value}.</div>`,
ViewModel: class App {
public items: IAppItem[] = Array.from({ length: 10 }, (_, idx) => {
return { name: `i-${idx}`, value: idx + 1, isDone: idx % 2 === 0 };
});
public get activeItems(): IAppItem[] {
return this.items.filter(i => !i.isDone);
}
public get total(): number {
return this.activeItems.reduce((total, item) => total + item.value, 0);
}
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '2.4.6.8.10.');
component.items[1].isDone = true;
// todo: why so eagerly?
assert.strictEqual(host.textContent, '4.6.8.10.');
},
},
{
title: 'Works with set/get (class property)',
template: `<input value.bind="nameProp.value">\${nameProp.value}`,
ViewModel: class App {
public items: IAppItem[] = [];
public total: number = 0;
public nameProp: Property = new Property('value', '');
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '');
const inputEl = host.querySelector('input');
inputEl.value = '50';
inputEl.dispatchEvent(new ctx.CustomEvent('input'));
assert.strictEqual(host.textContent, '');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '50');
assert.strictEqual(component.nameProp.value, '50');
assert.strictEqual(component.nameProp._value, '50');
const observerLocator = ctx.container.get(IObserverLocator);
const namePropValueObserver = observerLocator
.getObserver(component.nameProp, 'value') as ComputedObserver<Property>;
assert.instanceOf(namePropValueObserver, ComputedObserver);
assert.strictEqual(
namePropValueObserver.$get,
Object.getOwnPropertyDescriptor(Property.prototype, 'value').get,
'It should have kept information about the original descriptor [[get]]',
);
assert.strictEqual(
namePropValueObserver.$set,
Object.getOwnPropertyDescriptor(Property.prototype, 'value').set,
'It should have kept information about the original descriptor [[set]]',
);
},
},
{
title: 'Works with set/get (object literal property)',
template: `<input value.bind="nameProp.value">\${nameProp.value}`,
ViewModel: class App {
public items: IAppItem[] = [];
public total: number = 0;
public nameProp: any = {
_value: '',
get value() {
return this._value;
},
set value(v: string) {
this._value = v;
this.valueChanged.publish();
},
valueChanged: {
publish() {/* */ },
},
};
},
assertFn: (ctx, host, component) => {
assert.strictEqual(host.textContent, '');
const inputEl = host.querySelector('input');
inputEl.value = '50';
inputEl.dispatchEvent(new ctx.CustomEvent('input'));
assert.strictEqual(host.textContent, '');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, '50');
assert.strictEqual(component.nameProp.value, '50');
assert.strictEqual(component.nameProp._value, '50');
const observerLocator = ctx.container.get(IObserverLocator);
const namePropValueObserver = observerLocator
.getObserver(component.nameProp, 'value',) as ComputedObserver<any>;
assert.instanceOf(namePropValueObserver, ComputedObserver);
},
},
/* eslint-disable */
...(<[string, () => any][]>[
['ArrayBuffer', () => new ArrayBuffer(0)],
['Boolean', () => new Boolean()],
['DataView', () => new DataView(new ArrayBuffer(0))],
['Date', () => new Date()],
['Error', () => new Error()],
['EvalError', () => new EvalError()],
['Float32Array', () => new Float32Array()],
['Float64Array', () => new Float64Array()],
['Function', () => new Function('')],
['Int8Array', () => new Int8Array()],
['Int16Array', () => new Int16Array()],
['Int64Array', () => new Int32Array()],
['Number', () => new Number()],
['Promise', () => new Promise<void>(r => r())],
['RangeError', () => new RangeError()],
['ReferenceError', () => new ReferenceError()],
['RegExp', () => new RegExp('a')],
// ideally, properties on Map & Set that are not special (methods & 'size')
// should be treated as normal properties, and should be observable by getter/setter
// though probably it's good to start with not observing unless there's a need for it
// example: Map/Set subclasses that have special properties.
// todo: add connectable.observe(target, key) in proxy-observation.ts
['Map', () => new Map()],
['Set', () => new Set()],
['SharedArrayBuffer', () => new SharedArrayBuffer(0)],
['String', () => new String()],
['SyntaxError', () => new SyntaxError()],
['TypeError', () => new TypeError()],
['Uint8Array', () => new Uint8Array()],
['Uint8ClampedArray', () => new Uint8ClampedArray()],
['Uint16Array', () => new Uint16Array()],
['Uint32Array', () => new Uint32Array()],
['URIError', () => new URIError()],
['WeakMap', () => new WeakMap()],
['WeakSet', () => new WeakSet()],
['Math', () => Math],
['JSON', () => JSON],
['Reflect', () => Reflect],
['Atomics', () => Atomics],
]).filter(([title, createInstrinsic]) => {
try {
switch (Object.prototype.toString.call(createInstrinsic())) {
case '[object Object]':
case '[object Array]':
case '[object Set]':
case '[object Map]':
return false;
default:
return true;
}
} catch {
return false;
}
}).map(([title, createInstrinsic]) => {
return <IComputedObserverTestCase>{
title: `does not observe ${title}`,
template: `<div>\${someProp || 'no value'}</div>`,
ViewModel: class App {
public items: IAppItem[] = [];
public total: number = 0;
public instrinsic: any = createInstrinsic();
public get someProp() {
return this.instrinsic.someProp;
}
},
assertFn: (ctx, host, component: IApp & { instrinsic?: any; someProp?: any }) => {
assert.strictEqual(host.textContent, 'no value');
component.instrinsic.someProp = 'value';
assert.strictEqual(host.textContent, 'no value');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, 'no value');
component.instrinsic = { someProp: 'has value' };
assert.strictEqual(host.textContent, 'no value');
ctx.platform.domQueue.flush();
assert.strictEqual(host.textContent, 'has value');
},
}
}),
/* eslint-enable */
];
eachCartesianJoin(
[computedObserverTestCases],
({ only, title, template, ViewModel, assertFn }: IComputedObserverTestCase) => {
// eslint-disable-next-line mocha/no-exclusive-tests
const $it = (title_: string, fn: Mocha.Func) => only ? it.only(title_, fn) : it(title_, fn);
$it(title, async function () {
const { ctx, component, testHost, tearDown } = createFixture<any>(
template,
ViewModel,
);
await assertFn(ctx, testHost, component);
// test cases could be sharing the same context document
// so wait a bit before running the next test
await tearDown();
});
},
);
it('works with two layers of getter', async function () {
const { assertText } = createFixture(
`\${msg}`,
class MyApp {
public get one() {
return 'One';
}
public get onetwo() {
return `${this.one} two`;
}
public get msg(): string {
return this.onetwo;
}
}
);
assertText('One two');
});
it('observers property in 2nd layer getter', async function () {
const { component, assertText, flush } = createFixture(
`\${msg}`,
class MyApp {
public message = 'One';
public get one() {
return this.message;
}
public get onetwo() {
return `${this.one} two`;
}
public get msg(): string {
return this.onetwo;
}
}
);
assertText('One two');
component.message = '1';
flush();
assertText('1 two');
});
class Property {
private _value: string;
public readonly valueChanged: any;
public constructor(public readonly name: string, value: string) {
this._value = value;
this.valueChanged = {
publish: () => {
// todo
}
};
}
public get value(): string {
return this._value;
}
public set value(value: string) {
this._value = value;
this.valueChanged.publish();
}
}
});