Major Changes: - Moved legacy system to Legacy/ folder for archival - Built new modular architecture with strict separation of concerns - Created core system: Module, EventBus, ModuleLoader, Router - Added Application bootstrap with auto-start functionality - Implemented development server with ES6 modules support - Created comprehensive documentation and project context - Converted SBS-7-8 content to JSON format - Copied all legacy games and content to new structure New Architecture Features: - Sealed modules with WeakMap private data - Strict dependency injection system - Event-driven communication only - Inviolable responsibility patterns - Auto-initialization without commands - Component-based UI foundation ready Technical Stack: - Vanilla JS/HTML/CSS only - ES6 modules with proper imports/exports - HTTP development server (no file:// protocol) - Modular CSS with component scoping - Comprehensive error handling and debugging Ready for Phase 2: Converting legacy modules to new architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { createMockDOM, cleanupMockDOM, createLogCapture } from '../utils/test-helpers.js';
|
|
import { readFileSync } from 'fs';
|
|
import path from 'path';
|
|
|
|
// Test d'intégration pour le flux complet de chargement de contenu
|
|
describe('Flux de Chargement de Contenu - Tests d\'Intégration', () => {
|
|
let ContentScanner, GameLoader, EnvConfig;
|
|
let logCapture;
|
|
|
|
// Helper pour simuler fetch vers le proxy
|
|
function createProxyMockFetch() {
|
|
return async (url) => {
|
|
if (url.includes('localhost:8083/do-proxy/sbs-level-7-8-new.json')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
name: "SBS Level 7-8 (New)",
|
|
description: "Test content from remote",
|
|
difficulty: "intermediate",
|
|
vocabulary: {
|
|
"central": "中心的;中央的",
|
|
"avenue": "大街;林荫道",
|
|
"refrigerator": "冰箱"
|
|
}
|
|
})
|
|
};
|
|
}
|
|
|
|
if (url.includes('localhost:8083/do-proxy/english-class-demo.json')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
name: "English Class Demo",
|
|
description: "Demo content",
|
|
vocabulary: {
|
|
"hello": "bonjour",
|
|
"world": "monde"
|
|
}
|
|
})
|
|
};
|
|
}
|
|
|
|
// Local fallback
|
|
if (url.includes('js/content/')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
name: "Local Content",
|
|
vocabulary: { "local": "content" }
|
|
})
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({ error: 'Not found' })
|
|
};
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
createMockDOM();
|
|
logCapture = createLogCapture();
|
|
|
|
// Charger tous les modules nécessaires
|
|
const modules = [
|
|
{ name: 'EnvConfig', path: 'js/core/env-config.js' },
|
|
{ name: 'ContentScanner', path: 'js/core/content-scanner.js' },
|
|
{ name: 'GameLoader', path: 'js/core/game-loader.js' }
|
|
];
|
|
|
|
modules.forEach(({ name, path: modulePath }) => {
|
|
const fullPath = path.resolve(process.cwd(), modulePath);
|
|
const code = readFileSync(fullPath, 'utf8');
|
|
|
|
const testCode = code
|
|
.replace(/window\./g, 'global.')
|
|
.replace(/typeof window !== 'undefined'/g, 'true')
|
|
.replace(/typeof module !== 'undefined' && module\.exports/g, 'false');
|
|
|
|
eval(testCode);
|
|
});
|
|
|
|
EnvConfig = global.EnvConfig;
|
|
ContentScanner = global.ContentScanner;
|
|
GameLoader = global.GameLoader;
|
|
|
|
// Initialiser envConfig global
|
|
global.envConfig = new EnvConfig();
|
|
|
|
// Mock fetch
|
|
global.fetch = createProxyMockFetch();
|
|
|
|
// Mock GameModules
|
|
global.GameModules = {
|
|
WhackAMole: class {
|
|
constructor(options) {
|
|
this.container = options.container;
|
|
this.content = options.content;
|
|
this.started = false;
|
|
}
|
|
start() { this.started = true; }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
logCapture.restore();
|
|
cleanupMockDOM();
|
|
});
|
|
|
|
describe('Flux complet: Scan → Load → Game', () => {
|
|
test('devrait scanner et charger le contenu distant puis créer un jeu', async () => {
|
|
// Étape 1: Scanner le contenu
|
|
const scanner = new ContentScanner();
|
|
const scanResults = await scanner.scanAllContent();
|
|
|
|
assert.ok(scanResults);
|
|
assert.ok(scanResults.found.length > 0, 'Should find some content');
|
|
|
|
// Vérifier que les modules sont chargés
|
|
assert.ok(global.ContentModules);
|
|
assert.ok(global.ContentModules.SBSLevel78New || global.ContentModules.EnglishClassDemo,
|
|
'Should load at least one remote module');
|
|
|
|
// Étape 2: Charger un jeu avec le contenu trouvé
|
|
const loader = new GameLoader();
|
|
const container = { innerHTML: '' };
|
|
|
|
const contentId = scanResults.found[0].id;
|
|
const game = await loader.loadGame('whack-a-mole', contentId, container);
|
|
|
|
assert.ok(game);
|
|
assert.ok(game instanceof global.GameModules.WhackAMole);
|
|
assert.ok(game.content);
|
|
assert.ok(game.content.vocabulary);
|
|
|
|
// Étape 3: Démarrer le jeu
|
|
game.start();
|
|
assert.equal(game.started, true);
|
|
|
|
// Vérifier les logs
|
|
const logs = logCapture.getLogs();
|
|
assert.ok(logs.some(log => log.message.includes('Scan automatique')));
|
|
assert.ok(logs.some(log => log.message.includes('chargé avec succès')));
|
|
});
|
|
|
|
test('devrait gérer le fallback local si le distant échoue', async () => {
|
|
// Mock fetch qui échoue pour le distant
|
|
global.fetch = async (url) => {
|
|
if (url.includes('localhost:8083')) {
|
|
throw new Error('Network error');
|
|
}
|
|
if (url.includes('js/content/')) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Local Fallback",
|
|
vocabulary: { "fallback": "secours" }
|
|
})
|
|
};
|
|
}
|
|
throw new Error('Unknown URL');
|
|
};
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
// Forcer le chargement d'un fichier JSON
|
|
await scanner.loadJsonContent('test-content.json');
|
|
|
|
assert.ok(global.ContentModules.TestContent);
|
|
assert.equal(global.ContentModules.TestContent.name, "Local Fallback");
|
|
|
|
const logs = logCapture.getLogs('WARN');
|
|
assert.ok(logs.some(log => log.message.includes('Distant échoué')));
|
|
});
|
|
});
|
|
|
|
describe('Configuration et priorités', () => {
|
|
test('devrait respecter la configuration TRY_REMOTE_FIRST', async () => {
|
|
const config = new EnvConfig();
|
|
config.set('TRY_REMOTE_FIRST', true);
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
// Compter les appels fetch
|
|
let remoteAttempts = 0;
|
|
let localAttempts = 0;
|
|
|
|
global.fetch = async (url) => {
|
|
if (url.includes('localhost:8083')) {
|
|
remoteAttempts++;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ name: "Remote", vocabulary: { "remote": "distant" } })
|
|
};
|
|
}
|
|
if (url.includes('js/content/')) {
|
|
localAttempts++;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ name: "Local", vocabulary: { "local": "local" } })
|
|
};
|
|
}
|
|
throw new Error('Unknown URL');
|
|
};
|
|
|
|
await scanner.loadJsonContent('test.json');
|
|
|
|
assert.ok(remoteAttempts > 0, 'Should try remote first');
|
|
// Local ne devrait pas être appelé si remote réussit
|
|
assert.equal(localAttempts, 0, 'Should not fallback to local if remote succeeds');
|
|
});
|
|
|
|
test('devrait désactiver le distant si configuré', async () => {
|
|
const config = new EnvConfig();
|
|
config.set('USE_REMOTE_CONTENT', false);
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
let remoteAttempts = 0;
|
|
let localAttempts = 0;
|
|
|
|
global.fetch = async (url) => {
|
|
if (url.includes('localhost:8083')) {
|
|
remoteAttempts++;
|
|
throw new Error('Should not be called');
|
|
}
|
|
if (url.includes('js/content/')) {
|
|
localAttempts++;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ name: "Local Only", vocabulary: { "local": "only" } })
|
|
};
|
|
}
|
|
throw new Error('Unknown URL');
|
|
};
|
|
|
|
await scanner.loadJsonContent('test.json');
|
|
|
|
assert.equal(remoteAttempts, 0, 'Should not try remote when disabled');
|
|
assert.ok(localAttempts > 0, 'Should use local');
|
|
});
|
|
});
|
|
|
|
describe('Gestion des erreurs en cascade', () => {
|
|
test('devrait propager les erreurs si tout échoue', async () => {
|
|
global.fetch = async () => {
|
|
throw new Error('All methods failed');
|
|
};
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
try {
|
|
await scanner.loadJsonContent('failing-content.json');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('Impossible de charger JSON'));
|
|
}
|
|
|
|
const loader = new GameLoader();
|
|
|
|
try {
|
|
await loader.loadGame('whack-a-mole', 'nonexistent-content', {});
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('Contenu non trouvé'));
|
|
}
|
|
});
|
|
|
|
test('devrait logger toutes les erreurs intermédiaires', async () => {
|
|
global.fetch = async (url) => {
|
|
if (url.includes('localhost:8083')) {
|
|
throw new Error('Remote connection failed');
|
|
}
|
|
if (url.includes('js/content/')) {
|
|
throw new Error('Local file not found');
|
|
}
|
|
throw new Error('Unknown error');
|
|
};
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
try {
|
|
await scanner.loadJsonContent('test.json');
|
|
} catch (error) {
|
|
// Expected
|
|
}
|
|
|
|
const logs = logCapture.getLogs();
|
|
const errorLogs = logs.filter(log => log.level === 'WARN' || log.level === 'ERROR');
|
|
|
|
assert.ok(errorLogs.length > 0, 'Should log intermediate errors');
|
|
assert.ok(errorLogs.some(log => log.message.includes('Remote connection failed')));
|
|
assert.ok(errorLogs.some(log => log.message.includes('Local file not found')));
|
|
});
|
|
});
|
|
|
|
describe('Performance et cache', () => {
|
|
test('devrait éviter de recharger les modules déjà chargés', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
// Précharger un module
|
|
global.ContentModules = {
|
|
TestContent: {
|
|
name: "Already Loaded",
|
|
vocabulary: { "cached": "mis en cache" }
|
|
}
|
|
};
|
|
|
|
let fetchCalls = 0;
|
|
global.fetch = async () => {
|
|
fetchCalls++;
|
|
throw new Error('Should not be called');
|
|
};
|
|
|
|
// Essayer de scanner un fichier pour un module déjà chargé
|
|
const result = await scanner.scanContentFile('test-content.json');
|
|
|
|
assert.ok(result);
|
|
assert.equal(result.name, "Already Loaded");
|
|
assert.equal(fetchCalls, 0, 'Should not fetch if module already loaded');
|
|
});
|
|
|
|
test('le chargement simultané de contenu devrait fonctionner', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
const promises = [
|
|
scanner.loadJsonContent('content1.json'),
|
|
scanner.loadJsonContent('content2.json'),
|
|
scanner.loadJsonContent('content3.json')
|
|
];
|
|
|
|
let fetchCount = 0;
|
|
global.fetch = async (url) => {
|
|
fetchCount++;
|
|
await new Promise(resolve => setTimeout(resolve, 10)); // Simule latence
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
name: `Content ${fetchCount}`,
|
|
vocabulary: { [`word${fetchCount}`]: `translation${fetchCount}` }
|
|
})
|
|
};
|
|
};
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
assert.equal(results.length, 3);
|
|
results.forEach((result, index) => {
|
|
assert.equal(result.status, 'fulfilled', `Promise ${index} should succeed`);
|
|
});
|
|
|
|
// Vérifier que les modules sont chargés
|
|
assert.ok(global.ContentModules.Content1);
|
|
assert.ok(global.ContentModules.Content2);
|
|
assert.ok(global.ContentModules.Content3);
|
|
});
|
|
});
|
|
|
|
describe('Validation de l\'intégrité des données', () => {
|
|
test('devrait valider la structure du contenu chargé', async () => {
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Valid Content",
|
|
vocabulary: {
|
|
"valid": "valide",
|
|
"test": "test"
|
|
},
|
|
difficulty: "medium"
|
|
})
|
|
});
|
|
|
|
const scanner = new ContentScanner();
|
|
await scanner.loadJsonContent('valid-content.json');
|
|
|
|
const loader = new GameLoader();
|
|
|
|
// Devrait réussir avec un contenu valide
|
|
assert.doesNotThrow(() => {
|
|
loader.validateGameContent(global.ContentModules.ValidContent, 'whack-a-mole');
|
|
});
|
|
});
|
|
|
|
test('devrait rejeter le contenu invalide', async () => {
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Invalid Content",
|
|
// Pas de vocabulary
|
|
})
|
|
});
|
|
|
|
const scanner = new ContentScanner();
|
|
await scanner.loadJsonContent('invalid-content.json');
|
|
|
|
const loader = new GameLoader();
|
|
|
|
assert.throws(() => {
|
|
loader.validateGameContent(global.ContentModules.InvalidContent, 'whack-a-mole');
|
|
});
|
|
});
|
|
});
|
|
}); |