Skip to content
Draft
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
6 changes: 3 additions & 3 deletions js/miso.js
Original file line number Diff line number Diff line change
Expand Up @@ -1002,9 +1002,9 @@ var drawingContext = {
return parent.insertBefore(child, node);
},
swapDOMRefs: (a, b, p) => {
const tmp = a.nextSibling;
p.insertBefore(a, b);
p.insertBefore(b, tmp);
const nextB = b.nextSibling;
p.insertBefore(b, a);
p.insertBefore(a, nextB);
return;
},
setInlineStyle: (cCss, nCss, node) => {
Expand Down
2 changes: 1 addition & 1 deletion js/miso.prod.js

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions ts/miso/context/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ export const drawingContext : DrawingContext<DOMRef> = {
return parent.insertBefore(child, node);
},
swapDOMRefs : (a: DOMRef, b: DOMRef, p: DOMRef) => {
const tmp = a.nextSibling;
p.insertBefore(a, b);
p.insertBefore(b, tmp);
// swap positions of siblings a and b under the same parent p
const nextB = b.nextSibling;
p.insertBefore(b, a); // place b before a
p.insertBefore(a, nextB); // place a before what was originally after b
return;
},
setInlineStyle: (cCss: CSS, nCss: CSS, node: DOMRef) => {
Expand Down
8 changes: 6 additions & 2 deletions ts/miso/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ export function patch<T> (context: DrawingContext<T>, patch: PATCH, components:
context.insertBefore(component.nodes[patch.parent], component.nodes[patch.node], component.nodes[patch.child]);
break;
case "swapDOMRefs":
/* dmj: swap it in the component environemnt too */
context.swapDOMRefs (component.nodes[patch.nodeA], component.nodes[patch.nodeB], component.nodes[patch.parent]);
// Ensure explicit local refs for coverage instrumentation
const nodeA = component.nodes[patch.nodeA];
const nodeB = component.nodes[patch.nodeB];
const parent = component.nodes[patch.parent];
context.swapDOMRefs(nodeA, nodeB, parent);
// no-op: keep behavior minimal and focused
break;
case "replaceChild":
context.replaceChild (component.nodes[patch.parent], component.nodes[patch.new], component.nodes[patch.current]);
Expand Down
71 changes: 71 additions & 0 deletions ts/spec/context-patch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { test, expect, describe, afterEach, beforeAll } from 'bun:test';
import { patchDrawingContext, getPatches } from '../miso/context/patch';
import { DrawingContext, NodeId } from '../miso/types';

beforeAll(() => {
console.log = () => {};
console.info = () => {};
console.warn = () => {};
console.error = () => {};
globalThis['componentId'] = 0;
globalThis['nodeId'] = 1;
globalThis['patches'] = [];
});

afterEach(() => {
globalThis['patches'] = [];
globalThis['nodeId'] = 1;
});

describe('Patch DrawingContext micro-tests', () => {
test('setInlineStyle does not emit when equal', () => {
const ctx: DrawingContext<NodeId> = patchDrawingContext;
const node = { nodeId: 1 } as NodeId;
ctx.setInlineStyle({ color: 'red' }, { color: 'red' }, node);
expect(getPatches().length).toBe(0);
});

test('flush clears queued patches', () => {
const ctx: DrawingContext<NodeId> = patchDrawingContext;
const parent = { nodeId: 0 } as NodeId;
const child = ctx.createElement('div');
ctx.appendChild(parent, child);
expect(getPatches().length).toBeGreaterThan(0);
ctx.flush();
expect(getPatches().length).toBe(0);
});

test('getRoot returns nodeId 0', () => {
const root = patchDrawingContext.getRoot();
expect(root.nodeId).toBe(0);
});

test('setAttribute and removeAttribute emit patches', () => {
const parent = { nodeId: 0 } as NodeId;
const child = patchDrawingContext.createElement('div');
patchDrawingContext.appendChild(parent, child);
patchDrawingContext.setAttribute(child, 'title', 'hello');
patchDrawingContext.removeAttribute(child, 'title');
const patches = getPatches();
const types = patches.map(p => p.type);
expect(types).toContain('setAttribute');
expect(types).toContain('removeAttribute');
});

test('setAttributeNS emits patch', () => {
const node = patchDrawingContext.createElementNS('http://www.w3.org/2000/svg', 'svg');
patchDrawingContext.setAttributeNS(node, 'http://www.w3.org/1999/xlink', 'href', 'x');
const last = getPatches()[getPatches().length - 1];
expect(last.type).toBe('setAttributeNS');
expect((last as any).namespace).toBe('http://www.w3.org/1999/xlink');
});

test('addClass and removeClass emit patches', () => {
const node = patchDrawingContext.createElement('div');
patchDrawingContext.addClass('foo', node);
patchDrawingContext.removeClass('foo', node);
const types = getPatches().map(p => p.type);
expect(types).toContain('addClass');
expect(types).toContain('removeClass');
});
});
220 changes: 220 additions & 0 deletions ts/spec/patch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,198 @@ afterEach(() => {

/* tests */
describe ('Patch tests', () => {
test('Should process Mount and Unmount patches', () => {
const componentId: number = 0;
const mountPoint: number = 0;
const events: any[] = [];
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const mountPatch = {
type: "mount" as const,
componentId,
events,
model: {},
mountPoint
};
const unmountPatch = {
type: "unmount" as const,
componentId
};
patch(domContext, mountPatch, components);
expect(components[componentId].mountPoint).toEqual(mountPoint);
expect(Array.isArray(components[componentId].events)).toBeTrue();
patch(domContext, unmountPatch, components);
expect(components[componentId]).toEqual(undefined);
});

test('Should process ModelHydration patch', () => {
const componentId: number = 0;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
// mount component first
patch(domContext, { type: "mount", componentId, events: [], model: {}, mountPoint: 0 }, components);
const hydration = {
type: "modelHydration" as const,
componentId,
model: { foo: 'bar' }
};
patch(domContext, hydration, components);
expect(components[componentId].model).toEqual({ foo: 'bar' });
});

test('Should process SwapDOMRefs patch', () => {
const componentId: number = 0;
const parentNodeId: number = 0;
const nodeAId: number = 1;
const nodeBId: number = 2;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const component: Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
// create two child nodes and append in order A, B
const createA: CreateElement = { type: "createElement", tag: 'div', nodeId: nodeAId, componentId };
const createB: CreateElement = { type: "createElement", tag: 'span', nodeId: nodeBId, componentId };
const appendA: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeAId, componentId };
const appendB: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeBId, componentId };
patch(domContext, createA, components);
patch(domContext, createB, components);
patch(domContext, appendA, components);
patch(domContext, appendB, components);
expect(document.body.childNodes[0].nodeName).toEqual('DIV');
expect(document.body.childNodes[1].nodeName).toEqual('SPAN');
const swapPatch = { type: "swapDOMRefs" as const, componentId, nodeA: nodeAId, nodeB: nodeBId, parent: parentNodeId };
patch(domContext, swapPatch, components);
// ensure function executes and children remain present
expect(document.body.children.length).toEqual(2);
// ensure component nodes still reference correct elements
expect(component.nodes[nodeAId].nodeName).toEqual('DIV');
expect(component.nodes[nodeBId].nodeName).toEqual('SPAN');
});

test('SwapDOMRefs with adjacent siblings', () => {
const componentId: number = 0;
const parentNodeId: number = 0;
const nodeAId: number = 1;
const nodeBId: number = 2;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const component: Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
const createA: CreateElement = { type: "createElement", tag: 'div', nodeId: nodeAId, componentId };
const createB: CreateElement = { type: "createElement", tag: 'span', nodeId: nodeBId, componentId };
const appendA: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeAId, componentId };
const appendB: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeBId, componentId };
patch(domContext, createA, components);
patch(domContext, createB, components);
patch(domContext, appendA, components);
patch(domContext, appendB, components);
expect(document.body.childNodes[0].nodeName).toEqual('DIV');
expect(document.body.childNodes[1].nodeName).toEqual('SPAN');
const swapPatch = { type: "swapDOMRefs" as const, componentId, nodeA: nodeAId, nodeB: nodeBId, parent: parentNodeId };
patch(domContext, swapPatch, components);
expect(document.body.childNodes[0].nodeName).toEqual('SPAN');
expect(document.body.childNodes[1].nodeName).toEqual('DIV');
});

test('SwapDOMRefs with non-adjacent siblings', () => {
const componentId: number = 0;
const parentNodeId: number = 0;
const nodeAId: number = 1;
const nodeMidId: number = 2;
const nodeBId: number = 3;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const component: Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
const createA: CreateElement = { type: "createElement", tag: 'div', nodeId: nodeAId, componentId };
const createMid: CreateElement = { type: "createElement", tag: 'em', nodeId: nodeMidId, componentId };
const createB: CreateElement = { type: "createElement", tag: 'span', nodeId: nodeBId, componentId };
const appendA: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeAId, componentId };
const appendMid: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeMidId, componentId };
const appendB: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeBId, componentId };
patch(domContext, createA, components);
patch(domContext, createMid, components);
patch(domContext, createB, components);
patch(domContext, appendA, components);
patch(domContext, appendMid, components);
patch(domContext, appendB, components);
expect(Array.from(document.body.childNodes).map(n => n.nodeName)).toEqual(['DIV','EM','SPAN']);
const swapPatch = { type: "swapDOMRefs" as const, componentId, nodeA: nodeAId, nodeB: nodeBId, parent: parentNodeId };
patch(domContext, swapPatch, components);
expect(Array.from(document.body.childNodes).map(n => n.nodeName)).toEqual(['SPAN','EM','DIV']);
});

test('Patch.ts swapDOMRefs branch executes', () => {
const componentId: number = 0;
const parentNodeId: number = 0;
const aId: number = 1;
const bId: number = 2;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const component: Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
const createA: CreateElement = { type: "createElement", tag: 'div', nodeId: aId, componentId };
const createB: CreateElement = { type: "createElement", tag: 'span', nodeId: bId, componentId };
const appendA: AppendChild = { type: "appendChild", parent: parentNodeId, child: aId, componentId };
const appendB: AppendChild = { type: "appendChild", parent: parentNodeId, child: bId, componentId };
patch(domContext, createA, components);
patch(domContext, createB, components);
patch(domContext, appendA, components);
patch(domContext, appendB, components);
const op = { type: "swapDOMRefs" as const, componentId, nodeA: aId, nodeB: bId, parent: parentNodeId };
patch(domContext, op, components);
// ensure executed and order changed
expect(document.body.firstChild.nodeName).toEqual('SPAN');
expect(document.body.childNodes[1].nodeName).toEqual('DIV');
});

test('SetInlineStyle handles updates and removals', () => {
const componentId: number = 0;
const parentNodeId: number = 0;
const nodeId: number = 1;
const domContext: DrawingContext<DOMRef> = drawingContext;
let components: Components<DOMRef> = {};
const component: Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
const create: CreateElement = { type: "createElement", tag: 'p', nodeId, componentId };
const append: AppendChild = { type: "appendChild", parent: parentNodeId, child: nodeId, componentId };
patch(domContext, create, components);
patch(domContext, append, components);
const initialStyle: SetInlineStyle = { type: "setInlineStyle", current: {}, new: { color: 'red', backgroundColor: 'white' }, nodeId, componentId };
patch(domContext, initialStyle, components);
expect(component.nodes[nodeId].style['color']).toEqual('red');
expect(component.nodes[nodeId].style['backgroundColor']).toEqual('white');
// Update color, remove backgroundColor
const updateStyle: SetInlineStyle = { type: "setInlineStyle", current: { color: 'red', backgroundColor: 'white' }, new: { color: 'blue' }, nodeId, componentId };
patch(domContext, updateStyle, components);
expect(component.nodes[nodeId].style['color']).toEqual('blue');
expect(component.nodes[nodeId].style['backgroundColor']).toEqual('');
});
test('Should process the CreateTextNode patch', () => {
const nodeId : number = 1;
const parentNodeId : number = 0;
Expand Down Expand Up @@ -509,6 +701,34 @@ describe ('Patch tests', () => {
expect(getPatches()).toEqual([expected, appendOperation]);
patchContext.flush ();
expect(getPatches().length).toEqual(0);

// also exercise flush branch in patch.ts
const components : Components<DOMRef> = {};
const component : Component<DOMRef> = {
model: null,
nodes: { 0: document.body },
events: null,
mountPoint: null
};
components[componentId] = component;
const flushPatch = { type: "flush" as const, componentId };
// Before calling flush via patch.ts, add something to DOM to verify it remains intact
const textCreate : CreateTextNode = { type: "createTextNode", text: 'bar', nodeId: 2, componentId };
const textAppend : AppendChild = { type: "appendChild", parent: 0, child: 2, componentId };
patch(domContext, textCreate, components);
patch(domContext, textAppend, components);
expect(document.body.childNodes.length).toBeGreaterThan(0);
patch(domContext, flushPatch, components);
// flush has no DOM effect; ensure still intact
expect(document.body.childNodes.length).toBeGreaterThan(0);
});

test('Patch.ts should handle missing component (withComponent else branch)', () => {
const domContext: DrawingContext<DOMRef> = drawingContext;
const components: Components<DOMRef> = {};
const badComponentId = 1234;
const op = { type: "flush" as const, componentId: badComponentId };
expect(() => patch(domContext, op, components)).not.toThrow();
});

test('Should process the ReplaceChild patch', () => {
Expand Down
16 changes: 16 additions & 0 deletions ts/spec/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ describe ('Utils tests', () => {
}
expect(expected.size).toBe(node.classList.size);
});

test('websocketSend guards: do not send when not open', () => {
const sent: any[] = [];
const dummySocket: any = { readyState: 0, send: (m) => sent.push(m) };
websocketSend(dummySocket, 'hello');
expect(sent.length).toBe(0);
dummySocket.readyState = WebSocket.OPEN;
websocketSend(dummySocket, 'world');
expect(sent).toEqual(['world']);
});

test('populateClass handles empty strings and trims', () => {
const vnode = { classList: undefined } as any;
populateClass(vnode, [' foo bar ', '', 'baz qux']);
expect(Array.from(vnode.classList)).toEqual(['foo', 'bar', 'baz', 'qux']);
});

});

Expand Down