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>
563 lines
20 KiB
JavaScript
563 lines
20 KiB
JavaScript
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { createMockDOM, cleanupMockDOM, createLogCapture, createTimerMock } from '../utils/test-helpers.js';
|
|
import { readFileSync } from 'fs';
|
|
import path from 'path';
|
|
|
|
describe('Edge Cases - Tests des Cas Limites', () => {
|
|
let ContentScanner, GameLoader, EnvConfig;
|
|
let logCapture, timerMock;
|
|
|
|
beforeEach(() => {
|
|
createMockDOM();
|
|
logCapture = createLogCapture();
|
|
timerMock = createTimerMock();
|
|
|
|
// Charger les modules
|
|
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');
|
|
eval(testCode);
|
|
});
|
|
|
|
EnvConfig = global.EnvConfig;
|
|
ContentScanner = global.ContentScanner;
|
|
GameLoader = global.GameLoader;
|
|
|
|
global.envConfig = new EnvConfig();
|
|
});
|
|
|
|
afterEach(() => {
|
|
timerMock.restore();
|
|
logCapture.restore();
|
|
cleanupMockDOM();
|
|
});
|
|
|
|
describe('Cas de Données Corrompues', () => {
|
|
test('devrait gérer JSON malformé', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token in JSON');
|
|
}
|
|
});
|
|
|
|
try {
|
|
await scanner.loadJsonContent('corrupted.json');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('Impossible de charger JSON'));
|
|
}
|
|
});
|
|
|
|
test('devrait gérer contenu avec caractères spéciaux', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Contenu avec émojis 🎉🚀💥",
|
|
vocabulary: {
|
|
"café": "☕ boisson chaude",
|
|
"🌟": "étoile",
|
|
"测试": "test en chinois"
|
|
}
|
|
})
|
|
});
|
|
|
|
await scanner.loadJsonContent('special-chars.json');
|
|
|
|
assert.ok(global.ContentModules.SpecialChars);
|
|
assert.equal(global.ContentModules.SpecialChars.vocabulary["café"], "☕ boisson chaude");
|
|
});
|
|
|
|
test('devrait gérer contenu avec structure profonde', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
// Créer un objet très profond
|
|
let deepObject = { vocabulary: {} };
|
|
let current = deepObject;
|
|
for (let i = 0; i < 100; i++) {
|
|
current.nested = { level: i };
|
|
current = current.nested;
|
|
}
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => deepObject
|
|
});
|
|
|
|
await scanner.loadJsonContent('deep-structure.json');
|
|
assert.ok(global.ContentModules.DeepStructure);
|
|
});
|
|
|
|
test('devrait gérer contenu avec références circulaires', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => {
|
|
const obj = { name: "Circular" };
|
|
obj.self = obj; // Référence circulaire
|
|
return obj;
|
|
}
|
|
});
|
|
|
|
// Les références circulaires peuvent causer des problèmes lors de la sérialisation
|
|
try {
|
|
await scanner.loadJsonContent('circular.json');
|
|
// Si ça passe, c'est bon, sinon on teste l'erreur
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('circular') || error.message.includes('Converting'));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Cas de Réseau Instable', () => {
|
|
test('devrait gérer les déconnexions intermittentes', async () => {
|
|
const scanner = new ContentScanner();
|
|
let attemptCount = 0;
|
|
|
|
global.fetch = async () => {
|
|
attemptCount++;
|
|
if (attemptCount < 3) {
|
|
throw new Error('Network temporarily unavailable');
|
|
}
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ name: "Finally loaded", vocabulary: { "test": "test" } })
|
|
};
|
|
};
|
|
|
|
// Le premier appel devrait échouer, mais le fallback local pourrait réussir
|
|
try {
|
|
await scanner.loadJsonContent('unstable-network.json');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('Impossible de charger JSON'));
|
|
}
|
|
|
|
assert.ok(attemptCount >= 1);
|
|
});
|
|
|
|
test('devrait gérer les réponses partielles', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => {
|
|
// Simuler une réponse incomplète qui s'interrompt
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
throw new Error('Connection reset by peer');
|
|
}
|
|
});
|
|
|
|
try {
|
|
await scanner.loadJsonContent('partial-response.json');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('Impossible de charger JSON'));
|
|
}
|
|
});
|
|
|
|
test('devrait gérer les timeouts variables', async () => {
|
|
const config = new EnvConfig();
|
|
config.set('REMOTE_TIMEOUT', 100); // Timeout très court
|
|
|
|
global.fetch = async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 200)); // Plus long que timeout
|
|
return { ok: true, json: async () => ({}) };
|
|
};
|
|
|
|
const result = await config.testRemoteConnection();
|
|
assert.equal(result.success, false);
|
|
assert.equal(result.isTimeout, true);
|
|
});
|
|
|
|
test('devrait gérer les codes de statut inattendus', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
const statusCodes = [429, 502, 503, 504, 520, 521, 522, 523, 524];
|
|
|
|
for (const status of statusCodes) {
|
|
global.fetch = async () => ({
|
|
ok: false,
|
|
status: status,
|
|
json: async () => ({ error: `HTTP ${status}` })
|
|
});
|
|
|
|
try {
|
|
await scanner.tryRemoteLoad('test.json');
|
|
} catch (error) {
|
|
// Ces erreurs sont attendues
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Cas de Mémoire et Performance', () => {
|
|
test('devrait gérer des fichiers de contenu très volumineux', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
// Créer un gros objet (simuler 1MB+ de données)
|
|
const largeVocabulary = {};
|
|
for (let i = 0; i < 10000; i++) {
|
|
largeVocabulary[`word${i}`] = `translation${i}`.repeat(50);
|
|
}
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Large Content",
|
|
vocabulary: largeVocabulary
|
|
})
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
await scanner.loadJsonContent('large-content.json');
|
|
const endTime = Date.now();
|
|
|
|
assert.ok(global.ContentModules.LargeContent);
|
|
assert.ok(Object.keys(global.ContentModules.LargeContent.vocabulary).length === 10000);
|
|
|
|
// Vérifier que ça ne prend pas trop de temps
|
|
assert.ok(endTime - startTime < 5000); // Moins de 5 secondes
|
|
});
|
|
|
|
test('devrait gérer le chargement simultané de nombreux modules', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async (url) => {
|
|
const filename = url.split('/').pop();
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
name: `Module for ${filename}`,
|
|
vocabulary: { [`word_${filename}`]: 'translation' }
|
|
})
|
|
};
|
|
};
|
|
|
|
// Charger 50 modules simultanément
|
|
const promises = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
promises.push(scanner.loadJsonContent(`module${i}.json`));
|
|
}
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
const successful = results.filter(r => r.status === 'fulfilled');
|
|
|
|
assert.ok(successful.length >= 45); // Au moins 90% de succès
|
|
});
|
|
|
|
test('devrait nettoyer la mémoire après destruction', () => {
|
|
const loader = new GameLoader();
|
|
|
|
// Créer plusieurs jeux avec des références
|
|
for (let i = 0; i < 10; i++) {
|
|
const mockGame = {
|
|
data: new Array(1000).fill(`data${i}`), // Simule des données
|
|
destroy: function() {
|
|
this.data = null;
|
|
this.destroyed = true;
|
|
}
|
|
};
|
|
loader.currentGame = mockGame;
|
|
loader.cleanup();
|
|
}
|
|
|
|
// Vérifier que le dernier jeu est bien nettoyé
|
|
assert.equal(loader.currentGame, null);
|
|
});
|
|
});
|
|
|
|
describe('Cas de Concurrence et Race Conditions', () => {
|
|
test('devrait gérer le chargement concurrent du même module', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
let loadCount = 0;
|
|
global.fetch = async () => {
|
|
loadCount++;
|
|
await new Promise(resolve => setTimeout(resolve, Math.random() * 100));
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
name: "Concurrent Module",
|
|
vocabulary: { "test": "test" },
|
|
loadedAt: Date.now()
|
|
})
|
|
};
|
|
};
|
|
|
|
// Lancer 5 chargements simultanés du même fichier
|
|
const promises = Array(5).fill().map(() =>
|
|
scanner.loadJsonContent('concurrent.json')
|
|
);
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Le module devrait être chargé une seule fois (idéalement)
|
|
assert.ok(global.ContentModules.Concurrent);
|
|
// Mais on peut avoir plusieurs appels fetch (c'est normal sans cache)
|
|
});
|
|
|
|
test('devrait gérer les changements de configuration pendant le chargement', async () => {
|
|
const config = new EnvConfig();
|
|
const scanner = new ContentScanner();
|
|
|
|
global.fetch = async () => {
|
|
// Changer la config pendant le chargement
|
|
config.set('USE_REMOTE_CONTENT', false);
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ name: "Racing", vocabulary: { "race": "course" } })
|
|
};
|
|
};
|
|
|
|
try {
|
|
await scanner.loadJsonContent('racing.json');
|
|
} catch (error) {
|
|
// L'erreur est acceptable dans ce cas de race condition
|
|
}
|
|
});
|
|
|
|
test('devrait gérer la navigation pendant le chargement de jeu', async () => {
|
|
const loader = new GameLoader();
|
|
|
|
global.GameModules = {
|
|
SlowGame: class {
|
|
constructor(options) {
|
|
// Simuler un jeu qui prend du temps à charger
|
|
setTimeout(() => {
|
|
this.loaded = true;
|
|
}, 100);
|
|
}
|
|
start() {}
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
};
|
|
|
|
global.ContentModules = {
|
|
TestContent: { vocabulary: { "test": "test" } }
|
|
};
|
|
|
|
// Démarrer le chargement
|
|
const loadPromise = loader.loadGame('slow-game', 'test-content', {});
|
|
|
|
// Changer de jeu immédiatement (simuler navigation rapide)
|
|
setTimeout(() => {
|
|
loader.cleanup(); // L'utilisateur navigue ailleurs
|
|
}, 10);
|
|
|
|
try {
|
|
await loadPromise;
|
|
} catch (error) {
|
|
// Acceptable si le jeu a été nettoyé pendant le chargement
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Cas de Navigateur Non Supporté', () => {
|
|
test('devrait détecter l\'absence de fetch', async () => {
|
|
const originalFetch = global.fetch;
|
|
delete global.fetch;
|
|
|
|
const scanner = new ContentScanner();
|
|
|
|
try {
|
|
await scanner.loadJsonContent('no-fetch.json');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('fetch is not defined') ||
|
|
error.message.includes('Impossible de charger JSON'));
|
|
}
|
|
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
test('devrait détecter l\'absence de crypto.subtle', async () => {
|
|
const originalCrypto = global.crypto;
|
|
delete global.crypto;
|
|
|
|
const config = new EnvConfig();
|
|
|
|
try {
|
|
await config.generateAWSSignature('GET', 'https://test.com');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('crypto') || error.message.includes('subtle'));
|
|
}
|
|
|
|
global.crypto = originalCrypto;
|
|
});
|
|
|
|
test('devrait gérer l\'absence de URLSearchParams', () => {
|
|
const OriginalURLSearchParams = global.URLSearchParams;
|
|
delete global.URLSearchParams;
|
|
|
|
try {
|
|
// Test qui utilise URLSearchParams
|
|
const params = new URLSearchParams('test=value');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('URLSearchParams is not defined'));
|
|
}
|
|
|
|
global.URLSearchParams = OriginalURLSearchParams;
|
|
});
|
|
|
|
test('devrait gérer l\'absence de localStorage', () => {
|
|
const originalLocalStorage = global.localStorage;
|
|
delete global.localStorage;
|
|
|
|
// Simuler un code qui utilise localStorage
|
|
try {
|
|
localStorage.setItem('test', 'value');
|
|
assert.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
assert.ok(error.message.includes('localStorage is not defined'));
|
|
}
|
|
|
|
global.localStorage = originalLocalStorage;
|
|
});
|
|
});
|
|
|
|
describe('Cas de Données Invalides', () => {
|
|
test('devrait gérer des noms de module avec caractères spéciaux', async () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
const weirdFilenames = [
|
|
'module with spaces.json',
|
|
'module-with-🚀-emoji.json',
|
|
'module.with.dots.json',
|
|
'module_with_underscores.json',
|
|
'Module123WithNumbers.json'
|
|
];
|
|
|
|
for (const filename of weirdFilenames) {
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
name: `Module for ${filename}`,
|
|
vocabulary: { "test": "test" }
|
|
})
|
|
});
|
|
|
|
try {
|
|
await scanner.loadJsonContent(filename);
|
|
// Vérifier que le nom de module généré est valide
|
|
const moduleName = scanner.jsonFilenameToModuleName(filename);
|
|
assert.ok(typeof moduleName === 'string');
|
|
assert.ok(moduleName.length > 0);
|
|
} catch (error) {
|
|
// Certains cas peuvent légitimement échouer
|
|
}
|
|
}
|
|
});
|
|
|
|
test('devrait gérer des types de données inattendus', async () => {
|
|
const loader = new GameLoader();
|
|
|
|
const invalidContents = [
|
|
null,
|
|
undefined,
|
|
"string instead of object",
|
|
123,
|
|
[],
|
|
{ vocabulary: null },
|
|
{ vocabulary: "not an object" },
|
|
{ vocabulary: [] },
|
|
{ vocabulary: { /* empty */ } }
|
|
];
|
|
|
|
for (const content of invalidContents) {
|
|
global.ContentModules.InvalidContent = content;
|
|
|
|
try {
|
|
loader.validateGameContent(content, 'test-game');
|
|
assert.fail(`Should have rejected: ${JSON.stringify(content)}`);
|
|
} catch (error) {
|
|
// Ces erreurs sont attendues
|
|
assert.ok(error.message.includes('Contenu invalide') ||
|
|
error.message.includes('vocabulary'));
|
|
}
|
|
}
|
|
});
|
|
|
|
test('devrait gérer des URLs malformées', async () => {
|
|
const config = new EnvConfig();
|
|
|
|
const badUrls = [
|
|
'',
|
|
'not-a-url',
|
|
'http://',
|
|
'https://',
|
|
'ftp://invalid-protocol.com',
|
|
'http://[invalid-ipv6',
|
|
'http://toolong' + 'x'.repeat(2000) + '.com'
|
|
];
|
|
|
|
for (const badUrl of badUrls) {
|
|
try {
|
|
await config.generateAWSSignature('GET', badUrl);
|
|
} catch (error) {
|
|
// Ces erreurs sont attendues pour des URLs malformées
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Cas de Limites Système', () => {
|
|
test('devrait gérer un grand nombre de modules chargés', () => {
|
|
// Simuler 1000 modules chargés
|
|
global.ContentModules = {};
|
|
for (let i = 0; i < 1000; i++) {
|
|
global.ContentModules[`Module${i}`] = {
|
|
name: `Module ${i}`,
|
|
vocabulary: { [`word${i}`]: `translation${i}` }
|
|
};
|
|
}
|
|
|
|
const loader = new GameLoader();
|
|
|
|
// Vérifier que l'accès reste rapide
|
|
const startTime = Date.now();
|
|
const exists = loader.getContentModuleName('module500') in global.ContentModules;
|
|
const endTime = Date.now();
|
|
|
|
assert.ok(endTime - startTime < 100); // Moins de 100ms
|
|
});
|
|
|
|
test('devrait gérer l\'épuisement de la pile d\'appels', () => {
|
|
const scanner = new ContentScanner();
|
|
|
|
// Créer une chaîne d'appels très profonde
|
|
function deepRecursion(depth) {
|
|
if (depth > 10000) {
|
|
return scanner.toPascalCase('deep-recursion');
|
|
}
|
|
return deepRecursion(depth + 1);
|
|
}
|
|
|
|
try {
|
|
deepRecursion(0);
|
|
} catch (error) {
|
|
// Stack overflow est acceptable
|
|
assert.ok(error.message.includes('Maximum call stack') ||
|
|
error.message.includes('stack'));
|
|
}
|
|
});
|
|
});
|
|
}); |