diff --git a/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts b/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts new file mode 100644 index 00000000..8e99b7fb --- /dev/null +++ b/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' + +describe('segment expression & rule detection', () => { + it('direct 8 at ones: expression is "5 + 3", rule is Direct', () => { + const seq = generateUnifiedInstructionSequence(0, 8) // +8 + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('5+3') + expect(seg.plan.some(p => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(false) + expect(seg.plan.some(p => p.rule === 'TenComplement')).toBe(false) + }) + + it('direct 6 at ones: expression is "5 + 1", rule is Direct', () => { + const seq = generateUnifiedInstructionSequence(0, 6) // +6 + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('5+1') + expect(seg.plan.some(p => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(false) + }) + + it('direct 9 at ones: expression is "5 + 4", rule is Direct', () => { + const seq = generateUnifiedInstructionSequence(0, 9) // +9 + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('5+4') + expect(seg.plan.some(p => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(false) + }) + + it('five-complement at ones (start 2, +3): expression is "(5 - 2)"', () => { + const seq = generateUnifiedInstructionSequence(2, 5) + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('(5-2)') + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(true) + }) + + it('five-complement at ones (start 1, +4): expression is "(5 - 1)"', () => { + const seq = generateUnifiedInstructionSequence(1, 5) + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('(5-1)') + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(true) + }) + + it('ten-complement no cascade (19 + 1 = 20): has TenComplement but not Cascade', () => { + const seq = generateUnifiedInstructionSequence(19, 20) + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.plan.some(p => p.rule === 'TenComplement')).toBe(true) + expect(seg.plan.some(p => p.rule === 'Cascade')).toBe(false) + }) + + it('ten-complement with cascade (199 + 1 = 200): has Cascade', () => { + const seq = generateUnifiedInstructionSequence(199, 200) + const seg = seq.segments.find(s => s.place === 0)! + expect(seg.plan.some(p => p.rule === 'Cascade')).toBe(true) + }) + + it('ranges: segment.termRange encloses all step.termPosition', () => { + const seq = generateUnifiedInstructionSequence(3478, 3500) // +22 + const tensSeg = seq.segments.find(s => s.place === 1)! + expect(tensSeg).toBeDefined() + + tensSeg.stepIndices.forEach(i => { + const r = seq.steps[i].termPosition + expect(r.startIndex >= tensSeg.termRange.startIndex).toBe(true) + expect(r.endIndex <= tensSeg.termRange.endIndex).toBe(true) + }) + }) + + it('direct addition with multiple places maintains correct expressions', () => { + const seq = generateUnifiedInstructionSequence(0, 68) // +68 + + // Tens place: +6 should be "5 + 1" (direct) + const tensSeg = seq.segments.find(s => s.place === 1) + if (tensSeg) { + expect(tensSeg.expression.replace(/\s+/g, '')).toBe('50+10') + expect(tensSeg.plan.some(p => p.rule === 'Direct')).toBe(true) + } + + // Ones place: +8 should be "5 + 3" (direct) + const onesSeg = seq.segments.find(s => s.place === 0) + if (onesSeg) { + expect(onesSeg.expression.replace(/\s+/g, '')).toBe('5+3') + expect(onesSeg.plan.some(p => p.rule === 'Direct')).toBe(true) + } + }) + + it('ensures no false FiveComplement classification for direct 6-9 adds', () => { + // Test all direct 6-9 additions to ensure they're never classified as FiveComplement + for (let digit = 6; digit <= 9; digit++) { + const seq = generateUnifiedInstructionSequence(0, digit) + const seg = seq.segments.find(s => s.place === 0)! + + expect(seg.plan.some(p => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some(p => p.rule === 'FiveComplement')).toBe(false) + expect(seg.expression).toMatch(/^5 \+ \d+$/) + } + }) + + it('validates complement expressions use parentheses', () => { + // Five complement + const fiveCompSeq = generateUnifiedInstructionSequence(2, 5) + const fiveCompSeg = fiveCompSeq.segments.find(s => s.place === 0)! + expect(fiveCompSeg.expression).toMatch(/^\(.+\)$/) // Should be wrapped in parentheses + + // Ten complement + const tenCompSeq = generateUnifiedInstructionSequence(19, 20) + const tenCompSeg = tenCompSeq.segments.find(s => s.place === 0)! + expect(tenCompSeg.expression).toMatch(/^\(.+\)$/) // Should be wrapped in parentheses + }) +}) \ No newline at end of file diff --git a/apps/web/src/utils/unifiedStepGenerator.ts b/apps/web/src/utils/unifiedStepGenerator.ts index 0ae6bb74..3b529ae7 100644 --- a/apps/web/src/utils/unifiedStepGenerator.ts +++ b/apps/web/src/utils/unifiedStepGenerator.ts @@ -191,13 +191,18 @@ function decisionForTenComplement(a: number, d: number, nextIs9: boolean): Segme } function formatSegmentExpression(terms: string[]): string { - // single term -> "40" - if (terms.length === 1 && !terms[0].startsWith('-')) return terms[0] + if (terms.length === 0) return '' - // complement group -> "(pos - n1 - n2 - ...)" - const pos = terms[0] - const negs = terms.slice(1).map(t => t.replace(/^-/, '')) - return `(${pos} - ${negs.join(' - ')})` + const positives = terms.filter(t => !t.startsWith('-')) + const negatives = terms.filter(t => t.startsWith('-')).map(t => t.slice(1)) + + // All positive → join with pluses (no parentheses) + if (negatives.length === 0) { + return positives.join(' + ') + } + + // Complement group → (pos - n1 - n2 - …) + return `(${positives[0]} - ${negatives.join(' - ')})` } function formatSegmentGoal(digit: number, placeValue: number): string { @@ -260,34 +265,44 @@ function determineSegmentDecisions( ): SegmentDecision[] { const sum = currentDigit + digit - if (steps.length === 1) { + // If there is exactly one step and it's positive, it's direct. + if (steps.length === 1 && !steps[0].operation.startsWith('-')) { return [{ rule: 'Direct', conditions: [`a+d=${currentDigit}+${digit}=${sum} ≤ 9`], - explanation: ['Fits in this place; add earth beads directly.'] + explanation: ['Fits in this place; add beads directly.'] }] } const positives = steps.filter(s => !s.operation.startsWith('-')).map(s => parseInt(s.operation, 10)) const negatives = steps.filter(s => s.operation.startsWith('-')).map(s => Math.abs(parseInt(s.operation, 10))) + // No negatives → it's a direct (possibly 5+earth remainder) entry, not complement + if (negatives.length === 0) { + return [{ + rule: 'Direct', + conditions: [`a+d=${currentDigit}+${digit}=${sum} ≤ 9`], + explanation: ['Heaven bead (5) plus lower beads: still direct addition.'] + }] + } + + // There are negatives → complement family const hasFiveAdd = positives.some(v => Number.isInteger(v / 5) && isPowerOfTen(v / 5)) const tenAdd = positives.find(v => isPowerOfTenGE10(v)) const hasTenAdd = tenAdd !== undefined - if (hasFiveAdd && !hasTenAdd) { - return decisionForFiveComplement(currentDigit, digit) - } - if (hasTenAdd) { const tenAddPlace = Math.round(Math.log10(tenAdd!)) - // If the +10^k lands above the immediate next place, we must have rippled through 9s. - // Alternatively, multiple distinct higher places in negatives indicates cascade. const negPlaces = new Set(negatives.map(v => Math.floor(Math.log10(v)))) const cascades = tenAddPlace > place + 1 || negPlaces.size >= 2 return decisionForTenComplement(currentDigit, digit, cascades) } + if (hasFiveAdd) { + return decisionForFiveComplement(currentDigit, digit) + } + + // Fallback (unlikely with current generators) return [{ rule: 'Direct', conditions: [`processing digit ${digit} at ${getPlaceName(place)}`],