mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-26 17:45:09 +02:00
fix(resilience): land deep-dive widget integration on main (#2728)
* feat(resilience): integrate widget into country deep dive Wire the resilience widget into the country deep-dive summary area, keep it responsive next to the existing CII card, and destroy the widget on panel lifecycle transitions so auth subscriptions and fetches do not leak across country changes. Validation: - node --import tsx --test tests/resilience-country-brief.test.mjs tests/resilience-widget.test.mts - npm run typecheck (fails only on the existing Dodo/Clerk baseline already broken on this branch) * fix(resilience): centralize deep-dive widget teardown Remove duplicate ResilienceWidget destroys across panel state transitions and replace the source-text regression with a mini-DOM behavioural harness. * fix(test): correct mini-DOM querySelector semantics and widget stub fidelity - querySelectorAll no longer matches the root element itself (matches real DOM spec: only descendants are searched) - Widget stub destroy() no longer self-removes from DOM (matches real ResilienceWidget.destroy() which only unsubscribes) - Test now correctly asserts hide() destroys subscriptions but keeps DOM intact (panel is visually hidden, DOM cleared on next show()) --------- Co-authored-by: lspassos1 <lspassos@icloud.com>
This commit is contained in:
@@ -74,6 +74,17 @@ class MiniNode extends EventTarget {
|
||||
return child;
|
||||
}
|
||||
|
||||
append(...children) {
|
||||
children.forEach((child) => {
|
||||
if (child == null) return;
|
||||
if (typeof child === 'string' || typeof child === 'number') {
|
||||
this.appendChild(new MiniText(child));
|
||||
return;
|
||||
}
|
||||
this.appendChild(child);
|
||||
});
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.childNodes.indexOf(child);
|
||||
if (index >= 0) {
|
||||
@@ -109,6 +120,18 @@ class MiniNode extends EventTarget {
|
||||
return this.childNodes.at(-1) ?? null;
|
||||
}
|
||||
|
||||
get firstElementChild() {
|
||||
return this.childNodes.find((child) => child instanceof MiniElement) ?? null;
|
||||
}
|
||||
|
||||
get lastElementChild() {
|
||||
return [...this.childNodes].reverse().find((child) => child instanceof MiniElement) ?? null;
|
||||
}
|
||||
|
||||
get childElementCount() {
|
||||
return this.childNodes.filter((child) => child instanceof MiniElement).length;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this.childNodes.map((child) => child.textContent ?? '').join('');
|
||||
}
|
||||
@@ -116,6 +139,11 @@ class MiniNode extends EventTarget {
|
||||
set textContent(value) {
|
||||
this.childNodes = [new MiniText(value ?? '')];
|
||||
}
|
||||
|
||||
replaceChildren(...children) {
|
||||
this.childNodes = [];
|
||||
this.append(...children);
|
||||
}
|
||||
}
|
||||
|
||||
class MiniText extends MiniNode {
|
||||
@@ -220,15 +248,24 @@ class MiniElement extends MiniNode {
|
||||
if (name === 'class') this.className = '';
|
||||
}
|
||||
|
||||
querySelector() {
|
||||
return null;
|
||||
matches(selector) {
|
||||
return matchesSelector(this, selector);
|
||||
}
|
||||
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
querySelector(selector) {
|
||||
return querySelectorAll(this, selector)[0] ?? null;
|
||||
}
|
||||
|
||||
closest() {
|
||||
querySelectorAll(selector) {
|
||||
return querySelectorAll(this, selector);
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
let current = this;
|
||||
while (current instanceof MiniElement) {
|
||||
if (current.matches(selector)) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -242,6 +279,11 @@ class MiniElement extends MiniNode {
|
||||
return { width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 };
|
||||
}
|
||||
|
||||
focus() {
|
||||
const doc = this.ownerDocument ?? globalThis.document;
|
||||
if (doc) doc.activeElement = this;
|
||||
}
|
||||
|
||||
get nextElementSibling() {
|
||||
if (!this.parentNode) return null;
|
||||
const siblings = this.parentNode.childNodes.filter((child) => child instanceof MiniElement);
|
||||
@@ -263,6 +305,14 @@ class MiniElement extends MiniNode {
|
||||
get outerHTML() {
|
||||
return `<${this.tagName.toLowerCase()}>${this.innerHTML}</${this.tagName.toLowerCase()}>`;
|
||||
}
|
||||
|
||||
get children() {
|
||||
return this.childNodes.filter((child) => child instanceof MiniElement);
|
||||
}
|
||||
|
||||
get offsetParent() {
|
||||
return this.isConnected ? (this.parentElement ?? null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
class MiniStorage {
|
||||
@@ -294,11 +344,16 @@ class MiniDocument extends EventTarget {
|
||||
this.documentElement.clientHeight = 800;
|
||||
this.documentElement.clientWidth = 1200;
|
||||
this.body = new MiniElement('body');
|
||||
this.documentElement.ownerDocument = this;
|
||||
this.body.ownerDocument = this;
|
||||
this.documentElement.appendChild(this.body);
|
||||
this.activeElement = this.body;
|
||||
}
|
||||
|
||||
createElement(tagName) {
|
||||
return new MiniElement(tagName);
|
||||
const element = new MiniElement(tagName);
|
||||
element.ownerDocument = this;
|
||||
return element;
|
||||
}
|
||||
|
||||
createTextNode(value) {
|
||||
@@ -308,9 +363,122 @@ class MiniDocument extends EventTarget {
|
||||
createDocumentFragment() {
|
||||
return new MiniDocumentFragment();
|
||||
}
|
||||
|
||||
getElementById(id) {
|
||||
return querySelectorAll(this.documentElement, `#${id}`)[0] ?? null;
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.documentElement.querySelector(selector);
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
return this.documentElement.querySelectorAll(selector);
|
||||
}
|
||||
}
|
||||
|
||||
function createBrowserEnvironment() {
|
||||
function splitSelectorList(selector) {
|
||||
return String(selector)
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseSimpleSelector(selector) {
|
||||
const trimmed = selector.trim();
|
||||
const result = {
|
||||
tag: null,
|
||||
id: null,
|
||||
classes: [],
|
||||
attributes: [],
|
||||
notAttributes: [],
|
||||
};
|
||||
let remaining = trimmed;
|
||||
|
||||
const tagMatch = remaining.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
|
||||
if (tagMatch) {
|
||||
result.tag = tagMatch[0].toUpperCase();
|
||||
remaining = remaining.slice(tagMatch[0].length);
|
||||
}
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.startsWith('#')) {
|
||||
const match = remaining.match(/^#([A-Za-z0-9_-]+)/);
|
||||
if (!match) break;
|
||||
result.id = match[1];
|
||||
remaining = remaining.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remaining.startsWith('.')) {
|
||||
const match = remaining.match(/^\.([A-Za-z0-9_-]+)/);
|
||||
if (!match) break;
|
||||
result.classes.push(match[1]);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remaining.startsWith(':not(')) {
|
||||
const match = remaining.match(/^:not\(\[([^\]=]+)(?:="([^"]*)")?\]\)/);
|
||||
if (!match) break;
|
||||
result.notAttributes.push({ name: match[1], value: match[2] ?? null });
|
||||
remaining = remaining.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remaining.startsWith('[')) {
|
||||
const match = remaining.match(/^\[([^\]=]+)(?:="([^"]*)")?\]/);
|
||||
if (!match) break;
|
||||
result.attributes.push({ name: match[1], value: match[2] ?? null });
|
||||
remaining = remaining.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchesSelector(element, selector) {
|
||||
return splitSelectorList(selector).some((part) => {
|
||||
const parsed = parseSimpleSelector(part);
|
||||
if (parsed.tag && element.tagName !== parsed.tag) return false;
|
||||
if (parsed.id && element.id !== parsed.id) return false;
|
||||
if (parsed.classes.some((name) => !element.classList.contains(name))) return false;
|
||||
if (parsed.attributes.some(({ name, value }) => {
|
||||
if (!element.hasAttribute(name)) return true;
|
||||
return value != null && element.getAttribute(name) !== value;
|
||||
})) return false;
|
||||
if (parsed.notAttributes.some(({ name, value }) => {
|
||||
if (!element.hasAttribute(name)) return false;
|
||||
return value == null ? true : element.getAttribute(name) === value;
|
||||
})) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function querySelectorAll(root, selector) {
|
||||
const matches = [];
|
||||
|
||||
function visit(node) {
|
||||
if (!(node instanceof MiniElement)) return;
|
||||
if (node.matches(selector)) {
|
||||
matches.push(node);
|
||||
}
|
||||
node.childNodes.forEach(visit);
|
||||
}
|
||||
|
||||
if (root instanceof MiniElement) {
|
||||
root.childNodes.forEach(visit);
|
||||
return matches;
|
||||
}
|
||||
|
||||
root.childNodes.forEach(visit);
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function createBrowserEnvironment() {
|
||||
const document = new MiniDocument();
|
||||
const localStorage = new MiniStorage();
|
||||
const window = {
|
||||
@@ -321,6 +489,15 @@ function createBrowserEnvironment() {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
open() {},
|
||||
location: {
|
||||
origin: 'https://worldmonitor.test',
|
||||
href: 'https://worldmonitor.test/',
|
||||
},
|
||||
navigator: {
|
||||
clipboard: {
|
||||
async writeText() {},
|
||||
},
|
||||
},
|
||||
getComputedStyle() {
|
||||
return {
|
||||
display: '',
|
||||
@@ -335,10 +512,13 @@ function createBrowserEnvironment() {
|
||||
document,
|
||||
localStorage,
|
||||
window,
|
||||
requestAnimationFrame() {
|
||||
requestAnimationFrame(callback) {
|
||||
if (typeof callback === 'function') callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame() {},
|
||||
HTMLElement: MiniElement,
|
||||
HTMLButtonElement: MiniElement,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user