feat(practice): add cascading regrouping skills and improve help UX
- Add advanced.cascadingCarry and advanced.cascadingBorrow skills for detecting when carry/borrow propagates across 2+ consecutive columns (e.g., 999 + 1 = 1000 or 1000 - 1 = 999) - Update VerticalProblem help UX: replace answer boxes with help abacus instead of floating above terms (less confusing for kids) - Dim terms already in prefix sum at 40% opacity when in help mode - Enlarge current-help arrow indicator to 1.75rem - Add "Advanced Multi-Column Operations" category to ManualSkillSelector so teachers can manually enable these skills - Add unit tests for cascading regrouping detection (21 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5cfbeeb8df
commit
7cf689c3d9
|
|
@ -94,6 +94,7 @@ const SKILL_CATEGORY_NAMES: Record<string, string> = {
|
|||
fiveComplementsSub: '5-Complements (Sub)',
|
||||
tenComplements: '10-Complements',
|
||||
tenComplementsSub: '10-Complements (Sub)',
|
||||
advanced: 'Advanced',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@ const SKILL_CATEGORIES = {
|
|||
'-1=+9-10': '-1 = +9 - 10',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
name: 'Advanced Multi-Column Operations',
|
||||
skills: {
|
||||
cascadingCarry: 'Cascading Carry (e.g., 999 + 1 = 1000)',
|
||||
cascadingBorrow: 'Cascading Borrow (e.g., 1000 - 1 = 999)',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
type CategoryKey = keyof typeof SKILL_CATEGORIES
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ function TermsPlayground() {
|
|||
? {
|
||||
terms,
|
||||
answer: correctAnswer,
|
||||
skillsUsed: [],
|
||||
skillsRequired: [],
|
||||
}
|
||||
: null
|
||||
|
||||
|
|
@ -386,7 +386,7 @@ function TermsPlayground() {
|
|||
const newAnswer = newTerms.reduce((sum, t) => sum + t, 0)
|
||||
if (newTerms.length > 0) {
|
||||
interaction.loadProblem(
|
||||
{ terms: newTerms, answer: newAnswer, skillsUsed: [] },
|
||||
{ terms: newTerms, answer: newAnswer, skillsRequired: [] },
|
||||
0,
|
||||
problemKey + 1
|
||||
)
|
||||
|
|
@ -421,7 +421,11 @@ function TermsPlayground() {
|
|||
|
||||
const handleReset = () => {
|
||||
if (terms.length > 0) {
|
||||
interaction.loadProblem({ terms, answer: correctAnswer, skillsUsed: [] }, 0, problemKey + 1)
|
||||
interaction.loadProblem(
|
||||
{ terms, answer: correctAnswer, skillsRequired: [] },
|
||||
0,
|
||||
problemKey + 1
|
||||
)
|
||||
setProblemKey((k) => k + 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -531,8 +535,7 @@ function TermsPlayground() {
|
|||
interaction.ambiguousHelpTermIndex === i + 1 ? 'yellow.200' : 'blue.100',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
border:
|
||||
interaction.ambiguousHelpTermIndex === i + 1 ? '2px solid' : '1px solid',
|
||||
border: interaction.ambiguousHelpTermIndex === i + 1 ? '2px solid' : '1px solid',
|
||||
borderColor:
|
||||
interaction.ambiguousHelpTermIndex === i + 1 ? 'yellow.500' : 'transparent',
|
||||
})}
|
||||
|
|
@ -654,7 +657,9 @@ function TermsPlayground() {
|
|||
>
|
||||
<strong>How to test help detection:</strong>
|
||||
<ul className={css({ marginTop: '0.5rem', paddingLeft: '1.25rem' })}>
|
||||
<li>Type a prefix sum value (e.g., "100" for the first term) to trigger disambiguation</li>
|
||||
<li>
|
||||
Type a prefix sum value (e.g., "100" for the first term) to trigger disambiguation
|
||||
</li>
|
||||
<li>Wait 4 seconds for auto-help, or keep typing to continue to final answer</li>
|
||||
<li>
|
||||
Type with leading zero (e.g., "0100" or "063") to <em>immediately</em> request help
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface VerticalProblemProps {
|
|||
needHelpTermIndex?: number
|
||||
/** Rejected digit to show as red X (null = no rejection) */
|
||||
rejectedDigit?: string | null
|
||||
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
|
||||
/** Help overlay to render in place of answer boxes when in help mode */
|
||||
helpOverlay?: ReactNode
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +132,8 @@ export function VerticalProblem({
|
|||
const isCurrentHelp = index === currentHelpTermIndex
|
||||
// Check if this term row should show "need help?" prompt (ambiguous case)
|
||||
const showNeedHelp = index === needHelpTermIndex && !isCurrentHelp
|
||||
// Check if this term is already included in the prefix sum (when in help mode)
|
||||
const isInPrefixSum = currentHelpTermIndex !== undefined && index < currentHelpTermIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -144,6 +146,8 @@ export function VerticalProblem({
|
|||
gap: '2px',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s ease',
|
||||
// Dim terms already in prefix sum when in help mode
|
||||
opacity: isInPrefixSum ? 0.4 : 1,
|
||||
})}
|
||||
>
|
||||
{/* "Need help?" prompt for ambiguous prefix case */}
|
||||
|
|
@ -190,9 +194,9 @@ export function VerticalProblem({
|
|||
data-element="current-arrow"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '-1.5rem',
|
||||
left: '-2.2rem',
|
||||
color: isDark ? 'purple.300' : 'purple.600',
|
||||
fontSize: '0.875rem',
|
||||
fontSize: '1.75rem',
|
||||
})}
|
||||
>
|
||||
→
|
||||
|
|
@ -238,24 +242,6 @@ export function VerticalProblem({
|
|||
{digit}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Help overlay - positioned above this term row, translated up by its height */}
|
||||
{isCurrentHelp && helpOverlay && (
|
||||
<div
|
||||
data-section="term-help"
|
||||
data-help-term-index={index}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
// Position at bottom of this row, then translate up by 100% to sit above it
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
{helpOverlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -272,133 +258,150 @@ export function VerticalProblem({
|
|||
})}
|
||||
/>
|
||||
|
||||
{/* Answer row */}
|
||||
<div
|
||||
data-element="answer-row"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Equals column */}
|
||||
{/* Answer row - shows help abacus when in help mode, otherwise answer cells */}
|
||||
{currentHelpTermIndex !== undefined && helpOverlay ? (
|
||||
// Help mode: show the help abacus in place of answer boxes
|
||||
<div
|
||||
data-element="equals"
|
||||
data-element="help-area"
|
||||
data-help-term-index={currentHelpTermIndex}
|
||||
className={css({
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
padding: '0.5rem 0',
|
||||
})}
|
||||
>
|
||||
=
|
||||
{helpOverlay}
|
||||
</div>
|
||||
) : (
|
||||
// Normal mode: show answer digit cells
|
||||
<div
|
||||
data-element="answer-row"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Equals column */}
|
||||
<div
|
||||
data-element="equals"
|
||||
className={css({
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
=
|
||||
</div>
|
||||
|
||||
{/* Answer digit cells - show maxDigits cells total */}
|
||||
{Array(maxDigits)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
// Determine what to show in this cell
|
||||
const displayValue =
|
||||
isCompleted && isIncorrect ? correctAnswer?.toString() || '' : userAnswer
|
||||
const paddedValue = displayValue.padStart(maxDigits, '')
|
||||
const digit = paddedValue[index] || ''
|
||||
const isEmpty = digit === ''
|
||||
{/* Answer digit cells - show maxDigits cells total */}
|
||||
{Array(maxDigits)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
// Determine what to show in this cell
|
||||
const displayValue =
|
||||
isCompleted && isIncorrect ? correctAnswer?.toString() || '' : userAnswer
|
||||
const paddedValue = displayValue.padStart(maxDigits, '')
|
||||
const digit = paddedValue[index] || ''
|
||||
const isEmpty = digit === ''
|
||||
|
||||
// Check if this is the cell where a rejected digit should show
|
||||
// Digits are entered left-to-right, filling from left side of the answer area
|
||||
// So the next digit position is right after the current answer length
|
||||
const nextDigitIndex = userAnswer.length
|
||||
const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex
|
||||
// Check if this is the cell where a rejected digit should show
|
||||
// Digits are entered left-to-right, filling from left side of the answer area
|
||||
// So the next digit position is right after the current answer length
|
||||
const nextDigitIndex = userAnswer.length
|
||||
const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-element={
|
||||
isRejectedCell ? 'rejected-cell' : isEmpty ? 'empty-cell' : 'answer-cell'
|
||||
}
|
||||
className={css({
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
backgroundColor: isRejectedCell
|
||||
? isDark
|
||||
? 'red.900'
|
||||
: 'red.100'
|
||||
: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.800'
|
||||
: 'green.100'
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-element={
|
||||
isRejectedCell ? 'rejected-cell' : isEmpty ? 'empty-cell' : 'answer-cell'
|
||||
}
|
||||
className={css({
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
backgroundColor: isRejectedCell
|
||||
? isDark
|
||||
? 'red.900'
|
||||
: 'red.100'
|
||||
: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.800'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'red.800'
|
||||
: 'red.100'
|
||||
: isDark
|
||||
? 'red.800'
|
||||
: 'red.100'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
borderRadius: '4px',
|
||||
border: isEmpty && !isCompleted && !isRejectedCell ? '1px dashed' : '1px solid',
|
||||
borderColor: isRejectedCell
|
||||
? isDark
|
||||
? 'red.500'
|
||||
: 'red.400'
|
||||
: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.600'
|
||||
: 'green.300'
|
||||
: isDark
|
||||
? 'red.600'
|
||||
: 'red.300'
|
||||
: isEmpty
|
||||
? isFocused
|
||||
? 'blue.400'
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
borderRadius: '4px',
|
||||
border: isEmpty && !isCompleted && !isRejectedCell ? '1px dashed' : '1px solid',
|
||||
borderColor: isRejectedCell
|
||||
? isDark
|
||||
? 'red.500'
|
||||
: 'red.400'
|
||||
: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.600'
|
||||
: 'green.300'
|
||||
: isDark
|
||||
? 'red.600'
|
||||
: 'red.300'
|
||||
: isEmpty
|
||||
? isFocused
|
||||
? 'blue.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300'
|
||||
: 'gray.300',
|
||||
transition: 'all 0.15s ease-out',
|
||||
color: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.200'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
transition: 'all 0.15s ease-out',
|
||||
color: isCompleted
|
||||
? isCorrect
|
||||
? isDark
|
||||
? 'green.200'
|
||||
: 'green.700'
|
||||
? 'red.200'
|
||||
: 'red.700'
|
||||
: isDark
|
||||
? 'red.200'
|
||||
: 'red.700'
|
||||
: isDark
|
||||
? 'gray.200'
|
||||
: 'gray.800',
|
||||
// Shake animation for rejected cell
|
||||
...(isRejectedCell && {
|
||||
animation: 'shake 0.3s ease-out',
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{isRejectedCell ? (
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
) : (
|
||||
digit
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
? 'gray.200'
|
||||
: 'gray.800',
|
||||
// Shake animation for rejected cell
|
||||
...(isRejectedCell && {
|
||||
animation: 'shake 0.3s ease-out',
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{isRejectedCell ? (
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
) : (
|
||||
digit
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show user's incorrect answer below correct answer */}
|
||||
{isCompleted && isIncorrect && (
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ export interface SkillSet {
|
|||
heavenBeadSubtraction: boolean // Can remove heaven bead (5)
|
||||
simpleCombinationsSub: boolean // Can do 6-9 subtraction without complements
|
||||
}
|
||||
|
||||
// Advanced operations involving multiple place values
|
||||
advanced: {
|
||||
/** Carry cascades across 2+ columns (e.g., 999 + 1 = 1000) */
|
||||
cascadingCarry: boolean
|
||||
/** Borrow cascades across 2+ columns (e.g., 1000 - 1 = 999) */
|
||||
cascadingBorrow: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface PracticeStep {
|
||||
|
|
@ -168,6 +176,10 @@ export function createEmptySkillSet(): SkillSet {
|
|||
'-2=+8-10': false,
|
||||
'-1=+9-10': false,
|
||||
},
|
||||
advanced: {
|
||||
cascadingCarry: false,
|
||||
cascadingBorrow: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +227,10 @@ export function createBasicSkillSet(): SkillSet {
|
|||
'-2=+8-10': false,
|
||||
'-1=+9-10': false,
|
||||
},
|
||||
advanced: {
|
||||
cascadingCarry: false,
|
||||
cascadingBorrow: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { analyzeRequiredSkills, analyzeSubtractionStepSkills } from '../problemGenerator'
|
||||
|
||||
describe('cascading regrouping detection', () => {
|
||||
describe('cascading carry (addition)', () => {
|
||||
it('detects cascading carry when 999 + 1 = 1000', () => {
|
||||
// Adding 1 to 999 causes carry to cascade through 3 columns
|
||||
// 9+1=10 (carry), 9+1=10 (carry), 9+1=10 (carry) -> 1000
|
||||
const skills = analyzeRequiredSkills([999, 1], 1000)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('detects cascading carry when 99 + 1 = 100', () => {
|
||||
// Adding 1 to 99 causes carry through 2 columns (minimum for cascade)
|
||||
const skills = analyzeRequiredSkills([99, 1], 100)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading carry for 89 + 1 = 90', () => {
|
||||
// Only one column carries (9+1=10), tens column has 8+1=9 (no carry)
|
||||
const skills = analyzeRequiredSkills([89, 1], 90)
|
||||
expect(skills).not.toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading carry for 19 + 1 = 20', () => {
|
||||
// Only one column carries (9+1=10), tens column 1+1=2 (no cascade)
|
||||
const skills = analyzeRequiredSkills([19, 1], 20)
|
||||
expect(skills).not.toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading carry for simple addition 5 + 3 = 8', () => {
|
||||
const skills = analyzeRequiredSkills([5, 3], 8)
|
||||
expect(skills).not.toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('detects cascading carry for 199 + 1 = 200', () => {
|
||||
// 9+1=10 (carry), 9+1=10 (carry) -> cascading
|
||||
const skills = analyzeRequiredSkills([199, 1], 200)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('detects cascading carry for 9999 + 1 = 10000', () => {
|
||||
// Four consecutive carries
|
||||
const skills = analyzeRequiredSkills([9999, 1], 10000)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('detects cascading carry in multi-term problem', () => {
|
||||
// 98 + 1 + 1 = 100
|
||||
// First step: 98 + 1 = 99 (no cascade)
|
||||
// Second step: 99 + 1 = 100 (cascading carry)
|
||||
const skills = analyzeRequiredSkills([98, 1, 1], 100)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cascading borrow (subtraction)', () => {
|
||||
it('detects cascading borrow when 1000 - 1 = 999', () => {
|
||||
// Subtracting 1 from 1000: ones column 0-1 needs to borrow from tens,
|
||||
// tens column is 0 so borrows from hundreds, hundreds is 0 so borrows from thousands
|
||||
const skills = analyzeSubtractionStepSkills(1000, 1, 999)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading borrow when 100 - 1 = 99', () => {
|
||||
// 0-1 borrows from tens, tens is 0 so borrows from hundreds
|
||||
const skills = analyzeSubtractionStepSkills(100, 1, 99)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading borrow for 90 - 1 = 89', () => {
|
||||
// 0-1 borrows from tens, tens has 9 (no cascade needed)
|
||||
const skills = analyzeSubtractionStepSkills(90, 1, 89)
|
||||
expect(skills).not.toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading borrow for 20 - 1 = 19', () => {
|
||||
// Single borrow from tens column which has 2
|
||||
const skills = analyzeSubtractionStepSkills(20, 1, 19)
|
||||
expect(skills).not.toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('does NOT detect cascading borrow for simple subtraction 8 - 3 = 5', () => {
|
||||
const skills = analyzeSubtractionStepSkills(8, 3, 5)
|
||||
expect(skills).not.toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading borrow for 200 - 1 = 199', () => {
|
||||
// 0-1 borrows from tens (0), tens borrows from hundreds
|
||||
const skills = analyzeSubtractionStepSkills(200, 1, 199)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading borrow for 10000 - 1 = 9999', () => {
|
||||
// Four consecutive borrows
|
||||
const skills = analyzeSubtractionStepSkills(10000, 1, 9999)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading borrow for 300 - 5 = 295', () => {
|
||||
// 0-5 borrows from tens (0), tens borrows from hundreds
|
||||
const skills = analyzeSubtractionStepSkills(300, 5, 295)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading borrow in analyzeRequiredSkills with negative term', () => {
|
||||
// 1000 + (-1) = 999 should detect cascading borrow
|
||||
const skills = analyzeRequiredSkills([1000, -1], 999)
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles 1000 - 999 = 1 (no cascade - single borrow per column)', () => {
|
||||
// Each column may borrow but they don't cascade consecutively
|
||||
const skills = analyzeSubtractionStepSkills(1000, 999, 1)
|
||||
// This is actually more complex - let's check what happens
|
||||
// 0-9 borrows, 0-9 borrows (but 0-1=9 from the borrow), etc.
|
||||
// This might still be considered cascading since multiple borrows happen
|
||||
// The key is consecutive columns needing to borrow
|
||||
expect(skills).toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('does not count single carries as cascading', () => {
|
||||
// 45 + 5 = 50 - single carry from ones to tens
|
||||
const skills = analyzeRequiredSkills([45, 5], 50)
|
||||
expect(skills).not.toContain('advanced.cascadingCarry')
|
||||
})
|
||||
|
||||
it('does not count single borrows as cascading', () => {
|
||||
// 50 - 5 = 45 - single borrow needed
|
||||
const skills = analyzeSubtractionStepSkills(50, 5, 45)
|
||||
expect(skills).not.toContain('advanced.cascadingBorrow')
|
||||
})
|
||||
|
||||
it('detects cascading carry even when not all columns carry', () => {
|
||||
// 1099 + 1 = 1100: 9+1=10 (carry), 9+1=10 (carry), but 0+1=1 (no carry)
|
||||
// Still has 2 consecutive carries in ones and tens
|
||||
const skills = analyzeRequiredSkills([1099, 1], 1100)
|
||||
expect(skills).toContain('advanced.cascadingCarry')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -150,6 +150,7 @@ function generateStepExplanation(
|
|||
|
||||
/**
|
||||
* Analyzes skills needed for a single addition step: currentValue + term = newValue
|
||||
* Also detects cascading carries (when a carry propagates across 2+ columns).
|
||||
*/
|
||||
function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] {
|
||||
const skills: string[] = []
|
||||
|
|
@ -161,18 +162,42 @@ function analyzeStepSkills(currentValue: number, term: number, newValue: number)
|
|||
|
||||
const maxColumns = Math.max(currentDigits.length, termDigits.length, newDigits.length)
|
||||
|
||||
// Track carries for cascading detection
|
||||
let carryIn = 0
|
||||
let consecutiveCarries = 0
|
||||
let maxConsecutiveCarries = 0
|
||||
|
||||
for (let column = 0; column < maxColumns; column++) {
|
||||
const currentDigit = currentDigits[column] || 0
|
||||
const termDigit = termDigits[column] || 0
|
||||
const newDigit = newDigits[column] || 0
|
||||
|
||||
if (termDigit === 0) continue // No addition in this column
|
||||
// Check if this column produces a carry (including any carry-in from previous column)
|
||||
const sumInColumn = currentDigit + termDigit + carryIn
|
||||
const producesCarry = sumInColumn >= 10
|
||||
|
||||
if (producesCarry) {
|
||||
consecutiveCarries++
|
||||
maxConsecutiveCarries = Math.max(maxConsecutiveCarries, consecutiveCarries)
|
||||
carryIn = 1
|
||||
} else {
|
||||
// Reset consecutive carries when a column doesn't produce a carry
|
||||
consecutiveCarries = 0
|
||||
carryIn = 0
|
||||
}
|
||||
|
||||
if (termDigit === 0 && carryIn === 0) continue // No addition in this column (and no carry to process)
|
||||
|
||||
// Analyze what happens in this column
|
||||
const columnSkills = analyzeColumnAddition(currentDigit, termDigit, newDigit, column)
|
||||
skills.push(...columnSkills)
|
||||
}
|
||||
|
||||
// If we had 2+ consecutive carries, this is a cascading carry
|
||||
if (maxConsecutiveCarries >= 2) {
|
||||
skills.push('advanced.cascadingCarry')
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +357,8 @@ export function analyzeColumnSubtraction(
|
|||
|
||||
/**
|
||||
* Analyzes skills needed for a single subtraction step: currentValue - term = newValue
|
||||
* Works column by column from right to left, tracking borrows
|
||||
* Works column by column from right to left, tracking borrows.
|
||||
* Also detects cascading borrows (when a borrow propagates across 2+ columns).
|
||||
*/
|
||||
export function analyzeSubtractionStepSkills(
|
||||
currentValue: number,
|
||||
|
|
@ -350,6 +376,9 @@ export function analyzeSubtractionStepSkills(
|
|||
// Track borrows as we work from right to left
|
||||
let pendingBorrow = false
|
||||
|
||||
// Track consecutive borrows for cascading detection
|
||||
let consecutiveBorrows = 0
|
||||
|
||||
for (let column = 0; column < maxColumns; column++) {
|
||||
let currentDigit = currentDigits[column] || 0
|
||||
const termDigit = termDigits[column] || 0
|
||||
|
|
@ -367,7 +396,11 @@ export function analyzeSubtractionStepSkills(
|
|||
|
||||
if (needsBorrow) {
|
||||
pendingBorrow = true
|
||||
consecutiveBorrows++
|
||||
// After borrowing, we effectively have currentDigit + 10
|
||||
} else {
|
||||
// Reset consecutive borrows when a column doesn't need to borrow
|
||||
consecutiveBorrows = 0
|
||||
}
|
||||
|
||||
// Analyze skills needed for this column
|
||||
|
|
@ -379,6 +412,11 @@ export function analyzeSubtractionStepSkills(
|
|||
skills.push(...columnSkills)
|
||||
}
|
||||
|
||||
// If we had 2+ consecutive borrows, this is a cascading borrow
|
||||
if (consecutiveBorrows >= 2) {
|
||||
skills.push('advanced.cascadingBorrow')
|
||||
}
|
||||
|
||||
return [...new Set(skills)] // Remove duplicates
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +449,8 @@ function isSkillEnabled(skillPath: string, skillSet: SkillSet | Partial<SkillSet
|
|||
return skillSet.fiveComplementsSub[skill as keyof typeof skillSet.fiveComplementsSub] || false
|
||||
} else if (category === 'tenComplementsSub' && skillSet.tenComplementsSub) {
|
||||
return skillSet.tenComplementsSub[skill as keyof typeof skillSet.tenComplementsSub] || false
|
||||
} else if (category === 'advanced' && skillSet.advanced) {
|
||||
return skillSet.advanced[skill as keyof typeof skillSet.advanced] || false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -452,7 +492,8 @@ export function problemMatchesSkills(
|
|||
Object.values(targetSkills.fiveComplements || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.tenComplements || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.fiveComplementsSub || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.tenComplementsSub || {}).some(Boolean)
|
||||
Object.values(targetSkills.tenComplementsSub || {}).some(Boolean) ||
|
||||
Object.values(targetSkills.advanced || {}).some(Boolean)
|
||||
|
||||
if (hasAnyTargetSkill && !hasTargetSkill) return false
|
||||
}
|
||||
|
|
@ -780,6 +821,19 @@ function generateSequentialExplanation(terms: number[], sum: number, skills: str
|
|||
)
|
||||
}
|
||||
|
||||
// Advanced skill explanations
|
||||
if (skills.includes('advanced.cascadingCarry')) {
|
||||
explanations.push(
|
||||
'This problem involves cascading carry (carry propagates across 2+ place values).'
|
||||
)
|
||||
}
|
||||
|
||||
if (skills.includes('advanced.cascadingBorrow')) {
|
||||
explanations.push(
|
||||
'This problem involves cascading borrow (borrow propagates across 2+ place values).'
|
||||
)
|
||||
}
|
||||
|
||||
return explanations.join(' ')
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue