Backend CI / type-check, build, tests (push) Successful in 1m16s
Server grades levels 7-13 and keeps castle attempt results separate from stored progress. Co-authored-by: Cursor <cursoragent@cursor.com>
325 lines
12 KiB
TypeScript
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, 7)
|
|
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)
|
|
})
|