245 lines
9.3 KiB
JavaScript
245 lines
9.3 KiB
JavaScript
/**
|
|
* Data Validator
|
|
* Verify data structure cho từng game code
|
|
*
|
|
* Usage:
|
|
* ```typescript
|
|
* import { validateGameData, DataValidator } from 'game-iframe-sdk/client';
|
|
*
|
|
* const result = validateGameData('G001', receivedData);
|
|
* if (!result.valid) {
|
|
* console.error('Invalid data:', result.errors);
|
|
* }
|
|
* ```
|
|
*/
|
|
import { GAME_CODES } from '../kit/GameDataHandler';
|
|
// =============================================================================
|
|
// SCHEMAS FOR EACH GAME CODE
|
|
// =============================================================================
|
|
const QUIZ_BASE_SCHEMA = {
|
|
id: { type: 'string', required: true, description: 'Unique question ID' },
|
|
options: { type: 'array', required: true, arrayItemType: 'string', description: 'Answer options' },
|
|
answer: { type: 'number', required: true, description: 'Correct answer index (0-based)' },
|
|
};
|
|
const SCHEMAS = {
|
|
// Quiz variants
|
|
G001: {
|
|
...QUIZ_BASE_SCHEMA,
|
|
question: { type: 'string', required: true, description: 'Text question' },
|
|
},
|
|
G002: {
|
|
...QUIZ_BASE_SCHEMA,
|
|
question_audio: { type: 'string', required: true, description: 'Audio URL for question' },
|
|
},
|
|
G003: {
|
|
...QUIZ_BASE_SCHEMA,
|
|
question: { type: 'string', required: true, description: 'Text question' },
|
|
// options are audio URLs
|
|
},
|
|
G004: {
|
|
...QUIZ_BASE_SCHEMA,
|
|
question_image: { type: 'string', required: true, description: 'Image URL for question' },
|
|
question: { type: 'string', required: false, description: 'Optional text hint' },
|
|
},
|
|
// G005: Quiz Text-Image (options are image URLs, client picks index)
|
|
G005: {
|
|
...QUIZ_BASE_SCHEMA,
|
|
question: { type: 'string', required: true, description: 'Text question' },
|
|
// options are image URLs, answer is index pointing to correct image
|
|
},
|
|
// Sequence Word variants
|
|
G110: {
|
|
id: { type: 'string', required: true },
|
|
word: { type: 'string', required: true, description: 'The word to arrange' },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string', description: 'Letters/parts to arrange' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string', description: 'Correct order' },
|
|
},
|
|
G111: {
|
|
id: { type: 'string', required: true },
|
|
word: { type: 'string', required: true },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true, description: 'Audio hint URL' },
|
|
},
|
|
G112: {
|
|
id: { type: 'string', required: true },
|
|
word: { type: 'string', required: true },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true },
|
|
},
|
|
G113: {
|
|
id: { type: 'string', required: true },
|
|
word: { type: 'string', required: true },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true },
|
|
},
|
|
// Sequence Sentence variants
|
|
G120: {
|
|
id: { type: 'string', required: true },
|
|
sentence: { type: 'string', required: false, description: 'Full sentence (hint)' },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string', description: 'Words to arrange' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string', description: 'Correct word order' },
|
|
},
|
|
G121: {
|
|
id: { type: 'string', required: true },
|
|
sentence: { type: 'string', required: false },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true },
|
|
},
|
|
G122: {
|
|
id: { type: 'string', required: true },
|
|
sentence: { type: 'string', required: false },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true },
|
|
},
|
|
G123: {
|
|
id: { type: 'string', required: true },
|
|
sentence: { type: 'string', required: false },
|
|
parts: { type: 'array', required: true, arrayItemType: 'string' },
|
|
answer: { type: 'array', required: true, arrayItemType: 'string' },
|
|
audio_url: { type: 'string', required: true },
|
|
}
|
|
};
|
|
// =============================================================================
|
|
// VALIDATOR
|
|
// =============================================================================
|
|
/**
|
|
* Validate a single item against schema
|
|
*/
|
|
function validateItem(item, schema, itemIndex) {
|
|
const errors = [];
|
|
if (!item || typeof item !== 'object') {
|
|
errors.push(`Item [${itemIndex}]: Must be an object`);
|
|
return errors;
|
|
}
|
|
for (const [field, fieldSchema] of Object.entries(schema)) {
|
|
const value = item[field];
|
|
// Check required
|
|
if (fieldSchema.required && (value === undefined || value === null)) {
|
|
errors.push(`Item [${itemIndex}].${field}: Required field is missing`);
|
|
continue;
|
|
}
|
|
// Skip validation if optional and not present
|
|
if (!fieldSchema.required && (value === undefined || value === null)) {
|
|
continue;
|
|
}
|
|
// Check type
|
|
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
if (fieldSchema.type !== 'any' && actualType !== fieldSchema.type) {
|
|
errors.push(`Item [${itemIndex}].${field}: Expected ${fieldSchema.type}, got ${actualType}`);
|
|
continue;
|
|
}
|
|
// Check array items
|
|
if (fieldSchema.type === 'array' && fieldSchema.arrayItemType && fieldSchema.arrayItemType !== 'any') {
|
|
for (let i = 0; i < value.length; i++) {
|
|
const itemType = typeof value[i];
|
|
if (itemType !== fieldSchema.arrayItemType) {
|
|
errors.push(`Item [${itemIndex}].${field}[${i}]: Expected ${fieldSchema.arrayItemType}, got ${itemType}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
/**
|
|
* Validate game data payload
|
|
*/
|
|
export function validateGameData(gameCode, payload) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
// Check game code
|
|
if (!GAME_CODES[gameCode]) {
|
|
errors.push(`Unknown game code: ${gameCode}`);
|
|
return { valid: false, errors, warnings };
|
|
}
|
|
// Check payload structure
|
|
if (!payload || typeof payload !== 'object') {
|
|
errors.push('Payload must be an object');
|
|
return { valid: false, errors, warnings };
|
|
}
|
|
// Check data array
|
|
const items = payload.data || payload.items || payload.questions;
|
|
if (!items) {
|
|
errors.push('Missing data array (expected "data", "items", or "questions")');
|
|
return { valid: false, errors, warnings };
|
|
}
|
|
if (!Array.isArray(items)) {
|
|
errors.push('"data" must be an array');
|
|
return { valid: false, errors, warnings };
|
|
}
|
|
if (items.length === 0) {
|
|
warnings.push('Data array is empty');
|
|
}
|
|
// Validate each item
|
|
const schema = SCHEMAS[gameCode];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const itemErrors = validateItem(items[i], schema, i);
|
|
errors.push(...itemErrors);
|
|
}
|
|
// Check for duplicate IDs
|
|
const ids = items.map((item) => item.id).filter(Boolean);
|
|
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
if (duplicates.length > 0) {
|
|
warnings.push(`Duplicate IDs found: ${[...new Set(duplicates)].join(', ')}`);
|
|
}
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
/**
|
|
* Get schema for a game code
|
|
*/
|
|
export function getSchema(gameCode) {
|
|
return SCHEMAS[gameCode] ?? null;
|
|
}
|
|
/**
|
|
* Get schema documentation for a game code
|
|
*/
|
|
export function getSchemaDoc(gameCode) {
|
|
const schema = SCHEMAS[gameCode];
|
|
if (!schema)
|
|
return `Unknown game code: ${gameCode}`;
|
|
const gameInfo = GAME_CODES[gameCode];
|
|
const lines = [
|
|
`## ${gameCode}: ${gameInfo.name}`,
|
|
`Category: ${gameInfo.category}`,
|
|
'',
|
|
'### Fields:',
|
|
];
|
|
for (const [field, fieldSchema] of Object.entries(schema)) {
|
|
const required = fieldSchema.required ? '(required)' : '(optional)';
|
|
let type = fieldSchema.type;
|
|
if (fieldSchema.arrayItemType) {
|
|
type = `${fieldSchema.type}<${fieldSchema.arrayItemType}>`;
|
|
}
|
|
lines.push(`- **${field}**: ${type} ${required}`);
|
|
if (fieldSchema.description) {
|
|
lines.push(` - ${fieldSchema.description}`);
|
|
}
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
// =============================================================================
|
|
// DATA VALIDATOR CLASS
|
|
// =============================================================================
|
|
export class DataValidator {
|
|
constructor(gameCode) {
|
|
this.gameCode = gameCode;
|
|
}
|
|
validate(payload) {
|
|
return validateGameData(this.gameCode, payload);
|
|
}
|
|
getSchema() {
|
|
return getSchema(this.gameCode);
|
|
}
|
|
getSchemaDoc() {
|
|
return getSchemaDoc(this.gameCode);
|
|
}
|
|
}
|
|
//# sourceMappingURL=DataValidator.js.map
|