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>
200 lines
8.8 KiB
TypeScript
200 lines
8.8 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|