3598 lines
112 KiB
TypeScript
3598 lines
112 KiB
TypeScript
#!/usr/bin/env npx tsx
|
||
/**
|
||
* Seed script to create multiple test students with different BKT scenarios.
|
||
*
|
||
* Uses the REAL problem generator to create realistic problems with proper
|
||
* skill tagging. Each profile declares its INTENTION, and after generation
|
||
* the ACTUAL outcomes are appended to the student notes.
|
||
*
|
||
* =============================================================================
|
||
* CLI OPTIONS
|
||
* =============================================================================
|
||
*
|
||
* Usage:
|
||
* npm run seed:test-students [options]
|
||
*
|
||
* Options:
|
||
* --help, -h Show this help message
|
||
* --list, -l List all available students and categories
|
||
* --name, -n <name> Seed specific student(s) by name (can use multiple times)
|
||
* --category, -c <cat> Seed all students in a category (can use multiple times)
|
||
* --dry-run Show what would be seeded without creating students
|
||
*
|
||
* Categories:
|
||
* bkt Core BKT scenarios (deficient, blocker, progressing, etc.)
|
||
* session Session mode tests (remediation, progression, maintenance)
|
||
* edge Edge cases (empty, single skill, high volume, NaN stress test)
|
||
*
|
||
* Examples:
|
||
* npm run seed:test-students # Seed all students
|
||
* npm run seed:test-students -- --list # List available options
|
||
* npm run seed:test-students -- -n "💥 NaN Stress Test"
|
||
* npm run seed:test-students -- -c edge # Seed all edge case students
|
||
* npm run seed:test-students -- -c bkt -c session
|
||
* npm run seed:test-students -- -n "🔴 Multi-Skill Deficient" -n "🟢 Progressing Nicely"
|
||
*
|
||
* =============================================================================
|
||
* STUDENT PROFILES
|
||
* =============================================================================
|
||
*
|
||
* BKT Scenarios (--category bkt):
|
||
* 🔴 Multi-Skill Deficient - Early L1, struggling with basics
|
||
* 🟡 Single-Skill Blocker - Mid L1, one five-complement blocking
|
||
* 🟢 Progressing Nicely - Mid L1, healthy mix
|
||
* ⭐ Ready to Level Up - End of L1 addition, all strong
|
||
* 🚀 Overdue for Promotion - Has mastered L1, ready for L2
|
||
*
|
||
* Session Mode Tests (--category session):
|
||
* 🎯 Remediation Test - REMEDIATION mode (weak skills blocking)
|
||
* 📚 Progression Tutorial Test - PROGRESSION mode (tutorial required)
|
||
* 🚀 Progression Ready Test - PROGRESSION mode (tutorial done)
|
||
* 🏆 Maintenance Test - MAINTENANCE mode (all skills strong)
|
||
*
|
||
* Edge Cases (--category edge):
|
||
* 🆕 Brand New Student - Zero practicing skills, empty state
|
||
* 🔢 Single Skill Only - Only one skill practicing
|
||
* 📊 High Volume Learner - Many skills with lots of practice history
|
||
* ⚖️ Multi-Weak Remediation - Many weak skills needing remediation
|
||
* 🕰️ Stale Skills Test - Skills at various staleness levels
|
||
* 💥 NaN Stress Test - Stress tests BKT NaN handling
|
||
* 🧊 Forgotten Weaknesses - Weak skills that are also stale
|
||
*/
|
||
|
||
import { parseArgs } from 'node:util'
|
||
|
||
// =============================================================================
|
||
// CLI Argument Parsing
|
||
// =============================================================================
|
||
|
||
const { values: cliArgs } = parseArgs({
|
||
options: {
|
||
help: { type: 'boolean', short: 'h', default: false },
|
||
list: { type: 'boolean', short: 'l', default: false },
|
||
name: { type: 'string', short: 'n', multiple: true, default: [] },
|
||
category: { type: 'string', short: 'c', multiple: true, default: [] },
|
||
'dry-run': { type: 'boolean', default: false },
|
||
},
|
||
strict: true,
|
||
allowPositionals: false,
|
||
})
|
||
|
||
function showHelp(): void {
|
||
console.log(`
|
||
Usage:
|
||
npm run seed:test-students [options]
|
||
|
||
Options:
|
||
--help, -h Show this help message
|
||
--list, -l List all available students and categories
|
||
--name, -n <name> Seed specific student(s) by name (can use multiple times)
|
||
--category, -c <cat> Seed all students in a category (can use multiple times)
|
||
--dry-run Show what would be seeded without creating students
|
||
|
||
Categories:
|
||
bkt Core BKT scenarios (deficient, blocker, progressing, etc.)
|
||
session Session mode tests (remediation, progression, maintenance)
|
||
edge Edge cases (empty, single skill, high volume, NaN stress test)
|
||
|
||
Examples:
|
||
npm run seed:test-students # Seed all students
|
||
npm run seed:test-students -- --list # List available options
|
||
npm run seed:test-students -- -n "💥 NaN Stress Test"
|
||
npm run seed:test-students -- -c edge # Seed all edge case students
|
||
npm run seed:test-students -- -c bkt -c session
|
||
npm run seed:test-students -- -n "🔴 Multi-Skill Deficient" -n "🟢 Progressing Nicely"
|
||
`)
|
||
}
|
||
|
||
// Note: listProfiles is defined after TEST_PROFILES (below)
|
||
|
||
import { createId } from '@paralleldrive/cuid2'
|
||
import { desc, eq } from 'drizzle-orm'
|
||
import { db, schema } from '../src/db'
|
||
import { computeBktFromHistory, type SkillBktResult } from '../src/lib/curriculum/bkt'
|
||
import { applyLearning, bktUpdate } from '../src/lib/curriculum/bkt/bkt-core'
|
||
import { getDefaultParams } from '../src/lib/curriculum/bkt/skill-priors'
|
||
import { BKT_THRESHOLDS } from '../src/lib/curriculum/config/bkt-integration'
|
||
import { getRecentSessionResults } from '../src/lib/curriculum/session-planner'
|
||
import type {
|
||
GeneratedProblem,
|
||
SessionPart,
|
||
SessionSummary,
|
||
SlotResult,
|
||
} from '../src/db/schema/session-plans'
|
||
import {
|
||
generateSingleProblem,
|
||
type GeneratedProblem as GenProblem,
|
||
} from '../src/utils/problemGenerator'
|
||
import { createEmptySkillSet, type SkillSet } from '../src/types/tutorial'
|
||
import type { GameResultsReport } from '../src/lib/arcade/game-sdk/types'
|
||
// Import directly from specific manager files to avoid pulling in React components via barrel export
|
||
import { createClassroom, getTeacherClassroom } from '../src/lib/classroom/classroom-manager'
|
||
import { directEnrollStudent } from '../src/lib/classroom/enrollment-manager'
|
||
|
||
// =============================================================================
|
||
// BKT Simulation Utilities
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Simulate BKT computation for a sequence of correct/incorrect answers.
|
||
* Used to predict what pKnown will result from a given sequence.
|
||
*
|
||
* IMPORTANT: This matches the actual BKT computation behavior:
|
||
* - CORRECT: bktUpdate + applyLearning (student may have learned from this)
|
||
* - INCORRECT: bktUpdate only (no learning transition on failure)
|
||
*/
|
||
function simulateBktSequence(skillId: string, sequence: boolean[]): number {
|
||
const params = getDefaultParams(skillId)
|
||
let pKnown = params.pInit
|
||
|
||
for (const isCorrect of sequence) {
|
||
const updated = bktUpdate(pKnown, isCorrect, params)
|
||
// Only apply learning transition on CORRECT answers
|
||
// (matches updateOnCorrect vs updateOnIncorrect behavior)
|
||
pKnown = isCorrect ? applyLearning(updated, params.pLearn) : updated
|
||
}
|
||
|
||
return pKnown
|
||
}
|
||
|
||
/**
|
||
* Target classification for a skill
|
||
*/
|
||
type TargetClassification = 'weak' | 'developing' | 'strong'
|
||
|
||
/**
|
||
* Design a sequence of correct/incorrect answers that will reliably produce
|
||
* the target BKT classification.
|
||
*
|
||
* Key insight: The ORDER of correct/incorrect matters more than the ratio.
|
||
* - Ending with correct answers → higher pKnown
|
||
* - Ending with incorrect answers → lower pKnown
|
||
*
|
||
* IMPORTANT: BKT dynamics are "swingy" - a single correct can push pKnown
|
||
* from 0.3 to ~0.7, and a single incorrect can drop from 0.7 to ~0.3.
|
||
* The "developing" range (0.5-0.8) is narrow and requires careful calibration.
|
||
*/
|
||
function designSequenceForClassification(
|
||
skillId: string,
|
||
problemCount: number,
|
||
target: TargetClassification
|
||
): boolean[] {
|
||
// For very few problems, use simple patterns
|
||
if (problemCount <= 3) {
|
||
switch (target) {
|
||
case 'strong':
|
||
return Array(problemCount).fill(true)
|
||
case 'weak':
|
||
return Array(problemCount).fill(false)
|
||
case 'developing':
|
||
// All correct for tiny counts since multi-skill coupling pulls down
|
||
return Array(problemCount).fill(true)
|
||
}
|
||
}
|
||
|
||
// For longer sequences, use empirically-tuned patterns
|
||
switch (target) {
|
||
case 'strong': {
|
||
// 85% correct, ending with streak of correct
|
||
const incorrectCount = Math.max(1, Math.floor(problemCount * 0.15))
|
||
return [
|
||
...Array(incorrectCount).fill(false),
|
||
...Array(problemCount - incorrectCount).fill(true),
|
||
]
|
||
}
|
||
|
||
case 'weak': {
|
||
// 90% incorrect, ending with long streak of incorrect
|
||
const correctCount = Math.max(1, Math.floor(problemCount * 0.1))
|
||
return [...Array(correctCount).fill(true), ...Array(problemCount - correctCount).fill(false)]
|
||
}
|
||
|
||
case 'developing': {
|
||
// The developing range (0.5-0.8) is narrow and BKT is swingy.
|
||
// Try multiple pattern types to find one that lands in range.
|
||
|
||
// Pattern generators to try (in order of preference)
|
||
const patternGenerators = [
|
||
// Pattern 1: End with exactly 1 correct after many incorrect
|
||
// This leverages BKT's swingy nature - one correct from low pKnown lands ~0.65-0.75
|
||
(n: number, correct: number) => {
|
||
const endCorrect = 1
|
||
const startCorrect = correct - endCorrect
|
||
return [
|
||
...Array(startCorrect).fill(true),
|
||
...Array(n - correct).fill(false),
|
||
...Array(endCorrect).fill(true),
|
||
]
|
||
},
|
||
|
||
// Pattern 2: Alternating ending with correct
|
||
// Creates "oscillating" pKnown that can land in middle
|
||
(n: number, correct: number) => {
|
||
const seq: boolean[] = []
|
||
let remainingCorrect = correct
|
||
let remainingIncorrect = n - correct
|
||
// Interleave with bias toward incorrect first
|
||
while (remainingCorrect > 0 || remainingIncorrect > 0) {
|
||
if (
|
||
remainingIncorrect > 0 &&
|
||
(remainingIncorrect > remainingCorrect || remainingCorrect === 0)
|
||
) {
|
||
seq.push(false)
|
||
remainingIncorrect--
|
||
} else if (remainingCorrect > 0) {
|
||
seq.push(true)
|
||
remainingCorrect--
|
||
}
|
||
}
|
||
return seq
|
||
},
|
||
|
||
// Pattern 3: Front-loaded correct, then incorrect, ending with 1 correct
|
||
(n: number, correct: number) => {
|
||
const endCorrect = 1
|
||
const frontCorrect = correct - endCorrect
|
||
return [
|
||
...Array(frontCorrect).fill(true),
|
||
...Array(n - correct).fill(false),
|
||
...Array(endCorrect).fill(true),
|
||
]
|
||
},
|
||
|
||
// Pattern 4: Sandwich - incorrect, correct, incorrect
|
||
(n: number, correct: number) => {
|
||
const thirdIncorrect = Math.floor((n - correct) / 2)
|
||
return [
|
||
...Array(thirdIncorrect).fill(false),
|
||
...Array(correct).fill(true),
|
||
...Array(n - correct - thirdIncorrect).fill(false),
|
||
]
|
||
},
|
||
]
|
||
|
||
// Try different correct counts with each pattern
|
||
// For developing, we want something between strong (>80%) and weak (<50%)
|
||
// Try 40-70% correct with various patterns
|
||
for (const correctRatio of [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7]) {
|
||
const correctCount = Math.max(1, Math.round(problemCount * correctRatio))
|
||
|
||
for (const generatePattern of patternGenerators) {
|
||
const sequence = generatePattern(problemCount, correctCount)
|
||
|
||
// Verify sequence length is correct
|
||
if (sequence.length !== problemCount) continue
|
||
|
||
const pKnown = simulateBktSequence(skillId, sequence)
|
||
|
||
// Check if it lands in developing range
|
||
if (pKnown >= BKT_THRESHOLDS.weak && pKnown < BKT_THRESHOLDS.strong) {
|
||
return sequence
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we still can't find a pattern, try edge cases
|
||
// Sometimes a specific count lands in range
|
||
for (let correct = 1; correct < problemCount; correct++) {
|
||
// Try ending with 1 correct after all incorrect
|
||
const sequence = [
|
||
...Array(correct - 1).fill(true),
|
||
...Array(problemCount - correct).fill(false),
|
||
true, // End with one correct
|
||
]
|
||
const pKnown = simulateBktSequence(skillId, sequence)
|
||
if (pKnown >= BKT_THRESHOLDS.weak && pKnown < BKT_THRESHOLDS.strong) {
|
||
return sequence
|
||
}
|
||
}
|
||
|
||
// Ultimate fallback: Just end with 1 correct after all incorrect
|
||
// This typically lands around 0.65-0.70 from pInit
|
||
return [
|
||
...Array(problemCount - 1).fill(false),
|
||
true, // Single correct at end
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Realistic Problem Generation Utilities
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Maps a skill ID to the category and key for SkillSet modification
|
||
*/
|
||
function parseSkillId(skillId: string): { category: string; key: string } | null {
|
||
const parts = skillId.split('.')
|
||
if (parts.length !== 2) return null
|
||
return { category: parts[0], key: parts[1] }
|
||
}
|
||
|
||
/**
|
||
* Enables a specific skill in a SkillSet (mutates the set)
|
||
*/
|
||
function enableSkill(skillSet: SkillSet, skillId: string): void {
|
||
const parsed = parseSkillId(skillId)
|
||
if (!parsed) return
|
||
|
||
const { category, key } = parsed
|
||
if (category === 'basic' && key in skillSet.basic) {
|
||
;(skillSet.basic as Record<string, boolean>)[key] = true
|
||
} else if (category === 'fiveComplements' && key in skillSet.fiveComplements) {
|
||
;(skillSet.fiveComplements as Record<string, boolean>)[key] = true
|
||
} else if (category === 'tenComplements' && key in skillSet.tenComplements) {
|
||
;(skillSet.tenComplements as Record<string, boolean>)[key] = true
|
||
} else if (category === 'fiveComplementsSub' && key in skillSet.fiveComplementsSub) {
|
||
;(skillSet.fiveComplementsSub as Record<string, boolean>)[key] = true
|
||
} else if (category === 'tenComplementsSub' && key in skillSet.tenComplementsSub) {
|
||
;(skillSet.tenComplementsSub as Record<string, boolean>)[key] = true
|
||
} else if (category === 'advanced' && key in skillSet.advanced) {
|
||
;(skillSet.advanced as Record<string, boolean>)[key] = true
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get prerequisite skills that must be enabled for a target skill to be reachable.
|
||
* For example, to use fiveComplements.3=5-2, we need basic.directAddition to reach
|
||
* states where adding 3 triggers +5-2.
|
||
*/
|
||
function getPrerequisiteSkills(skillId: string): string[] {
|
||
const category = skillId.split('.')[0]
|
||
|
||
switch (category) {
|
||
case 'basic':
|
||
// Basic skills need directAddition enabled (except directAddition itself)
|
||
if (skillId === 'basic.directAddition') {
|
||
return []
|
||
}
|
||
return ['basic.directAddition']
|
||
case 'fiveComplements':
|
||
// Five complements need directAddition and heavenBead to reach necessary states
|
||
return ['basic.directAddition', 'basic.heavenBead']
|
||
case 'tenComplements':
|
||
// Ten complements need basics plus five complements to reach carry states
|
||
return [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
]
|
||
case 'fiveComplementsSub':
|
||
// Subtraction five complements need subtraction basics
|
||
return ['basic.directSubtraction', 'basic.heavenBeadSubtraction']
|
||
case 'tenComplementsSub':
|
||
// Subtraction ten complements need all subtraction skills
|
||
return [
|
||
'basic.directSubtraction',
|
||
'basic.heavenBeadSubtraction',
|
||
'basic.simpleCombinationsSub',
|
||
'fiveComplementsSub.-4=-5+1',
|
||
'fiveComplementsSub.-3=-5+2',
|
||
'fiveComplementsSub.-2=-5+3',
|
||
'fiveComplementsSub.-1=-5+4',
|
||
]
|
||
default:
|
||
return []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Creates a SkillSet that enables the target skill plus prerequisites
|
||
*/
|
||
function createSkillSetForTarget(targetSkill: string): SkillSet {
|
||
const skillSet = createEmptySkillSet()
|
||
|
||
// Enable prerequisites first
|
||
const prereqs = getPrerequisiteSkills(targetSkill)
|
||
for (const prereq of prereqs) {
|
||
enableSkill(skillSet, prereq)
|
||
}
|
||
|
||
// Enable the target skill
|
||
enableSkill(skillSet, targetSkill)
|
||
|
||
return skillSet
|
||
}
|
||
|
||
/**
|
||
* Creates a target SkillSet with only the target skill enabled (for problem matching)
|
||
*/
|
||
function createTargetSkillSet(targetSkill: string): Partial<SkillSet> {
|
||
const skillSet = createEmptySkillSet()
|
||
enableSkill(skillSet, targetSkill)
|
||
return skillSet
|
||
}
|
||
|
||
/**
|
||
* Generated problem with metadata for seeding
|
||
*/
|
||
interface RealisticProblem {
|
||
terms: number[]
|
||
answer: number
|
||
skillsUsed: string[]
|
||
generationTrace?: GenProblem['generationTrace']
|
||
}
|
||
|
||
/**
|
||
* Generates a batch of realistic problems targeting a specific skill.
|
||
* IMPORTANT: Only returns problems that actually exercise the target skill.
|
||
* This ensures BKT sees the correct skill in skillsExercised.
|
||
*/
|
||
function generateRealisticProblems(
|
||
targetSkill: string,
|
||
count: number,
|
||
maxAttempts: number = 100
|
||
): RealisticProblem[] {
|
||
const problems: RealisticProblem[] = []
|
||
const allowedSkills = createSkillSetForTarget(targetSkill)
|
||
const targetSkillSet = createTargetSkillSet(targetSkill)
|
||
|
||
// Determine number range based on skill category
|
||
const category = targetSkill.split('.')[0]
|
||
let numberRange = { min: 1, max: 9 }
|
||
let maxSum = 20
|
||
|
||
if (category === 'tenComplements' || category === 'tenComplementsSub') {
|
||
numberRange = { min: 1, max: 99 }
|
||
maxSum = 200
|
||
} else if (category === 'fiveComplements' || category === 'fiveComplementsSub') {
|
||
numberRange = { min: 1, max: 9 }
|
||
maxSum = 20
|
||
}
|
||
|
||
let attempts = 0
|
||
while (problems.length < count && attempts < count * maxAttempts) {
|
||
attempts++
|
||
|
||
const problem = generateSingleProblem({
|
||
constraints: {
|
||
numberRange,
|
||
maxSum,
|
||
maxTerms: 3,
|
||
minTerms: 2,
|
||
problemCount: 1,
|
||
},
|
||
allowedSkills,
|
||
targetSkills: targetSkillSet,
|
||
attempts: 20,
|
||
})
|
||
|
||
// STRICT: Only accept problems that actually use the target skill
|
||
if (problem && problem.skillsUsed.includes(targetSkill)) {
|
||
problems.push({
|
||
terms: problem.terms,
|
||
answer: problem.answer,
|
||
// IMPORTANT: Force single-skill annotation for predictable BKT outcomes.
|
||
// Multi-skill problems cause blame distribution which our simulation doesn't model.
|
||
// This ensures the generated patterns reliably produce target classifications.
|
||
skillsUsed: [targetSkill],
|
||
generationTrace: problem.generationTrace,
|
||
})
|
||
}
|
||
}
|
||
|
||
// If we couldn't generate enough problems, log a warning and synthesize
|
||
// problems that claim to use the target skill (for testing purposes)
|
||
if (problems.length < count) {
|
||
console.warn(
|
||
`[Seed] Could only generate ${problems.length}/${count} problems for ${targetSkill}. ` +
|
||
`Synthesizing ${count - problems.length} more.`
|
||
)
|
||
|
||
while (problems.length < count) {
|
||
// Synthesize a problem that uses the target skill
|
||
// The actual math doesn't matter for BKT - only skillsUsed matters
|
||
const a = Math.floor(Math.random() * 8) + 1
|
||
const b = Math.floor(Math.random() * 8) + 1
|
||
problems.push({
|
||
terms: [a, b],
|
||
answer: a + b,
|
||
// IMPORTANT: Include the target skill so BKT processes it
|
||
skillsUsed: [targetSkill],
|
||
})
|
||
}
|
||
}
|
||
|
||
return problems
|
||
}
|
||
|
||
// =============================================================================
|
||
// Test Student Profiles
|
||
// =============================================================================
|
||
|
||
interface SkillConfig {
|
||
skillId: string
|
||
/** Target BKT classification - sequences will be designed to achieve this */
|
||
targetClassification: TargetClassification
|
||
/** Number of problems to generate */
|
||
problems: number
|
||
/** Days ago this skill was practiced (default: 1 day) */
|
||
ageDays?: number
|
||
/** Simulate legacy data by omitting hadHelp field (tests NaN handling) */
|
||
simulateLegacyData?: boolean
|
||
}
|
||
|
||
/**
|
||
* Success criteria for a profile - defines what "success" means
|
||
*/
|
||
interface SuccessCriteria {
|
||
/** Minimum number of weak skills required */
|
||
minWeak?: number
|
||
/** Maximum number of weak skills allowed */
|
||
maxWeak?: number
|
||
/** Minimum number of developing skills required */
|
||
minDeveloping?: number
|
||
/** Maximum number of developing skills allowed */
|
||
maxDeveloping?: number
|
||
/** Minimum number of strong skills required */
|
||
minStrong?: number
|
||
/** Maximum number of strong skills allowed */
|
||
maxStrong?: number
|
||
}
|
||
|
||
/**
|
||
* Tuning adjustment to apply when criteria aren't met
|
||
*/
|
||
interface TuningAdjustment {
|
||
/** Skill ID to adjust (or 'all' for all skills) */
|
||
skillId: string | 'all'
|
||
/** Multiply accuracy by this factor */
|
||
accuracyMultiplier?: number
|
||
/** Add this many problems */
|
||
problemsAdd?: number
|
||
/** Multiply problems by this factor */
|
||
problemsMultiplier?: number
|
||
}
|
||
|
||
/**
|
||
* Configuration for seeding game results (scoreboard data)
|
||
*/
|
||
interface GameResultConfig {
|
||
/** Which game: 'matching', 'card-sorting', 'complement-race', etc. */
|
||
gameName: string
|
||
/** Human-readable display name */
|
||
displayName: string
|
||
/** Game icon emoji */
|
||
icon: string
|
||
/** Category for leaderboard grouping */
|
||
category: 'puzzle' | 'memory' | 'speed' | 'strategy' | 'geography'
|
||
/** Target score range (0-100), actual will vary within ±5 */
|
||
targetScore: number
|
||
/** Number of games to seed */
|
||
gameCount: number
|
||
/** Days ago spread (games will be distributed over this period) */
|
||
spreadDays?: number
|
||
}
|
||
|
||
/** Profile category for CLI filtering */
|
||
type ProfileCategory = 'bkt' | 'session' | 'edge'
|
||
|
||
interface TestStudentProfile {
|
||
name: string
|
||
emoji: string
|
||
color: string
|
||
/** Category for CLI filtering: 'bkt', 'session', or 'edge' */
|
||
category: ProfileCategory
|
||
description: string
|
||
/** Intention notes - what this profile is TRYING to achieve */
|
||
intentionNotes: string
|
||
/** Skills that should have isPracticing = true (realistic curriculum progression) */
|
||
practicingSkills: string[]
|
||
/** Skills with problem history (can include non-practicing for testing edge cases) */
|
||
skillHistory: SkillConfig[]
|
||
/**
|
||
* If true, auto-generate problems for all practicing skills that don't have explicit history.
|
||
* This ensures all practicing skills have BKT data for proper session mode detection.
|
||
*/
|
||
ensureAllPracticingHaveHistory?: boolean
|
||
/** Curriculum phase this student is nominally at */
|
||
currentPhaseId: string
|
||
/** Skills that should have their tutorial marked as completed */
|
||
tutorialCompletedSkills?: string[]
|
||
/** Expected session mode for this profile */
|
||
expectedSessionMode?: 'remediation' | 'progression' | 'maintenance'
|
||
/** Success criteria for this profile */
|
||
successCriteria?: SuccessCriteria
|
||
/** Tuning adjustments to apply if criteria aren't met */
|
||
tuningAdjustments?: TuningAdjustment[]
|
||
/**
|
||
* Minimum number of practice sessions to create.
|
||
* Problems will be distributed across sessions over time.
|
||
* Default: 5
|
||
*/
|
||
minSessions?: number
|
||
/**
|
||
* Number of days to spread sessions across.
|
||
* Sessions will be distributed evenly across this period.
|
||
* Default: 30
|
||
*/
|
||
sessionSpreadDays?: number
|
||
/**
|
||
* Game results to seed for scoreboard testing.
|
||
* Each entry creates multiple game result records.
|
||
*/
|
||
gameHistory?: GameResultConfig[]
|
||
}
|
||
|
||
// =============================================================================
|
||
// Realistic Curriculum Skill Progressions
|
||
// =============================================================================
|
||
|
||
/** Early Level 1 - just learning basics */
|
||
const EARLY_L1_SKILLS = ['basic.directAddition', 'basic.heavenBead']
|
||
|
||
/** Mid Level 1 - basics strong, learning five complements */
|
||
const MID_L1_SKILLS = [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
]
|
||
|
||
/** Late Level 1 Addition - all addition skills */
|
||
const LATE_L1_ADD_SKILLS = [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
]
|
||
|
||
/** Complete Level 1 - includes subtraction basics */
|
||
const COMPLETE_L1_SKILLS = [
|
||
...LATE_L1_ADD_SKILLS,
|
||
'basic.directSubtraction',
|
||
'basic.heavenBeadSubtraction',
|
||
'basic.simpleCombinationsSub',
|
||
'fiveComplementsSub.-4=-5+1',
|
||
'fiveComplementsSub.-3=-5+2',
|
||
'fiveComplementsSub.-2=-5+3',
|
||
'fiveComplementsSub.-1=-5+4',
|
||
]
|
||
|
||
/** Level 2 skills (ten complements for addition) */
|
||
const L2_ADD_SKILLS = [
|
||
'tenComplements.9=10-1',
|
||
'tenComplements.8=10-2',
|
||
'tenComplements.7=10-3',
|
||
'tenComplements.6=10-4',
|
||
]
|
||
|
||
// All test student profiles
|
||
const TEST_PROFILES: TestStudentProfile[] = [
|
||
{
|
||
name: '🔴 Multi-Skill Deficient',
|
||
emoji: '😰',
|
||
color: '#ef4444', // red
|
||
category: 'bkt',
|
||
description: 'Struggling with many skills - needs intervention',
|
||
currentPhaseId: 'L1.add.+3.direct',
|
||
practicingSkills: EARLY_L1_SKILLS,
|
||
intentionNotes: `INTENTION: Multi-Skill Deficient
|
||
|
||
This student is in early Level 1 and struggling with basic bead movements. Their BKT estimates show multiple weak skills in the foundational "basic" category.
|
||
|
||
Curriculum position: Early L1 (L1.add.+3.direct)
|
||
Practicing skills: basic.directAddition, basic.heavenBead
|
||
|
||
This profile represents a student who:
|
||
- Is struggling with the very basics of abacus operation
|
||
- May need hands-on teacher guidance
|
||
- Could benefit from slower progression and more scaffolding
|
||
- Might have difficulty with fine motor skills or conceptual understanding
|
||
|
||
Use this student to test how the UI handles intervention alerts for foundational skill deficits.`,
|
||
skillHistory: [
|
||
// Weak in basics - this is concerning at this stage
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'weak',
|
||
problems: 15,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'weak',
|
||
problems: 12,
|
||
},
|
||
],
|
||
// Game history: Struggling student - low scores, few games
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 35, // Struggling
|
||
gameCount: 3,
|
||
spreadDays: 14,
|
||
},
|
||
],
|
||
// Tuning: Need at least 2 weak skills
|
||
successCriteria: { minWeak: 2 },
|
||
tuningAdjustments: [{ skillId: 'all', problemsAdd: 10 }],
|
||
},
|
||
{
|
||
name: '🟡 Single-Skill Blocker',
|
||
emoji: '🤔',
|
||
color: '#f59e0b', // amber
|
||
category: 'bkt',
|
||
description: 'One weak skill blocking progress, others are fine',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: MID_L1_SKILLS,
|
||
intentionNotes: `INTENTION: Single-Skill Blocker
|
||
|
||
This student is progressing well through Level 1 but has ONE specific five-complement skill that's blocking advancement. Most skills are strong, but fiveComplements.3=5-2 is weak.
|
||
|
||
Curriculum position: Mid L1 (L1.add.+2.five)
|
||
Practicing skills: basics + first two five complements
|
||
|
||
The blocking skill is: fiveComplements.3=5-2 (adding 3 via +5-2)
|
||
|
||
This profile represents a student who:
|
||
- Understands the general concepts well
|
||
- Has a specific gap that needs targeted practice
|
||
- Should NOT be held back on other skills
|
||
- May benefit from focused tutoring on the specific technique
|
||
|
||
Use this student to test targeted intervention recommendations.`,
|
||
skillHistory: [
|
||
// Strong basics
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 15,
|
||
},
|
||
// Strong in first five complement
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 16,
|
||
},
|
||
// THE BLOCKER - weak despite practice
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 18,
|
||
},
|
||
],
|
||
// Game history: Mixed results - good at some games, struggling with blocker skill
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 65, // Decent but not great
|
||
gameCount: 5,
|
||
spreadDays: 21,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 70, // Better at puzzles
|
||
gameCount: 4,
|
||
spreadDays: 21,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🟢 Progressing Nicely',
|
||
emoji: '😊',
|
||
color: '#22c55e', // green
|
||
category: 'bkt',
|
||
description: 'Healthy progression - mostly strong with one skill in progress',
|
||
currentPhaseId: 'L1.add.+3.five',
|
||
practicingSkills: MID_L1_SKILLS,
|
||
intentionNotes: `INTENTION: Progressing Nicely
|
||
|
||
This student shows a healthy learning trajectory - most skills are mastered, with one newer skill still being learned (weak).
|
||
|
||
Curriculum position: Mid L1 (L1.add.+3.five)
|
||
Practicing skills: basics + first two five complements
|
||
|
||
Expected outcome:
|
||
• Most skills strong (mastered basics and early five-complements)
|
||
• One weak skill (newest in curriculum, still learning)
|
||
|
||
This is what a "healthy" student looks like - no intervention flags, steady progress.
|
||
|
||
Use this student to verify:
|
||
• Normal dashboard display without intervention alerts
|
||
• Mixed skill states that don't trigger remediation
|
||
• Typical student who is making good progress`,
|
||
skillHistory: [
|
||
// Strong basics (mastered)
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
// Developing - in the middle zone
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'developing',
|
||
problems: 12,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'developing',
|
||
problems: 10,
|
||
},
|
||
// Just started (expected to be weak)
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 8,
|
||
},
|
||
],
|
||
// Success criteria: Need at least 1 developing to prove the system works
|
||
successCriteria: { minDeveloping: 1 },
|
||
// Game history: Healthy player - good scores, regular game play
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 75, // Good scores
|
||
gameCount: 8,
|
||
spreadDays: 30,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 72,
|
||
gameCount: 6,
|
||
spreadDays: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '⭐ Ready to Level Up',
|
||
emoji: '🌟',
|
||
color: '#8b5cf6', // violet
|
||
category: 'bkt',
|
||
description: 'All skills strong - ready for next curriculum phase',
|
||
currentPhaseId: 'L1.add.+1.five',
|
||
practicingSkills: LATE_L1_ADD_SKILLS,
|
||
intentionNotes: `INTENTION: Ready to Level Up
|
||
|
||
This student has mastered ALL Level 1 addition skills and is ready to move to subtraction or Level 2.
|
||
|
||
Curriculum position: End of L1 Addition (L1.add.+1.five - last addition phase)
|
||
Practicing skills: All Level 1 addition skills
|
||
|
||
All skills at strong mastery (85%+):
|
||
• basic.directAddition, heavenBead, simpleCombinations
|
||
• All four fiveComplements
|
||
|
||
This student should be promoted to L1 subtraction or could start L2 addition with carrying.
|
||
|
||
Use this student to test:
|
||
- "Ready to advance" indicators
|
||
- Promotion recommendations
|
||
- Session planning when all skills are strong`,
|
||
skillHistory: [
|
||
// All strong
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
],
|
||
// Game history: Excellent player - high scores, ready to advance
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 88, // Excellent scores
|
||
gameCount: 12,
|
||
spreadDays: 45,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 85,
|
||
gameCount: 10,
|
||
spreadDays: 45,
|
||
},
|
||
{
|
||
gameName: 'complement-race',
|
||
displayName: 'Complement Race',
|
||
icon: '🏁',
|
||
category: 'speed',
|
||
targetScore: 82,
|
||
gameCount: 8,
|
||
spreadDays: 45,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🚀 Overdue for Promotion',
|
||
emoji: '🏆',
|
||
color: '#06b6d4', // cyan
|
||
category: 'bkt',
|
||
description: 'All skills mastered long ago - should have leveled up already',
|
||
currentPhaseId: 'L2.add.+9.ten',
|
||
practicingSkills: [...COMPLETE_L1_SKILLS, ...L2_ADD_SKILLS],
|
||
intentionNotes: `INTENTION: Overdue for Promotion
|
||
|
||
This student has MASSIVELY exceeded mastery requirements. They've mastered ALL of Level 1 (addition AND subtraction) plus several Level 2 skills!
|
||
|
||
Curriculum position: Should be deep in L2 (L2.add.+9.ten)
|
||
Practicing skills: Complete L1 + early L2
|
||
|
||
All skills at very high mastery (88-98%):
|
||
• ALL basic skills (addition and subtraction)
|
||
• ALL four fiveComplements (addition)
|
||
• ALL four fiveComplementsSub (subtraction)
|
||
• Four tenComplements (L2 addition with carrying)
|
||
|
||
This is a "red flag" scenario - the system should have advanced this student long ago.
|
||
|
||
Use this student to test:
|
||
- Urgent promotion alerts
|
||
- Detection of stale curriculum placement
|
||
- Over-mastery warnings`,
|
||
skillHistory: [
|
||
// Extremely strong basics
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'basic.directSubtraction',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBeadSubtraction',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinationsSub',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
// All five complements mastered
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
// Subtraction five complements too
|
||
{
|
||
skillId: 'fiveComplementsSub.-4=-5+1',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-3=-5+2',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-2=-5+3',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-1=-5+4',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
// Even L2 ten complements
|
||
{
|
||
skillId: 'tenComplements.9=10-1',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.8=10-2',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.7=10-3',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.6=10-4',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
],
|
||
// Game history: Top-tier player - highest scores, extensive game history
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 95, // Near-perfect
|
||
gameCount: 25,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 92,
|
||
gameCount: 20,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'complement-race',
|
||
displayName: 'Complement Race',
|
||
icon: '🏁',
|
||
category: 'speed',
|
||
targetScore: 90,
|
||
gameCount: 18,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'memory-quiz',
|
||
displayName: 'Memory Quiz',
|
||
icon: '🧠',
|
||
category: 'memory',
|
||
targetScore: 88,
|
||
gameCount: 15,
|
||
spreadDays: 90,
|
||
},
|
||
],
|
||
},
|
||
|
||
// =============================================================================
|
||
// Session Mode Test Profiles
|
||
// =============================================================================
|
||
|
||
{
|
||
name: '🎯 Remediation Test',
|
||
emoji: '🎯',
|
||
color: '#dc2626', // red-600
|
||
category: 'session',
|
||
description: 'REMEDIATION MODE - Weak skills blocking promotion',
|
||
currentPhaseId: 'L1.add.+3.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
],
|
||
expectedSessionMode: 'remediation',
|
||
intentionNotes: `INTENTION: Remediation Mode
|
||
|
||
This student is specifically configured to trigger REMEDIATION mode.
|
||
|
||
Session Mode: REMEDIATION (with blocked promotion)
|
||
|
||
What you should see:
|
||
• SessionModeBanner shows "Skills need practice" with weak skills listed
|
||
• Banner shows blocked promotion: "Ready for +3 (five-complement) once skills are strong"
|
||
• StartPracticeModal shows remediation-focused CTA
|
||
|
||
How it works:
|
||
• Has 4 skills practicing: basic.directAddition, heavenBead, simpleCombinations, fiveComplements.4=5-1
|
||
• Two skills have low accuracy (< 50%) with enough problems to be confident
|
||
• The next skill (fiveComplements.3=5-2) is available but blocked by weak skills
|
||
|
||
Use this to test the remediation UI in dashboard and modal.`,
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
],
|
||
skillHistory: [
|
||
// Strong skills
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
},
|
||
// WEAK skills - will trigger remediation
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'weak',
|
||
problems: 15,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'weak',
|
||
problems: 18,
|
||
},
|
||
],
|
||
// Game history: Struggling student - low scores, few games
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 40, // Struggling
|
||
gameCount: 3,
|
||
spreadDays: 14,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '📚 Progression Tutorial Test',
|
||
emoji: '📚',
|
||
color: '#7c3aed', // violet-600
|
||
category: 'session',
|
||
description: 'PROGRESSION MODE - Ready for new skill, tutorial required',
|
||
currentPhaseId: 'L1.add.+3.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
],
|
||
ensureAllPracticingHaveHistory: true, // All practicing skills must be strong for progression
|
||
expectedSessionMode: 'progression',
|
||
intentionNotes: `INTENTION: Progression Mode (Tutorial Required)
|
||
|
||
This student is specifically configured to trigger PROGRESSION mode with tutorial gate.
|
||
|
||
Session Mode: PROGRESSION (tutorialRequired: true)
|
||
|
||
What you should see:
|
||
• SessionModeBanner shows "New Skill Available" with next skill name
|
||
• Banner has "Start Tutorial" button (not "Start Practice")
|
||
• StartPracticeModal shows tutorial CTA with skill description
|
||
|
||
How it works:
|
||
• Has 4 skills practicing, ALL are strong (>= 80% accuracy)
|
||
• The next skill in curriculum (fiveComplements.3=5-2) is available
|
||
• Tutorial for that skill has NOT been completed
|
||
|
||
Use this to test the progression UI and tutorial gate flow.`,
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
// NOTE: fiveComplements.3=5-2 tutorial NOT completed - triggers tutorial gate
|
||
],
|
||
skillHistory: [
|
||
// All skills STRONG
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
],
|
||
// Game history: Good player learning new skills - solid scores
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 78,
|
||
gameCount: 6,
|
||
spreadDays: 21,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 75,
|
||
gameCount: 5,
|
||
spreadDays: 21,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🚀 Progression Ready Test',
|
||
emoji: '🚀',
|
||
color: '#059669', // emerald-600
|
||
category: 'session',
|
||
description: 'PROGRESSION MODE - Tutorial done, ready to practice',
|
||
currentPhaseId: 'L1.add.+3.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
],
|
||
ensureAllPracticingHaveHistory: true, // All practicing skills must be strong for progression
|
||
expectedSessionMode: 'progression',
|
||
intentionNotes: `INTENTION: Progression Mode (Tutorial Already Done)
|
||
|
||
This student is specifically configured to trigger PROGRESSION mode with tutorial satisfied.
|
||
|
||
Session Mode: PROGRESSION (tutorialRequired: false)
|
||
|
||
What you should see:
|
||
• SessionModeBanner shows "New Skill Available" with next skill name
|
||
• Banner has "Start Practice" button (tutorial already done)
|
||
• StartPracticeModal shows practice CTA (may show skip count if any)
|
||
|
||
How it works:
|
||
• Has 4 skills practicing, ALL are strong (>= 80% accuracy)
|
||
• The next skill in curriculum (fiveComplements.3=5-2) is available
|
||
• Tutorial for that skill HAS been completed (tutorialCompleted: true)
|
||
|
||
Use this to test the progression UI when tutorial is already satisfied.`,
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2', // Tutorial already completed!
|
||
],
|
||
skillHistory: [
|
||
// All skills STRONG
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
],
|
||
// Game history: Strong player ready for more - consistent scores
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 80,
|
||
gameCount: 7,
|
||
spreadDays: 28,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 78,
|
||
gameCount: 5,
|
||
spreadDays: 28,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🏆 Maintenance Test',
|
||
emoji: '🏆',
|
||
color: '#0891b2', // cyan-600
|
||
category: 'session',
|
||
description: 'MAINTENANCE MODE - All skills strong, mixed practice',
|
||
currentPhaseId: 'L1.add.+4.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
],
|
||
ensureAllPracticingHaveHistory: true, // All practicing skills must be strong for maintenance
|
||
expectedSessionMode: 'maintenance',
|
||
intentionNotes: `INTENTION: Maintenance Mode
|
||
|
||
This student is specifically configured to trigger MAINTENANCE mode.
|
||
|
||
Session Mode: MAINTENANCE
|
||
|
||
What you should see:
|
||
• SessionModeBanner shows "Mixed Practice" or similar
|
||
• Banner indicates all skills are strong
|
||
• StartPracticeModal shows general practice CTA
|
||
|
||
How it works:
|
||
• Has 7 skills practicing (all L1 addition), ALL are strong (>= 80%)
|
||
• All practicing skills have enough history to be confident
|
||
• There IS a next skill available but this student is at a natural "pause" point
|
||
(actually to force maintenance, we make the next skill's tutorial NOT exist)
|
||
|
||
NOTE: True maintenance mode is rare in practice - usually there's always a next skill.
|
||
This profile demonstrates the maintenance case.
|
||
|
||
Use this to test the maintenance mode UI in dashboard and modal.`,
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
],
|
||
skillHistory: [
|
||
// All L1 addition skills STRONG with high confidence
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
},
|
||
],
|
||
// Game history: Excellent all-around player - high scores across many games
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 90,
|
||
gameCount: 15,
|
||
spreadDays: 60,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 88,
|
||
gameCount: 12,
|
||
spreadDays: 60,
|
||
},
|
||
{
|
||
gameName: 'complement-race',
|
||
displayName: 'Complement Race',
|
||
icon: '🏁',
|
||
category: 'speed',
|
||
targetScore: 85,
|
||
gameCount: 10,
|
||
spreadDays: 60,
|
||
},
|
||
],
|
||
},
|
||
|
||
// =============================================================================
|
||
// Edge Case Test Profiles
|
||
// =============================================================================
|
||
|
||
{
|
||
name: '🆕 Brand New Student',
|
||
emoji: '🌱',
|
||
color: '#84cc16', // lime-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Zero practicing skills, empty state',
|
||
currentPhaseId: 'L1.add.+1.direct',
|
||
practicingSkills: [], // No skills practicing yet!
|
||
intentionNotes: `INTENTION: Brand New Student (Edge Case)
|
||
|
||
This student has NO skills practicing yet - they just created their account.
|
||
|
||
What you should see:
|
||
• Dashboard shows empty state or prompts to start placement test
|
||
• SkillHealth may be undefined or have zero counts
|
||
• Session mode determination may fall back to progression
|
||
|
||
This tests the empty state handling in the dashboard.
|
||
|
||
Use this to verify the dashboard handles zero practicing skills gracefully.`,
|
||
skillHistory: [], // No history at all
|
||
// NO gameHistory - intentionally empty for testing empty states
|
||
},
|
||
{
|
||
name: '🔢 Single Skill Only',
|
||
emoji: '1️⃣',
|
||
color: '#a855f7', // purple-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Only one skill practicing',
|
||
currentPhaseId: 'L1.add.+1.direct',
|
||
practicingSkills: ['basic.directAddition'],
|
||
tutorialCompletedSkills: ['basic.directAddition'],
|
||
intentionNotes: `INTENTION: Single Skill Only (Edge Case)
|
||
|
||
This student is practicing exactly ONE skill. This is the minimum case.
|
||
|
||
What you should see:
|
||
• Dashboard shows counts with total: 1
|
||
• Skill badges show correctly with single count
|
||
• Progress calculations work with minimal data
|
||
|
||
Use this to verify the dashboard handles single-skill students correctly.`,
|
||
skillHistory: [
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'developing',
|
||
problems: 12,
|
||
},
|
||
],
|
||
// Game history: Just getting started - few games, developing scores
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 55, // Still learning
|
||
gameCount: 2,
|
||
spreadDays: 7,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '📊 High Volume Learner',
|
||
emoji: '📈',
|
||
color: '#3b82f6', // blue-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Many skills with lots of practice history',
|
||
currentPhaseId: 'L1.sub.-3.five',
|
||
practicingSkills: [
|
||
// All L1 addition
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
// L1 subtraction basics
|
||
'basic.directSubtraction',
|
||
'basic.heavenBeadSubtraction',
|
||
],
|
||
ensureAllPracticingHaveHistory: true,
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
'fiveComplements.1=5-4',
|
||
'basic.directSubtraction',
|
||
'basic.heavenBeadSubtraction',
|
||
],
|
||
intentionNotes: `INTENTION: High Volume Learner
|
||
|
||
This student has practiced MANY skills with extensive history - tests dashboard with lots of data.
|
||
|
||
Curriculum position: Mid L1 Subtraction (L1.sub.-3.five)
|
||
Practicing skills: All L1 addition + early subtraction (9 skills total)
|
||
|
||
Use this to verify:
|
||
• Dashboard handles many skills gracefully
|
||
• Skill list scrolling/pagination works
|
||
• Performance with larger skill counts
|
||
• Progress calculations with extensive history`,
|
||
skillHistory: [
|
||
// All L1 addition - strong
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 40,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
// Subtraction - developing
|
||
{
|
||
skillId: 'basic.directSubtraction',
|
||
targetClassification: 'developing',
|
||
problems: 15,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBeadSubtraction',
|
||
targetClassification: 'developing',
|
||
problems: 12,
|
||
},
|
||
],
|
||
// Game history: Lots of gameplay - many games across all types
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 82,
|
||
gameCount: 30,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 80,
|
||
gameCount: 25,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'complement-race',
|
||
displayName: 'Complement Race',
|
||
icon: '🏁',
|
||
category: 'speed',
|
||
targetScore: 78,
|
||
gameCount: 20,
|
||
spreadDays: 90,
|
||
},
|
||
{
|
||
gameName: 'memory-quiz',
|
||
displayName: 'Memory Quiz',
|
||
icon: '🧠',
|
||
category: 'memory',
|
||
targetScore: 75,
|
||
gameCount: 15,
|
||
spreadDays: 90,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '⚖️ Multi-Weak Remediation',
|
||
emoji: '⚖️',
|
||
color: '#f97316', // orange-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Many weak skills needing remediation',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
intentionNotes: `INTENTION: Multi-Weak Remediation (Edge Case)
|
||
|
||
Originally intended as "balanced mix" with 2 strong + 2 developing + 2 weak,
|
||
but BKT's binary nature pushes skills to extremes. Actual output:
|
||
• 2 Strong (basic.directAddition, basic.heavenBead)
|
||
• 4 Weak (simpleCombinations, fiveComplements.4/3/2=5-...)
|
||
|
||
REFRAMED PURPOSE - Tests important app features:
|
||
• Remediation mode with MANY weak skills (4+)
|
||
• Dashboard weak skills display with overflow
|
||
• Session mode banner showing multiple skills to strengthen
|
||
• Skill list with many red/weak indicators
|
||
|
||
Use this to verify UI handles many weak skills gracefully.
|
||
Complements 🔴 Multi-Skill Deficient (which has only 2 weak).`,
|
||
skillHistory: [
|
||
// 2 Strong
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 22,
|
||
},
|
||
// 2 Developing
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'developing',
|
||
problems: 15,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'developing',
|
||
problems: 14,
|
||
},
|
||
// 2 Weak
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 18,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'weak',
|
||
problems: 16,
|
||
},
|
||
],
|
||
// Need at least 2 weak for remediation testing
|
||
successCriteria: { minWeak: 2 },
|
||
// Game history: Struggling student - low scores across games
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 42,
|
||
gameCount: 5,
|
||
spreadDays: 30,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 38,
|
||
gameCount: 4,
|
||
spreadDays: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🕰️ Stale Skills Test',
|
||
emoji: '⏰',
|
||
color: '#6b7280', // gray-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Skills at various staleness levels',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
intentionNotes: `INTENTION: Stale Skills Test
|
||
|
||
This student has skills at various staleness levels to test the Stale Skills Section in the Skills tab.
|
||
|
||
Session Mode: Will depend on BKT state after decay is applied.
|
||
|
||
Staleness levels:
|
||
• 2 skills practiced recently (1 day ago) - should NOT appear in stale section
|
||
• 2 skills practiced 10 days ago - "Not practiced recently"
|
||
• 1 skill practiced 20 days ago - "Getting rusty"
|
||
• 1 skill practiced 45 days ago - "Very stale"
|
||
|
||
Use this to test:
|
||
• StaleSkillsSection component rendering
|
||
• "Mark Current" refresh functionality
|
||
• Different staleness warning messages
|
||
• BKT decay effects on old skills`,
|
||
skillHistory: [
|
||
// Recent skills (1 day ago) - NOT stale
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
ageDays: 1,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
ageDays: 1,
|
||
},
|
||
// "Not practiced recently" (7-14 days)
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 15,
|
||
ageDays: 10,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 16,
|
||
ageDays: 10,
|
||
},
|
||
// "Getting rusty" (14-30 days)
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
ageDays: 20,
|
||
},
|
||
// "Very stale" (30+ days)
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 16,
|
||
ageDays: 45,
|
||
},
|
||
],
|
||
// Game history: Some old games to match staleness theme
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 70,
|
||
gameCount: 8,
|
||
spreadDays: 60, // Games spread over 60 days (some stale)
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '💥 NaN Stress Test',
|
||
emoji: '💥',
|
||
color: '#dc2626', // red-600
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Stress tests BKT NaN handling with extreme data',
|
||
currentPhaseId: 'L1.add.+3.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
intentionNotes: `INTENTION: NaN Stress Test
|
||
|
||
This student is specifically designed to stress test the BKT NaN handling code.
|
||
|
||
ROOT CAUSE TESTED: The production NaN bug was caused by legacy data missing
|
||
the 'hadHelp' field. The helpWeight() function had no default case,
|
||
returning undefined, which caused 'undefined * rtWeight = NaN' to propagate.
|
||
|
||
The profile includes:
|
||
• LEGACY DATA: Skills missing 'hadHelp' (tests the actual root cause)
|
||
• Skills with EXTREME accuracy values (0.01 and 0.99)
|
||
• Very high problem counts (100+ per skill)
|
||
• Mixed recent and very old practice dates
|
||
• Boundary conditions that could trigger floating point edge cases
|
||
|
||
The BKT calculation should handle all of these gracefully:
|
||
• No NaN values in the output
|
||
• Legacy data should be processed with weight 1.0 (neutral)
|
||
• UI should display valid percentages for all skills
|
||
|
||
If you see "⚠️ Data Error" or NaN values in the dashboard:
|
||
1. Check browser console for [BKT] warnings
|
||
2. Investigate the specific skill that failed
|
||
3. Check the problem history for that skill
|
||
|
||
Use this profile to verify:
|
||
• Legacy data without hadHelp is handled (weight defaults to 1.0)
|
||
• BKT core calculations handle extreme pKnown values
|
||
• Conjunctive BKT blame attribution works with edge cases
|
||
• Evidence quality weights don't produce NaN
|
||
• UI gracefully shows errors for any corrupted data`,
|
||
skillHistory: [
|
||
// LEGACY DATA TEST - missing hadHelp (the actual root cause)
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
simulateLegacyData: true,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'developing',
|
||
problems: 25,
|
||
simulateLegacyData: true,
|
||
},
|
||
// STRONG with many problems
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 100,
|
||
},
|
||
// WEAK with many problems
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'weak',
|
||
problems: 100,
|
||
},
|
||
// DEVELOPING
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'developing',
|
||
problems: 50,
|
||
},
|
||
// Very old skill with legacy data (tests decay + legacy handling)
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 40,
|
||
ageDays: 90,
|
||
simulateLegacyData: true,
|
||
},
|
||
],
|
||
// Game history: Random mix for stress testing
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 60, // Middle-of-the-road
|
||
gameCount: 6,
|
||
spreadDays: 45,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🧊 Forgotten Weaknesses',
|
||
emoji: '🧊',
|
||
color: '#3b82f6', // blue-500
|
||
category: 'edge',
|
||
description: 'EDGE CASE - Weak skills that are also stale (urgent remediation needed)',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
tutorialCompletedSkills: [
|
||
'basic.directAddition',
|
||
'basic.heavenBead',
|
||
'basic.simpleCombinations',
|
||
'fiveComplements.4=5-1',
|
||
'fiveComplements.3=5-2',
|
||
'fiveComplements.2=5-3',
|
||
],
|
||
intentionNotes: `INTENTION: Forgotten Weaknesses
|
||
|
||
This student has a realistic mix of weak and stale skills - NOT the same set.
|
||
|
||
Session Mode: Should trigger REMEDIATION.
|
||
|
||
Skill breakdown:
|
||
• 1 skill STRONG + recent (healthy baseline)
|
||
• 1 skill STRONG + stale 20 days (stale-only, should refresh easily)
|
||
• 1 skill WEAK + recent (weak-only, actively struggling)
|
||
• 1 skill WEAK + stale 14 days (overlap: weak AND stale)
|
||
• 1 skill WEAK + stale 35 days (overlap: urgent forgotten weakness)
|
||
• 1 skill DEVELOPING + stale 25 days (borderline, needs attention)
|
||
|
||
This tests:
|
||
• Different combinations of weak/stale indicators
|
||
• UI distinguishing "stale but strong" from "stale AND weak"
|
||
• Session planning prioritizing weak+stale over strong+stale
|
||
• BKT decay effects on skills at different mastery levels
|
||
|
||
Real-world scenario: Student has been practicing inconsistently. Some skills
|
||
are rusty from neglect (stale), others they just can't get (weak), and some
|
||
are both - the forgotten weaknesses that need urgent attention.`,
|
||
skillHistory: [
|
||
// STRONG + recent (healthy baseline)
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 20,
|
||
ageDays: 1,
|
||
},
|
||
// STRONG + stale 20 days (stale-only - "Getting rusty" but should be fine)
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 18,
|
||
ageDays: 20,
|
||
},
|
||
// WEAK + recent (weak-only - actively struggling with this)
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'weak',
|
||
problems: 15,
|
||
ageDays: 2,
|
||
},
|
||
// WEAK + stale 14 days (overlap: weak AND "Not practiced recently")
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'weak',
|
||
problems: 14,
|
||
ageDays: 14,
|
||
},
|
||
// WEAK + stale 35 days (overlap: urgent - weak AND "Very stale")
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 18,
|
||
ageDays: 35,
|
||
},
|
||
// DEVELOPING + stale 25 days (borderline - needs practice)
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'developing',
|
||
problems: 16,
|
||
ageDays: 25,
|
||
},
|
||
],
|
||
// Need at least 3 weak for this profile
|
||
successCriteria: { minWeak: 3 },
|
||
// Game history: Low scores from struggling + old games
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 45,
|
||
gameCount: 4,
|
||
spreadDays: 45, // Some games are old
|
||
},
|
||
],
|
||
},
|
||
|
||
// =============================================================================
|
||
// Chart Edge Case Profiles
|
||
// =============================================================================
|
||
// These profiles specifically test the SkillProgressChart component behavior
|
||
|
||
{
|
||
name: '📉 Chart: 1 Session Only',
|
||
emoji: '📉',
|
||
color: '#64748b', // slate-500
|
||
category: 'edge',
|
||
description: 'CHART EDGE - Only 1 session, chart shows legend only (no area chart)',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: MID_L1_SKILLS,
|
||
minSessions: 1, // Force exactly 1 session
|
||
sessionSpreadDays: 1,
|
||
tutorialCompletedSkills: MID_L1_SKILLS,
|
||
intentionNotes: `INTENTION: Chart Edge Case - 1 Session Only
|
||
|
||
This student has exactly ONE completed practice session.
|
||
|
||
What you should see:
|
||
• SkillProgressChart shows legend cards ONLY (no stacked area chart)
|
||
• Legend cards show current skill distribution
|
||
• Filter functionality still works
|
||
• Motivational message prompts for more practice
|
||
|
||
Use this to verify the chart gracefully handles the minimum history case.`,
|
||
skillHistory: [
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 8,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 6,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'developing',
|
||
problems: 5,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'developing',
|
||
problems: 4,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 3,
|
||
},
|
||
],
|
||
// Game history: Minimal - just started
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 50,
|
||
gameCount: 1,
|
||
spreadDays: 1,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '📊 Chart: 2 Sessions (Min)',
|
||
emoji: '📊',
|
||
color: '#0ea5e9', // sky-500
|
||
category: 'edge',
|
||
description: 'CHART EDGE - Exactly 2 sessions, minimum to show stacked area chart',
|
||
currentPhaseId: 'L1.add.+2.five',
|
||
practicingSkills: MID_L1_SKILLS,
|
||
minSessions: 2, // Force exactly 2 sessions
|
||
sessionSpreadDays: 7,
|
||
tutorialCompletedSkills: MID_L1_SKILLS,
|
||
intentionNotes: `INTENTION: Chart Edge Case - 2 Sessions (Minimum for Chart)
|
||
|
||
This student has exactly TWO completed practice sessions.
|
||
|
||
What you should see:
|
||
• SkillProgressChart shows stacked area chart with 2 data points
|
||
• Chart shows progression from session 1 to session 2
|
||
• Legend cards show current skill distribution
|
||
• Filter functionality works on both chart and skill lists
|
||
|
||
Use this to verify the chart renders correctly at the minimum viable history.`,
|
||
skillHistory: [
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 12,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 10,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'developing',
|
||
problems: 8,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'developing',
|
||
problems: 6,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'weak',
|
||
problems: 5,
|
||
},
|
||
],
|
||
// Game history: 2 games to match 2 sessions
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 55,
|
||
gameCount: 2,
|
||
spreadDays: 7,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '📈 Chart: 25 Sessions',
|
||
emoji: '📈',
|
||
color: '#10b981', // emerald-500
|
||
category: 'edge',
|
||
description: 'CHART EDGE - 25 sessions, tests the 20-session display limit',
|
||
currentPhaseId: 'L1.add.+1.five',
|
||
practicingSkills: LATE_L1_ADD_SKILLS,
|
||
minSessions: 25, // More than the 20-session limit
|
||
sessionSpreadDays: 60,
|
||
ensureAllPracticingHaveHistory: true,
|
||
tutorialCompletedSkills: LATE_L1_ADD_SKILLS,
|
||
intentionNotes: `INTENTION: Chart Edge Case - 25 Sessions (Tests 20-Limit)
|
||
|
||
This student has 25 completed practice sessions over 60 days.
|
||
The chart only shows the LAST 20 sessions.
|
||
|
||
What you should see:
|
||
• SkillProgressChart shows stacked area chart with 20 data points (not 25)
|
||
• Chart shows smooth progression over 2 months
|
||
• Skills transition from weak → developing → strong over time
|
||
• Legend cards accurately reflect current state
|
||
• X-axis dates span ~40 days (the last 20 sessions)
|
||
|
||
Use this to verify:
|
||
• The 20-session limit is enforced correctly
|
||
• Chart handles medium-length histories well
|
||
• Date labels are readable and not overcrowded`,
|
||
skillHistory: [
|
||
// Higher problem counts to distribute across 25 sessions
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 50,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 45,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 40,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 30,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'developing',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'developing',
|
||
problems: 20,
|
||
},
|
||
],
|
||
// Game history: Moderate amount of games over 2 months
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 75,
|
||
gameCount: 15,
|
||
spreadDays: 60,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 72,
|
||
gameCount: 10,
|
||
spreadDays: 60,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🏋️ Chart: 150 Sessions',
|
||
emoji: '🏋️',
|
||
color: '#8b5cf6', // violet-500
|
||
category: 'edge',
|
||
description: 'CHART EDGE - 150 sessions, stress test for high-volume history',
|
||
currentPhaseId: 'L2.add.+9.ten',
|
||
practicingSkills: [...COMPLETE_L1_SKILLS, ...L2_ADD_SKILLS],
|
||
minSessions: 150, // Very high session count
|
||
sessionSpreadDays: 180, // 6 months of history
|
||
ensureAllPracticingHaveHistory: true,
|
||
tutorialCompletedSkills: [...COMPLETE_L1_SKILLS, ...L2_ADD_SKILLS],
|
||
intentionNotes: `INTENTION: Chart Edge Case - 150 Sessions (Stress Test)
|
||
|
||
This student has 150 completed practice sessions over 6 months.
|
||
This is a STRESS TEST for database queries and chart performance.
|
||
|
||
What you should see:
|
||
• SkillProgressChart shows stacked area chart with exactly 20 data points
|
||
• Chart only shows most recent 20 sessions (not all 150)
|
||
• Page loads without noticeable delay
|
||
• All skills are mastered (strong) after this much practice
|
||
• Motivational message reflects the extensive progress
|
||
|
||
Use this to verify:
|
||
• Database query performance with large history
|
||
• Chart rendering doesn't slow down with lots of data
|
||
• The 20-session limit keeps the UI responsive
|
||
• Memory usage stays reasonable`,
|
||
skillHistory: [
|
||
// Very high problem counts for 150 sessions
|
||
// Total ~2000 problems across all skills
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 150,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 140,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 130,
|
||
},
|
||
{
|
||
skillId: 'basic.directSubtraction',
|
||
targetClassification: 'strong',
|
||
problems: 120,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBeadSubtraction',
|
||
targetClassification: 'strong',
|
||
problems: 110,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinationsSub',
|
||
targetClassification: 'strong',
|
||
problems: 100,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 90,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'strong',
|
||
problems: 85,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'strong',
|
||
problems: 80,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'strong',
|
||
problems: 75,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-4=-5+1',
|
||
targetClassification: 'strong',
|
||
problems: 70,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-3=-5+2',
|
||
targetClassification: 'strong',
|
||
problems: 65,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-2=-5+3',
|
||
targetClassification: 'strong',
|
||
problems: 60,
|
||
},
|
||
{
|
||
skillId: 'fiveComplementsSub.-1=-5+4',
|
||
targetClassification: 'strong',
|
||
problems: 55,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.9=10-1',
|
||
targetClassification: 'strong',
|
||
problems: 50,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.8=10-2',
|
||
targetClassification: 'strong',
|
||
problems: 45,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.7=10-3',
|
||
targetClassification: 'strong',
|
||
problems: 40,
|
||
},
|
||
{
|
||
skillId: 'tenComplements.6=10-4',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
],
|
||
// Game history: LOTS of games over 6 months to match 150 sessions
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 88,
|
||
gameCount: 75,
|
||
spreadDays: 180,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 85,
|
||
gameCount: 50,
|
||
spreadDays: 180,
|
||
},
|
||
{
|
||
gameName: 'complement-race',
|
||
displayName: 'Complement Race',
|
||
icon: '🏁',
|
||
category: 'speed',
|
||
targetScore: 82,
|
||
gameCount: 40,
|
||
spreadDays: 180,
|
||
},
|
||
{
|
||
gameName: 'memory-quiz',
|
||
displayName: 'Memory Quiz',
|
||
icon: '🧠',
|
||
category: 'memory',
|
||
targetScore: 80,
|
||
gameCount: 30,
|
||
spreadDays: 180,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: '🌈 Chart: Dramatic Progress',
|
||
emoji: '🌈',
|
||
color: '#f43f5e', // rose-500
|
||
category: 'edge',
|
||
description: 'CHART EDGE - Shows dramatic improvement trajectory for motivational display',
|
||
currentPhaseId: 'L1.add.+1.five',
|
||
practicingSkills: LATE_L1_ADD_SKILLS,
|
||
minSessions: 15, // Good number for visible progression
|
||
sessionSpreadDays: 45, // 6 weeks of progress
|
||
ensureAllPracticingHaveHistory: true,
|
||
tutorialCompletedSkills: LATE_L1_ADD_SKILLS,
|
||
intentionNotes: `INTENTION: Chart Edge Case - Dramatic Progress
|
||
|
||
This student shows a clear learning trajectory where skills go from
|
||
mostly weak → developing → mostly strong over 15 sessions.
|
||
|
||
What you should see:
|
||
• SkillProgressChart shows beautiful upward progress
|
||
• Early sessions: lots of red (weak) and blue (developing)
|
||
• Middle sessions: transition happening
|
||
• Recent sessions: mostly green (strong)
|
||
• Motivational message celebrates the progress
|
||
|
||
Use this to verify:
|
||
• Chart visually shows the learning journey
|
||
• Color transitions are smooth and readable
|
||
• Motivational message correctly detects improvement`,
|
||
skillHistory: [
|
||
// Mix that should show progression when computed at each session point
|
||
{
|
||
skillId: 'basic.directAddition',
|
||
targetClassification: 'strong',
|
||
problems: 35,
|
||
},
|
||
{
|
||
skillId: 'basic.heavenBead',
|
||
targetClassification: 'strong',
|
||
problems: 32,
|
||
},
|
||
{
|
||
skillId: 'basic.simpleCombinations',
|
||
targetClassification: 'strong',
|
||
problems: 28,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.4=5-1',
|
||
targetClassification: 'strong',
|
||
problems: 25,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.3=5-2',
|
||
targetClassification: 'developing',
|
||
problems: 18,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.2=5-3',
|
||
targetClassification: 'developing',
|
||
problems: 15,
|
||
},
|
||
{
|
||
skillId: 'fiveComplements.1=5-4',
|
||
targetClassification: 'weak',
|
||
problems: 10,
|
||
},
|
||
],
|
||
// Game history: Showing improvement - scores getting better over time
|
||
gameHistory: [
|
||
{
|
||
gameName: 'matching',
|
||
displayName: 'Matching Pairs',
|
||
icon: '⚔️',
|
||
category: 'memory',
|
||
targetScore: 78, // Good but still improving
|
||
gameCount: 12,
|
||
spreadDays: 45,
|
||
},
|
||
{
|
||
gameName: 'card-sorting',
|
||
displayName: 'Card Sorting',
|
||
icon: '🔢',
|
||
category: 'puzzle',
|
||
targetScore: 75,
|
||
gameCount: 8,
|
||
spreadDays: 45,
|
||
},
|
||
],
|
||
},
|
||
]
|
||
|
||
// =============================================================================
|
||
// CLI Helper Functions
|
||
// =============================================================================
|
||
|
||
function listProfiles(): void {
|
||
console.log('\n📋 Available Test Students:\n')
|
||
|
||
const categories: Record<ProfileCategory, TestStudentProfile[]> = {
|
||
bkt: [],
|
||
session: [],
|
||
edge: [],
|
||
}
|
||
|
||
for (const profile of TEST_PROFILES) {
|
||
categories[profile.category].push(profile)
|
||
}
|
||
|
||
console.log('BKT Scenarios (--category bkt):')
|
||
for (const p of categories.bkt) {
|
||
console.log(` ${p.name}`)
|
||
console.log(` ${p.description}`)
|
||
}
|
||
|
||
console.log('\nSession Mode Tests (--category session):')
|
||
for (const p of categories.session) {
|
||
console.log(` ${p.name}`)
|
||
console.log(` ${p.description}`)
|
||
}
|
||
|
||
console.log('\nEdge Cases (--category edge):')
|
||
for (const p of categories.edge) {
|
||
console.log(` ${p.name}`)
|
||
console.log(` ${p.description}`)
|
||
}
|
||
|
||
console.log(`\nTotal: ${TEST_PROFILES.length} students\n`)
|
||
}
|
||
|
||
/**
|
||
* Filter profiles based on CLI args (name and category filters)
|
||
*/
|
||
function filterProfiles(profiles: TestStudentProfile[]): TestStudentProfile[] {
|
||
const names = cliArgs.name as string[]
|
||
const categories = cliArgs.category as string[]
|
||
|
||
// If no filters, return all
|
||
if (names.length === 0 && categories.length === 0) {
|
||
return profiles
|
||
}
|
||
|
||
return profiles.filter((profile) => {
|
||
// Check name filter (partial match, case-insensitive)
|
||
const matchesName =
|
||
names.length === 0 ||
|
||
names.some(
|
||
(n) =>
|
||
profile.name.toLowerCase().includes(n.toLowerCase()) ||
|
||
n.toLowerCase().includes(profile.name.toLowerCase())
|
||
)
|
||
|
||
// Check category filter
|
||
const matchesCategory = categories.length === 0 || categories.includes(profile.category)
|
||
|
||
// If both filters specified, must match at least one
|
||
if (names.length > 0 && categories.length > 0) {
|
||
return matchesName || matchesCategory
|
||
}
|
||
|
||
// If only one filter type, must match that one
|
||
return matchesName && matchesCategory
|
||
})
|
||
}
|
||
|
||
// =============================================================================
|
||
// Helpers
|
||
// =============================================================================
|
||
|
||
function generateSlotResults(
|
||
config: SkillConfig,
|
||
startIndex: number,
|
||
sessionStartTime: Date
|
||
): SlotResult[] {
|
||
// Generate realistic problems targeting the skill
|
||
const realisticProblems = generateRealisticProblems(config.skillId, config.problems)
|
||
|
||
// Design a sequence that will reliably produce the target BKT classification
|
||
// This replaces random shuffling with deterministic patterns
|
||
const correctnessSequence = designSequenceForClassification(
|
||
config.skillId,
|
||
config.problems,
|
||
config.targetClassification
|
||
)
|
||
|
||
return realisticProblems.map((realistic, i) => {
|
||
const isCorrect = correctnessSequence[i]
|
||
|
||
// Convert to the schema's GeneratedProblem format
|
||
const problem: GeneratedProblem = {
|
||
terms: realistic.terms,
|
||
answer: realistic.answer,
|
||
skillsRequired: realistic.skillsUsed,
|
||
generationTrace: realistic.generationTrace,
|
||
}
|
||
|
||
// Generate a plausible wrong answer if incorrect
|
||
const wrongAnswer =
|
||
realistic.answer + (Math.random() > 0.5 ? 1 : -1) * (Math.floor(Math.random() * 3) + 1)
|
||
|
||
const baseResult = {
|
||
partNumber: 1 as const,
|
||
slotIndex: startIndex + i,
|
||
problem,
|
||
studentAnswer: isCorrect ? realistic.answer : wrongAnswer,
|
||
isCorrect,
|
||
responseTimeMs: 4000 + Math.random() * 2000,
|
||
skillsExercised: realistic.skillsUsed, // ALL skills used, not just target
|
||
usedOnScreenAbacus: false,
|
||
timestamp: new Date(sessionStartTime.getTime() + (startIndex + i) * 10000),
|
||
incorrectAttempts: isCorrect ? 0 : 1,
|
||
}
|
||
|
||
// If simulating legacy data, omit hadHelp and helpTrigger
|
||
// This tests the NaN handling code path for old data missing these fields
|
||
if (config.simulateLegacyData) {
|
||
return baseResult as SlotResult
|
||
}
|
||
|
||
return {
|
||
...baseResult,
|
||
hadHelp: false,
|
||
helpTrigger: 'none' as const,
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Check if a profile's outcomes meet its success criteria
|
||
*/
|
||
function checkSuccessCriteria(
|
||
classifications: Record<string, number>,
|
||
criteria?: SuccessCriteria
|
||
): { success: boolean; reasons: string[] } {
|
||
if (!criteria) {
|
||
return { success: true, reasons: [] }
|
||
}
|
||
|
||
const reasons: string[] = []
|
||
const { weak, developing, strong } = classifications
|
||
|
||
if (criteria.minWeak !== undefined && weak < criteria.minWeak) {
|
||
reasons.push(`Need at least ${criteria.minWeak} weak skills, got ${weak}`)
|
||
}
|
||
if (criteria.maxWeak !== undefined && weak > criteria.maxWeak) {
|
||
reasons.push(`Need at most ${criteria.maxWeak} weak skills, got ${weak}`)
|
||
}
|
||
if (criteria.minDeveloping !== undefined && developing < criteria.minDeveloping) {
|
||
reasons.push(`Need at least ${criteria.minDeveloping} developing skills, got ${developing}`)
|
||
}
|
||
if (criteria.maxDeveloping !== undefined && developing > criteria.maxDeveloping) {
|
||
reasons.push(`Need at most ${criteria.maxDeveloping} developing skills, got ${developing}`)
|
||
}
|
||
if (criteria.minStrong !== undefined && strong < criteria.minStrong) {
|
||
reasons.push(`Need at least ${criteria.minStrong} strong skills, got ${strong}`)
|
||
}
|
||
if (criteria.maxStrong !== undefined && strong > criteria.maxStrong) {
|
||
reasons.push(`Need at most ${criteria.maxStrong} strong skills, got ${strong}`)
|
||
}
|
||
|
||
return { success: reasons.length === 0, reasons }
|
||
}
|
||
|
||
/**
|
||
* Apply tuning adjustments to skill history
|
||
*/
|
||
function applyTuningAdjustments(
|
||
skillHistory: SkillConfig[],
|
||
adjustments?: TuningAdjustment[]
|
||
): SkillConfig[] {
|
||
if (!adjustments || adjustments.length === 0) {
|
||
return skillHistory
|
||
}
|
||
|
||
return skillHistory.map((config) => {
|
||
const newConfig = { ...config }
|
||
|
||
for (const adj of adjustments) {
|
||
if (adj.skillId === 'all' || adj.skillId === config.skillId) {
|
||
if (adj.problemsAdd !== undefined) {
|
||
newConfig.problems = newConfig.problems + adj.problemsAdd
|
||
}
|
||
if (adj.problemsMultiplier !== undefined) {
|
||
newConfig.problems = Math.round(newConfig.problems * adj.problemsMultiplier)
|
||
}
|
||
}
|
||
}
|
||
|
||
return newConfig
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Tuning history entry
|
||
*/
|
||
interface TuningRound {
|
||
round: number
|
||
classifications: Record<string, number>
|
||
success: boolean
|
||
failureReasons: string[]
|
||
adjustmentsApplied: string[]
|
||
}
|
||
|
||
/**
|
||
* Format tuning history for notes
|
||
*/
|
||
function formatTuningHistory(history: TuningRound[]): string {
|
||
if (history.length <= 1) {
|
||
return '' // No tuning needed
|
||
}
|
||
|
||
const lines: string[] = []
|
||
lines.push('')
|
||
lines.push('───────────────────────────────────────────────────────────')
|
||
lines.push('TUNING HISTORY')
|
||
lines.push('───────────────────────────────────────────────────────────')
|
||
|
||
for (const round of history) {
|
||
lines.push('')
|
||
lines.push(`Round ${round.round}:`)
|
||
lines.push(
|
||
` Classifications: 🔴 ${round.classifications.weak} weak, 📚 ${round.classifications.developing} developing, ✅ ${round.classifications.strong} strong`
|
||
)
|
||
|
||
if (round.success) {
|
||
lines.push(` Result: ✅ Success`)
|
||
} else {
|
||
lines.push(` Result: ❌ Failed`)
|
||
for (const reason of round.failureReasons) {
|
||
lines.push(` - ${reason}`)
|
||
}
|
||
if (round.adjustmentsApplied.length > 0) {
|
||
lines.push(` Adjustments applied for next round:`)
|
||
for (const adj of round.adjustmentsApplied) {
|
||
lines.push(` - ${adj}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return lines.join('\n')
|
||
}
|
||
|
||
/**
|
||
* Format BKT results into a human-readable summary for notes
|
||
*/
|
||
function formatActualOutcomes(
|
||
bktResult: { skills: SkillBktResult[] },
|
||
profile: TestStudentProfile,
|
||
tuningHistory?: TuningRound[]
|
||
): string {
|
||
const skillsByClassification: Record<string, SkillBktResult[]> = {
|
||
weak: [],
|
||
developing: [],
|
||
strong: [],
|
||
}
|
||
|
||
for (const skill of bktResult.skills) {
|
||
if (skill.masteryClassification) {
|
||
skillsByClassification[skill.masteryClassification].push(skill)
|
||
}
|
||
}
|
||
|
||
const lines: string[] = []
|
||
lines.push('')
|
||
lines.push('═══════════════════════════════════════════════════════════')
|
||
lines.push('ACTUAL OUTCOMES (generated by seeder)')
|
||
lines.push('═══════════════════════════════════════════════════════════')
|
||
lines.push('')
|
||
lines.push(`BKT Classification Counts:`)
|
||
lines.push(` 🔴 Weak: ${skillsByClassification.weak.length}`)
|
||
lines.push(` 📚 Developing: ${skillsByClassification.developing.length}`)
|
||
lines.push(` ✅ Strong: ${skillsByClassification.strong.length}`)
|
||
lines.push('')
|
||
|
||
if (profile.expectedSessionMode) {
|
||
lines.push(`Expected Session Mode: ${profile.expectedSessionMode.toUpperCase()}`)
|
||
// Determine actual mode based on BKT
|
||
let actualMode = 'maintenance'
|
||
if (skillsByClassification.weak.length > 0) {
|
||
actualMode = 'remediation'
|
||
} else if (skillsByClassification.strong.length === profile.practicingSkills.length) {
|
||
actualMode = 'progression'
|
||
}
|
||
const matches = actualMode === profile.expectedSessionMode ? '✅' : '⚠️'
|
||
lines.push(`Actual Session Mode: ${actualMode.toUpperCase()} ${matches}`)
|
||
lines.push('')
|
||
}
|
||
|
||
// List skills by classification with pKnown values
|
||
if (skillsByClassification.weak.length > 0) {
|
||
lines.push('Weak Skills (pKnown < 0.5):')
|
||
for (const skill of skillsByClassification.weak) {
|
||
lines.push(` - ${skill.skillId}: ${(skill.pKnown * 100).toFixed(0)}%`)
|
||
}
|
||
lines.push('')
|
||
}
|
||
|
||
if (skillsByClassification.developing.length > 0) {
|
||
lines.push('Developing Skills (0.5 ≤ pKnown < 0.8):')
|
||
for (const skill of skillsByClassification.developing) {
|
||
lines.push(` - ${skill.skillId}: ${(skill.pKnown * 100).toFixed(0)}%`)
|
||
}
|
||
lines.push('')
|
||
}
|
||
|
||
if (skillsByClassification.strong.length > 0) {
|
||
lines.push('Strong Skills (pKnown ≥ 0.8):')
|
||
for (const skill of skillsByClassification.strong) {
|
||
lines.push(` - ${skill.skillId}: ${(skill.pKnown * 100).toFixed(0)}%`)
|
||
}
|
||
lines.push('')
|
||
}
|
||
|
||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||
|
||
// Add tuning history if present
|
||
if (tuningHistory && tuningHistory.length > 0) {
|
||
lines.push(formatTuningHistory(tuningHistory))
|
||
}
|
||
|
||
return lines.join('\n')
|
||
}
|
||
|
||
async function createTestStudent(
|
||
profile: TestStudentProfile,
|
||
userId: string,
|
||
skillHistoryOverride?: SkillConfig[]
|
||
): Promise<{
|
||
playerId: string
|
||
classifications: Record<string, number>
|
||
bktResult: { skills: SkillBktResult[] }
|
||
}> {
|
||
let effectiveSkillHistory = skillHistoryOverride ?? profile.skillHistory
|
||
|
||
// If ensureAllPracticingHaveHistory is set, add missing practicing skills with default strong history
|
||
if (profile.ensureAllPracticingHaveHistory) {
|
||
const historySkillIds = new Set(effectiveSkillHistory.map((c) => c.skillId))
|
||
const missingSkills: SkillConfig[] = []
|
||
|
||
for (const skillId of profile.practicingSkills) {
|
||
if (!historySkillIds.has(skillId)) {
|
||
// Add a default "strong" config for missing skills
|
||
missingSkills.push({
|
||
skillId,
|
||
targetClassification: 'strong',
|
||
problems: 15,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (missingSkills.length > 0) {
|
||
effectiveSkillHistory = [...effectiveSkillHistory, ...missingSkills]
|
||
}
|
||
}
|
||
// Delete existing player with this name (and their parent_child relationship)
|
||
const existing = await db.query.players.findFirst({
|
||
where: eq(schema.players.name, profile.name),
|
||
})
|
||
if (existing) {
|
||
// Delete parent_child first (foreign key constraint)
|
||
await db.delete(schema.parentChild).where(eq(schema.parentChild.childPlayerId, existing.id))
|
||
await db.delete(schema.players).where(eq(schema.players.id, existing.id))
|
||
}
|
||
|
||
// Create player with intention notes only (will update with actual outcomes later)
|
||
const playerId = createId()
|
||
await db.insert(schema.players).values({
|
||
id: playerId,
|
||
userId,
|
||
name: profile.name,
|
||
emoji: profile.emoji,
|
||
color: profile.color,
|
||
isActive: true,
|
||
notes: profile.intentionNotes,
|
||
})
|
||
|
||
// Create parent-child relationship so access control works
|
||
await db.insert(schema.parentChild).values({
|
||
parentUserId: userId,
|
||
childPlayerId: playerId,
|
||
})
|
||
|
||
// Build a map of skill -> age from skill history
|
||
const skillAgeMap = new Map<string, number>()
|
||
for (const config of effectiveSkillHistory) {
|
||
skillAgeMap.set(config.skillId, config.ageDays ?? 1)
|
||
}
|
||
|
||
// Create skill mastery records for practicing skills
|
||
// Note: attempts/correct are computed on-the-fly from session results
|
||
for (const skillId of profile.practicingSkills) {
|
||
const ageDays = skillAgeMap.get(skillId) ?? 1
|
||
const lastPracticedAt = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000)
|
||
|
||
await db.insert(schema.playerSkillMastery).values({
|
||
id: createId(),
|
||
playerId,
|
||
skillId,
|
||
isPracticing: true,
|
||
lastPracticedAt,
|
||
})
|
||
}
|
||
|
||
// Create tutorial progress records for completed tutorials
|
||
if (profile.tutorialCompletedSkills) {
|
||
for (const skillId of profile.tutorialCompletedSkills) {
|
||
await db.insert(schema.skillTutorialProgress).values({
|
||
id: createId(),
|
||
playerId,
|
||
skillId,
|
||
tutorialCompleted: true,
|
||
completedAt: new Date(Date.now() - 48 * 60 * 60 * 1000), // 2 days ago
|
||
teacherOverride: false,
|
||
skipCount: 0,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// MULTI-SESSION DISTRIBUTION
|
||
// ==========================================================================
|
||
// We need to balance two requirements:
|
||
// 1. Honor explicit `ageDays` for staleness testing profiles
|
||
// 2. Create multiple sessions for chart testing profiles
|
||
//
|
||
// Strategy:
|
||
// - Group skills by their ageDays to preserve staleness intentions
|
||
// - Within each age group, distribute problems across multiple mini-sessions
|
||
// - This ensures skills practiced "45 days ago" actually have their last
|
||
// problem 45 days ago, while still creating enough sessions for the chart
|
||
// ==========================================================================
|
||
|
||
const minSessions = profile.minSessions ?? 5
|
||
const sessionSpreadDays = profile.sessionSpreadDays ?? 30
|
||
|
||
// Group problems by their skill's ageDays (for staleness preservation)
|
||
interface ProblemWithMeta {
|
||
result: SlotResult
|
||
skillId: string
|
||
skillAgeDays: number
|
||
}
|
||
|
||
const problemsByAge = new Map<number, ProblemWithMeta[]>()
|
||
for (const config of effectiveSkillHistory) {
|
||
const ageDays = config.ageDays ?? 1
|
||
const sessionStartTime = new Date() // Placeholder, will be updated per-session
|
||
const results = generateSlotResults(config, 0, sessionStartTime)
|
||
|
||
const existing = problemsByAge.get(ageDays) ?? []
|
||
for (const result of results) {
|
||
existing.push({
|
||
result,
|
||
skillId: config.skillId,
|
||
skillAgeDays: ageDays,
|
||
})
|
||
}
|
||
problemsByAge.set(ageDays, existing)
|
||
}
|
||
|
||
// Count total problems
|
||
let totalProblems = 0
|
||
for (const problems of problemsByAge.values()) {
|
||
totalProblems += problems.length
|
||
}
|
||
|
||
// If no problems, skip session creation
|
||
if (totalProblems === 0) {
|
||
// No sessions to create - empty history
|
||
} else {
|
||
// Determine the actual spread: use explicit sessionSpreadDays or the max ageDays
|
||
const maxAgeDays = Math.max(...Array.from(problemsByAge.keys()))
|
||
const actualSpreadDays = Math.max(sessionSpreadDays, maxAgeDays)
|
||
|
||
// Calculate target sessions per age group
|
||
// We want at least minSessions total, distributed proportionally
|
||
const ageGroups = Array.from(problemsByAge.keys()).sort((a, b) => b - a) // oldest first
|
||
const totalAgeGroups = ageGroups.length
|
||
|
||
// Minimum sessions per age group (at least 1, more if we have many problems)
|
||
const baseSessionsPerGroup = Math.max(1, Math.floor(minSessions / totalAgeGroups))
|
||
|
||
// Track all sessions we create for final count
|
||
let sessionNumber = 0
|
||
|
||
for (const ageDays of ageGroups) {
|
||
const groupProblems = problemsByAge.get(ageDays)!
|
||
|
||
// Determine how many sessions for this age group
|
||
// More problems = more sessions, but at least baseSessionsPerGroup
|
||
const problemsPerSession = Math.max(3, Math.ceil(groupProblems.length / baseSessionsPerGroup))
|
||
const sessionsForGroup = Math.max(
|
||
baseSessionsPerGroup,
|
||
Math.ceil(groupProblems.length / problemsPerSession)
|
||
)
|
||
|
||
// Distribute sessions across a time window ending at ageDays
|
||
// If ageDays is 45, sessions might be at 49, 48, 47, 46, 45 days ago
|
||
// This preserves staleness (most recent at ageDays) while creating multiple sessions
|
||
// IMPORTANT: First problems go to OLDEST sessions so BKT sees them first
|
||
let problemIndex = 0
|
||
for (let i = 0; i < sessionsForGroup; i++) {
|
||
// Calculate session date: FIRST problems go to OLDEST session
|
||
// so that BKT (which processes chronologically) sees the learning sequence correctly
|
||
const sessionAgeDays = ageDays + (sessionsForGroup - 1 - i)
|
||
const sessionStartTime = new Date(Date.now() - sessionAgeDays * 24 * 60 * 60 * 1000)
|
||
|
||
// Calculate how many problems in this session
|
||
const remainingProblems = groupProblems.length - problemIndex
|
||
const remainingSessions = sessionsForGroup - i
|
||
const isLastSession = i === sessionsForGroup - 1
|
||
const problemsThisSession = isLastSession
|
||
? remainingProblems
|
||
: Math.ceil(remainingProblems / remainingSessions)
|
||
|
||
if (problemsThisSession === 0) continue
|
||
|
||
// Get problems for this session
|
||
const sessionProblems = groupProblems.slice(
|
||
problemIndex,
|
||
problemIndex + problemsThisSession
|
||
)
|
||
problemIndex += problemsThisSession
|
||
sessionNumber++
|
||
|
||
// Update timestamps
|
||
const orderedResults: SlotResult[] = sessionProblems.map((p, idx) => ({
|
||
...p.result,
|
||
slotIndex: idx,
|
||
timestamp: new Date(sessionStartTime.getTime() + idx * 10000),
|
||
}))
|
||
|
||
// Create session
|
||
const sessionId = createId()
|
||
const sessionEndTime = new Date(sessionStartTime.getTime() + orderedResults.length * 10000)
|
||
|
||
const slots = orderedResults.map((r, idx) => ({
|
||
index: idx,
|
||
purpose: 'focus' as const,
|
||
constraints: {},
|
||
problem: r.problem,
|
||
}))
|
||
|
||
const parts: SessionPart[] = [
|
||
{
|
||
partNumber: 1,
|
||
type: 'linear',
|
||
format: 'linear',
|
||
useAbacus: false,
|
||
slots,
|
||
estimatedMinutes: 30,
|
||
},
|
||
]
|
||
|
||
const summary: SessionSummary = {
|
||
focusDescription: `Test session ${sessionNumber} for ${profile.name} (${sessionAgeDays} days ago)`,
|
||
totalProblemCount: orderedResults.length,
|
||
estimatedMinutes: 30,
|
||
parts: [
|
||
{
|
||
partNumber: 1,
|
||
type: 'linear',
|
||
description: 'Mental Math (Linear)',
|
||
problemCount: orderedResults.length,
|
||
estimatedMinutes: 30,
|
||
},
|
||
],
|
||
}
|
||
|
||
await db.insert(schema.sessionPlans).values({
|
||
id: sessionId,
|
||
playerId,
|
||
targetDurationMinutes: 30,
|
||
estimatedProblemCount: orderedResults.length,
|
||
avgTimePerProblemSeconds: 30, // Realistic timing - 5 was causing problem count explosion
|
||
parts,
|
||
summary,
|
||
masteredSkillIds: profile.practicingSkills,
|
||
status: 'completed',
|
||
currentPartIndex: 1,
|
||
currentSlotIndex: 0,
|
||
sessionHealth: {
|
||
overall: 'good',
|
||
accuracy: 0.6,
|
||
pacePercent: 100,
|
||
currentStreak: 0,
|
||
avgResponseTimeMs: 5000,
|
||
},
|
||
adjustments: [],
|
||
results: orderedResults,
|
||
createdAt: sessionStartTime,
|
||
approvedAt: sessionStartTime,
|
||
startedAt: sessionStartTime,
|
||
completedAt: sessionEndTime,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute BKT classifications from the generated data
|
||
// Note: Skill stats (attempts/correct) are computed on-the-fly from session results
|
||
// so we don't need to update playerSkillMastery aggregate columns
|
||
// Use a high limit to ensure BKT includes all problems for high-volume test profiles
|
||
const problemHistory = await getRecentSessionResults(playerId, 5000)
|
||
const bktResult = computeBktFromHistory(problemHistory, {
|
||
confidenceThreshold: BKT_THRESHOLDS.confidence,
|
||
})
|
||
|
||
const classifications: Record<string, number> = {
|
||
weak: 0,
|
||
developing: 0,
|
||
strong: 0,
|
||
}
|
||
for (const skill of bktResult.skills) {
|
||
if (skill.masteryClassification) {
|
||
classifications[skill.masteryClassification]++
|
||
}
|
||
}
|
||
|
||
return { playerId, classifications, bktResult }
|
||
}
|
||
|
||
/**
|
||
* Generate game results for scoreboard testing.
|
||
* Creates realistic game history based on the profile's gameHistory configs.
|
||
*/
|
||
async function generateGameResults(playerId: string, profile: TestStudentProfile): Promise<number> {
|
||
if (!profile.gameHistory || profile.gameHistory.length === 0) {
|
||
return 0
|
||
}
|
||
|
||
let totalGames = 0
|
||
const now = Date.now()
|
||
|
||
for (const gameConfig of profile.gameHistory) {
|
||
const spreadMs = (gameConfig.spreadDays ?? 30) * 24 * 60 * 60 * 1000
|
||
|
||
for (let i = 0; i < gameConfig.gameCount; i++) {
|
||
// Spread games evenly over the time period
|
||
const gameAgeMs = (spreadMs * i) / Math.max(1, gameConfig.gameCount - 1)
|
||
const playedAt = new Date(now - spreadMs + gameAgeMs)
|
||
|
||
// Add some variation to scores (±5 from target)
|
||
const scoreVariation = (Math.random() - 0.5) * 10
|
||
const normalizedScore = Math.max(0, Math.min(100, gameConfig.targetScore + scoreVariation))
|
||
|
||
// Calculate accuracy based on score (roughly correlate)
|
||
const accuracy = normalizedScore * (0.8 + Math.random() * 0.2)
|
||
|
||
// Determine difficulty based on score
|
||
let difficulty: 'easy' | 'medium' | 'hard' | 'expert'
|
||
if (normalizedScore >= 85) difficulty = 'hard'
|
||
else if (normalizedScore >= 70) difficulty = 'medium'
|
||
else difficulty = 'easy'
|
||
|
||
// Generate duration (2-10 minutes)
|
||
const durationMs = (120 + Math.random() * 480) * 1000
|
||
|
||
// Create a minimal fullReport for display
|
||
const fullReport: GameResultsReport = {
|
||
gameName: gameConfig.gameName,
|
||
gameDisplayName: gameConfig.displayName,
|
||
gameIcon: gameConfig.icon,
|
||
durationMs,
|
||
completedNormally: true,
|
||
startedAt: playedAt.getTime() - durationMs,
|
||
endedAt: playedAt.getTime(),
|
||
gameMode: 'single-player',
|
||
playerCount: 1,
|
||
playerResults: [
|
||
{
|
||
playerId,
|
||
playerName: profile.name.replace(/^[^\s]+\s*/, ''), // Remove emoji prefix
|
||
playerEmoji: profile.emoji,
|
||
userId: '',
|
||
score: Math.round(normalizedScore),
|
||
rank: 1,
|
||
},
|
||
],
|
||
leaderboardEntry: {
|
||
normalizedScore,
|
||
category: gameConfig.category,
|
||
difficulty,
|
||
},
|
||
headline:
|
||
normalizedScore >= 90 ? 'Excellent!' : normalizedScore >= 70 ? 'Great Job!' : 'Good Try!',
|
||
resultTheme: normalizedScore >= 90 ? 'success' : normalizedScore >= 70 ? 'good' : 'neutral',
|
||
}
|
||
|
||
await db.insert(schema.gameResults).values({
|
||
playerId,
|
||
gameName: gameConfig.gameName,
|
||
gameDisplayName: gameConfig.displayName,
|
||
gameIcon: gameConfig.icon,
|
||
sessionType: 'practice-break',
|
||
normalizedScore,
|
||
rawScore: Math.round(normalizedScore),
|
||
accuracy,
|
||
category: gameConfig.category,
|
||
difficulty,
|
||
durationMs: Math.round(durationMs),
|
||
playedAt,
|
||
fullReport,
|
||
})
|
||
|
||
totalGames++
|
||
}
|
||
}
|
||
|
||
return totalGames
|
||
}
|
||
|
||
/**
|
||
* Create a test student with iterative tuning (up to maxRounds)
|
||
*/
|
||
async function createTestStudentWithTuning(
|
||
profile: TestStudentProfile,
|
||
userId: string,
|
||
maxRounds: number = 3
|
||
): Promise<{
|
||
playerId: string
|
||
classifications: Record<string, number>
|
||
tuningHistory: TuningRound[]
|
||
}> {
|
||
const tuningHistory: TuningRound[] = []
|
||
let currentSkillHistory = profile.skillHistory
|
||
let result: {
|
||
playerId: string
|
||
classifications: Record<string, number>
|
||
bktResult: { skills: SkillBktResult[] }
|
||
}
|
||
|
||
for (let round = 1; round <= maxRounds; round++) {
|
||
// Generate the student
|
||
result = await createTestStudent(profile, userId, currentSkillHistory)
|
||
|
||
// Check success criteria
|
||
const { success, reasons } = checkSuccessCriteria(
|
||
result.classifications,
|
||
profile.successCriteria
|
||
)
|
||
|
||
// Record this round
|
||
const roundEntry: TuningRound = {
|
||
round,
|
||
classifications: { ...result.classifications },
|
||
success,
|
||
failureReasons: reasons,
|
||
adjustmentsApplied: [],
|
||
}
|
||
|
||
if (success || round === maxRounds) {
|
||
// Success or final round - we're done
|
||
tuningHistory.push(roundEntry)
|
||
break
|
||
}
|
||
|
||
// Need to tune - apply adjustments
|
||
if (profile.tuningAdjustments) {
|
||
currentSkillHistory = applyTuningAdjustments(currentSkillHistory, profile.tuningAdjustments)
|
||
roundEntry.adjustmentsApplied = profile.tuningAdjustments.map((adj) => {
|
||
const parts: string[] = []
|
||
if (adj.accuracyMultiplier) parts.push(`accuracy × ${adj.accuracyMultiplier}`)
|
||
if (adj.problemsAdd) parts.push(`problems + ${adj.problemsAdd}`)
|
||
if (adj.problemsMultiplier) parts.push(`problems × ${adj.problemsMultiplier}`)
|
||
return `${adj.skillId}: ${parts.join(', ')}`
|
||
})
|
||
}
|
||
|
||
tuningHistory.push(roundEntry)
|
||
|
||
// Delete the student so we can recreate with adjusted params
|
||
await db.delete(schema.players).where(eq(schema.players.id, result.playerId))
|
||
}
|
||
|
||
// Update the final student's notes with tuning history
|
||
const actualOutcomes = formatActualOutcomes(result!.bktResult, profile, tuningHistory)
|
||
const fullNotes = profile.intentionNotes + actualOutcomes
|
||
|
||
await db
|
||
.update(schema.players)
|
||
.set({ notes: fullNotes })
|
||
.where(eq(schema.players.id, result!.playerId))
|
||
|
||
// Generate game results for this student
|
||
const gameCount = await generateGameResults(result!.playerId, profile)
|
||
if (gameCount > 0) {
|
||
console.log(` Generated ${gameCount} game results`)
|
||
}
|
||
|
||
return {
|
||
playerId: result!.playerId,
|
||
classifications: result!.classifications,
|
||
tuningHistory,
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Main
|
||
// =============================================================================
|
||
|
||
async function main() {
|
||
// Handle --help
|
||
if (cliArgs.help) {
|
||
showHelp()
|
||
process.exit(0)
|
||
}
|
||
|
||
// Handle --list
|
||
if (cliArgs.list) {
|
||
listProfiles()
|
||
process.exit(0)
|
||
}
|
||
|
||
// Filter profiles based on CLI args
|
||
const profilesToSeed = filterProfiles(TEST_PROFILES)
|
||
|
||
if (profilesToSeed.length === 0) {
|
||
console.log('❌ No students match the specified filters.')
|
||
console.log(' Use --list to see available students.')
|
||
process.exit(1)
|
||
}
|
||
|
||
// Handle --dry-run
|
||
if (cliArgs['dry-run']) {
|
||
console.log('🧪 DRY RUN - Would seed the following students:\n')
|
||
for (const profile of profilesToSeed) {
|
||
console.log(` ${profile.name} [${profile.category}]`)
|
||
console.log(` ${profile.description}`)
|
||
}
|
||
console.log(`\nTotal: ${profilesToSeed.length} students`)
|
||
process.exit(0)
|
||
}
|
||
|
||
console.log('🧪 Seeding Test Students for BKT Testing...\n')
|
||
|
||
// Show filter info if applicable
|
||
const names = cliArgs.name as string[]
|
||
const categories = cliArgs.category as string[]
|
||
if (names.length > 0 || categories.length > 0) {
|
||
console.log(` Filtering: ${profilesToSeed.length} of ${TEST_PROFILES.length} students`)
|
||
if (names.length > 0) console.log(` Names: ${names.join(', ')}`)
|
||
if (categories.length > 0) console.log(` Categories: ${categories.join(', ')}`)
|
||
console.log('')
|
||
}
|
||
|
||
// Find the most recent browser session by looking at recent session activity
|
||
// This is more reliable than player creation time
|
||
console.log('1. Finding most recent browser session...')
|
||
|
||
// First, try to find the most recent session from a real (non-test) player
|
||
const recentSession = await db.query.sessionPlans.findFirst({
|
||
orderBy: [desc(schema.sessionPlans.createdAt)],
|
||
})
|
||
|
||
let userId: string | null = null
|
||
let foundVia = ''
|
||
|
||
if (recentSession) {
|
||
// Look up the player for this session
|
||
const sessionPlayer = await db.query.players.findFirst({
|
||
where: eq(schema.players.id, recentSession.playerId),
|
||
})
|
||
|
||
// Check if this is a test user (exclude test-user-* pattern)
|
||
if (sessionPlayer && !sessionPlayer.userId.startsWith('test-user')) {
|
||
userId = sessionPlayer.userId
|
||
foundVia = `session activity from player: ${sessionPlayer.name}`
|
||
}
|
||
}
|
||
|
||
// Fallback: find a real player (exclude test users and test emoji names)
|
||
if (!userId) {
|
||
const testEmojiPatterns = [
|
||
'🔴',
|
||
'🟡',
|
||
'🟢',
|
||
'⭐',
|
||
'🚀',
|
||
'🎯',
|
||
'📚',
|
||
'🏆',
|
||
'🆕',
|
||
'🔢',
|
||
'📊',
|
||
'⚖️',
|
||
'🕰️',
|
||
]
|
||
|
||
const realPlayer = await db.query.players.findFirst({
|
||
where: (players, { not, like, and, notLike }) =>
|
||
and(
|
||
not(like(players.name, '%Test%')),
|
||
notLike(players.userId, 'test-user%'),
|
||
// Exclude common test emoji prefixes
|
||
...testEmojiPatterns.map((emoji) => notLike(players.name, `${emoji}%`))
|
||
),
|
||
orderBy: [desc(schema.players.createdAt)],
|
||
})
|
||
|
||
if (realPlayer) {
|
||
userId = realPlayer.userId
|
||
foundVia = `player: ${realPlayer.name}`
|
||
}
|
||
}
|
||
|
||
if (!userId) {
|
||
console.error('❌ No real users found! Create a student at /practice first.')
|
||
console.error(' (Make sure you have a non-test player in your browser session)')
|
||
process.exit(1)
|
||
}
|
||
|
||
console.log(` Found user via ${foundVia}`)
|
||
|
||
// Step 2: Ensure the user has a classroom (create one if needed)
|
||
console.log('\n2. Setting up teacher classroom...')
|
||
|
||
// Look up the user record
|
||
let user = await db.query.users.findFirst({
|
||
where: eq(schema.users.guestId, userId),
|
||
})
|
||
|
||
if (!user) {
|
||
// Create the user record if it doesn't exist
|
||
const [newUser] = await db.insert(schema.users).values({ guestId: userId }).returning()
|
||
user = newUser
|
||
console.log(` Created user record for ${userId}`)
|
||
}
|
||
|
||
// Check if they have a classroom
|
||
let classroom = await getTeacherClassroom(user.id)
|
||
|
||
if (!classroom) {
|
||
// Create a classroom for them
|
||
const result = await createClassroom({
|
||
teacherId: user.id,
|
||
name: 'Test Classroom',
|
||
})
|
||
if (result.success) {
|
||
classroom = result.classroom
|
||
console.log(` Created classroom: ${classroom.name} (code: ${classroom.code})`)
|
||
} else {
|
||
console.error(` ❌ Failed to create classroom: ${result.error}`)
|
||
process.exit(1)
|
||
}
|
||
} else {
|
||
console.log(` Using existing classroom: ${classroom.name} (code: ${classroom.code})`)
|
||
}
|
||
|
||
// Create each test profile with iterative tuning (up to 3 rounds)
|
||
console.log('\n3. Creating test students (with up to 2 tuning rounds if needed)...\n')
|
||
|
||
for (const profile of profilesToSeed) {
|
||
const { playerId, classifications, tuningHistory } = await createTestStudentWithTuning(
|
||
profile,
|
||
userId,
|
||
3 // maxRounds: initial + 2 tuning rounds
|
||
)
|
||
const { weak, developing, strong } = classifications
|
||
|
||
// Enroll the student in the teacher's classroom
|
||
await directEnrollStudent(classroom.id, playerId)
|
||
|
||
console.log(` ${profile.name}`)
|
||
console.log(` ${profile.description}`)
|
||
console.log(` Phase: ${profile.currentPhaseId}`)
|
||
console.log(` Practicing: ${profile.practicingSkills.length} skills`)
|
||
console.log(
|
||
` Classifications: 🔴 ${weak} weak, 📚 ${developing} developing, ✅ ${strong} strong`
|
||
)
|
||
if (profile.expectedSessionMode) {
|
||
console.log(` Expected Mode: ${profile.expectedSessionMode.toUpperCase()}`)
|
||
}
|
||
if (profile.tutorialCompletedSkills) {
|
||
console.log(` Tutorials Completed: ${profile.tutorialCompletedSkills.length} skills`)
|
||
}
|
||
if (tuningHistory.length > 1) {
|
||
const finalRound = tuningHistory[tuningHistory.length - 1]
|
||
console.log(
|
||
` Tuning: ${tuningHistory.length} rounds, final: ${finalRound.success ? '✅ success' : '⚠️ best effort'}`
|
||
)
|
||
}
|
||
console.log(` Player ID: ${playerId}`)
|
||
console.log('')
|
||
}
|
||
|
||
console.log('✅ All test students created and enrolled!')
|
||
console.log(`\n Classroom: ${classroom.name} (code: ${classroom.code})`)
|
||
console.log(` Students enrolled: ${profilesToSeed.length}`)
|
||
console.log('\n Visit http://localhost:3000/practice to see them.')
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('Error seeding test students:', err)
|
||
process.exit(1)
|
||
})
|