feat: complete the thirteen-stage learning experience and E2E journey
Frontend CI / unit (test, type-check, build) (push) Successful in 7m21s
Frontend CI / browser E2E (frontend + pinned backend) (push) Successful in 11m38s

Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-22 19:42:04 +03:30
co-authored by Cursor
parent bb29cc6bdb
commit c53a67831d
38 changed files with 3764 additions and 8 deletions
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
adjustBudget,
budgetReserve,
canDecrease,
canIncrease,
budgetSubmission,
cloneBudget,
emptyBudget,
missingEssentials,
} from '../src/modules/gold-land/budgeting/budgetMath'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
const categories = [
{ id: 'transport' as const, label: 'رفت‌وآمد', requiredInitial: 20, requiredFinal: 30 },
{ id: 'food' as const, label: 'آب و غذا', requiredInitial: 25, requiredFinal: 25 },
{ id: 'savings' as const, label: 'پس‌انداز', requiredInitial: null, requiredFinal: null },
{ id: 'fun' as const, label: 'تفریح', requiredInitial: null, requiredFinal: null },
]
test('reserve and button limits stay inside 100 coins', () => {
let amounts = emptyBudget()
assert.equal(budgetReserve(100, amounts), 100)
assert.equal(canDecrease(amounts, 'fun', 5), false)
for (let i = 0; i < 20; i += 1) {
amounts = adjustBudget(amounts, 'fun', 5, 5, 100)
}
assert.equal(budgetReserve(100, amounts), 0)
assert.equal(canIncrease(amounts, 5, 100), false)
const unchanged = adjustBudget(amounts, 'food', 5, 5, 100)
assert.equal(unchanged.food, 0)
assert.equal(unchanged.fun, 100)
})
test('editing the final budget does not change the submitted initial allocations', () => {
const initial = { transport: 20, food: 25, savings: 20, fun: 0 }
const finalPlan = cloneBudget(initial)
const payload = budgetSubmission(initial, finalPlan)
finalPlan.transport += 10
finalPlan.fun = 15
assert.deepEqual(payload[0]?.allocations, { transport: 20, food: 25, savings: 20, fun: 0 })
assert.equal(payload[1]?.allocations.transport, 20)
assert.equal(missingEssentials(initial, categories, 'initial').length, 0)
assert.equal(missingEssentials(finalPlan, categories, 'final').some((item) => item.id === 'food'), false)
})
test('network and late session responses do not apply a new account', async () => {
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(
() => runMeMutation(async () => {
throw new ApiNetworkError()
}),
ApiNetworkError,
)
assert.equal(applied, 0)
await assert.rejects(
() =>
runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 999 } } }
}),
StaleSessionError,
)
assert.equal(applied, 0)
})
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { clearExamAnswers, sectionIds, unansweredCount } from '../src/modules/gold-land/castle-exam/castleState'
const questions = [
{ id: 'castle-q1', sectionId: 'basics' },
{ id: 'castle-q2', sectionId: 'basics' },
{ id: 'castle-q5', sectionId: 'market' },
{ id: 'castle-q9', sectionId: 'choices' },
]
test('sections keep answers while moving and incomplete exams do not submit', () => {
assert.deepEqual(sectionIds(questions), ['basics', 'market', 'choices'])
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
answers = { ...answers, 'castle-q5': 'no' }
assert.equal(answers['castle-q1'], 'exchange')
assert.equal(unansweredCount(questions.map((question) => question.id), answers), 2)
assert.equal(unansweredCount(questions.map((question) => question.id), answers) === 0, false)
})
test('network and session failures keep or clear exam answers without applying me', async () => {
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
assert.equal(answers['castle-q1'], 'exchange')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
answers = clearExamAnswers()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.deepEqual(answers, {})
})
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { formatCoinTenths, nextShown, previousShown, revealPeriods } from '../src/modules/gold-land/lesson/growthTable'
const periods = [1000, 1100, 1210, 1331]
test('period reveal can move forward and back without changing amounts', () => {
const snapshot = periods.join(',')
let shown = 1
assert.deepEqual(revealPeriods(periods, shown), [1000])
shown = nextShown(shown, periods.length)
shown = nextShown(shown, periods.length)
shown = nextShown(shown, periods.length)
assert.deepEqual(revealPeriods(periods, shown), periods)
shown = previousShown(shown)
assert.equal(revealPeriods(periods, shown).at(-1), 1210)
assert.equal(periods.join(','), snapshot)
assert.equal(formatCoinTenths(1331), '۱۳۳٫۱')
assert.equal(formatCoinTenths(1210), '۱۲۱')
assert.equal(formatCoinTenths(1300), '۱۳۰')
})
test('review keeps the chosen answer and a failed submit does not apply me', async () => {
let answer = '120'
answer = '121'
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(answer, '121')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 9 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answer, '121')
})
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { clonePlan, initialPlan, revisedPlan } from '../src/modules/gold-land/financial-goal/goalMath'
test('changing the later rate does not change the initial snapshot', () => {
const initial = initialPlan(30, 120, 15, 30, 10)
assert.equal(initial.weeks, 6)
assert.equal(initial.remaining, 90)
const snapshot = clonePlan(15)
const later = revisedPlan(30, 120, snapshot, 5, 20)
assert.equal(snapshot, 15)
assert.equal(later.balanceAfterTwo, 50)
assert.equal(later.totalWeeks, 2 + Math.ceil(70 / 20))
const paused = revisedPlan(30, 120, snapshot, 0, 15)
assert.equal(paused.balanceAfterTwo, 45)
assert.equal(snapshot, 15)
})
test('review and a failed submit keep the chosen plan', async () => {
let answers = { 'q-left': '120' }
answers = { ...answers, 'q-left': '90' }
const plan = clonePlan({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 })
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-left'], '90')
assert.equal(plan.exceptionWeekly, 5)
})
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import {
adjustFund,
canIncreaseFund,
changeLabel,
emptyFunds,
fundSum,
previewScenarios,
remainingCoins,
} from '../src/modules/gold-land/portfolio/portfolioMath'
import { setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
const scenarios = [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال',
changes: [
{ fundId: 'sun' as const, percent: 20 },
{ fundId: 'rain' as const, percent: -10 },
{ fundId: 'wind' as const, percent: 0 },
],
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال',
changes: [
{ fundId: 'sun' as const, percent: -10 },
{ fundId: 'rain' as const, percent: -10 },
{ fundId: 'wind' as const, percent: -10 },
],
},
]
const funds = [
{ id: 'sun' as const, label: 'آفتاب' },
{ id: 'rain' as const, label: 'باران' },
{ id: 'wind' as const, label: 'باد' },
]
test('allocation stays on step 10 and cannot pass 100 or go negative', () => {
let amounts = emptyFunds()
assert.equal(remainingCoins(100, amounts), 100)
assert.equal(canIncreaseFund(amounts, 10, 100), true)
for (let index = 0; index < 4; index += 1) amounts = adjustFund(amounts, 'sun', 10, 10, 100)
amounts = adjustFund(amounts, 'rain', 10, 10, 100)
amounts = adjustFund(amounts, 'wind', 50, 10, 100)
assert.equal(fundSum(amounts), 100)
assert.equal(remainingCoins(100, amounts), 0)
assert.deepEqual(adjustFund(amounts, 'sun', 10, 10, 100), amounts)
assert.deepEqual(adjustFund(amounts, 'rain', -20, 10, 100), amounts)
const reduced = adjustFund(amounts, 'rain', -10, 10, 100)
assert.equal(reduced.rain, 0)
assert.equal(remainingCoins(100, reduced), 10)
})
test('changing the mix refreshes all shown outcomes and loss is written in words', () => {
const first = previewScenarios({ sun: 100, rain: 0, wind: 0 }, scenarios, funds)
assert.equal(first[0]?.changeLabel, 'سود 20 سکه')
const drop = first.find((item) => item.id === 'drop')
assert.equal(drop?.total, 90)
assert.equal(drop?.changeLabel, changeLabel(90))
assert.equal(drop?.changeLabel.includes('زیان'), true)
const second = previewScenarios({ sun: 40, rain: 30, wind: 30 }, scenarios, funds)
assert.equal(second[0]?.total, 105)
assert.notEqual(second[0]?.total, first[0]?.total)
})
test('review keeps answers and a network or stale session does not apply me', async () => {
let answers = setRiskAnswer({}, 'q-one', 'safe')
answers = setRiskAnswer(answers, 'q-one', 'depend')
assert.equal(answers['q-one'], 'depend')
const mix = { sun: 40, rain: 30, wind: 30 }
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(applied, 0)
assert.equal(mix.sun, 40)
assert.equal(answers['q-one'], 'depend')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-one'], 'depend')
})
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { offerOutcomeLabels, riskSubmission, setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
const offers = [
{ label: 'پیشنهاد الف', outcomes: [20, 20, 22, 22] },
{ label: 'پیشنهاد ب', outcomes: [8, 16, 28, 36] },
]
test('both offers show all four outcomes in text', () => {
const labels = offers.flatMap((offer) => offerOutcomeLabels(offer.label, offer.outcomes))
assert.equal(labels.length, 8)
assert.equal(labels.filter((label) => label.includes('20 سکه')).length, 2)
assert.equal(labels.some((label) => label.includes('کارت 1 از 4: 8 سکه')), true)
assert.equal(labels.some((label) => label.includes('36 سکه')), true)
assert.equal(labels.some((label) => label.includes('22 سکه')), true)
})
test('review can change an answer before submit', () => {
let answers = setRiskAnswer({}, 'q-loss', 'a')
answers = setRiskAnswer(answers, 'q-loss', 'b')
const payload = riskSubmission(['q-loss', 'q-certain'], answers)
assert.equal(payload[0]?.answerId, 'b')
assert.equal(payload[1]?.answerId, '')
answers = setRiskAnswer(answers, 'q-certain', 'no')
assert.equal(riskSubmission(['q-certain'], answers)[0]?.answerId, 'no')
})
test('network failure and a late session do not clear local answers or apply me', async () => {
let answers = setRiskAnswer({}, 'q-loss', 'b')
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(answers['q-loss'], 'b')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-loss'], 'b')
})
+7
View File
@@ -11,6 +11,13 @@ const files = [
'catalog-api.test.ts',
'mutation-queue.test.ts',
'gold-guess-rewards.test.ts',
'budgeting-mission.test.ts',
'risk-return.test.ts',
'portfolio.test.ts',
'compound-interest.test.ts',
'smart-shop.test.ts',
'financial-goal.test.ts',
'castle-exam.test.ts',
]
let failed = false
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { moneyText, setShopAnswer } from '../src/modules/gold-land/smart-shop/shopText'
test('need budget and cost parts stay distinct, and unknown is not zero', () => {
assert.equal(moneyText(50).includes('۵۰'), true)
assert.equal(moneyText(45).includes('۴۵'), true)
assert.equal(moneyText(null), 'نامشخص')
assert.notEqual(moneyText(null), moneyText(0))
})
test('review can change a choice and a failed submit keeps it', async () => {
let answers = setShopAnswer({}, 'notebook-choice', 'a')
answers = setShopAnswer(answers, 'notebook-choice', 'b')
assert.equal(answers['notebook-choice'], 'b')
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['notebook-choice'], 'b')
})