change: Falt-Voreinstellung wird ein Umschalter, Schwelle ab M abwärts (D44)

Statt eines Aufklapp-Menüs mit zwei Einträgen jetzt ein Umschalter neben
„verworfene einblenden" und „günstigster Pfad". Die beiden Einträge waren nie
unabhängige Befehle, sondern die zwei Stellungen einer Sache — jeder
beschreibt einen vollständigen Faltzustand, zusammen decken sie ihn ab.

Die Schwelle schließt M jetzt ein (atMostM statt belowM): Ein M-Paket ist
selbst noch die Einheit, die man als Ganzes plant, seine Zerlegung ist schon
das Innere. Offen bleiben L, XL, XXL — und Knoten ohne Größenangabe.

Der Zustand des Knopfes wird bei jedem Rendern am Baum abgelesen, nicht
gemerkt: Klappt jemand danach einen Knoten von Hand um, springt er von selbst
heraus, statt etwas zu behaupten, das nicht mehr stimmt. Deshalb auch nicht
persistiert — der Faltzustand steht im Text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 10:44:03 +02:00
co-authored by Claude Fable 5
parent 9bbe7d78cc
commit 417006960f
8 changed files with 124 additions and 144 deletions
+8 -5
View File
@@ -340,12 +340,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad,
D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die
Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren
sie den Text. **Voreinstellungen** im Diagramm-Kopf (`applyFoldPreset`, D44)
gehen denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen
(`belowM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus
sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
geht denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen
(`atMostM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus
`writeFoldToText()` herausgelöste Voll-Rewrite; einen minimalen Patch gibt es
hier nicht. Ein Knoten **ohne** Größe zählt nicht als „unter M" (anders als
bei den Pfadkosten, D18). **`replaceTextUndoable()` meldet bei unveränderten Text
hier nicht. Ein Knoten **ohne** Größe zählt nicht als „bis M" (anders als
bei den Pfadkosten, D18). Sein `aria-pressed` wird **in `render()` am Baum
abgelesen**, nicht gemerkt und nicht persistiert — sonst behauptete er nach
einem einzelnen Umklappen etwas Falsches.
**`replaceTextUndoable()` meldet bei unveränderten Text
`false`** (D38-Nachtrag 3): Ohne Textänderung feuert kein `input`, also läuft
kein `render()` — der Aufrufer muss dann selbst zeichnen. Sonst bleibt das
Bild stehen (aufgefallen beim Aufklappen eines nur per Überlagerung
+5 -17
View File
@@ -159,23 +159,11 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg>
<span class="freshcount" id="freshCount" aria-hidden="true">0</span>
</button>
<!-- Faltung für den ganzen Baum (SPEC §9, D44). Ein Auslöser, zwei
Optionen — deshalb ein Aufklapp-Menü und keine zwei Knöpfe. -->
<div class="foldgroup" id="foldGroup">
<button type="button" class="copybtn foldtrigger" id="foldTrigger" aria-haspopup="true" aria-expanded="false" data-i18n-title="foldMenu" data-i18n-aria="foldMenu" title="Teilbäume auf- und zuklappen" aria-label="Teilbäume auf- und zuklappen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 4h13M8 9h13M8 15h13M8 20h13M2 6l2.5 2.5L2 11"/></svg>
</button>
<div class="foldmenu">
<button type="button" class="copybtn foldbtn" id="foldExpandAll">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v18M8 7l4-4 4 4M8 17l4 4 4-4"/></svg>
<span class="fold-label" data-i18n="foldExpandAll">alle aufklappen</span>
</button>
<button type="button" class="copybtn foldbtn" id="foldCollapseSmall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21V3M8 8l4 4 4-4M8 16l4-4 4 4"/></svg>
<span class="fold-label" data-i18n="foldCollapseSmall">unter Größe M zuklappen</span>
</button>
</div>
</div>
<!-- Faltung für den ganzen Baum (SPEC §9, D44): ein Umschalter wie die
beiden Nachbarn. Gedrückt = alles ab Größe M abwärts ist zugeklappt. -->
<button type="button" class="copybtn togglebtn" id="foldSmallBtn" aria-pressed="false" data-i18n-title="foldSmallTooltip" data-i18n-aria="foldSmallTooltip" title="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf" aria-label="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21V3M8 8l4 4 4-4M8 16l4-4 4 4"/></svg>
</button>
<div class="seg" role="radiogroup" aria-label="Darstellung">
<label data-i18n-title="modeHorizontal" title="Horizontal Organigramm, Diagramm über dem Editor">
<input type="radio" name="layout" value="horizontal" checked data-i18n-aria="modeHorizontal" aria-label="Horizontal Organigramm">
+35 -53
View File
@@ -1,6 +1,6 @@
import './style.css';
import { parse, setFoldMark } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, belowM } from './model.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js';
@@ -124,13 +124,25 @@ function render(){
const initFold = initialCollapsed(roots, true);
const keys = nodeKeys(roots);
const collapsedSet = new Set();
let foldSmallExact = true, foldSmallAny = false;
foldByLine = new Map();
keys.forEach((key, n) => {
const ov = foldOverrides.get(key);
const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
if(collapsed) collapsedSet.add(n);
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
/* Der Umschalter (D44) merkt sich nichts, er liest ab: gedrückt genau
dann, wenn JEDER faltbare Knoten so steht, wie die Voreinstellung ihn
stellen würde. */
if(n.children.length > 0){
if(atMostM(n)) foldSmallAny = true;
if(collapsed !== atMostM(n)) foldSmallExact = false;
}
});
/* `foldSmallAny`: Gäbe es gar keinen Knoten bis M, wären „zugeklappt" und
„alles offen" derselbe Zustand — der Knopf säße dann gedrückt da, ohne
dass etwas zugeklappt ist. */
foldSmallBtn.setAttribute('aria-pressed', foldSmallExact && foldSmallAny ? 'true' : 'false');
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
freshSet, collapsedSet,
effStatus: effectiveStatus(roots)});
@@ -1001,7 +1013,7 @@ function applyFoldPreset(mode){
if(!roots.length) return;
nodeKeys(roots).forEach((key, n) => {
if(!n.children.length) return; /* nur faltbare Knoten */
foldOverrides.set(key, mode === 'small' ? belowM(n) : false);
foldOverrides.set(key, mode === 'small' ? atMostM(n) : false);
});
if(!src.readOnly && writeAllFoldMarks(roots, desiredFoldKeys(roots))) foldOverrides.clear();
else render(); /* Pad o. nicht ausdrückbar: Überlagerung trägt */
@@ -1399,6 +1411,18 @@ function discardedShown(){ return showc.getAttribute('aria-pressed') === 'true';
function setDiscarded(on){ showc.setAttribute('aria-pressed', on ? 'true' : 'false'); }
showc.addEventListener('click', () => { setDiscarded(!discardedShown()); render(); saveUI(); });
/* Faltung für den ganzen Baum (SPEC §9, D44): ein Umschalter wie die beiden
Nachbarn. Gedrückt heißt „alles unter Größe M ist zugeklappt". Der Zustand
wird nicht gemerkt, sondern bei jedem Rendern am Baum ABGELESEN
(`syncFoldSmallBtn`) — klappt jemand danach einen Knoten von Hand um, geht
der Knopf von selbst heraus, statt etwas zu behaupten, das nicht mehr
stimmt. Deshalb auch nicht in `werkbaum-ui` persistiert: Der Faltzustand
steht im Text (D38-Nachtrag 2), der Knopf liest ihn nur ab. */
const foldSmallBtn = document.getElementById('foldSmallBtn');
foldSmallBtn.addEventListener('click', () => {
applyFoldPreset(foldSmallBtn.getAttribute('aria-pressed') === 'true' ? 'all' : 'small');
});
/* Günstigster-Pfad-Hervorhebung an/aus */
const cheapBtn = document.getElementById('cheapBtn');
cheapBtn.addEventListener('click', () => {
@@ -1466,30 +1490,6 @@ document.addEventListener('click', e => {
if(dlGroup.classList.contains('open') && !dlGroup.contains(e.target)) closeDlMenu();
});
/* Faltung für den ganzen Baum (SPEC §9, D44) — dasselbe Menü-Idiom, hier auf
allen Bildschirmgrößen, weil die Einträge Text tragen. */
const foldGroup = document.getElementById('foldGroup');
const foldTrigger = document.getElementById('foldTrigger');
function closeFoldMenu(){
foldGroup.classList.remove('open');
foldTrigger.setAttribute('aria-expanded', 'false');
}
foldTrigger.addEventListener('click', () => {
const open = foldGroup.classList.toggle('open');
foldTrigger.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', e => {
if(foldGroup.classList.contains('open') && !foldGroup.contains(e.target)) closeFoldMenu();
});
document.addEventListener('keydown', e => {
if(e.key === 'Escape' && foldGroup.classList.contains('open')){ closeFoldMenu(); foldTrigger.focus(); }
});
document.getElementById('foldExpandAll').addEventListener('click', () => {
closeFoldMenu(); applyFoldPreset('all');
});
document.getElementById('foldCollapseSmall').addEventListener('click', () => {
closeFoldMenu(); applyFoldPreset('small');
});
/* ---------- Internationalisierung (DE/EN/ES/FR) ---------- */
const I18N = {
@@ -1516,9 +1516,7 @@ const I18N = {
riskTooltip:"High Risk Aufwand noch unklar.",
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
cheapTooltip:"Günstigsten Pfad hervorheben nicht benötigte Alternativen treten zurück",
foldMenu:"Teilbäume auf- und zuklappen",
foldExpandAll:"alle aufklappen",
foldCollapseSmall:"unter Größe M zuklappen",
foldSmallTooltip:"Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf",
implicitSizeTooltip:"Keine Größe angegeben für die Kostenschätzung als M angenommen",
fullscreenTooltip:"Vollbild Panels nutzen die ganze Fensterbreite",
brandTooltip:"„Werkbaum“ bedeutet so viel wie Werk-Baum — der Baum des Projektstrukturplans (WBS).",
@@ -1599,9 +1597,7 @@ const I18N = {
riskTooltip:"High risk effort still unclear.",
discardedTooltip:"Show/hide discarded nodes and their subtree",
cheapTooltip:"Highlight the cheapest path unneeded alternatives recede",
foldMenu:"Expand and collapse subtrees",
foldExpandAll:"expand all",
foldCollapseSmall:"collapse below size M",
foldSmallTooltip:"Collapse nodes of size M and smaller press again to expand them all",
implicitSizeTooltip:"No size given assumed as M for the cost estimate",
fullscreenTooltip:"Full screen panels use the full window width",
brandTooltip:"“Werkbaum” means roughly work tree — the tree of the work breakdown structure (WBS).",
@@ -1682,9 +1678,7 @@ const I18N = {
riskTooltip:"Alto riesgo esfuerzo aún incierto.",
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan",
foldMenu:"Desplegar y plegar subárboles",
foldExpandAll:"desplegar todo",
foldCollapseSmall:"plegar por debajo de la talla M",
foldSmallTooltip:"Plegar los nodos de talla M o menor: pulsa de nuevo para desplegarlos todos",
implicitSizeTooltip:"Sin tamaño indicado: se asume M para el cálculo de costes",
fullscreenTooltip:"Pantalla completa los paneles usan todo el ancho de la ventana",
brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo — el árbol de la estructura de desglose del trabajo (EDT).",
@@ -1765,9 +1759,7 @@ const I18N = {
riskTooltip:"Risque élevé effort encore incertain.",
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
cheapTooltip:"Mettre en évidence le chemin le moins coûteux les alternatives inutiles s'estompent",
foldMenu:"plier et replier les sous-arbres",
foldExpandAll:"tout déplier",
foldCollapseSmall:"replier en dessous de la taille M",
foldSmallTooltip:"Replier les nœuds de taille M et moins appuyez à nouveau pour tout déplier",
implicitSizeTooltip:"Aucune taille indiquée considérée comme M pour l'estimation des coûts",
fullscreenTooltip:"Plein écran les panneaux occupent toute la largeur de la fenêtre",
brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — larbre de lorganigramme des tâches (WBS).",
@@ -1848,9 +1840,7 @@ const I18N = {
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
cheapTooltip:"Wyróżnij najtańszą ścieżkę niepotrzebne alternatywy są przygaszone",
foldMenu:"Rozwijanie i zwijanie poddrzew",
foldExpandAll:"rozwiń wszystko",
foldCollapseSmall:"zwiń poniżej rozmiaru M",
foldSmallTooltip:"Zwiń węzły o rozmiarze M i mniejsze naciśnij ponownie, aby rozwinąć wszystkie",
implicitSizeTooltip:"Nie podano rozmiaru przyjęto M do szacowania kosztów",
fullscreenTooltip:"Pełny ekran panele wykorzystują całą szerokość okna",
brandTooltip:"„Werkbaum” znaczy mniej więcej drzewo pracy — drzewo struktury podziału pracy (WBS).",
@@ -1931,9 +1921,7 @@ const I18N = {
riskTooltip:"Высокий риск – оценка ещё не ясна.",
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
foldMenu:"Развернуть и свернуть поддеревья",
foldExpandAll:"развернуть все",
foldCollapseSmall:"свернуть меньше размера M",
foldSmallTooltip:"Свернуть узлы размера M и меньше — нажмите ещё раз, чтобы развернуть все",
implicitSizeTooltip:"Размер не указан — для оценки затрат принят как M",
fullscreenTooltip:"Полный экран – панели занимают всю ширину окна",
brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).",
@@ -2014,9 +2002,7 @@ const I18N = {
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
foldMenu:"उपवृक्ष खोलें और समेटें",
foldExpandAll:"सभी खोलें",
foldCollapseSmall:"आकार M से छोटे समेटें",
foldSmallTooltip:"आकार M और उससे छोटे नोड समेटें – सभी को खोलने के लिए फिर दबाएँ",
implicitSizeTooltip:"कोई आकार नहीं दिया गया – लागत अनुमान के लिए M माना गया",
fullscreenTooltip:"पूर्ण स्क्रीन – पैनल पूरी विंडो चौड़ाई का उपयोग करते हैं",
brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।",
@@ -2086,9 +2072,7 @@ const I18N = {
fullscreenTooltip:"全屏——面板占据整个窗口宽度",
discardedTooltip:"显示/隐藏已放弃的节点及其子树",
cheapTooltip:"突出显示成本最低的路径——不需要的备选项将淡化",
foldMenu:"展开和折叠子树",
foldExpandAll:"全部展开",
foldCollapseSmall:"折叠小于 M 的节点",
foldSmallTooltip:"折叠尺寸 M 及更小的节点——再次点击可全部展开",
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
ghostTooltip:"从 M 号起,元素应进一步细分。",
jumpHint:"Alt+点击:跳转到文本中的该行",
@@ -2169,9 +2153,7 @@ const I18N = {
fullscreenTooltip:"全画面 — パネルがウィンドウ幅いっぱいを使用",
discardedTooltip:"破棄したノードとその下位ツリーを表示/非表示",
cheapTooltip:"最も低コストの経路を強調 – 不要な選択肢は控えめに表示",
foldMenu:"サブツリーの展開と折りたたみ",
foldExpandAll:"すべて展開",
foldCollapseSmall:"サイズ M 未満を折りたたむ",
foldSmallTooltip:"サイズ M 以下のノードを折りたたむ – もう一度押すとすべて展開",
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
jumpHint:"Alt+クリック:テキストの該当行へ移動",
+7 -6
View File
@@ -21,12 +21,13 @@ export function needsBreakdown(n){
return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
}
/* Faltung-Voreinstellung „alles unter Größe M zuklappen" (SPEC §9, D44):
Knoten mit ANGEGEBENER Größe kleiner als M. Fehlt die Angabe, wird nichts
angenommen — der günstigste Pfad rechnet sie zwar als M (D18), das ist aber
eine Kostenannahme des Werkzeugs und keine Aussage des Autors; danach den
Baum zuzuklappen hieße, eine Vermutung wie eine Angabe zu behandeln. */
export function belowM(n){ return !!n.size && SIZE_RANK[n.size] < SIZE_RANK.M; }
/* Faltung-Voreinstellung „Größe M und kleiner zuklappen" (SPEC §9, D44):
Knoten mit ANGEGEBENER Größe bis einschließlich M — offen bleiben also nur
L, XL, XXL. Fehlt die Angabe, wird nichts angenommen: Der günstigste Pfad
rechnet sie zwar als M (D18), das ist aber eine Kostenannahme des Werkzeugs
und keine Aussage des Autors; danach den Baum zuzuklappen hieße, eine
Vermutung wie eine Angabe zu behandeln. */
export function atMostM(n){ return !!n.size && SIZE_RANK[n.size] <= SIZE_RANK.M; }
/* Sichtbare Kinder: verworfene ausblenden, außer showDiscarded ist gesetzt. */
export function visibleChildren(n, showDiscarded){
-14
View File
@@ -397,20 +397,6 @@
.dlgroup{display:inline-flex;align-items:center;gap:3px}
.dlmenu{display:inline-flex;gap:3px}
.dltrigger{display:none}
/* Faltung für den ganzen Baum (SPEC §9, D44): Anders als die Download-Gruppe
klappt diese IMMER auf — die beiden Einträge tragen Text, der inline die
Kopfzeile sprengte. Idiom und Maße wie dort. */
.foldgroup{display:inline-flex;align-items:center;position:relative}
.foldtrigger[aria-expanded="true"]{border-color:var(--or);color:var(--or)}
.foldmenu{display:none}
.foldgroup.open .foldmenu{
display:flex;flex-direction:column;gap:5px;
position:absolute;top:calc(100% + 6px);right:0;z-index:40;
background:var(--card);border:1px solid rgba(36,52,71,.15);
border-radius:8px;padding:5px;box-shadow:0 6px 18px rgba(36,52,71,.28);
}
.foldbtn{gap:6px;width:100%;justify-content:flex-start;padding-inline:8px;white-space:nowrap}
.fold-label{font-size:.68rem;font-weight:500;letter-spacing:.01em}
.legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */
.panetoggle{display:none} /* ebenso: dort gibt es nur einen Bereich */
/* Zurückschreiben der Faltung (D38-Nachtrag 2): Für die Dauer des SYNCHRONEN
+8 -8
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { parse, setFoldMark } from '../src/parser.js';
import { initialCollapsed, computeCheapSet, belowM } from '../src/model.js';
import { initialCollapsed, computeCheapSet, atMostM } from '../src/model.js';
import { renderTreeHtml } from '../src/render.js';
const t = key => key;
@@ -256,12 +256,12 @@ describe('Günstigster Pfad an eingeklappten Knoten', () => {
/* Voreinstellung „unter Größe M zuklappen" im Diagramm-Kopf (SPEC §9, D44).
Das Falten selbst liegt in app.js (kein headless-Testziel); geprüft wird
das Prädikat, das entscheidet, WELCHE Knoten es trifft. */
describe('belowM — Auswahl für „unter Größe M zuklappen"', () => {
const groessen = txt => roots(txt).map(n => [n.label, belowM(n)]);
describe('atMostM — Auswahl für „Größe M und kleiner zuklappen"', () => {
const groessen = txt => roots(txt).map(n => [n.label, atMostM(n)]);
it('trifft XS und S, nicht M und darüber', () => {
it('trifft XS, S und M — offen bleiben nur L, XL, XXL', () => {
expect(groessen('[ ] a (XS)\n[ ] b (S)\n[ ] c (M)\n[ ] d (L)\n[ ] e (XL)\n[ ] f (XXL)'))
.toEqual([['a', true], ['b', true], ['c', false], ['d', false],
.toEqual([['a', true], ['b', true], ['c', true], ['d', false],
['e', false], ['f', false]]);
});
@@ -277,8 +277,8 @@ describe('belowM — Auswahl für „unter Größe M zuklappen"', () => {
});
it('gilt auch für Knoten tief im Baum', () => {
const [w] = roots('[ ] W (XL)\n - [ ] A (S)\n - [ ] A1 (XS)\n - [ ] B (L)');
expect([belowM(w), ...w.children.map(belowM)]).toEqual([false, true, false]);
expect(belowM(w.children[0].children[0])).toBe(true);
const [w] = roots('[ ] W (XL)\n - [ ] A (M)\n - [ ] A1 (XS)\n - [ ] B (L)');
expect([atMostM(w), ...w.children.map(atMostM)]).toEqual([false, true, false]);
expect(atMostM(w.children[0].children[0])).toBe(true);
});
});