chat / client / test / harness / minidom.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/**
 * A MODEL DOM — enough of one for `replicant.dom` to reconcile into, and no
 * more. It exists to answer one question that a string renderer structurally
 * cannot: does a keyed node keep its ELEMENT across a re-render and a re-order?
 *
 * WHY THIS AND NOT jsdom. The spike asked exactly this of jsdom, and jsdom is
 * the obvious answer. It is also ~40 transitive packages added to a repo whose
 * every dependency is exact-pinned and whose lockfile carries three git deps
 * that must not be re-resolved by accident (dev/docs/CLJS.md, house rule 2) —
 * for a test that only needs tree semantics. The surface `replicant.dom`
 * actually touches is the twenty-odd operations below, and every one of them is
 * unambiguous: appendChild moves a node out of its old parent, insertBefore
 * puts it at an index, removeChild detaches it. So this is a faithful model
 * rather than a stub, in the sense harness/dom.mjs's header means: it behaves
 * like the real thing for the operations under test, or it throws.
 *
 * AND IT IS PROBED. test/tiles-view.test.mjs runs the identity questions
 * against an UNKEYED render of the same list as well as a keyed one, and
 * requires the unkeyed one to FAIL them. A model DOM lenient enough to pass
 * both would be a model DOM that proves nothing, and that is the failure mode
 * this whole file is exposed to.
 *
 * What it deliberately does NOT model: layout, CSS, event dispatch (handlers
 * are recorded, never fired), selectors, namespaces beyond storing them,
 * `getComputedStyle` (replicant reaches for it only for :replicant/mounting and
 * :replicant/unmounting transitions, which nothing in this repo uses — if a
 * screen ever grows one, this harness must grow with it or say so loudly).
 */

class ClassList {
  constructor(el) {
    this._el = el;
  }
  add(cn) {
    if (!this._el._classes.includes(cn)) this._el._classes.push(cn);
  }
  remove(cn) {
    this._el._classes = this._el._classes.filter((c) => c !== cn);
  }
  contains(cn) {
    return this._el._classes.includes(cn);
  }
}

class Style {
  constructor() {
    this._props = new Map();
  }
  setProperty(k, v) {
    this._props.set(k, v);
  }
  removeProperty(k) {
    this._props.delete(k);
  }
  toString() {
    return [...this._props].map(([k, v]) => `${k}: ${v}`).join("; ");
  }
}

let uid = 0;

class Node {
  constructor(kind) {
    this.__id = ++uid;
    this.__kind = kind;
    this.parentNode = null;
    this.childNodes = [];
  }

  get isConnected() {
    let n = this;
    while (n.parentNode) n = n.parentNode;
    return n.__isRoot === true;
  }

  _detach(child) {
    const i = this.childNodes.indexOf(child);
    if (i >= 0) this.childNodes.splice(i, 1);
  }

  appendChild(child) {
    if (child.parentNode) child.parentNode._detach(child);
    child.parentNode = this;
    this.childNodes.push(child);
    return child;
  }

  insertBefore(child, ref) {
    if (child.parentNode) child.parentNode._detach(child);
    child.parentNode = this;
    const i = ref == null ? -1 : this.childNodes.indexOf(ref);
    if (i < 0) this.childNodes.push(child);
    else this.childNodes.splice(i, 0, child);
    return child;
  }

  removeChild(child) {
    if (child.parentNode !== this) throw new Error("removeChild: not a child");
    this._detach(child);
    child.parentNode = null;
    return child;
  }

  replaceChild(insert, replace) {
    const i = this.childNodes.indexOf(replace);
    if (i < 0) throw new Error("replaceChild: not a child");
    if (insert.parentNode) insert.parentNode._detach(insert);
    this.childNodes[i] = insert;
    insert.parentNode = this;
    replace.parentNode = null;
    return replace;
  }

  /** The one property replicant sets to clear a subtree. */
  set textContent(v) {
    if (v !== "") throw new Error(`minidom: textContent may only be cleared, got ${JSON.stringify(v)}`);
    for (const c of this.childNodes) c.parentNode = null;
    this.childNodes = [];
  }
  get textContent() {
    if (this.__kind === "text") return this._text;
    return this.childNodes.map((c) => c.textContent).join("");
  }
}

class Text extends Node {
  constructor(text) {
    super("text");
    this._text = String(text);
  }
  get textContent() {
    return this._text;
  }
  set textContent(v) {
    this._text = String(v);
  }
}

class Element extends Node {
  constructor(tagName, ns) {
    super("element");
    this.tagName = tagName;
    this.namespaceURI = ns ?? null;
    this._attrs = new Map();
    this._classes = [];
    this._listeners = new Map();
    this.style = new Style();
    this.classList = new ClassList(this);
  }

  setAttribute(a, v) {
    this._attrs.set(String(a), String(v));
  }
  setAttributeNS(_ns, a, v) {
    this._attrs.set(String(a), String(v));
  }
  getAttribute(a) {
    return this._attrs.has(String(a)) ? this._attrs.get(String(a)) : null;
  }
  removeAttribute(a) {
    this._attrs.delete(String(a));
  }
  addEventListener(ev, fn) {
    this._listeners.set(ev, fn);
  }
  removeEventListener(ev) {
    this._listeners.delete(ev);
  }

  set innerHTML(v) {
    if (v !== "") throw new Error("minidom: innerHTML may only be cleared");
    for (const c of this.childNodes) c.parentNode = null;
    this.childNodes = [];
  }
  get innerHTML() {
    return "";
  }

  get className() {
    return this._classes.join(" ");
  }
}

/** A detached tree root: everything under it reports isConnected. */
export function makeRoot(tagName = "div") {
  const el = new Element(tagName, null);
  el.__isRoot = true;
  return el;
}

/**
 * Point `globalThis.document`'s three factory methods at this model, leaving
 * harness/dom.mjs's stub in place for everything modules touch at import time.
 * Returns a restore function.
 */
export function installMiniDom() {
  const d = globalThis.document;
  const saved = {
    createElement: d.createElement,
    createTextNode: d.createTextNode,
    createElementNS: d.createElementNS,
  };
  d.createElement = (tag) => new Element(tag, null);
  d.createTextNode = (text) => new Text(text);
  d.createElementNS = (ns, tag) => new Element(tag, ns);
  return () => Object.assign(d, saved);
}

/** Flatten a node's element children (text nodes skipped). */
export const kids = (el) => el.childNodes.filter((n) => n.__kind === "element");

/**
 * Depth-first find of the first element carrying `id`. Not a selector engine —
 * replicant writes the id with setAttribute, so this reads exactly what it
 * wrote, and nothing here parses a query string.
 */
export function byId(el, id) {
  for (const c of el.childNodes) {
    if (c.__kind !== "element") continue;
    if (c.getAttribute("id") === id) return c;
    const found = byId(c, id);
    if (found) return found;
  }
  return null;
}

/** Depth-first find of the first element whose tagName matches. */
export function firstTag(el, tagName) {
  for (const c of el.childNodes) {
    if (c.__kind !== "element") continue;
    if (c.tagName === tagName) return c;
    const found = firstTag(c, tagName);
    if (found) return found;
  }
  return null;
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git