Files

325 lines
12 KiB
TypeScript

/**
* Gold Land progression tests — always uses a temporary SQLite file.
* Run: npm run test:progression
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { LessonDefinition, LessonSubmissionAnswer } from '../src/gold-land/lessons/lesson.types'
import { ProgressionError } from '../src/gold-land/exceptions/progression.exception'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import { RewardService } from '../src/gold-land/services/reward.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const TEST_USER = 'progression-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-nest-progression-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3998'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
)
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
function resetTestUser(dbService: DatabaseService) {
const db = dbService.getDb()
db.prepare('DELETE FROM users WHERE id = ?').run(TEST_USER)
db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(TEST_USER)
db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(TEST_USER)
const state = createDefaultUserState(TEST_USER)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
TEST_USER,
JSON.stringify(state),
new Date().toISOString(),
)
}
function grantStars(dbService: DatabaseService, levelId: number, stars: number, score: number) {
const now = new Date().toISOString()
dbService.getDb().prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(user_id, level_id) DO UPDATE SET
best_score = excluded.best_score,
stars = excluded.stars,
completed_at = excluded.completed_at,
updated_at = excluded.updated_at
`).run(TEST_USER, levelId, score, stars, now, now, now)
}
function buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
return getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function buildPartialCorrect(lesson: LessonDefinition, correctCount: number): LessonSubmissionAnswer[] {
const gradable = getGradableQuestions(lesson)
return gradable.map(({ questionId, step }, index) => {
const isCorrect = index < correctCount
if (step.type === 'sort') {
const order = isCorrect ? [...step.correctOrder] : [...step.correctOrder].reverse()
return { questionId, orderedItemIds: order }
}
if (!isCorrect) {
const wrong =
step.type === 'true_false'
? step.correctAnswerId === 'true'
? 'false'
: 'true'
: step.options.find((o) => o.id !== step.correctAnswerId)?.id ?? 'wrong'
return { questionId, answerId: wrong }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function expectError(fn: () => unknown, statusCode: number) {
try {
fn()
assert.fail('Expected error')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, statusCode)
}
}
async function runTests() {
const app = await createApp()
const dbService = (await import('../src/database/database.service')).DatabaseService
const database = app.get(dbService)
const progression = app.get(GoldLandProgressionService)
const rewards = app.get(RewardService)
const { getAllPlayableLessons, countGradableQuestions } = await import(
'../src/gold-land/lessons/lesson.registry'
)
const { loadLessonForLevel } = await import('../src/gold-land/lessons/lesson.loader')
const { sanitizeLesson } = await import('../src/gold-land/lessons/lesson.sanitizer')
const { gradeLessonSubmission, LessonGradingError } = await import(
'../src/gold-land/lessons/lesson.grader'
)
const { inflationLesson } = await import('../src/gold-land/lessons/inflation.lesson')
const { needsAndWantsLesson } = await import('../src/gold-land/lessons/needs-and-wants.lesson')
const { isLevelUnlocked, starsFromScore } = await import(
'../src/gold-land/services/progression.service'
)
try {
resetTestUser(database)
let items = progression.buildLevelItems(TEST_USER)
const playable = getAllPlayableLessons()
assert.equal(playable.length, 6)
for (const lesson of playable) {
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
}
assert.equal(isLevelUnlocked(1, new Map()), true)
assert.notEqual(items.find((l) => l.id === 1)?.status, 'locked')
assert.equal(items.find((l) => l.id === 2)?.status, 'locked')
assert.equal(items.find((l) => l.id === 6)?.status, 'locked')
expectError(() => progression.startLevel(TEST_USER, 6), 403)
expectError(
() => progression.completeLevel(TEST_USER, 6, { answers: buildAllCorrect(inflationLesson) }),
403,
)
for (let id = 1; id <= 5; id++) {
grantStars(database, id, 1, 65)
}
items = progression.buildLevelItems(TEST_USER)
assert.equal(items.find((l) => l.id === 6)?.status, 'available')
const ALL_CORRECT_INFLATION = buildAllCorrect(inflationLesson)
const THREE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 3)
const FOUR_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 4)
const ONE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 1)
try {
progression.completeLevel(TEST_USER, 6, { answers: [{ questionId: 'unknown-q', answerId: 'option-b' }] })
assert.fail('expected invalid question')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
try {
progression.completeLevel(TEST_USER, 6, {
answers: [{ questionId: 'inflation-q1', answerId: 'invalid-option' }],
})
assert.fail('expected invalid answer')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(inflationLesson, [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q1', answerId: 'option-a' },
])
assert.fail('expected duplicate questionId')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(needsAndWantsLesson, [
{
questionId: 'needs-q3',
orderedItemIds: ['water', 'food', 'unknown', 'shoes', 'toy', 'crown'],
},
])
assert.fail('expected unknown sort item')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(needsAndWantsLesson, [
{
questionId: 'needs-q3',
orderedItemIds: ['water', 'water', 'medicine', 'shoes', 'toy', 'crown'],
},
])
assert.fail('expected duplicate sort item')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
assert.equal(gradeLessonSubmission(inflationLesson, THREE_CORRECT_INFLATION).score, 60)
assert.equal(gradeLessonSubmission(inflationLesson, FOUR_CORRECT_INFLATION).score, 80)
assert.equal(gradeLessonSubmission(inflationLesson, ALL_CORRECT_INFLATION).score, 100)
assert.equal(starsFromScore(59), 0)
assert.equal(starsFromScore(60), 1)
assert.equal(starsFromScore(74), 1)
assert.equal(starsFromScore(75), 2)
assert.equal(starsFromScore(89), 2)
assert.equal(starsFromScore(90), 3)
try {
progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION })
assert.fail('expected fail below 60')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
progression.startLevel(TEST_USER, 6)
const first = progression.completeLevel(TEST_USER, 6, {
answers: THREE_CORRECT_INFLATION,
durationMs: 84000,
})
assert.equal(first.stars, 1)
assert.equal(first.score, 60)
assert.ok(first.isFirstCompletion)
assert.ok(first.bestScoreImproved)
assert.ok(rewards.hasReward(TEST_USER, 'level:6:first-completion'))
const duplicate = progression.completeLevel(TEST_USER, 6, { answers: THREE_CORRECT_INFLATION })
assert.equal(duplicate.isFirstCompletion, false)
assert.equal(duplicate.reward.xp, 0)
assert.equal(duplicate.reward.coins, 0)
try {
progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION })
assert.fail('expected low score rejection')
} catch (error) {
assert.ok(error instanceof ProgressionError)
}
assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 6)!.stars, 1)
const improved = progression.completeLevel(TEST_USER, 6, { answers: ALL_CORRECT_INFLATION })
assert.equal(improved.stars, 3)
assert.ok(improved.bestScoreImproved)
assert.ok(improved.reward.xp > 0)
assert.ok(rewards.hasReward(TEST_USER, 'level:6:star:3'))
const replayLower = progression.completeLevel(TEST_USER, 6, { answers: FOUR_CORRECT_INFLATION })
assert.equal(replayLower.stars, 3)
assert.equal(replayLower.reward.xp, 0)
const detail = progression.getGoldLandLevel(TEST_USER, 6)
const json = JSON.stringify(detail)
assert.ok(!json.includes('correctAnswerId'))
assert.ok(!json.includes('correctOrder'))
assert.ok(!json.includes('answerKey'))
const sanitized = sanitizeLesson(loadLessonForLevel('inflation')!)
for (const step of sanitized.steps) {
assert.ok(!('correctAnswerId' in step))
assert.ok(!('correctOrder' in step))
}
const moneyLesson = loadLessonForLevel('what-is-money')!
const moneyAnswers = buildAllCorrect(moneyLesson)
try {
progression.completeLevel(TEST_USER, 6, { answers: moneyAnswers })
assert.fail('expected wrong lesson answers for level 6')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
resetTestUser(database)
progression.startLevel(TEST_USER, 1)
const level1Answers = buildAllCorrect(loadLessonForLevel('what-is-money')!)
const level1Result = progression.completeLevel(TEST_USER, 1, { answers: level1Answers })
assert.equal(level1Result.stars, 3)
assert.equal(level1Result.unlockedLevelId, 2)
assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 2)!.status, 'available')
const level1Again = progression.completeLevel(TEST_USER, 1, { answers: level1Answers })
assert.equal(level1Again.isFirstCompletion, false)
assert.equal(level1Again.reward.xp, 0)
console.log('progression tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})