feat: Größen-Konflikt — Teilpakete müssen zur angegebenen Größe passen (D62)
Größen zählen für diese eine Prüfung als Bereiche (Untergrenzen verdoppeln sich, XXL nach oben offen); Konflikt erst, wenn selbst die günstigste Lesart der Kinder die großzügigste des Elternknotens erreicht. Warnung sizeConflict an der Elternzeile, Badge in --warn, Grund in Tooltip und aria-label. Nichts wird automatisch korrigiert. Nebenbefund behoben: der Grafikexport zeichnete das Größen-Badge mit festen Farben — das invertierte implizite M stand seit jeher gefüllt im Bild. Die acht berechtigten Konflikte im eigenen Plan sind nachgezogen. 372 Tests, 16 neue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
257e1a9c17
commit
5b37c117e1
@@ -109,6 +109,15 @@ One node per line. Everything except the label is optional.
|
||||
- From `(M)` upward a node **should be decomposed further**; a node ≥ M
|
||||
without children gets a placeholder hint in the diagram.
|
||||
- For cost estimation a missing size counts as `M`.
|
||||
- **Size conflict check:** a given size must fit the direct children. Each
|
||||
size is read as a range (lower bounds double: XS=1, S=2, M=4, L=8, XL=16,
|
||||
XXL=32; the upper bound is the next lower bound, XXL is open-ended). It is
|
||||
a conflict — warning `sizeConflict` at the parent's line — only when the sum
|
||||
of the children's lower bounds reaches the parent's upper bound, i.e. when
|
||||
it is wrong under *every* reading. Only sized, non-optional, non-discarded
|
||||
direct children count; in an `|`/`=` group only the smallest alternative.
|
||||
An XXL parent never warns; nothing is corrected automatically. When you
|
||||
write plans, pick parent sizes that pass this check.
|
||||
|
||||
### Token extraction order (why nothing collides)
|
||||
|
||||
|
||||
+28
-1
@@ -608,8 +608,17 @@ function diagramToSvg(){
|
||||
parts.push(`<circle cx="${rb.cx.toFixed(1)}" cy="${rb.cy.toFixed(1)}" r="${(Math.min(rb.w,rb.h)/2).toFixed(1)}" fill="#ffffff" stroke="#F97316" stroke-width="1.5"/>`);
|
||||
parts.push(`<text x="${rb.cx.toFixed(1)}" y="${(rb.cy+3.5).toFixed(1)}" text-anchor="middle" fill="#F97316" font-size="10">⚠︎</text>`);
|
||||
}
|
||||
/* Gemessene Farben statt festem Petrol: Das invertierte implizite M (D18)
|
||||
und das warnfarbene Konflikt-Badge (SPEC §5/D62) kämen sonst als
|
||||
gewöhnliches gefülltes Badge ins Bild — beim impliziten M war genau das
|
||||
seit jeher der Fall (Nebenbefund D62). */
|
||||
const sizeEl = node.querySelector('.size');
|
||||
if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff');
|
||||
if(sizeEl){
|
||||
const s = getComputedStyle(sizeEl);
|
||||
/* Der Rand gehört mit: beim invertierten Badge ist er die einzige Kontur
|
||||
(weiße Füllung auf weißer Karte). */
|
||||
drawBadge(sizeEl, s.backgroundColor, s.color, s.borderTopColor);
|
||||
}
|
||||
node.querySelectorAll('.tag').forEach(tg => {
|
||||
const t = getComputedStyle(tg);
|
||||
drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor);
|
||||
@@ -1955,6 +1964,8 @@ const I18N = {
|
||||
unknownDepWarn:"Zeile {line}: Abhängigkeit #{id} — es gibt keinen Knoten mit dieser ID.",
|
||||
unknownDescWarn:"Zeile {line}: Beschreibung für #{id} — es gibt keinen Knoten mit dieser ID.",
|
||||
descStrayWarn:"Zeile {line}: Beschreibungszeile ohne Bezug — ihr fehlt der Knoten bzw. der #id-Block davor.",
|
||||
sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.",
|
||||
sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe",
|
||||
cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).",
|
||||
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
|
||||
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
|
||||
@@ -2056,6 +2067,8 @@ const I18N = {
|
||||
unknownDepWarn:"Line {line}: dependency #{id} — no node has this ID.",
|
||||
unknownDescWarn:"Line {line}: description for #{id} — no node has this ID.",
|
||||
descStrayWarn:"Line {line}: description line with nothing to attach to — it needs a node or an #id block before it.",
|
||||
sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.",
|
||||
sizeConflictTooltip:"The sub-packages together exceed the given size",
|
||||
cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).",
|
||||
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
|
||||
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
|
||||
@@ -2157,6 +2170,8 @@ const I18N = {
|
||||
unknownDepWarn:"Línea {line}: dependencia #{id} — ningún nodo tiene esta ID.",
|
||||
unknownDescWarn:"Línea {line}: descripción para #{id} — ningún nodo tiene esta ID.",
|
||||
descStrayWarn:"Línea {line}: línea de descripción sin referencia — le falta un nodo o un bloque #id delante.",
|
||||
sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.",
|
||||
sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado",
|
||||
cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).",
|
||||
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
|
||||
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
|
||||
@@ -2258,6 +2273,8 @@ const I18N = {
|
||||
unknownDepWarn:"Ligne {line} : dépendance #{id} — aucun nœud ne porte cet ID.",
|
||||
unknownDescWarn:"Ligne {line} : description pour #{id} — aucun nœud ne porte cet ID.",
|
||||
descStrayWarn:"Ligne {line} : ligne de description sans rattachement — il lui manque un nœud ou un bloc #id avant.",
|
||||
sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.",
|
||||
sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée",
|
||||
cheapApproxWarn:"Trop de groupes d’alternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).",
|
||||
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
|
||||
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
|
||||
@@ -2359,6 +2376,8 @@ const I18N = {
|
||||
unknownDepWarn:"Wiersz {line}: zależność #{id} — żaden węzeł nie ma tego ID.",
|
||||
unknownDescWarn:"Wiersz {line}: opis dla #{id} — żaden węzeł nie ma tego ID.",
|
||||
descStrayWarn:"Wiersz {line}: wiersz opisu bez odniesienia — brakuje węzła lub bloku #id przed nim.",
|
||||
sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.",
|
||||
sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar",
|
||||
cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).",
|
||||
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
|
||||
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
|
||||
@@ -2460,6 +2479,8 @@ const I18N = {
|
||||
unknownDepWarn:"Строка {line}: зависимость #{id} — узла с таким ID нет.",
|
||||
unknownDescWarn:"Строка {line}: описание для #{id} — узла с таким ID нет.",
|
||||
descStrayWarn:"Строка {line}: строка описания без привязки — перед ней нет узла или блока #id.",
|
||||
sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.",
|
||||
sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
|
||||
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
|
||||
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
|
||||
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
|
||||
@@ -2561,6 +2582,8 @@ const I18N = {
|
||||
unknownDepWarn:"पंक्ति {line}: निर्भरता #{id} — इस आईडी वाला कोई नोड नहीं है।",
|
||||
unknownDescWarn:"पंक्ति {line}: #{id} के लिए विवरण — इस आईडी वाला कोई नोड नहीं है।",
|
||||
descStrayWarn:"पंक्ति {line}: विवरण पंक्ति बिना संदर्भ — इससे पहले कोई नोड या #id ब्लॉक नहीं है।",
|
||||
sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।",
|
||||
sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
|
||||
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
|
||||
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
|
||||
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
|
||||
@@ -2662,6 +2685,8 @@ const I18N = {
|
||||
unknownDepWarn:"第 {line} 行:依赖 #{id}——没有节点使用此 ID。",
|
||||
unknownDescWarn:"第 {line} 行:#{id} 的描述——没有节点使用此 ID。",
|
||||
descStrayWarn:"第 {line} 行:描述行没有归属——前面缺少节点或 #id 块。",
|
||||
sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。",
|
||||
sizeConflictTooltip:"子项合计超出所标注的尺寸",
|
||||
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
|
||||
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
|
||||
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
|
||||
@@ -2763,6 +2788,8 @@ const I18N = {
|
||||
unknownDepWarn:"{line} 行目:依存 #{id} — この ID を持つノードはありません。",
|
||||
unknownDescWarn:"{line} 行目:#{id} の説明 — この ID を持つノードはありません。",
|
||||
descStrayWarn:"{line} 行目:説明行の帰属先がありません — 直前にノードまたは #id ブロックが必要です。",
|
||||
sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。",
|
||||
sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
|
||||
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
|
||||
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
|
||||
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
|
||||
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
|
||||
|
||||
/* Größen als BEREICHE für die Konflikt-Prüfung (SPEC §5/D62): Untergrenze
|
||||
2^Rang, Obergrenze die Untergrenze der nächsten Größe — XXL ist nach oben
|
||||
offen, ein XXL-Element warnt also nie. Die Skala bleibt ansonsten ordinal
|
||||
(D46: „S+S ≠ M" gilt weiter für alles außer dieser einen Prüfung). */
|
||||
export const sizeMin = size => 2 ** SIZE_RANK[size];
|
||||
export const sizeMax = size => size === 'XXL' ? Infinity : 2 ** (SIZE_RANK[size] + 1);
|
||||
|
||||
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
|
||||
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
|
||||
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
|
||||
@@ -384,5 +391,33 @@ export function parse(text){
|
||||
kids.forEach(checkXor);
|
||||
})(virtualRoot);
|
||||
|
||||
/* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten
|
||||
Kindern passen. Jede Größe zählt als BEREICH (sizeMin/sizeMax); Konflikt
|
||||
erst, wenn selbst die günstigste Lesart der Kinder die großzügigste des
|
||||
Elternknotens erreicht — gemeldet wird nur, was unter jeder Lesart falsch
|
||||
ist. Es zählen nur Kinder MIT Größe (fehlende Größe ist keine Aussage —
|
||||
anders als bei den Pfadkosten wird hier kein M angenommen, D44-Linie),
|
||||
ohne verworfene und ohne optionale; in einer disjunktiven Gruppe wird nur
|
||||
eine Alternative realisiert, dort zählt die kleinste. Nichts wird
|
||||
korrigiert — Warnung an der Elternzeile plus Flag für das Badge. */
|
||||
(function checkSizes(node){
|
||||
const kids = node.children;
|
||||
if(node.size && kids.length && sizeMax(node.size) !== Infinity){
|
||||
const counted = kids.filter(k =>
|
||||
k.size && !k.optional && !(k.status && k.status.key === 'verworfen'));
|
||||
if(counted.length){
|
||||
const mins = counted.map(k => sizeMin(k.size));
|
||||
const need = kids[0].type !== 'and'
|
||||
? Math.min(...mins)
|
||||
: mins.reduce((a, b) => a + b, 0);
|
||||
if(need >= sizeMax(node.size)){
|
||||
node.sizeConflict = true;
|
||||
warnings.push({type:'sizeConflict', line: node.line, size: node.size});
|
||||
}
|
||||
}
|
||||
}
|
||||
kids.forEach(checkSizes);
|
||||
})(virtualRoot);
|
||||
|
||||
return {roots: virtualRoot.children, warnings};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ function nodeAria(n, opts, fold){
|
||||
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
|
||||
if(n.size) parts.push(t('a11ySize', {size: n.size}));
|
||||
else if(cheapPath && !isDone(n)) parts.push(t('a11ySizeImplicit'));
|
||||
/* Größen-Konflikt (SPEC §5/D62): die Warnfärbung des Badges kommt beim
|
||||
Screenreader sonst nicht an. */
|
||||
if(n.sizeConflict) parts.push(t('sizeConflictTooltip'));
|
||||
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
|
||||
/* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung
|
||||
im Diagramm — sichtbar nur im Tooltip und hier. */
|
||||
@@ -138,6 +141,9 @@ function nodeHtml(n, extra, opts, fold){
|
||||
eigenen `title` am Badge — der erschiene neben dem Fenster
|
||||
ein zweites Mal. Sie gehört ohnehin zu den Kurz-Fakten. */
|
||||
(!n.size && cheapPath && !isDone(n)) ? t('implicitSizeTooltip') : '',
|
||||
/* Größen-Konflikt (SPEC §5/D62): das warnfarbene Badge braucht
|
||||
seine Begründung dort, wo man nachsieht. */
|
||||
n.sizeConflict ? t('sizeConflictTooltip') : '',
|
||||
t('jumpHint')]
|
||||
.filter(Boolean).join(' · ');
|
||||
const tip = n.desc && facts ? n.desc + '\n\n' + TIP_RULE + '\n' + facts
|
||||
@@ -154,8 +160,10 @@ function nodeHtml(n, extra, opts, fold){
|
||||
erledigten Knoten wird keine getroffen — er kostet nichts mehr (D46) —,
|
||||
dort bleibt es deshalb weg. */
|
||||
const implicitSize = !n.size && cheapPath && !isDone(n);
|
||||
/* Größen-Konflikt (SPEC §5/D62): das Badge wechselt auf die Warnfarbe —
|
||||
die Größe selbst bleibt stehen, korrigiert wird nichts. */
|
||||
const sizeBadge = n.size
|
||||
? `<span class="size" aria-hidden="true">${n.size}</span>`
|
||||
? `<span class="size${n.sizeConflict ? ' conflict' : ''}" aria-hidden="true">${n.size}</span>`
|
||||
: (implicitSize ? `<span class="size implicit" aria-hidden="true">M</span>` : '');
|
||||
/* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken
|
||||
Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */
|
||||
|
||||
@@ -910,6 +910,10 @@
|
||||
.cheap-on .node:not(.cheap).done{opacity:1;filter:none}
|
||||
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
|
||||
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
|
||||
/* Größen-Konflikt (SPEC §5/D62): die Teilpakete übersteigen zusammen die
|
||||
angegebene Größe — das Badge wechselt auf die Warnfarbe, der weiße
|
||||
Trennrand zur Knotenkante bleibt. */
|
||||
.size.conflict{background:var(--warn)}
|
||||
/* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch
|
||||
die Endknoten (Blätter) des Pfads. Zwei Overlays in #out (erben dessen
|
||||
zoom): die kräftige Linie liegt HINTER den Knoten (nur in den Lücken
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
Beschreibungsteil ohne #id-Block; meldet
|
||||
auch die von einem versehentlichen
|
||||
`---` verschluckten Knotenzeilen
|
||||
- sizeConflict { line, size } — Teilpakete übersteigen zusammen die
|
||||
angegebene Größe des Elternknotens, selbst
|
||||
in der günstigsten Bereichs-Lesart
|
||||
(SPEC §5/D62); Zeile = Elternknoten
|
||||
- cheapApprox { } — günstigster Pfad nur gierig geschätzt:
|
||||
zu viele gekoppelte Gruppen für die
|
||||
exakte Suche (D42); zeilenlos
|
||||
@@ -72,6 +76,8 @@ function build(w, t, esc){
|
||||
return t('unknownDescWarn', {line: w.line, id: esc(w.id)});
|
||||
case 'descStray':
|
||||
return t('descStrayWarn', {line: w.line});
|
||||
case 'sizeConflict':
|
||||
return t('sizeConflictWarn', {line: w.line, size: esc(w.size)});
|
||||
case 'cheapApprox':
|
||||
return t('cheapApproxWarn');
|
||||
case 'sourceLoad':
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse, sizeMin, sizeMax } from '../src/parser.js';
|
||||
|
||||
/* Größen-Konflikt (SPEC §5/D62): Die angegebene Größe muss zu den direkten
|
||||
Kindern passen. Jede Größe zählt als Bereich (Untergrenze 2^Rang, Obergrenze
|
||||
die nächste Untergrenze, XXL offen); Konflikt erst, wenn selbst die
|
||||
günstigste Lesart der Kinder die großzügigste des Elternknotens erreicht.
|
||||
Nichts wird automatisch korrigiert. */
|
||||
|
||||
const conflicts = txt => parse(txt).warnings.filter(w => w.type === 'sizeConflict');
|
||||
const node = (txt, label) => {
|
||||
const find = ns => {
|
||||
for(const n of ns){
|
||||
if(n.label === label) return n;
|
||||
const hit = find(n.children);
|
||||
if(hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return find(parse(txt).roots);
|
||||
};
|
||||
|
||||
describe('Bereichs-Skala', () => {
|
||||
it('Untergrenzen verdoppeln sich, Obergrenze ist die nächste Untergrenze', () => {
|
||||
expect(['XS','S','M','L','XL'].map(sizeMin)).toEqual([1,2,4,8,16]);
|
||||
expect(['XS','S','M','L','XL'].map(sizeMax)).toEqual([2,4,8,16,32]);
|
||||
});
|
||||
it('XXL ist nach oben offen', () => {
|
||||
expect(sizeMin('XXL')).toBe(32);
|
||||
expect(sizeMax('XXL')).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Konflikt-Regel (konjunktiv: Summe der Untergrenzen)', () => {
|
||||
it('vier S unter einem M sind ein Konflikt (8 >= 8), drei nicht', () => {
|
||||
const vier = 'A (M)\n - B (S)\n - C (S)\n - D (S)\n - E (S)';
|
||||
expect(conflicts(vier)).toEqual([{type:'sizeConflict', line:1, size:'M'}]);
|
||||
expect(node(vier, 'A').sizeConflict).toBe(true);
|
||||
expect(conflicts('A (M)\n - B (S)\n - C (S)\n - D (S)')).toEqual([]);
|
||||
});
|
||||
it('ein einzelnes Kind derselben Größe passt, ein größeres nie', () => {
|
||||
expect(conflicts('A (M)\n - B (M)')).toEqual([]);
|
||||
expect(conflicts('A (M)\n - B (L)'))
|
||||
.toEqual([{type:'sizeConflict', line:1, size:'M'}]);
|
||||
});
|
||||
it('das kanonische Beispiel bleibt sauber: XL = XL-Kind + 2×M', () => {
|
||||
expect(conflicts('A (XL)\n - B (M)\n - C (XL)\n - D (M)')).toEqual([]);
|
||||
});
|
||||
it('zwei M unter einem M sind ein Konflikt (4+4 >= 8)', () => {
|
||||
expect(conflicts('A (M)\n - B (M)\n - C (M)').length).toBe(1);
|
||||
});
|
||||
it('ein XXL-Elternknoten warnt nie', () => {
|
||||
const txt = 'A (XXL)\n' + Array.from({length: 40}, (_, i) => ` - K${i} (XL)`).join('\n');
|
||||
expect(conflicts(txt)).toEqual([]);
|
||||
});
|
||||
it('geprüft wird je Ebene, die Warnung zeigt auf die Elternzeile', () => {
|
||||
const txt = 'A (XXL)\n - B (S)\n - C (S)\n - D (S)';
|
||||
expect(conflicts(txt)).toEqual([{type:'sizeConflict', line:2, size:'S'}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Was nicht zählt', () => {
|
||||
it('ein Elternknoten ohne Größe macht keine Aussage', () => {
|
||||
expect(conflicts('A\n - B (XXL)\n - C (XXL)')).toEqual([]);
|
||||
});
|
||||
it('Kinder ohne Größe zählen nicht — hier wird kein M angenommen', () => {
|
||||
expect(conflicts('A (XS)\n - B\n - C\n - D')).toEqual([]);
|
||||
/* die größenlosen bleiben auch neben gezählten außen vor */
|
||||
expect(conflicts('A (M)\n - B (S)\n - C\n - D')).toEqual([]);
|
||||
});
|
||||
it('verworfene Kinder zählen nicht', () => {
|
||||
expect(conflicts('A (M)\n - [-] B (XL)\n - C (S)')).toEqual([]);
|
||||
});
|
||||
it('optionale Kinder (`+`) zählen nicht', () => {
|
||||
expect(conflicts('A (M)\n - B (S)\n + C (XL)')).toEqual([]);
|
||||
});
|
||||
it('die Gegenrichtung warnt nicht: Eltern größer als die Kindersumme', () => {
|
||||
expect(conflicts('A (XXL)\n - B (XS)')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disjunktive Gruppen: nur die kleinste Alternative zählt', () => {
|
||||
it('| mit einer passenden Alternative ist kein Konflikt', () => {
|
||||
expect(conflicts('A (M)\n | B (L)\n | C (S)')).toEqual([]);
|
||||
});
|
||||
it('| warnt erst, wenn selbst die kleinste Alternative sprengt', () => {
|
||||
expect(conflicts('A (M)\n | B (L)\n | C (L)').length).toBe(1);
|
||||
});
|
||||
it('= verhält sich wie |', () => {
|
||||
expect(conflicts('A (M)\n = B (L)\n = C (S)')).toEqual([]);
|
||||
expect(conflicts('A (M)\n = B (L)\n = C (L)').length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user