feat: complete the thirteen-stage financial learning path
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>
This commit is contained in:
2026-10-22 19:41:24 +03:30
co-authored by Cursor
parent dfb20ca92c
commit 866be22395
52 changed files with 3840 additions and 30 deletions
+233
View File
@@ -0,0 +1,233 @@
/**
* Budgeting mission — temporary SQLite only.
*/
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 { BudgetingGradingError, gradeBudgetSubmission } from '../src/gold-land/budgeting/budgeting.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import { whatIsMoneyLesson } from '../src/gold-land/lessons/what-is-money.lesson'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'budget-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-budget-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function plan(transport: number, food: number, savings: number, fun: number) {
return { transport, food, savings, fun }
}
function answers(initial: ReturnType<typeof plan>, finalPlan: ReturnType<typeof plan>) {
return [
{ questionId: 'initial', allocations: initial },
{ questionId: 'final', allocations: finalPlan },
]
}
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
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const perfectA = gradeBudgetSubmission(answers(plan(20, 25, 20, 35), plan(30, 25, 20, 25)))
const perfectB = gradeBudgetSubmission(answers(plan(25, 30, 25, 0), plan(35, 30, 20, 0)))
assert.equal(perfectA.score, 100)
assert.equal(perfectB.score, 100)
const shortSavings = gradeBudgetSubmission(answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)))
assert.equal(shortSavings.score, 80)
assert.equal(shortSavings.feedback.plans[0]?.criteria.find((c) => c.id === 'savings-goal')?.earned, false)
assert.equal(shortSavings.feedback.plans[1]?.criteria.find((c) => c.id === 'transport')?.earned, true)
assert.throws(
() => gradeBudgetSubmission(answers(plan(20, 25, 20, 0), plan(20, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs' && error.statusCode === 422,
)
assert.throws(
() => gradeBudgetSubmission(answers(plan(0, 0, 20, 0), plan(30, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs',
)
for (const bad of [
answers(plan(-5, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(20.5 as unknown as number, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(12, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(50, 50, 25, 25), plan(30, 25, 20, 0)),
]) {
assert.throws(() => gradeBudgetSubmission(bad))
}
assert.throws(() =>
gradeBudgetSubmission([
{ questionId: 'initial', allocations: { ...plan(20, 25, 20, 0), extra: 5 } as never },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
]),
)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
new Date().toISOString(),
)
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 70, 1, 1, ?, ?, ?)
`)
for (let id = 1; id <= 5; id += 1) insert.run(USER, id, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'locked')
assert.throws(() => progression.startLevel(USER, 7))
insert.run(USER, 6, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'locked')
const lessonAnswers = getGradableQuestions(whatIsMoneyLesson).map(({ questionId, step }) =>
step.type === 'sort'
? { questionId, orderedItemIds: [...step.correctOrder] }
: { questionId, answerId: step.correctAnswerId },
)
const lesson = progression.completeLevel(USER, 1, { answers: lessonAnswers })
assert.equal(lesson.score, 100)
assert.equal(lesson.feedback, undefined)
const first = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(first.score, 80)
assert.equal(first.stars, 2)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.reward.xp, 45 + 10)
assert.equal(first.reward.coins, 18 + 6)
const same = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(same.isFirstCompletion, false)
assert.equal(same.reward.xp, 0)
assert.equal(same.reward.coins, 0)
const improved = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
})
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 5)
assert.equal(improved.reward.coins, 3)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'locked')
const reg = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username: `budget_http_${Date.now()}`, password: 'pass1234', name: 'Budget' },
})
assert.equal(reg.statusCode, 200)
const token = JSON.parse(reg.payload) as { token: string }
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${token.token}` },
payload: {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0, bonus: 1 } },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
],
},
})
assert.equal(bad.statusCode, 400)
const badBody = JSON.parse(bad.payload) as { error: string }
assert.match(badBody.error, /property bonus should not exist|نباید/)
const parallelId = 'budget-parallel-user'
db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM users WHERE id = ?').run(parallelId)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 6; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const parallelToken = app.get(JwtService).sign({ userId: parallelId })
const parallelPayload = {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
}
const [firstRace, secondRace] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
])
assert.equal(firstRace.statusCode, 200)
assert.equal(secondRace.statusCode, 200)
const raceBodies = [firstRace, secondRace].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number }; stars: number },
)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const ledger = db.prepare(
'SELECT reward_key, xp_delta, coins_delta FROM reward_ledger WHERE user_id = ? ORDER BY reward_key',
).all(parallelId) as Array<{ reward_key: string; xp_delta: number; coins_delta: number }>
assert.deepEqual(ledger.map((row) => row.reward_key), [
'level:7:first-completion',
'level:7:star:1',
'level:7:star:2',
'level:7:star:3',
])
const me = await app.inject({
method: 'GET',
url: '/api/me',
headers: { authorization: `Bearer ${parallelToken}` },
})
const meBody = JSON.parse(me.payload) as { user: { coins: number } }
assert.equal(meBody.user.coins, 1350 + 18 + 9)
console.log('budgeting tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// Windows may keep the sqlite handle briefly.
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+141
View File
@@ -0,0 +1,141 @@
/**
* Castle exam — temporary SQLite only.
*/
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 { gradeCastleExam } from '../src/gold-land/castle-exam/castle.grader'
import { castleExamPublic, getCastleQuestionBank } from '../src/gold-land/castle-exam/castle.mission'
import { GOLD_LAND_LEVELS } from '../src/gold-land/levels/levels'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'castle-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-castle-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '4001'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctIds = ['exchange', 'regular', 'water', 'moves', 'no', 'less', 'valid', 'keep', 'no', '121', 'b', '6']
function answersFor(count: number) {
return getCastleQuestionBank().map((question, index) => ({
questionId: question.id,
answerId: index < count ? question.correctOptionId : question.options.find((option) => option.id !== question.correctOptionId)!.id,
}))
}
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
}
async function run() {
const scores = Array.from({ length: 13 }, (_, count) => gradeCastleExam(answersFor(count)).score)
assert.deepEqual(scores, [0, 8, 17, 25, 33, 42, 50, 58, 67, 75, 83, 92, 100])
assert.equal(gradeCastleExam(answersFor(7)).feedback.completed, false)
assert.equal(gradeCastleExam(answersFor(8)).feedback.completed, true)
assert.equal(JSON.stringify(castleExamPublic).includes('correctOptionId'), false)
assert.equal(JSON.stringify(castleExamPublic).includes('explanation'), false)
assert.deepEqual(castleExamPublic.questions.map((question) => question.sourceLevelId), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
assert.throws(() => gradeCastleExam(answersFor(12).slice(0, 11)))
assert.throws(() => gradeCastleExam([...answersFor(12), answersFor(1)[0]!]))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer, index) => (index === 0 ? { ...answer, answerId: 'nope' } : answer))))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer) => ({ ...answer, score: 100 }))))
assert.deepEqual(correctIds, getCastleQuestionBank().map((question) => question.correctOptionId))
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 100, 3, 1, ?, ?, ?)
`)
for (let id = 1; id <= 11; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 13))
insert.run(USER, 12, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
const coinsOf = () => JSON.parse((db.prepare('SELECT state_json FROM users WHERE id = ?').get(USER) as { state_json: string }).state_json).coins as number
const beforeCoins = coinsOf()
const failed = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(failed.score, 58)
assert.equal(failed.stars, 0)
assert.equal(failed.reward.coins, 0)
assert.equal(failed.unlockedLevelId, null)
assert.equal(failed.feedback && 'completed' in failed.feedback && failed.feedback.completed, false)
const row = db.prepare('SELECT completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { completed_at: string | null } | undefined
assert.equal(row?.completed_at ?? null, null)
assert.equal(coinsOf(), beforeCoins)
const first = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 100 + 5)
assert.equal(first.reward.coins, 50 + 3)
assert.equal(first.unlockedLevelId, null)
const saved = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
const afterPass = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(afterPass.score, 58)
assert.equal(afterPass.reward.coins, 0)
assert.equal(afterPass.feedback && 'completed' in afterPass.feedback && afterPass.feedback.completed, false)
assert.equal(afterPass.previousStars, 1)
const still = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
assert.equal(still.best_score, saved.best_score)
assert.equal(still.stars, saved.stars)
assert.equal(still.completed_at, saved.completed_at)
const replay = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 13, { answers: answersFor(12) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(improved.unlockedLevelId, null)
assert.equal(GOLD_LAND_LEVELS.length, 13)
assert.equal(progression.buildLevelItems(USER).some((level) => level.id === 14), false)
const parallelId = 'castle-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 12; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: answersFor(11) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number }; unlockedLevelId: number | null })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 50 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 100 + 15)
assert.equal(bodies.every((item) => item.unlockedLevelId === null), true)
console.log('castle-exam tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+172
View File
@@ -0,0 +1,172 @@
/**
* Compound-interest lesson — temporary SQLite only.
*/
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 { buildGrowthExample } from '../src/gold-land/lessons/growth-example'
import { compoundInterestLesson } from '../src/gold-land/lessons/compound-interest.lesson'
import { gradeLessonSubmission } from '../src/gold-land/lessons/lesson.grader'
import { sanitizeLesson } from '../src/gold-land/lessons/lesson.sanitizer'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'compound-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-compound-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3997'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'compound-q1', answerId: 'on-balance' },
{ questionId: 'compound-q2', answerId: '121' },
{ questionId: 'compound-q3', answerId: 'from-110' },
{ questionId: 'compound-q4', answerId: 'compound' },
{ questionId: 'compound-q5', answerId: 'false' },
]
const wrong = ['initial-only', '120', 'from-100', 'simple', 'true']
function withCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrong[index] ?? 'true' },
)
}
function ends(methodId: 'simple' | 'compound') {
return buildGrowthExample().periods.map((period) => period.rows.find((row) => row.methodId === methodId)?.endTenths)
}
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
}
async function run() {
assert.deepEqual(ends('simple'), [1000, 1100, 1200, 1300])
assert.deepEqual(ends('compound'), [1000, 1100, 1210, 1331])
const period2 = buildGrowthExample().periods[2]
const compound2 = period2?.rows.find((row) => row.methodId === 'compound')
assert.equal(compound2?.startTenths, 1100)
assert.equal(compound2?.growthTenths, 110)
assert.equal(compound2?.endTenths, 1210)
const before = JSON.stringify(buildGrowthExample().periods)
const viewed = buildGrowthExample().periods.slice(0, 2)
assert.equal(viewed.length, 2)
assert.equal(JSON.stringify(buildGrowthExample().periods), before)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(0)).score, 0)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(2)).score, 40)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(3)).score, 60)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(4)).score, 80)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(5)).score, 100)
const perfect = gradeLessonSubmission(compoundInterestLesson, correct)
assert.equal(perfect.feedback?.questions.length, 5)
assert.equal(perfect.feedback?.questions.every((item) => item.earned), true)
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, correct.slice(0, 4)))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [...correct, correct[0]!]))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [{ questionId: 'missing', answerId: '121' }]))
assert.throws(() =>
gradeLessonSubmission(compoundInterestLesson, correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
const publicLesson = JSON.stringify(sanitizeLesson(compoundInterestLesson))
assert.equal(publicLesson.includes('correctAnswerId'), false)
assert.equal(publicLesson.includes('explanation'), false)
assert.equal(publicLesson.includes('1331'), true)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 8; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 10))
insert.run(USER, 9, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 10, { answers: withCount(2) }))
const first = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 10, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
const parallelId = 'compound-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 9; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
console.log('compound-interest tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -266,7 +266,7 @@ async function runTests() {
assert.equal(body.user.xp, xpBefore + 50)
}
// --- coming_soon level cannot start ---
// Level 7 stays locked until level 6 has a star. Inactive level 8 is covered in budgeting tests.
{
const token = await registerUser(app, `locked7-${Date.now()}`)
const res = await app.inject({
+149
View File
@@ -0,0 +1,149 @@
/**
* Financial goal — temporary SQLite only.
*/
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 { initialPlan, revisedPlan, INITIAL_RATES, EXCEPTION_RATES, CONTINUE_RATES } from '../src/gold-land/financial-goal/goal.math'
import { gradeFinancialGoalSubmission } from '../src/gold-land/financial-goal/goal.grader'
import { financialGoalMission } from '../src/gold-land/financial-goal/goal.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'goal-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-goal-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3999'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-goal', answerId: 'specific' },
{ questionId: 'q-left', answerId: '90' },
{ questionId: 'q-weeks', answerId: '6' },
{ questionId: 'q-need', answerId: 'adjust' },
{ questionId: 'q-change', answerId: 'update' },
]
const wrong = ['vague', '120', '5', 'drop', 'keep']
function submission(plan: { initialWeekly: number; exceptionWeekly: number; continueWeekly: number }, count: number) {
return [
{ questionId: 'plan', plan },
...correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! })),
]
}
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
}
async function run() {
assert.deepEqual(INITIAL_RATES.map((rate) => initialPlan(rate).weeks), [18, 9, 6, 5])
const sample = revisedPlan(15, 5, 15)
assert.equal(sample.balanceAfterTwo, 50)
assert.equal(sample.remainingWeeks, 5)
assert.equal(sample.totalWeeks, 7)
assert.equal(sample.finalBalance, 125)
const paused = revisedPlan(15, 0, 15)
assert.equal(paused.balanceAfterTwo, 45)
assert.equal(paused.totalWeeks, 7)
const totals: number[] = []
for (const initial of INITIAL_RATES) {
for (const exception of EXCEPTION_RATES) {
for (const cont of CONTINUE_RATES) {
const result = revisedPlan(initial, exception, cont)
assert.equal(result.totalWeeks, 2 + result.remainingWeeks)
assert.equal(result.finalBalance, result.balanceAfterTwo + result.remainingWeeks * cont)
assert.ok(result.finalBalance >= 120)
totals.push(result.totalWeeks)
}
}
}
assert.equal(Math.min(...totals), 5)
const slow = { initialWeekly: 5, exceptionWeekly: 0, continueWeekly: 5 }
const fast = { initialWeekly: 20, exceptionWeekly: 10, continueWeekly: 20 }
assert.equal(gradeFinancialGoalSubmission(submission(slow, 0)).score, 0)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 2)).score, 40)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 3)).score, 60)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 4)).score, 80)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 5)).score, 100)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 5)).score, 100)
assert.throws(() => gradeFinancialGoalSubmission(submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 0 }, 5)))
assert.throws(() => gradeFinancialGoalSubmission([...submission(fast, 5), correct[0]!]))
assert.throws(() => gradeFinancialGoalSubmission(submission(fast, 5).map((answer) => ({ ...answer, weeks: 7 }))))
assert.equal(JSON.stringify(financialGoalMission).includes('correctOptionId'), false)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 10; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 12))
insert.run(USER, 11, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 12, { answers: submission(fast, 2) }))
const first = progression.completeLevel(USER, 12, { answers: submission(slow, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 12, { answers: submission(fast, 3) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 12, { answers: submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 }, 5) })
assert.equal(improved.feedback?.timeline.totalWeeks, 7)
assert.equal(improved.feedback?.timeline.finalBalance, 125)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).length, 13)
const parallelId = 'goal-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 11; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: submission(fast, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
console.log('financial-goal tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -428,7 +428,7 @@ async function runTests() {
assert.equal(levelsMeta.statusCode, 200)
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
assert.equal(levelsBody.levels.length, GOLD_LAND_LEVELS.length)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 6)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 13)
const noAuthProgress = await app.inject({ method: 'GET', url: '/api/gold-land/progress' })
assert.equal(noAuthProgress.statusCode, 401)
+199
View File
@@ -0,0 +1,199 @@
/**
* Portfolio game — temporary SQLite only.
*/
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 { gradePortfolioSubmission } from '../src/gold-land/portfolio/portfolio.grader'
import { simulatePortfolio } from '../src/gold-land/portfolio/portfolio.math'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'portfolio-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-portfolio-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctQuestions = [
{ questionId: 'q-one', answerId: 'depend' },
{ questionId: 'q-loss', answerId: 'no' },
{ questionId: 'q-name', answerId: 'no' },
{ questionId: 'q-best', answerId: 'no' },
{ questionId: 'q-approach', answerId: 'balanced' },
]
const wrongIds = ['safe', 'yes', 'yes', 'yes', 'count']
function mix(holding: { sun: number; rain: number; wind: number }, count: number) {
return [
{ questionId: 'portfolio', holdings: holding },
...correctQuestions.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'safe' },
),
]
}
function scenario(holding: { sun: number; rain: number; wind: number }, id: string) {
const found = simulatePortfolio(holding).find((item) => item.id === id)
assert.ok(found)
return found
}
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
}
async function run() {
const concentrated = { sun: 100, rain: 0, wind: 0 }
const split = { sun: 40, rain: 30, wind: 30 }
const other = { sun: 20, rain: 50, wind: 30 }
assert.deepEqual(
[scenario(concentrated, 'sunny').total, scenario(concentrated, 'rainy').total, scenario(concentrated, 'drop').total],
[120, 80, 90],
)
assert.equal(scenario(concentrated, 'sunny').changeLabel, 'سود 20 سکه')
assert.equal(scenario(concentrated, 'rainy').changeLabel, 'زیان 20 سکه')
assert.deepEqual(
[scenario(split, 'sunny').total, scenario(split, 'rainy').total, scenario(split, 'drop').total],
[105, 95, 90],
)
assert.equal(scenario(split, 'sunny').changeLabel, 'سود 5 سکه')
assert.equal(scenario(split, 'rainy').changeLabel, 'زیان 5 سکه')
assert.deepEqual(
[scenario(other, 'sunny').total, scenario(other, 'rainy').total, scenario(other, 'drop').total],
[99, 101, 90],
)
for (const holding of [concentrated, split, other]) {
const drop = scenario(holding, 'drop')
assert.equal(drop.total, 90)
assert.equal(drop.changeLabel, 'زیان 10 سکه')
}
assert.equal(gradePortfolioSubmission(mix(concentrated, 0)).score, 0)
assert.equal(gradePortfolioSubmission(mix(split, 2)).score, 40)
assert.equal(gradePortfolioSubmission(mix(other, 3)).score, 60)
assert.equal(gradePortfolioSubmission(mix(concentrated, 4)).score, 80)
assert.equal(gradePortfolioSubmission(mix(split, 5)).score, 100)
assert.equal(gradePortfolioSubmission(mix(concentrated, 5)).score, 100)
assert.throws(() => gradePortfolioSubmission(mix({ sun: 90, rain: 0, wind: 0 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: 12, rain: 44, wind: 44 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: -10, rain: 60, wind: 50 }, 5)))
assert.throws(() =>
gradePortfolioSubmission([{ questionId: 'portfolio', holdings: { sun: 100, rain: 0, wind: 0, gold: 0 } as never }, ...correctQuestions]),
)
assert.throws(() => gradePortfolioSubmission([...mix(split, 5), correctQuestions[0]!]))
assert.throws(() =>
gradePortfolioSubmission(mix(split, 5).map((answer, index) => (index === 1 ? { ...answer, answerId: 'nope' } : answer))),
)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 7; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 9))
insert.run(USER, 8, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const detail = progression.getGoldLandLevel(USER, 9)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('آفتاب'), true)
assert.throws(() => progression.completeLevel(USER, 9, { answers: mix(split, 2) }))
const first = progression.completeLevel(USER, 9, { answers: mix(concentrated, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 9, { answers: mix(split, 3) })
assert.equal(replay.score, 60)
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 9, { answers: mix(other, 5) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
const parallelId = 'portfolio-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 8; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: mix(split, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:9:first-completion',
'level:9:star:1',
'level:9:star:2',
'level:9:star:3',
])
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/9/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'portfolio', holdings: { sun: 50, rain: 50 } }] },
})
assert.equal(bad.statusCode, 400)
console.log('portfolio tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -139,7 +139,7 @@ async function runTests() {
let items = progression.buildLevelItems(TEST_USER)
const playable = getAllPlayableLessons()
assert.equal(playable.length, 6)
assert.equal(playable.length, 7)
for (const lesson of playable) {
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Risk-return challenge — temporary SQLite only.
*/
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 { gradeRiskReturnSubmission } from '../src/gold-land/risk-return/risk-return.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'risk-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-risk-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-loss', answerId: 'b' },
{ questionId: 'q-certain', answerId: 'no' },
{ questionId: 'q-need', answerId: 'keep' },
{ questionId: 'q-compare', answerId: 'range' },
{ questionId: 'q-promise', answerId: 'check' },
]
const wrongIds = ['a', 'yes', 'a', 'certain-gain', 'trust']
function withCorrectCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'a' },
)
}
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
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
assert.equal(gradeRiskReturnSubmission(withCorrectCount(0)).score, 0)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(2)).score, 40)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(3)).score, 60)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(4)).score, 80)
const perfect = gradeRiskReturnSubmission(correct)
const perfectAgain = gradeRiskReturnSubmission([...correct].reverse())
assert.equal(perfect.score, 100)
assert.equal(perfectAgain.score, 100)
assert.equal(perfect.feedback.summary.includes('تضمین سود نیست'), true)
assert.throws(() => gradeRiskReturnSubmission([...correct, correct[0]!]))
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, questionId: 'nope' } : answer))),
)
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 6; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 8))
insert.run(USER, 7, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
const detail = progression.getGoldLandLevel(USER, 8)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('[20,20,22,22]') || payload.includes('20,20,22,22') || payload.includes('22'), true)
assert.equal(payload.includes('8,16,28,36') || payload.includes('[8,16,28,36]'), true)
assert.throws(() => progression.completeLevel(USER, 8, { answers: withCorrectCount(2) }))
const first = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 8, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const parallelId = 'risk-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 7; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } },
)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:8:first-completion',
'level:8:star:1',
'level:8:star:2',
'level:8:star:3',
])
console.log('risk-return tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+138
View File
@@ -0,0 +1,138 @@
/**
* Smart shop — temporary SQLite only.
*/
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 { finalCost } from '../src/gold-land/smart-shop/shop.math'
import { gradeSmartShopSubmission } from '../src/gold-land/smart-shop/shop.grader'
import { smartShopMission } from '../src/gold-land/smart-shop/shop.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'shop-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-shop-'))
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'
const correct = [
{ questionId: 'notebook-choice', answerId: 'b' },
{ questionId: 'notebook-why', answerId: 'fits-and-cheaper' },
{ questionId: 'discount-choice', answerId: 'b' },
{ questionId: 'discount-why', answerId: 'pay-amount' },
{ questionId: 'info-choice', answerId: 'ask' },
{ questionId: 'info-why', answerId: 'incomplete' },
]
const wrong = ['a', 'cheapest-tag', 'a', 'bigger-discount', 'buy-now', 'hurry']
function withCount(count: number) {
return correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! }))
}
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
}
async function run() {
assert.equal(finalCost({ price: 40, delivery: 5 }), 45)
assert.equal(finalCost({ price: 38, delivery: 12 }), 50)
assert.equal(finalCost({ price: 30, delivery: 0 }), 30)
assert.equal(finalCost({ price: 28, delivery: 0 }), 28)
assert.equal(finalCost({ price: 45, delivery: null }), null)
assert.notEqual(finalCost({ price: 45, delivery: null }), 0)
const scores = [0, 1, 2, 3, 4, 5, 6].map((count) => gradeSmartShopSubmission(withCount(count)).score)
assert.deepEqual(scores, [0, 17, 33, 50, 67, 83, 100])
assert.throws(() => gradeSmartShopSubmission(correct.slice(0, 5)))
assert.throws(() => gradeSmartShopSubmission([...correct, correct[0]!]))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, answerId: 'ask' } : answer))))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer) => ({ ...answer, price: 1 }))))
const payload = JSON.stringify(smartShopMission)
assert.equal(payload.includes('correctAnswerId'), false)
assert.equal(payload.includes('"explanation"'), false)
const info = smartShopMission.missions[2]?.cards[0]
assert.equal(info?.delivery, null)
assert.equal(info?.finalCost, null)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 9; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 11))
insert.run(USER, 10, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 11, { answers: withCount(3) }))
const first = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 11, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
const parallelId = 'shop-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 10; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/11/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'notebook-choice', answerId: 'b', score: 100 }] },
})
assert.equal(bad.statusCode, 400)
console.log('smart-shop tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})