Class_generator/src/DRS/SmartPreviewOrchestrator.js
StillHammer f5cef0c913 Add comprehensive testing suite with UI/UX and E2E integration tests
- Create complete integration test system (test-integration.js)
- Add UI/UX interaction testing with real event simulation (test-uiux-integration.js)
- Implement end-to-end scenario testing for user journeys (test-e2e-scenarios.js)
- Add console testing commands for rapid development testing (test-console-commands.js)
- Create comprehensive test guide documentation (TEST-GUIDE.md)
- Integrate test buttons in debug panel (F12 → 3 test types)
- Add vocabulary modal two-progress-bar system integration
- Fix flashcard retry system for "don't know" cards
- Update IntelligentSequencer for task distribution validation

🧪 Testing Coverage:
- 35+ integration tests (architecture/modules)
- 20+ UI/UX tests (real user interactions)
- 5 E2E scenarios (complete user journeys)
- Console commands for rapid testing
- Debug panel integration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 23:04:38 +08:00

785 lines
29 KiB
JavaScript

/**
* SmartPreviewOrchestrator - Main controller for Dynamic Revision System
* Manages dynamic loading/unloading of exercise modules and coordinates shared services
*/
import Module from '../core/Module.js';
const privateData = new WeakMap();
class SmartPreviewOrchestrator extends Module {
constructor(name, dependencies, config) {
super(name, ['eventBus', 'contentLoader']);
// Validate dependencies
if (!dependencies.eventBus) {
throw new Error('SmartPreviewOrchestrator requires EventBus dependency');
}
if (!dependencies.contentLoader) {
throw new Error('SmartPreviewOrchestrator requires ContentLoader dependency');
}
// Store dependencies and configuration
this._eventBus = dependencies.eventBus;
this._contentLoader = dependencies.contentLoader;
this._config = config || {};
// Initialize private data
privateData.set(this, {
loadedModules: new Map(),
availableModules: new Map(),
currentModule: null,
sharedServices: {
llmValidator: null,
prerequisiteEngine: null,
contextMemory: null,
aiReportInterface: null
},
sessionState: {
currentChapter: null,
chapterContent: null,
masteredVocabulary: new Set(),
masteredPhrases: new Set(),
masteredGrammar: new Set(),
sessionProgress: {},
exerciseSequence: [],
sequenceIndex: 0
},
moduleRegistry: {
'vocabulary': './exercise-modules/VocabularyModule.js',
'phrase': './exercise-modules/PhraseModule.js',
'text': './exercise-modules/TextModule.js',
'text-analysis': './exercise-modules/TextAnalysisModule.js',
'audio': './exercise-modules/AudioModule.js',
'image': './exercise-modules/ImageModule.js',
'grammar': './exercise-modules/GrammarModule.js',
'grammar-analysis': './exercise-modules/GrammarAnalysisModule.js',
'translation': './exercise-modules/TranslationModule.js'
}
});
Object.seal(this);
}
async init() {
this._validateNotDestroyed();
try {
console.log('🎯 Initializing Smart Preview Orchestrator...');
// Initialize shared services
await this._initializeSharedServices();
// Set up event listeners
this._setupEventListeners();
// Register available module types
this._registerModuleTypes();
this._setInitialized();
console.log('✅ Smart Preview Orchestrator initialized successfully');
} catch (error) {
console.error('❌ SmartPreviewOrchestrator initialization failed:', error);
throw error;
}
}
async destroy() {
this._validateNotDestroyed();
try {
console.log('🧹 Cleaning up Smart Preview Orchestrator...');
// Unload all loaded modules
await this._unloadAllModules();
// Cleanup shared services
await this._cleanupSharedServices();
// Remove event listeners
this._eventBus.off('drs:startSession', this._handleStartSession, this.name);
this._eventBus.off('drs:switchModule', this._handleSwitchModule, this.name);
this._eventBus.off('drs:updateProgress', this._handleUpdateProgress, this.name);
this._setDestroyed();
console.log('✅ Smart Preview Orchestrator destroyed successfully');
} catch (error) {
console.error('❌ SmartPreviewOrchestrator cleanup failed:', error);
throw error;
}
}
// Public API Methods
/**
* Start a new revision session for a chapter
* @param {string} bookId - Book identifier
* @param {string} chapterId - Chapter identifier
* @returns {Promise<boolean>} - Success status
*/
async startRevisionSession(bookId, chapterId) {
this._validateInitialized();
try {
console.log(`🚀 Starting revision session: ${bookId} - ${chapterId}`);
// Load chapter content
const chapterContent = await this._contentLoader.loadContent(chapterId);
const data = privateData.get(this);
data.sessionState.currentChapter = { bookId, chapterId };
data.sessionState.chapterContent = chapterContent;
// Load existing progress from files
if (window.getChapterProgress) {
try {
const savedProgress = await window.getChapterProgress(bookId, chapterId);
// Populate session state with saved progress (handle both old and new format)
if (savedProgress.masteredVocabulary) {
const vocabItems = savedProgress.masteredVocabulary.map(entry => {
return typeof entry === 'string' ? entry : entry.item;
});
data.sessionState.masteredVocabulary = new Set(vocabItems);
}
if (savedProgress.masteredPhrases) {
const phraseItems = savedProgress.masteredPhrases.map(entry => {
return typeof entry === 'string' ? entry : entry.item;
});
data.sessionState.masteredPhrases = new Set(phraseItems);
}
if (savedProgress.masteredGrammar) {
const grammarItems = savedProgress.masteredGrammar.map(entry => {
return typeof entry === 'string' ? entry : entry.item;
});
data.sessionState.masteredGrammar = new Set(grammarItems);
}
console.log(`📁 Loaded existing progress: ${savedProgress.masteredVocabulary.length} vocab, ${savedProgress.masteredPhrases.length} phrases, mastery count: ${savedProgress.masteryCount}`);
} catch (error) {
console.warn('Failed to load existing progress:', error);
}
}
// Initialize prerequisites
await this._analyzePrerequisites(chapterContent);
// Generate exercise sequence
await this._generateExerciseSequence();
// Start AI reporting session
if (data.sharedServices.llmValidator && data.sharedServices.aiReportInterface) {
const sessionId = data.sharedServices.llmValidator.startReportSession({
bookId,
chapterId,
difficulty: this._config.difficulty || 'medium',
exerciseTypes: Array.from(data.availableModules.keys()),
totalExercises: data.sessionState.exerciseSequence.length
});
// Notify the report interface
data.sharedServices.aiReportInterface.onSessionStart({
bookId,
chapterId,
sessionId
});
console.log(`📊 Started AI report session: ${sessionId}`);
}
// Start with first available exercise
await this._startNextExercise();
// Emit session started event
this._eventBus.emit('drs:sessionStarted', {
bookId,
chapterId,
totalExercises: data.sessionState.exerciseSequence.length,
availableModules: Array.from(data.availableModules.keys())
}, this.name);
return true;
} catch (error) {
console.error('❌ Failed to start revision session:', error);
this._eventBus.emit('drs:sessionError', { error: error.message }, this.name);
return false;
}
}
/**
* Get available exercise modules based on current prerequisites
* @returns {Array<string>} - Available module names
*/
getAvailableModules() {
this._validateInitialized();
const data = privateData.get(this);
return Array.from(data.availableModules.keys());
}
/**
* Get shared services for external access
* @returns {Object} - Shared services
*/
getSharedServices() {
this._validateInitialized();
const data = privateData.get(this);
return data.sharedServices;
}
/**
* Switch to a different exercise module
* @param {string} moduleType - Type of module to switch to
* @returns {Promise<boolean>} - Success status
*/
async switchToModule(moduleType) {
this._validateInitialized();
try {
const data = privateData.get(this);
if (!data.availableModules.has(moduleType)) {
throw new Error(`Module type ${moduleType} is not available`);
}
// Unload current module
if (data.currentModule) {
await this._unloadModule(data.currentModule);
}
// Load new module
const module = await this._loadModule(moduleType);
data.currentModule = moduleType;
// Present exercise
const exerciseData = await this._getExerciseData(moduleType);
const container = document.getElementById('drs-exercise-container');
await module.present(container, exerciseData);
this._eventBus.emit('drs:moduleActivated', { moduleType, exerciseData }, this.name);
return true;
} catch (error) {
console.error(`❌ Failed to switch to module ${moduleType}:`, error);
return false;
}
}
/**
* Get current session progress
* @returns {Object} - Progress information
*/
getSessionProgress() {
this._validateInitialized();
const data = privateData.get(this);
const state = data.sessionState;
return {
currentChapter: state.currentChapter,
masteredVocabulary: state.masteredVocabulary.size,
masteredPhrases: state.masteredPhrases.size,
masteredGrammar: state.masteredGrammar.size,
completedExercises: state.sequenceIndex,
totalExercises: state.exerciseSequence.length,
progressPercentage: Math.round((state.sequenceIndex / state.exerciseSequence.length) * 100)
};
}
// Private Methods
async _initializeSharedServices() {
console.log('🔧 Initializing shared services...');
const data = privateData.get(this);
try {
// Initialize LLMValidator (mock for now)
const { default: LLMValidator } = await import('./services/LLMValidator.js');
data.sharedServices.llmValidator = new LLMValidator(this._config.llm || {});
// Initialize AIReportInterface
const { default: AIReportInterface } = await import('../components/AIReportInterface.js');
data.sharedServices.aiReportInterface = new AIReportInterface(
data.sharedServices.llmValidator,
this._config.aiReporting || {}
);
// Initialize PrerequisiteEngine
const { default: PrerequisiteEngine } = await import('./services/PrerequisiteEngine.js');
data.sharedServices.prerequisiteEngine = new PrerequisiteEngine();
// Initialize ContextMemory
const { default: ContextMemory } = await import('./services/ContextMemory.js');
data.sharedServices.contextMemory = new ContextMemory();
console.log('✅ Shared services initialized');
} catch (error) {
console.error('❌ Failed to initialize shared services:', error);
throw error;
}
}
async _cleanupSharedServices() {
const data = privateData.get(this);
// Cleanup services if they have cleanup methods
Object.values(data.sharedServices).forEach(service => {
if (service && typeof service.cleanup === 'function') {
service.cleanup();
}
});
}
_setupEventListeners() {
this._eventBus.on('drs:startSession', this._handleStartSession.bind(this), this.name);
this._eventBus.on('drs:switchModule', this._handleSwitchModule.bind(this), this.name);
this._eventBus.on('drs:updateProgress', this._handleUpdateProgress.bind(this), this.name);
this._eventBus.on('drs:exerciseCompleted', this._handleExerciseCompleted.bind(this), this.name);
}
_registerModuleTypes() {
const data = privateData.get(this);
// Register all available module types
Object.keys(data.moduleRegistry).forEach(moduleType => {
data.availableModules.set(moduleType, {
path: data.moduleRegistry[moduleType],
loaded: false,
instance: null
});
});
}
async _loadModule(moduleType) {
const data = privateData.get(this);
const moduleInfo = data.availableModules.get(moduleType);
if (!moduleInfo) {
throw new Error(`Unknown module type: ${moduleType}`);
}
if (data.loadedModules.has(moduleType)) {
return data.loadedModules.get(moduleType);
}
try {
console.log(`📦 Loading module: ${moduleType}`);
// Dynamic import of module
const modulePath = moduleInfo.path.startsWith('./') ?
moduleInfo.path : `./${moduleInfo.path}`;
const { default: ModuleClass } = await import(modulePath);
// Create instance with shared services
const moduleInstance = new ModuleClass(
this, // orchestrator reference
data.sharedServices.llmValidator,
data.sharedServices.prerequisiteEngine,
data.sharedServices.contextMemory
);
// Initialize module
await moduleInstance.init();
data.loadedModules.set(moduleType, moduleInstance);
moduleInfo.loaded = true;
moduleInfo.instance = moduleInstance;
console.log(`✅ Module loaded: ${moduleType}`);
return moduleInstance;
} catch (error) {
console.error(`❌ Failed to load module ${moduleType}:`, error);
throw error;
}
}
async _unloadModule(moduleType) {
const data = privateData.get(this);
const module = data.loadedModules.get(moduleType);
if (module) {
try {
await module.cleanup();
data.loadedModules.delete(moduleType);
const moduleInfo = data.availableModules.get(moduleType);
if (moduleInfo) {
moduleInfo.loaded = false;
moduleInfo.instance = null;
}
console.log(`📤 Module unloaded: ${moduleType}`);
} catch (error) {
console.error(`❌ Error unloading module ${moduleType}:`, error);
}
}
}
async _unloadAllModules() {
const data = privateData.get(this);
const moduleTypes = Array.from(data.loadedModules.keys());
for (const moduleType of moduleTypes) {
await this._unloadModule(moduleType);
}
}
async _analyzePrerequisites(chapterContent) {
const data = privateData.get(this);
// Use PrerequisiteEngine to analyze chapter content
const prerequisites = data.sharedServices.prerequisiteEngine.analyzeChapter(chapterContent);
console.log('📊 Prerequisites analyzed:', prerequisites);
}
async _generateExerciseSequence() {
const data = privateData.get(this);
// Generate exercise sequence based on content and mastery
const chapterContent = data.sessionState.chapterContent;
const masteredVocab = data.sessionState.masteredVocabulary;
const masteredPhrases = data.sessionState.masteredPhrases;
// Filter content to focus on non-mastered items
const allVocab = Object.keys(chapterContent.vocabulary || {});
const allPhrases = Object.keys(chapterContent.phrases || {});
const unmasteredVocab = allVocab.filter(word => !masteredVocab.has(word));
const unmasteredPhrases = allPhrases.filter(phrase => !masteredPhrases.has(phrase));
console.log(`📊 Content analysis:`);
console.log(` 📚 Vocabulary: ${unmasteredVocab.length}/${allVocab.length} unmastered`);
console.log(` 💬 Phrases: ${unmasteredPhrases.length}/${allPhrases.length} unmastered`);
const sequence = [];
// Create vocabulary groups (focus on unmastered, but include some mastered for review)
const vocabGroupSize = 5;
const vocabGroups = Math.ceil(unmasteredVocab.length / vocabGroupSize);
for (let i = 0; i < vocabGroups; i++) {
sequence.push({
type: 'vocabulary',
subtype: 'group',
groupSize: vocabGroupSize,
groupIndex: i,
adaptive: true // Mark as adaptive sequence
});
}
// Add unmastered phrases (prioritize new content)
unmasteredPhrases.forEach((phrase, index) => {
if (index < 10) { // Limit to 10 phrases per session
sequence.push({
type: 'phrase',
subtype: 'individual',
index: allPhrases.indexOf(phrase),
adaptive: true
});
}
});
// Add some review items if we have extra capacity
if (sequence.length < 15) {
const reviewVocab = [...masteredVocab].slice(0, 3);
const reviewPhrases = [...masteredPhrases].slice(0, 2);
reviewVocab.forEach(word => {
sequence.push({
type: 'vocabulary',
subtype: 'review',
word: word,
adaptive: true
});
});
reviewPhrases.forEach(phrase => {
sequence.push({
type: 'phrase',
subtype: 'review',
index: allPhrases.indexOf(phrase),
adaptive: true
});
});
}
// Shuffle for variety
for (let i = sequence.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sequence[i], sequence[j]] = [sequence[j], sequence[i]];
}
const adaptiveInfo = unmasteredVocab.length === 0 && unmasteredPhrases.length === 0 ?
' (Review mode - all content mastered!)' :
' (Adaptive - focusing on unmastered content)';
console.log(`🧠 Generated adaptive sequence: ${sequence.length} exercises${adaptiveInfo}`);
data.sessionState.exerciseSequence = sequence;
data.sessionState.sequenceIndex = 0;
}
async _startNextExercise() {
const data = privateData.get(this);
const sequence = data.sessionState.exerciseSequence;
const currentIndex = data.sessionState.sequenceIndex;
if (currentIndex >= sequence.length) {
// End AI reporting session
if (data.sharedServices.llmValidator && data.sharedServices.aiReportInterface) {
const sessionStats = this.getSessionProgress();
// End the report session
data.sharedServices.llmValidator.endReportSession();
// Notify the report interface
data.sharedServices.aiReportInterface.onSessionEnd({
exerciseCount: sequence.length,
averageScore: sessionStats.averageScore || 0,
completedAt: new Date()
});
console.log('📊 Ended AI report session');
}
// Session complete - mark as completed and save
const currentChapter = data.sessionState.currentChapter;
if (currentChapter && window.markChapterCompleted) {
try {
await window.markChapterCompleted(currentChapter.bookId, currentChapter.chapterId);
console.log(`🏆 Chapter marked as completed: ${currentChapter.bookId}/${currentChapter.chapterId}`);
} catch (error) {
console.warn('Failed to mark chapter as completed:', error);
}
}
this._eventBus.emit('drs:sessionComplete', this.getSessionProgress(), this.name);
return;
}
const exercise = sequence[currentIndex];
await this.switchToModule(exercise.type);
}
async _getExerciseData(moduleType) {
const data = privateData.get(this);
const chapterContent = data.sessionState.chapterContent;
const sequence = data.sessionState.exerciseSequence;
const currentExercise = sequence[data.sessionState.sequenceIndex];
// Generate exercise data based on module type and current exercise parameters
switch (moduleType) {
case 'vocabulary':
return this._generateVocabularyExerciseData(chapterContent, currentExercise);
case 'phrase':
return this._generatePhraseExerciseData(chapterContent, currentExercise);
case 'text':
return this._generateTextExerciseData(chapterContent, currentExercise);
default:
return { type: moduleType, content: chapterContent };
}
}
_generateVocabularyExerciseData(chapterContent, exercise) {
const vocabulary = chapterContent.vocabulary || {};
const vocabArray = Object.entries(vocabulary);
const startIndex = exercise.groupIndex * exercise.groupSize;
const endIndex = Math.min(startIndex + exercise.groupSize, vocabArray.length);
const vocabGroup = vocabArray.slice(startIndex, endIndex);
return {
type: 'vocabulary',
subtype: exercise.subtype,
groupIndex: exercise.groupIndex,
vocabulary: vocabGroup.map(([word, data]) => ({
word,
translation: data.user_language,
pronunciation: data.pronunciation,
type: data.type
}))
};
}
_generatePhraseExerciseData(chapterContent, exercise) {
const phrases = chapterContent.phrases || {};
const phraseEntries = Object.entries(phrases);
const phraseIndex = exercise.index || 0;
// Check if phrase exists at this index
if (phraseIndex >= phraseEntries.length) {
console.warn(`⚠️ Phrase at index ${phraseIndex} not found (total: ${phraseEntries.length})`);
return null;
}
const [phraseText, phraseData] = phraseEntries[phraseIndex];
// Create phrase object for compatibility
const phrase = {
id: `phrase_${phraseIndex}`,
english: phraseText,
text: phraseText,
translation: phraseData.user_language,
user_language: phraseData.user_language,
pronunciation: phraseData.pronunciation,
context: phraseData.context || 'general',
...phraseData
};
// Verify prerequisites for this phrase
const data = privateData.get(this);
const unlockStatus = data.sharedServices.prerequisiteEngine.canUnlock('phrase', phrase);
return {
type: 'phrase',
subtype: exercise.subtype,
phrase: phrase,
phraseIndex: phraseIndex,
totalPhrases: phraseEntries.length,
unlockStatus: unlockStatus,
chapterContent: chapterContent, // For language detection
metadata: {
userLanguage: chapterContent.metadata?.userLanguage || 'English',
targetLanguage: chapterContent.metadata?.targetLanguage || 'French'
}
};
}
_generateTextExerciseData(chapterContent, exercise) {
const texts = chapterContent.texts || [];
const textIndex = exercise.textIndex || 0;
const text = texts[textIndex];
return {
type: 'text',
subtype: exercise.subtype,
text,
sentenceIndex: exercise.sentenceIndex || 0
};
}
// Event Handlers
async _handleStartSession(event) {
const { bookId, chapterId } = event.data;
await this.startRevisionSession(bookId, chapterId);
}
async _handleSwitchModule(event) {
const { moduleType } = event.data;
await this.switchToModule(moduleType);
}
async _handleUpdateProgress(event) {
const data = privateData.get(this);
const { type, item, mastered } = event.data;
const currentChapter = data.sessionState.currentChapter;
if (type === 'vocabulary' && mastered) {
data.sessionState.masteredVocabulary.add(item);
// Save to persistent storage with metadata
if (currentChapter && window.addMasteredItem) {
try {
const metadata = {
exerciseType: 'vocabulary',
sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
moduleType: 'VocabularyModule',
sequenceIndex: data.sessionState.sequenceIndex
};
await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'vocabulary', item, metadata);
} catch (error) {
console.warn('Failed to save vocabulary progress:', error);
}
}
} else if (type === 'phrase' && mastered) {
data.sessionState.masteredPhrases.add(item);
// Save to persistent storage with metadata
if (currentChapter && window.addMasteredItem) {
try {
const metadata = {
exerciseType: 'phrase',
sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
moduleType: 'PhraseModule',
sequenceIndex: data.sessionState.sequenceIndex,
aiValidated: true
};
await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'phrases', item, metadata);
} catch (error) {
console.warn('Failed to save phrase progress:', error);
}
}
} else if (type === 'grammar' && mastered) {
data.sessionState.masteredGrammar.add(item);
// Save to persistent storage with metadata
if (currentChapter && window.addMasteredItem) {
try {
const metadata = {
exerciseType: 'grammar',
sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
moduleType: 'GrammarModule',
sequenceIndex: data.sessionState.sequenceIndex
};
await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'grammar', item, metadata);
} catch (error) {
console.warn('Failed to save grammar progress:', error);
}
}
}
this._eventBus.emit('drs:progressUpdated', this.getSessionProgress(), this.name);
}
async _handleExerciseCompleted(event) {
const data = privateData.get(this);
data.sessionState.sequenceIndex++;
// Move to next exercise
await this._startNextExercise();
}
/**
* Get mastery progress from PrerequisiteEngine
* @returns {Object} - Progress statistics
*/
getMasteryProgress() {
this._validateInitialized();
const data = privateData.get(this);
if (!data.sharedServices.prerequisiteEngine) {
return {
vocabulary: { mastered: 0, total: 0, percentage: 0 },
phrases: { mastered: 0, total: 0, percentage: 0 },
grammar: { mastered: 0, total: 0, percentage: 0 }
};
}
return data.sharedServices.prerequisiteEngine.getMasteryProgress();
}
/**
* Get list of mastered words from PrerequisiteEngine
* @returns {Array} - Array of mastered words
*/
getMasteredWords() {
this._validateInitialized();
const data = privateData.get(this);
if (!data.sharedServices.prerequisiteEngine) {
return [];
}
return Array.from(data.sharedServices.prerequisiteEngine.masteredWords);
}
}
export default SmartPreviewOrchestrator;