feat: complete the thirteen-stage learning experience and E2E journey
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:
@@ -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)
|
||||
})
|
||||
@@ -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, {})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user