Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 111 additions & 1 deletion packages/tests/Data/DataBind.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { it, describe, expect, vi } from 'vitest';
import { Action, DataBind, DataComputed, DataEffect, DataModel, DataScope } from '@studiometa/ui';
import {
Action,
DataBind,
DataComputed,
DataEffect,
DataModel,
DataScope,
type DataValue,
} from '@studiometa/ui';
import { nextTick } from '@studiometa/js-toolkit/utils';
import { destroy, hConnected as h, mount } from '#test-utils';

Expand Down Expand Up @@ -306,6 +314,108 @@ describe('The DataBind component', () => {
expect(instance4.$el.id).toBe('bar');
});

it('should deliver group updates through the public set method', async () => {
const legacySource = new DataBind(h('div', { dataOptionGroup: 'custom-set' }));
const legacySubscriber = new DataBind(h('div', { dataOptionGroup: 'custom-set' }));
const legacySet = vi.spyOn(legacySubscriber, 'set');
await mount(legacySource, legacySubscriber);

legacySource.set('legacy');
expect(legacySet).toHaveBeenCalledWith('legacy', false);

const root = h('div', { dataOptionGroup: 'person' });
const input = h('input', { name: 'first' });
const output = h('div', { dataOptionKey: 'first' });
root.append(input, output);
const scope = new DataScope(root);
const scopedSource = new DataModel(input);
const scopedSubscriber = new DataBind(output);
const scopedSet = vi.spyOn(scopedSubscriber, 'set');
await mount(scope, scopedSource, scopedSubscriber);

scopedSource.set('scoped');
expect(scopedSet).toHaveBeenCalledWith('scoped', false);

await destroy(legacySource, legacySubscriber, scope, scopedSource, scopedSubscriber);
});

it('should preserve the latest value during reentrant group updates', async () => {
class ReentrantDataBind extends DataBind {
private dispatched = false;

set(value: DataValue, dispatch = true) {
super.set(value, dispatch);

if (!dispatch && value === 'outer' && !this.dispatched) {
this.dispatched = true;
super.set('inner');
}
}
}

const source = new DataBind(h('div', { dataOptionGroup: 'reentrant' }));
const reentrant = new ReentrantDataBind(h('div', { dataOptionGroup: 'reentrant' }));
const output = new DataBind(h('div', { dataOptionGroup: 'reentrant' }));
await mount(source, reentrant, output);

source.set('outer');

expect(source.value).toBe('inner');
expect(reentrant.value).toBe('inner');
expect(output.value).toBe('inner');

await destroy(source, reentrant, output);
});

it('should not run effects on mount unless an immediate value is dispatched', async () => {
const passiveElement = h('div', {
dataOptionGroup: 'passive-effect',
dataOptionEffect: 'target.dataset.called = "true"',
});
const immediateElement = h('div', {
dataOptionGroup: 'immediate-effect',
dataOptionEffect:
'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)',
dataOptionImmediate: true,
});
const passive = new DataEffect(passiveElement);
const immediate = new DataEffect(immediateElement);

await mount(passive, immediate);
expect(passiveElement.dataset.called).toBeUndefined();
expect(immediateElement.dataset.calls).toBeUndefined();

await nextTick();
expect(passiveElement.dataset.called).toBeUndefined();
expect(immediateElement.dataset.calls).toBe('1');

await destroy(passive, immediate);
});

it('should dispose and recreate group subscriptions across lifecycle changes', async () => {
const source = new DataBind(h('div', { dataOptionGroup: 'lifecycle' }));
const effectElement = h('div', {
dataOptionGroup: 'lifecycle',
dataOptionEffect:
'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)',
});
const effect = new DataEffect(effectElement);
await mount(source, effect);

source.set('first');
expect(effectElement.dataset.calls).toBe('1');

await destroy(effect);
source.set('second');
expect(effectElement.dataset.calls).toBe('1');

await mount(effect);
source.set('third');
expect(effectElement.dataset.calls).toBe('2');

await destroy(source, effect);
});

it('should forget related instances not in the DOM anymore', async () => {
const fragment = new Document();
const inputA = h('input', {
Expand Down
62 changes: 62 additions & 0 deletions packages/tests/Data/DataScope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,37 @@ describe('The DataScope component', () => {
});
});

it('should synchronously notify scoped subscribers for repeated equal writes', async () => {
const root = h('div', { dataOptionGroup: 'person' });
const input = h('input', { name: 'first' });
const output = h('div', { dataOptionKey: 'first' });
const effectElement = h('div', {
dataOptionEffect:
'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1); target.dataset.value = value',
});
root.append(input, output, effectElement);

const scope = new DataScope(root);
const model = new DataModel(input);
const bind = new DataBind(output);
const effect = new DataEffect(effectElement);
await mount(scope, model, bind, effect);

model.set('Ada');
const firstSnapshot = model.$data;
expect(bind.value).toBe('Ada');
expect(effectElement.dataset.calls).toBe('1');
expect(effectElement.dataset.value).toBe('Ada');

model.set('Ada');
expect(bind.value).toBe('Ada');
expect(effectElement.dataset.calls).toBe('2');
expect(model.$data).toEqual({ first: 'Ada' });
expect(model.$data).not.toBe(firstSnapshot);

await destroy(scope, model, bind, effect);
});

it('should recompute unkeyed subscribers for every keyed update', async () => {
const root = h('div', { dataOptionGroup: 'values' });
const firstInput = h('input', {
Expand Down Expand Up @@ -340,6 +371,37 @@ describe('The DataScope component', () => {
await destroy(scope, first, last, computed, effect);
});

it('should notify once when hydrating multiple immediate sources for the same key', async () => {
const root = h('div', { dataOptionGroup: 'person' });
const firstInput = h('input', {
name: 'first',
value: 'Ada',
dataOptionImmediate: true,
});
const secondInput = h('input', {
name: 'first',
value: 'Ada',
dataOptionImmediate: true,
});
const effectElement = h('div', {
dataOptionEffect:
'target.dataset.values = (target.dataset.values || "") + $data.first + "|"',
});
root.append(firstInput, secondInput, effectElement);

const scope = new DataScope(root);
const first = new DataModel(firstInput);
const second = new DataModel(secondInput);
const effect = new DataEffect(effectElement);
await mount(scope, first, second, effect);
await nextTick();

expect(effectElement.dataset.values?.split('|').filter(Boolean)).toEqual(['Ada']);
expect(scope.getData('person')).toEqual({ first: 'Ada' });

await destroy(scope, first, second, effect);
});

it('should hydrate and track only the selected radio', async () => {
const root = h('div', { dataOptionGroup: 'tabs' });
const overviewElement = h('input', {
Expand Down
Loading
Loading