|
| 1 | +/** |
| 2 | + * Verifies hub % matches weighted slice fill for representative progress mixes. |
| 3 | + * Run: node scripts/verify-pie-progress.mjs |
| 4 | + */ |
| 5 | +import { |
| 6 | + capturedFillRatio, |
| 7 | + hubCapturedPercent, |
| 8 | + weightedCapturedRatio, |
| 9 | +} from "../src/lib/pie-progress.ts"; |
| 10 | + |
| 11 | +/** Real deck sizes (Basics is larger than the other lifecycle slices). */ |
| 12 | +const DECK = [ |
| 13 | + { id: "basics", total: 50 }, |
| 14 | + { id: "find", total: 22 }, |
| 15 | + { id: "shape", total: 22 }, |
| 16 | + { id: "bid", total: 22 }, |
| 17 | + { id: "vehicle", total: 22 }, |
| 18 | + { id: "team", total: 22 }, |
| 19 | + { id: "propose", total: 22 }, |
| 20 | + { id: "win", total: 22 }, |
| 21 | +]; |
| 22 | + |
| 23 | +function slice(total, cleared, { mastered = cleared, learning = 0 } = {}) { |
| 24 | + const ratio = total === 0 ? 0 : cleared / total; |
| 25 | + return { |
| 26 | + cleared, |
| 27 | + total, |
| 28 | + ratio, |
| 29 | + masteredRatio: total === 0 ? 0 : mastered / total, |
| 30 | + learningRatio: total === 0 ? 0 : learning / total, |
| 31 | + }; |
| 32 | +} |
| 33 | + |
| 34 | +function scenario(name, clearedById) { |
| 35 | + const slices = DECK.map(({ id, total }) => { |
| 36 | + const cleared = clearedById[id] ?? 0; |
| 37 | + // Split: first clear = learning, second+ = mastered (approx for fill layers). |
| 38 | + const learning = Math.min(cleared, Math.ceil(cleared * 0.3)); |
| 39 | + const mastered = cleared - learning; |
| 40 | + return slice(total, cleared, { mastered, learning }); |
| 41 | + }); |
| 42 | + |
| 43 | + const hub = hubCapturedPercent(slices); |
| 44 | + const weighted = weightedCapturedRatio(slices); |
| 45 | + const weightedPct = Math.round(weighted * 100); |
| 46 | + const fillMatchesRatio = slices.every( |
| 47 | + (s) => Math.abs(capturedFillRatio(s) - s.ratio) < 1e-9, |
| 48 | + ); |
| 49 | + |
| 50 | + const ok = hub === weightedPct && fillMatchesRatio; |
| 51 | + const detail = DECK.map(({ id, total }) => { |
| 52 | + const c = clearedById[id] ?? 0; |
| 53 | + const pct = total ? Math.round((c / total) * 100) : 0; |
| 54 | + const angle = Math.round((total / DECK.reduce((n, d) => n + d.total, 0)) * 100); |
| 55 | + return `${id} ${pct}% of slice (angle ~${angle}% of wheel, ${c}/${total})`; |
| 56 | + }).join("; "); |
| 57 | + |
| 58 | + console.log(`${ok ? "PASS" : "FAIL"} ${name}`); |
| 59 | + console.log(` hub=${hub}% weightedArea=${weightedPct}% fill≡ratio=${fillMatchesRatio}`); |
| 60 | + console.log(` ${detail}`); |
| 61 | + if (!ok) process.exitCode = 1; |
| 62 | + return ok; |
| 63 | +} |
| 64 | + |
| 65 | +scenario("empty", {}); |
| 66 | +scenario("user-like ~20% (Basics heavy)", { basics: 32, vehicle: 8 }); |
| 67 | +scenario("Basics 80% only", { basics: 40 }); |
| 68 | +scenario("Vehicle 100% only", { vehicle: 22 }); |
| 69 | +scenario("every slice 50%", Object.fromEntries(DECK.map((d) => [d.id, Math.floor(d.total / 2)]))); |
| 70 | +scenario("full capture", Object.fromEntries(DECK.map((d) => [d.id, d.total]))); |
| 71 | +scenario("thin progress across many slices", { |
| 72 | + basics: 5, |
| 73 | + find: 5, |
| 74 | + shape: 5, |
| 75 | + bid: 5, |
| 76 | + vehicle: 5, |
| 77 | + team: 5, |
| 78 | + propose: 5, |
| 79 | + win: 5, |
| 80 | +}); |
| 81 | +scenario("one slice fully mastered look (learning+mastered layers)", { |
| 82 | + basics: 50, // all cleared — fill uses mastered+learning ratios in UI |
| 83 | +}); |
| 84 | + |
| 85 | +if (!process.exitCode) console.log("\nAll pie-progress scenarios passed."); |
0 commit comments