diff --git a/e2e/backend.contract.json b/e2e/backend.contract.json index cfe0f4a..cf12a5a 100644 --- a/e2e/backend.contract.json +++ b/e2e/backend.contract.json @@ -2,6 +2,6 @@ "repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git", "checkoutPath": "backend-nest", "localRef": "", - "pinnedRef": "dfb20ca92cb13f1d25c6aca64b6256c09176e603", + "pinnedRef": "866be223956ef5d3961b0d835b36adedfeb35c82", "notes": "CI uses E2E_BACKEND_REF when set, otherwise pinnedRef (full SHA only). Never falls back to main. Local sibling: E2E_BACKEND_ROOT=../backend-nest and skip ci:checkout-backend." } diff --git a/e2e/budgeting-mission.spec.ts b/e2e/budgeting-mission.spec.ts new file mode 100644 index 0000000..33ac3fd --- /dev/null +++ b/e2e/budgeting-mission.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +const perfectLessons: Record>> = { + 1: [ + { questionId: 'money-q1', answerId: 'option-a' }, + { questionId: 'money-q2', answerId: 'true' }, + { questionId: 'money-q3', answerId: 'option-a' }, + { questionId: 'money-q4', answerId: 'true' }, + { questionId: 'money-q5', answerId: 'option-b' }, + ], + 2: [ + { questionId: 'saving-q1', answerId: 'option-b' }, + { questionId: 'saving-q2', answerId: 'true' }, + { questionId: 'saving-q3', answerId: 'option-a' }, + { questionId: 'saving-q4', answerId: 'true' }, + { questionId: 'saving-q5', answerId: 'option-a' }, + ], + 3: [ + { questionId: 'needs-q1', answerId: 'option-a' }, + { questionId: 'needs-q2', answerId: 'true' }, + { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, + { questionId: 'needs-q4', answerId: 'option-a' }, + { questionId: 'needs-q5', answerId: 'true' }, + ], + 4: [ + { questionId: 'gold-q1', answerId: 'option-a' }, + { questionId: 'gold-q2', answerId: 'true' }, + { questionId: 'gold-q3', answerId: 'option-a' }, + { questionId: 'gold-q4', answerId: 'false' }, + { questionId: 'gold-q5', answerId: 'option-b' }, + ], + 5: [ + { questionId: 'market-q1', answerId: 'option-a' }, + { questionId: 'market-q2', answerId: 'false' }, + { questionId: 'market-q3', answerId: 'option-a' }, + { questionId: 'market-q4', answerId: 'true' }, + { questionId: 'market-q5', answerId: 'option-b' }, + ], + 6: [ + { questionId: 'inflation-q1', answerId: 'option-b' }, + { questionId: 'inflation-q2', answerId: 'true' }, + { questionId: 'inflation-q3', answerId: 'option-a' }, + { questionId: 'inflation-q4', answerId: 'false' }, + { questionId: 'inflation-q5', answerId: 'option-b' }, + ], +} + +async function authHeader(page: import('@playwright/test').Page) { + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + if (!token) throw new Error('missing token') + return { authorization: `Bearer ${token}` } +} + +test('budget mission from the map, then replay without extra reward', async ({ page }) => { + const user = uniqueUsername('budget') + await registerViaUi(page, user, 'pass1234', 'بودجه') + const headers = await authHeader(page) + + for (let levelId = 1; levelId <= 6; levelId += 1) { + const start = await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers }) + expect(start.ok()).toBeTruthy() + const done = await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { + headers, + data: { answers: perfectLessons[levelId] }, + }) + expect(done.ok()).toBeTruthy() + } + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 7: بودجه‌بندی' }).click() + await page.getByRole('button', { name: 'شروع بازی' }).click() + await page.getByRole('button', { name: 'ساخت بودجه' }).click() + await page.getByRole('button', { name: 'بررسی برنامه' }).click() + await expect(page.getByRole('button', { name: 'تأیید برنامه' })).toBeDisabled() + await expect(page.getByText('رفت‌وآمد حداقل ۲۰ سکه')).toBeVisible() + await page.getByRole('button', { name: 'اصلاح برنامه' }).click() + + for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + for (let i = 0; i < 5; i += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click() + for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click() + await page.getByRole('button', { name: 'بررسی برنامه' }).click() + await page.getByRole('button', { name: 'تأیید برنامه' }).click() + await expect(page.getByText('هزینه رفت‌وآمد ۱۰ سکه بیشتر شد')).toBeVisible() + await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click() + await expect(page.getByRole('button', { name: 'ثبت نتیجه' })).toBeDisabled() + await expect(page.getByText('حداقل ۳۰ سکه')).toBeVisible() + for (let i = 0; i < 2; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + await expect(page.getByRole('button', { name: 'ثبت نتیجه' })).toBeEnabled() + + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش:')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.waitForURL(/\/map/) + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 7)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 18 + 9) + + await page.getByRole('button', { name: 'مرحله 7: بودجه‌بندی' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'ساخت بودجه' }).click() + for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + for (let i = 0; i < 5; i += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click() + for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click() + await page.getByRole('button', { name: 'بررسی برنامه' }).click() + await page.getByRole('button', { name: 'تأیید برنامه' }).click() + await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click() + for (let i = 0; i < 2; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + expect(replayAfter.user.xp).toBe(replayBefore.user.xp) + + await page.goto('/map') + await expect(page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' })).toBeDisabled() +}) diff --git a/e2e/castle-exam.spec.ts b/e2e/castle-exam.spec.ts new file mode 100644 index 0000000..670a2ad --- /dev/null +++ b/e2e/castle-exam.spec.ts @@ -0,0 +1,112 @@ +import { test, expect, type Page } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +const correct = [ + 'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد', + 'مبلغی متناسب با شرایط را منظم کنار بگذاریم', + 'اول آب آشامیدنی را تأمین کنیم', + 'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود', + 'خیر', + 'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید', + 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰', + 'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم', + 'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند', + '۱۲۱', + 'ب، چون ۴۵ در برابر ۵۰ است', + '۶ هفته', +] +const wrongTail = [ + 'همان پول ضروری را برای سود بیشتر وارد پیشنهاد نامطمئن کنیم', + 'بله، با چند گزینه دیگر زیان ممکن نیست', + '۱۲۰', + 'الف، چون عدد روی کارت کوچک‌تر است', + '۸ هفته', +] + +async function finishThroughGoal(page: Page, headers: { authorization: string }) { + const lessons: Record = { + 1: [{ questionId: 'money-q1', answerId: 'option-a' }, { questionId: 'money-q2', answerId: 'true' }, { questionId: 'money-q3', answerId: 'option-a' }, { questionId: 'money-q4', answerId: 'true' }, { questionId: 'money-q5', answerId: 'option-b' }], + 2: [{ questionId: 'saving-q1', answerId: 'option-b' }, { questionId: 'saving-q2', answerId: 'true' }, { questionId: 'saving-q3', answerId: 'option-a' }, { questionId: 'saving-q4', answerId: 'true' }, { questionId: 'saving-q5', answerId: 'option-a' }], + 3: [{ questionId: 'needs-q1', answerId: 'option-a' }, { questionId: 'needs-q2', answerId: 'true' }, { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, { questionId: 'needs-q4', answerId: 'option-a' }, { questionId: 'needs-q5', answerId: 'true' }], + 4: [{ questionId: 'gold-q1', answerId: 'option-a' }, { questionId: 'gold-q2', answerId: 'true' }, { questionId: 'gold-q3', answerId: 'option-a' }, { questionId: 'gold-q4', answerId: 'false' }, { questionId: 'gold-q5', answerId: 'option-b' }], + 5: [{ questionId: 'market-q1', answerId: 'option-a' }, { questionId: 'market-q2', answerId: 'false' }, { questionId: 'market-q3', answerId: 'option-a' }, { questionId: 'market-q4', answerId: 'true' }, { questionId: 'market-q5', answerId: 'option-b' }], + 6: [{ questionId: 'inflation-q1', answerId: 'option-b' }, { questionId: 'inflation-q2', answerId: 'true' }, { questionId: 'inflation-q3', answerId: 'option-a' }, { questionId: 'inflation-q4', answerId: 'false' }, { questionId: 'inflation-q5', answerId: 'option-b' }], + } + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy() + } + const later = [ + { answers: [{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }] }, + { answers: [{ 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' }] }, + { answers: [{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, { 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' }] }, + { answers: [{ 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' }] }, + { answers: [{ 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' }] }, + { answers: [{ questionId: 'plan', plan: { initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 } }, { 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' }] }, + ] + for (let index = 0; index < later.length; index += 1) { + const levelId = index + 7 + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: later[index] })).ok()).toBeTruthy() + } +} + +async function answerExam(page: Page, labels: string[]) { + for (let index = 0; index < labels.length; index += 1) { + await page.getByText(labels[index]!, { exact: true }).click() + const name = index === labels.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' + await page.getByRole('button', { name }).click() + } +} + +test('castle exam from the map', async ({ page }) => { + const user = uniqueUsername('castle') + await registerViaUi(page, user, 'pass1234', 'قلعه') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + await finishThroughGoal(page, { authorization: `Bearer ${token}` }) + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click() + await page.getByRole('button', { name: 'شروع آزمون' }).click() + await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click() + await expect(page.getByText('پایه‌های پول')).toBeVisible() + const beforeFail = await fetchMe(page) + await answerExam(page, [...correct.slice(0, 7), ...wrongTail]) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('این تلاش کامل نشد')).toBeVisible() + await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0) + const afterFail = await fetchMe(page) + expect(afterFail.user.coins).toBe(beforeFail.user.coins) + + await page.getByRole('button', { name: 'تلاش مجدد' }).click() + await answerExam(page, correct) + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toBeVisible() + await expect(page.getByText('پاداش:')).toBeVisible() + await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0) + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 13)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 50 + 9) + + await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click() + await answerExam(page, correct) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.getByRole('button', { name: 'تلاش مجدد' }).click() + await answerExam(page, [...correct.slice(0, 7), ...wrongTail]) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('این تلاش کامل نشد')).toBeVisible() + await expect(page.getByText('ستاره ذخیره‌شده همچنان 3 از ۳ است')).toBeVisible() + await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toHaveCount(0) + const kept = await fetchMe(page) + expect(kept.progress.mapLevels.find((level) => level.id === 13)?.stars).toBe(3) + expect(kept.user.coins).toBe(replayAfter.user.coins) +}) diff --git a/e2e/compound-interest.spec.ts b/e2e/compound-interest.spec.ts new file mode 100644 index 0000000..0760f53 --- /dev/null +++ b/e2e/compound-interest.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +const lessons: Record>> = { + 1: [ + { questionId: 'money-q1', answerId: 'option-a' }, + { questionId: 'money-q2', answerId: 'true' }, + { questionId: 'money-q3', answerId: 'option-a' }, + { questionId: 'money-q4', answerId: 'true' }, + { questionId: 'money-q5', answerId: 'option-b' }, + ], + 2: [ + { questionId: 'saving-q1', answerId: 'option-b' }, + { questionId: 'saving-q2', answerId: 'true' }, + { questionId: 'saving-q3', answerId: 'option-a' }, + { questionId: 'saving-q4', answerId: 'true' }, + { questionId: 'saving-q5', answerId: 'option-a' }, + ], + 3: [ + { questionId: 'needs-q1', answerId: 'option-a' }, + { questionId: 'needs-q2', answerId: 'true' }, + { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, + { questionId: 'needs-q4', answerId: 'option-a' }, + { questionId: 'needs-q5', answerId: 'true' }, + ], + 4: [ + { questionId: 'gold-q1', answerId: 'option-a' }, + { questionId: 'gold-q2', answerId: 'true' }, + { questionId: 'gold-q3', answerId: 'option-a' }, + { questionId: 'gold-q4', answerId: 'false' }, + { questionId: 'gold-q5', answerId: 'option-b' }, + ], + 5: [ + { questionId: 'market-q1', answerId: 'option-a' }, + { questionId: 'market-q2', answerId: 'false' }, + { questionId: 'market-q3', answerId: 'option-a' }, + { questionId: 'market-q4', answerId: 'true' }, + { questionId: 'market-q5', answerId: 'option-b' }, + ], + 6: [ + { questionId: 'inflation-q1', answerId: 'option-b' }, + { questionId: 'inflation-q2', answerId: 'true' }, + { questionId: 'inflation-q3', answerId: 'option-a' }, + { questionId: 'inflation-q4', answerId: 'false' }, + { questionId: 'inflation-q5', answerId: 'option-b' }, + ], +} + +async function answerCompound(page: import('@playwright/test').Page) { + const choices = [ + 'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود', + '۱۲۱ سکه', + 'چون ۱۰٪ از ۱۱۰ محاسبه شده است', + 'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰', + ] + for (const label of choices) { + await page.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + } + await page.getByRole('button', { name: 'نادرست', exact: true }).click() + await page.getByRole('button', { name: 'بعدی' }).click() +} + +test('compound interest lesson from the map', async ({ page }) => { + const user = uniqueUsername('cmp') + await registerViaUi(page, user, 'pass1234', 'مرکب') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + const headers = { authorization: `Bearer ${token}` } + + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { + headers, + data: { answers: lessons[levelId] }, + })).ok()).toBeTruthy() + } + expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/7/complete', { + headers, + data: { + answers: [ + { questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, + { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }, + ], + }, + })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/8/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/8/complete', { + headers, + data: { + answers: [ + { 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' }, + ], + }, + })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/9/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/9/complete', { + headers, + data: { + answers: [ + { questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, + { 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' }, + ], + }, + })).ok()).toBeTruthy() + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 10: سود مرکب' }).click() + await page.getByRole('button', { name: 'شروع درس' }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await expect(page.getByText('۱۳۳٫۱ سکه').first()).toBeVisible() + await expect(page.getByText('۱۲۱ + ۱۲٫۱ = ۱۳۳٫۱')).toBeVisible() + await page.getByRole('button', { name: 'دوره قبل' }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + await answerCompound(page) + const before = await fetchMe(page) + await page.getByRole('button', { name: 'اتمام درس' }).click() + await expect(page.getByText('۱۰٪ آن ۱۱ سکه')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 10)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 18 + 9) + + await page.getByRole('button', { name: 'مرحله 10: سود مرکب' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + await answerCompound(page) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'اتمام درس' }).click() + await expect(page.getByText('امتیاز')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.goto('/map') + await expect(page.getByRole('button', { name: 'مرحله 12: هدف مالی' })).toBeDisabled() +}) diff --git a/e2e/financial-goal.spec.ts b/e2e/financial-goal.spec.ts new file mode 100644 index 0000000..9c0c7e8 --- /dev/null +++ b/e2e/financial-goal.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, type Page } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +async function finishThroughShop(page: Page, headers: { authorization: string }) { + const lessons: Record = { + 1: [{ questionId: 'money-q1', answerId: 'option-a' }, { questionId: 'money-q2', answerId: 'true' }, { questionId: 'money-q3', answerId: 'option-a' }, { questionId: 'money-q4', answerId: 'true' }, { questionId: 'money-q5', answerId: 'option-b' }], + 2: [{ questionId: 'saving-q1', answerId: 'option-b' }, { questionId: 'saving-q2', answerId: 'true' }, { questionId: 'saving-q3', answerId: 'option-a' }, { questionId: 'saving-q4', answerId: 'true' }, { questionId: 'saving-q5', answerId: 'option-a' }], + 3: [{ questionId: 'needs-q1', answerId: 'option-a' }, { questionId: 'needs-q2', answerId: 'true' }, { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, { questionId: 'needs-q4', answerId: 'option-a' }, { questionId: 'needs-q5', answerId: 'true' }], + 4: [{ questionId: 'gold-q1', answerId: 'option-a' }, { questionId: 'gold-q2', answerId: 'true' }, { questionId: 'gold-q3', answerId: 'option-a' }, { questionId: 'gold-q4', answerId: 'false' }, { questionId: 'gold-q5', answerId: 'option-b' }], + 5: [{ questionId: 'market-q1', answerId: 'option-a' }, { questionId: 'market-q2', answerId: 'false' }, { questionId: 'market-q3', answerId: 'option-a' }, { questionId: 'market-q4', answerId: 'true' }, { questionId: 'market-q5', answerId: 'option-b' }], + 6: [{ questionId: 'inflation-q1', answerId: 'option-b' }, { questionId: 'inflation-q2', answerId: 'true' }, { questionId: 'inflation-q3', answerId: 'option-a' }, { questionId: 'inflation-q4', answerId: 'false' }, { questionId: 'inflation-q5', answerId: 'option-b' }], + } + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy() + } + const later = [ + { answers: [{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }] }, + { answers: [{ 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' }] }, + { answers: [{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, { 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' }] }, + { answers: [{ 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' }] }, + { answers: [{ 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' }] }, + ] + for (let index = 0; index < later.length; index += 1) { + const levelId = index + 7 + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: later[index] })).ok()).toBeTruthy() + } +} + +async function answerGoal(page: Page) { + const labels = [ + 'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز', + '۹۰ سکه', + '۶ هفته', + 'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن', + 'برنامه و زمان را دوباره بررسی و به‌روز کن', + ] + for (let index = 0; index < labels.length; index += 1) { + await page.getByText(labels[index]!, { exact: true }).click() + if (index < labels.length - 1) await page.getByRole('button', { name: 'بعدی' }).click() + else await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click() + } +} + +async function buildPlan(page: Page) { + await page.getByRole('button', { name: 'ساخت برنامه' }).click() + await page.getByRole('button', { name: '15 سکه در هفته' }).click() + await expect(page.getByText('برآورد زمان: 6 هفته')).toBeVisible() + await page.getByRole('button', { name: 'تأیید برنامه اولیه' }).click() + await page.getByRole('button', { name: 'هفته دوم: 5 سکه' }).click() + await page.getByRole('button', { name: 'ادامه: 15 سکه' }).click() + await expect(page.getByText('کل زمان: 7 هفته')).toBeVisible() + await expect(page.getByText('موجودی نهایی برآوردی: 125 سکه')).toBeVisible() + await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click() + await answerGoal(page) +} + +test('financial goal from the map', async ({ page }) => { + const user = uniqueUsername('goal') + await registerViaUi(page, user, 'pass1234', 'هدف') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + await finishThroughShop(page, { authorization: `Bearer ${token}` }) + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 12: هدف مالی' }).click() + await page.getByRole('button', { name: 'شروع چالش' }).click() + await buildPlan(page) + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش:')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 12)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 20 + 9) + + await page.getByRole('button', { name: 'مرحله 12: هدف مالی' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await buildPlan(page) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click() + await expect(page.getByRole('button', { name: 'شروع آزمون' })).toBeEnabled() +}) diff --git a/e2e/full-path.spec.ts b/e2e/full-path.spec.ts new file mode 100644 index 0000000..3c7c98b --- /dev/null +++ b/e2e/full-path.spec.ts @@ -0,0 +1,242 @@ +import { test, expect, type Page } from '@playwright/test' +import { fetchMe, loginViaUi, logoutViaProfile, registerViaUi, uniqueUsername } from './helpers/auth' + +test.describe.configure({ mode: 'serial' }) + +async function openLevel(page: Page, name: string, start: string) { + await page.goto('/map') + await page.getByRole('button', { name }).click() + await page.getByRole('button', { name: start }).click() + await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => undefined) +} + +async function clickAnswer(page: Page, label: string) { + if (label === 'درست' || label === 'نادرست') { + await page.getByRole('button', { name: label, exact: true }).click() + return + } + const button = page.getByRole('button', { name: label, exact: true }) + if (await button.count()) { + await button.first().click() + return + } + await page.getByText(label, { exact: true }).click() +} + +async function finishLesson(page: Page, labels: string[]) { + let cursor = 0 + for (let step = 0; step < 30; step += 1) { + if (await page.getByText('امتیاز').first().isVisible().catch(() => false)) return + if (await page.getByText('خوراکی ضروری').isVisible().catch(() => false)) { + for (let move = 0; move < 4; move += 1) { + await page.locator('.sort-item').filter({ hasText: 'خوراکی ضروری' }).getByRole('button', { name: '↑' }).click() + } + } + if (cursor < labels.length) { + const label = labels[cursor]! + const visible = await page.getByText(label, { exact: true }).isVisible().catch(() => false) + if (visible) { + await clickAnswer(page, label) + cursor += 1 + } + } + const finish = page.getByRole('button', { name: 'اتمام درس' }) + if (await finish.isVisible().catch(() => false) && await finish.isEnabled().catch(() => false)) { + await finish.click() + await page.getByText('امتیاز').first().waitFor() + return + } + const next = page.getByRole('button', { name: 'بعدی' }) + if (await next.isEnabled().catch(() => false)) await next.click() + } + throw new Error('lesson did not finish') +} + +async function starsOf(page: Page, levelId: number) { + const me = await fetchMe(page) + return me.progress.mapLevels.find((level) => level.id === levelId)?.stars ?? 0 +} + +test('full thirteen-level journey from the interface', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'full journey runs once on desktop') + test.setTimeout(600_000) + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + + const user = uniqueUsername('path') + await registerViaUi(page, user, 'pass1234', 'مسیر') + + const lessons: Array<{ name: string; labels: string[] }> = [ + { name: 'مرحله 1: پول چیه؟', labels: ['مبادله آسان‌تر کالاها', 'درست', 'چون همه همیشه همان چیز را می‌خواهند', 'درست', 'پول وسیلهٔ مبادله و نگه‌داری ارزش است'] }, + { name: 'مرحله 2: پس‌انداز', labels: ['بخشی از پول را برای بعد کنار گذاشتن', 'درست', 'برای هدفش برنامه نداشت و همه را خرج کرد', 'درست', 'هدف آینده چیزی است که برایش برنامه می‌ریزیم'] }, + { name: 'مرحله 3: نیاز و خواسته', labels: ['چیزی که برای زندگی سالم لازم است', 'درست', 'اول نیازها را برطرف کنیم', 'درست'] }, + { name: 'مرحله 4: طلا چیه؟', labels: ['چون واحد استاندارد وزن طلا است', 'درست', 'زیورآلات و صنعت', 'نادرست', 'طلا دارایی با ارزش است که قیمتش می‌تواند تغییر کند'] }, + { name: 'مرحله 5: بازار طلا', labels: ['قیمت بالا و پایین می‌شود', 'نادرست', 'چون ممکن است وقت کافی برای فکر کردن نداشته باشیم', 'درست', 'فکر کردن و پراکنده نکردن تمام پول در یک دارایی'] }, + { name: 'مرحله 6: تورم', labels: ['افزایش عمومی قیمت‌ها در طول زمان', 'درست', 'قدرت خرید پول کم شده', 'نادرست', 'ممکن است ارزش واقعی‌اش کم شود'] }, + ] + for (let index = 0; index < lessons.length; index += 1) { + const lesson = lessons[index]! + await openLevel(page, lesson.name, 'شروع درس') + await finishLesson(page, lesson.labels) + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, index + 1)).toBeGreaterThan(0) + if (index < 5) { + await expect(page.getByRole('button', { name: lessons[index + 1]!.name })).toBeEnabled() + } + } + await page.reload() + expect(await starsOf(page, 6)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 7: بودجه‌بندی', 'شروع بازی') + await page.getByRole('button', { name: 'ساخت بودجه' }).click() + for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + for (let index = 0; index < 5; index += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click() + for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click() + await page.getByRole('button', { name: 'بررسی برنامه' }).click() + await page.getByRole('button', { name: 'تأیید برنامه' }).click() + await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click() + for (let index = 0; index < 2; index += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click() + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 7)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 8: ریسک و بازده', 'شروع چالش') + await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click() + await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click() + const risk = [ + 'پیشنهاد ب', + 'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است', + 'این پول ضروری را وارد پیشنهادهای نامطمئن نکن', + 'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست', + 'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر', + ] + for (let index = 0; index < risk.length; index += 1) { + await page.getByText(risk[index]!, { exact: true }).click() + await page.getByRole('button', { name: index === risk.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click() + } + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 8)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 9: سبد سرمایه‌گذاری', 'شروع بازی') + await page.getByRole('button', { name: 'ساخت سبد' }).click() + for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش آفتاب' }).click() + for (let index = 0; index < 3; index += 1) await page.getByRole('button', { name: 'افزایش باران' }).click() + for (let index = 0; index < 3; index += 1) await page.getByRole('button', { name: 'افزایش باد' }).click() + await page.getByRole('button', { name: 'دیدن سه سناریو' }).click() + await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click() + const portfolio = [ + 'نتیجه بیشتر به همان گزینه وابسته می‌شود', + 'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند', + 'خیر؛ رفتار و وابستگی آن‌ها مهم است', + 'خیر؛ یک سناریو آینده را تضمین نمی‌کند', + 'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد', + ] + for (let index = 0; index < portfolio.length; index += 1) { + await page.getByText(portfolio[index]!, { exact: true }).click() + await page.getByRole('button', { name: index === portfolio.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click() + } + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 9)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 10: سود مرکب', 'شروع درس') + await page.getByRole('button', { name: 'بعدی' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await page.getByRole('button', { name: 'دوره بعد' }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + const compound = [ + 'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود', + '۱۲۱ سکه', + 'چون ۱۰٪ از ۱۱۰ محاسبه شده است', + 'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰', + ] + for (const label of compound) { + await page.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + } + await page.getByRole('button', { name: 'نادرست', exact: true }).click() + await page.getByRole('button', { name: 'بعدی' }).click() + await page.getByRole('button', { name: 'اتمام درس' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 10)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 11: خرید هوشمند', 'شروع بازی') + await page.getByRole('button', { name: 'شروع خریدها' }).click() + const shop = [ + ['دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است'], + ['مداد با قیمت نهایی ۲۸، بدون تخفیف', 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست'], + ['فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد', 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست'], + ] + for (let index = 0; index < shop.length; index += 1) { + await page.getByText(shop[index]![0]!, { exact: true }).click() + await page.getByText(shop[index]![1]!, { exact: true }).click() + await page.getByRole('button', { name: index === shop.length - 1 ? 'مرور انتخاب‌ها' : 'مأموریت بعد' }).click() + } + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 11)).toBeGreaterThan(0) + + await openLevel(page, 'مرحله 12: هدف مالی', 'شروع چالش') + await page.getByRole('button', { name: 'ساخت برنامه' }).click() + await page.getByRole('button', { name: '15 سکه در هفته' }).click() + await page.getByRole('button', { name: 'تأیید برنامه اولیه' }).click() + await page.getByRole('button', { name: 'هفته دوم: 5 سکه' }).click() + await page.getByRole('button', { name: 'ادامه: 15 سکه' }).click() + await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click() + const goal = [ + 'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز', + '۹۰ سکه', + '۶ هفته', + 'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن', + 'برنامه و زمان را دوباره بررسی و به‌روز کن', + ] + for (let index = 0; index < goal.length; index += 1) { + await page.getByText(goal[index]!, { exact: true }).click() + await page.getByRole('button', { name: index === goal.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click() + } + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 12)).toBeGreaterThan(0) + await page.reload() + expect(await starsOf(page, 12)).toBeGreaterThan(0) + await expect(page.getByRole('button', { name: 'مرحله 14', exact: false })).toHaveCount(0) + + await openLevel(page, 'مرحله 13: آزمون قلعه', 'شروع آزمون') + await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click() + const castle = [ + 'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد', + 'مبلغی متناسب با شرایط را منظم کنار بگذاریم', + 'اول آب آشامیدنی را تأمین کنیم', + 'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود', + 'خیر', + 'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید', + 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰', + 'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم', + 'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند', + '۱۲۱', + 'ب، چون ۴۵ در برابر ۵۰ است', + '۶ هفته', + ] + for (let index = 0; index < castle.length; index += 1) { + await page.getByText(castle[index]!, { exact: true }).click() + await page.getByRole('button', { name: index === castle.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click() + } + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toBeVisible() + await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0) + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + expect(await starsOf(page, 13)).toBeGreaterThan(0) + + await logoutViaProfile(page) + await loginViaUi(page, user, 'pass1234') + await page.goto('/map') + for (let levelId = 1; levelId <= 13; levelId += 1) { + expect(await starsOf(page, levelId)).toBeGreaterThan(0) + } + expect(errors.filter((error) => !/favicon|Download the React DevTools/i.test(error))).toEqual([]) +}) diff --git a/e2e/portfolio.spec.ts b/e2e/portfolio.spec.ts new file mode 100644 index 0000000..892a7fb --- /dev/null +++ b/e2e/portfolio.spec.ts @@ -0,0 +1,152 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +const lessons: Record>> = { + 1: [ + { questionId: 'money-q1', answerId: 'option-a' }, + { questionId: 'money-q2', answerId: 'true' }, + { questionId: 'money-q3', answerId: 'option-a' }, + { questionId: 'money-q4', answerId: 'true' }, + { questionId: 'money-q5', answerId: 'option-b' }, + ], + 2: [ + { questionId: 'saving-q1', answerId: 'option-b' }, + { questionId: 'saving-q2', answerId: 'true' }, + { questionId: 'saving-q3', answerId: 'option-a' }, + { questionId: 'saving-q4', answerId: 'true' }, + { questionId: 'saving-q5', answerId: 'option-a' }, + ], + 3: [ + { questionId: 'needs-q1', answerId: 'option-a' }, + { questionId: 'needs-q2', answerId: 'true' }, + { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, + { questionId: 'needs-q4', answerId: 'option-a' }, + { questionId: 'needs-q5', answerId: 'true' }, + ], + 4: [ + { questionId: 'gold-q1', answerId: 'option-a' }, + { questionId: 'gold-q2', answerId: 'true' }, + { questionId: 'gold-q3', answerId: 'option-a' }, + { questionId: 'gold-q4', answerId: 'false' }, + { questionId: 'gold-q5', answerId: 'option-b' }, + ], + 5: [ + { questionId: 'market-q1', answerId: 'option-a' }, + { questionId: 'market-q2', answerId: 'false' }, + { questionId: 'market-q3', answerId: 'option-a' }, + { questionId: 'market-q4', answerId: 'true' }, + { questionId: 'market-q5', answerId: 'option-b' }, + ], + 6: [ + { questionId: 'inflation-q1', answerId: 'option-b' }, + { questionId: 'inflation-q2', answerId: 'true' }, + { questionId: 'inflation-q3', answerId: 'option-a' }, + { questionId: 'inflation-q4', answerId: 'false' }, + { questionId: 'inflation-q5', answerId: 'option-b' }, + ], +} + +async function answerPortfolio(page: import('@playwright/test').Page) { + const choices = [ + 'نتیجه بیشتر به همان گزینه وابسته می‌شود', + 'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند', + 'خیر؛ رفتار و وابستگی آن‌ها مهم است', + 'خیر؛ یک سناریو آینده را تضمین نمی‌کند', + 'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد', + ] + for (let index = 0; index < choices.length; index += 1) { + await page.getByText(choices[index]!, { exact: true }).click() + if (index < choices.length - 1) await page.getByRole('button', { name: 'بعدی' }).click() + else await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click() + } +} + +test('portfolio game from the map', async ({ page }) => { + const user = uniqueUsername('port') + await registerViaUi(page, user, 'pass1234', 'سبد') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + const headers = { authorization: `Bearer ${token}` } + + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { + headers, + data: { answers: lessons[levelId] }, + })).ok()).toBeTruthy() + } + + expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/7/complete', { + headers, + data: { + answers: [ + { questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, + { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }, + ], + }, + })).ok()).toBeTruthy() + + expect((await page.request.post('/api/gold-land/levels/8/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/8/complete', { + headers, + data: { + answers: [ + { 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' }, + ], + }, + })).ok()).toBeTruthy() + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' }).click() + await page.getByRole('button', { name: 'شروع بازی' }).click() + await page.getByRole('button', { name: 'ساخت سبد' }).click() + await expect(page.getByRole('button', { name: 'دیدن سه سناریو' })).toBeDisabled() + + const sunUp = page.getByRole('button', { name: 'افزایش آفتاب' }) + const rainUp = page.getByRole('button', { name: 'افزایش باران' }) + const windUp = page.getByRole('button', { name: 'افزایش باد' }) + for (let index = 0; index < 4; index += 1) await sunUp.click() + for (let index = 0; index < 3; index += 1) await rainUp.click() + for (let index = 0; index < 3; index += 1) await windUp.click() + await page.getByRole('button', { name: 'دیدن سه سناریو' }).click() + await expect(page.getByText('سود 5 سکه')).toBeVisible() + await expect(page.getByText('زیان 5 سکه')).toBeVisible() + await expect(page.getByText('زیان 10 سکه').first()).toBeVisible() + + await page.getByRole('button', { name: 'تغییر ترکیب' }).click() + await page.getByRole('button', { name: 'کاهش آفتاب' }).click() + await page.getByRole('button', { name: 'افزایش باد' }).click() + await page.getByRole('button', { name: 'دیدن سه سناریو' }).click() + await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click() + await answerPortfolio(page) + + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش:')).toBeVisible() + await expect(page.getByText('زیان را حذف یا سود را تضمین نمی‌کند')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 9)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 20 + 9) + + await page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'ساخت سبد' }).click() + for (let index = 0; index < 10; index += 1) await page.getByRole('button', { name: 'افزایش آفتاب' }).click() + await page.getByRole('button', { name: 'دیدن سه سناریو' }).click() + await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click() + await answerPortfolio(page) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.goto('/map') + await expect(page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' })).toBeDisabled() +}) diff --git a/e2e/risk-return.spec.ts b/e2e/risk-return.spec.ts new file mode 100644 index 0000000..3e9d511 --- /dev/null +++ b/e2e/risk-return.spec.ts @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +const lessons: Record>> = { + 1: [ + { questionId: 'money-q1', answerId: 'option-a' }, + { questionId: 'money-q2', answerId: 'true' }, + { questionId: 'money-q3', answerId: 'option-a' }, + { questionId: 'money-q4', answerId: 'true' }, + { questionId: 'money-q5', answerId: 'option-b' }, + ], + 2: [ + { questionId: 'saving-q1', answerId: 'option-b' }, + { questionId: 'saving-q2', answerId: 'true' }, + { questionId: 'saving-q3', answerId: 'option-a' }, + { questionId: 'saving-q4', answerId: 'true' }, + { questionId: 'saving-q5', answerId: 'option-a' }, + ], + 3: [ + { questionId: 'needs-q1', answerId: 'option-a' }, + { questionId: 'needs-q2', answerId: 'true' }, + { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, + { questionId: 'needs-q4', answerId: 'option-a' }, + { questionId: 'needs-q5', answerId: 'true' }, + ], + 4: [ + { questionId: 'gold-q1', answerId: 'option-a' }, + { questionId: 'gold-q2', answerId: 'true' }, + { questionId: 'gold-q3', answerId: 'option-a' }, + { questionId: 'gold-q4', answerId: 'false' }, + { questionId: 'gold-q5', answerId: 'option-b' }, + ], + 5: [ + { questionId: 'market-q1', answerId: 'option-a' }, + { questionId: 'market-q2', answerId: 'false' }, + { questionId: 'market-q3', answerId: 'option-a' }, + { questionId: 'market-q4', answerId: 'true' }, + { questionId: 'market-q5', answerId: 'option-b' }, + ], + 6: [ + { questionId: 'inflation-q1', answerId: 'option-b' }, + { questionId: 'inflation-q2', answerId: 'true' }, + { questionId: 'inflation-q3', answerId: 'option-a' }, + { questionId: 'inflation-q4', answerId: 'false' }, + { questionId: 'inflation-q5', answerId: 'option-b' }, + ], +} + +async function answerChallenge(page: import('@playwright/test').Page) { + const choices = [ + 'پیشنهاد ب', + 'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است', + 'این پول ضروری را وارد پیشنهادهای نامطمئن نکن', + 'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست', + 'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر', + ] + for (let index = 0; index < choices.length; index += 1) { + await page.getByText(choices[index]!, { exact: true }).click() + if (index < choices.length - 1) { + await page.getByRole('button', { name: 'بعدی' }).click() + } else { + await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click() + } + } +} + +test('risk and return challenge from the map', async ({ page }) => { + const user = uniqueUsername('risk') + await registerViaUi(page, user, 'pass1234', 'ریسک') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + const headers = { authorization: `Bearer ${token}` } + + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { + headers, + data: { answers: lessons[levelId] }, + })).ok()).toBeTruthy() + } + + const budget = { + answers: [ + { questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, + { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }, + ], + } + expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy() + expect((await page.request.post('/api/gold-land/levels/7/complete', { headers, data: budget })).ok()).toBeTruthy() + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 8: ریسک و بازده' }).click() + await page.getByRole('button', { name: 'شروع چالش' }).click() + await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click() + await expect(page.getByText('کارت 1 از 4: 8 سکه')).toBeVisible() + await expect(page.getByText('کارت 4 از 4: 36 سکه')).toBeVisible() + await expect(page.getByText('کارت 3 از 4: 22 سکه')).toBeVisible() + await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click() + await answerChallenge(page) + + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش:')).toBeVisible() + await expect(page.getByText('امکان سود بیشتر، تضمین سود نیست')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 8)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 20 + 9) + + await page.getByRole('button', { name: 'مرحله 8: ریسک و بازده' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click() + await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click() + await answerChallenge(page) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.goto('/map') + await expect(page.getByRole('button', { name: 'مرحله 10: سود مرکب' })).toBeDisabled() +}) diff --git a/e2e/smart-shop.spec.ts b/e2e/smart-shop.spec.ts new file mode 100644 index 0000000..1e47803 --- /dev/null +++ b/e2e/smart-shop.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +async function completePrior(page: import('@playwright/test').Page, headers: { authorization: string }) { + const lessons: Record>> = { + 1: [ + { questionId: 'money-q1', answerId: 'option-a' }, + { questionId: 'money-q2', answerId: 'true' }, + { questionId: 'money-q3', answerId: 'option-a' }, + { questionId: 'money-q4', answerId: 'true' }, + { questionId: 'money-q5', answerId: 'option-b' }, + ], + 2: [ + { questionId: 'saving-q1', answerId: 'option-b' }, + { questionId: 'saving-q2', answerId: 'true' }, + { questionId: 'saving-q3', answerId: 'option-a' }, + { questionId: 'saving-q4', answerId: 'true' }, + { questionId: 'saving-q5', answerId: 'option-a' }, + ], + 3: [ + { questionId: 'needs-q1', answerId: 'option-a' }, + { questionId: 'needs-q2', answerId: 'true' }, + { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, + { questionId: 'needs-q4', answerId: 'option-a' }, + { questionId: 'needs-q5', answerId: 'true' }, + ], + 4: [ + { questionId: 'gold-q1', answerId: 'option-a' }, + { questionId: 'gold-q2', answerId: 'true' }, + { questionId: 'gold-q3', answerId: 'option-a' }, + { questionId: 'gold-q4', answerId: 'false' }, + { questionId: 'gold-q5', answerId: 'option-b' }, + ], + 5: [ + { questionId: 'market-q1', answerId: 'option-a' }, + { questionId: 'market-q2', answerId: 'false' }, + { questionId: 'market-q3', answerId: 'option-a' }, + { questionId: 'market-q4', answerId: 'true' }, + { questionId: 'market-q5', answerId: 'option-b' }, + ], + 6: [ + { questionId: 'inflation-q1', answerId: 'option-b' }, + { questionId: 'inflation-q2', answerId: 'true' }, + { questionId: 'inflation-q3', answerId: 'option-a' }, + { questionId: 'inflation-q4', answerId: 'false' }, + { questionId: 'inflation-q5', answerId: 'option-b' }, + ], + } + for (let levelId = 1; levelId <= 6; levelId += 1) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy() + } + const posts: Array<[string, unknown]> = [ + ['7', { answers: [ + { questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, + { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }, + ] }], + ['8', { answers: [ + { 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' }, + ] }], + ['9', { answers: [ + { questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, + { 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' }, + ] }], + ['10', { answers: [ + { 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' }, + ] }], + ] + for (const [levelId, data] of posts) { + expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy() + expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data })).ok()).toBeTruthy() + } +} + +async function answerShop(page: import('@playwright/test').Page) { + const picks = [ + ['دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است'], + ['مداد با قیمت نهایی ۲۸، بدون تخفیف', 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست'], + ['فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد', 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست'], + ] + for (let index = 0; index < picks.length; index += 1) { + if (index === 2) await expect(page.getByText('نامشخص').first()).toBeVisible() + await page.getByText(picks[index]![0]!, { exact: true }).click() + await page.getByText(picks[index]![1]!, { exact: true }).click() + await page.getByRole('button', { name: index === picks.length - 1 ? 'مرور انتخاب‌ها' : 'مأموریت بعد' }).click() + } +} + +test('smart shop from the map', async ({ page }) => { + const user = uniqueUsername('shop') + await registerViaUi(page, user, 'pass1234', 'خرید') + const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token')) + await completePrior(page, { authorization: `Bearer ${token}` }) + + await page.goto('/map') + await page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' }).click() + await page.getByRole('button', { name: 'شروع بازی' }).click() + await page.getByRole('button', { name: 'شروع خریدها' }).click() + await expect(page.getByText('بودجه:')).toBeVisible() + await expect(page.getByText('جمع نهایی: ۴۵ سکه')).toBeVisible() + await answerShop(page) + const before = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش:')).toBeVisible() + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.reload() + const after = await fetchMe(page) + expect(after.progress.mapLevels.find((level) => level.id === 11)?.stars).toBe(3) + expect(after.user.coins).toBe(before.user.coins + 18 + 9) + + await page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' }).click() + await page.getByRole('button', { name: 'مرور مجدد' }).click() + await page.getByRole('button', { name: 'شروع خریدها' }).click() + await answerShop(page) + const replayBefore = await fetchMe(page) + await page.getByRole('button', { name: 'ثبت نتیجه' }).click() + await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible() + const replayAfter = await fetchMe(page) + expect(replayAfter.user.coins).toBe(replayBefore.user.coins) + + await page.goto('/map') + await expect(page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' })).toBeDisabled() +}) diff --git a/src/modules/gold-land/budgeting/budgetMath.ts b/src/modules/gold-land/budgeting/budgetMath.ts new file mode 100644 index 0000000..b5b204b --- /dev/null +++ b/src/modules/gold-land/budgeting/budgetMath.ts @@ -0,0 +1,69 @@ +export interface BudgetAmounts { + transport: number + food: number + savings: number + fun: number +} + +export type BudgetKey = keyof BudgetAmounts + +export interface BudgetCategoryRule { + id: BudgetKey + label: string + requiredInitial: number | null + requiredFinal: number | null +} + +export function emptyBudget(): BudgetAmounts { + return { transport: 0, food: 0, savings: 0, fun: 0 } +} + +export function budgetSum(amounts: BudgetAmounts): number { + return amounts.transport + amounts.food + amounts.savings + amounts.fun +} + +export function budgetReserve(total: number, amounts: BudgetAmounts): number { + return total - budgetSum(amounts) +} + +export function canIncrease(amounts: BudgetAmounts, step: number, total: number): boolean { + return budgetSum(amounts) + step <= total +} + +export function canDecrease(amounts: BudgetAmounts, key: BudgetKey, step: number): boolean { + return amounts[key] - step >= 0 +} + +export function adjustBudget( + amounts: BudgetAmounts, + key: BudgetKey, + delta: number, + step: number, + total: number, +): BudgetAmounts { + if (delta > 0 && !canIncrease(amounts, step, total)) return amounts + if (delta < 0 && !canDecrease(amounts, key, step)) return amounts + return { ...amounts, [key]: amounts[key] + delta } +} + +export function missingEssentials( + amounts: BudgetAmounts, + categories: BudgetCategoryRule[], + phase: 'initial' | 'final', +): BudgetCategoryRule[] { + return categories.filter((category) => { + const required = phase === 'initial' ? category.requiredInitial : category.requiredFinal + return required != null && amounts[category.id] < required + }) +} + +export function cloneBudget(amounts: BudgetAmounts): BudgetAmounts { + return { ...amounts } +} + +export function budgetSubmission(initial: BudgetAmounts, finalAmounts: BudgetAmounts) { + return [ + { questionId: 'initial' as const, allocations: cloneBudget(initial) }, + { questionId: 'final' as const, allocations: cloneBudget(finalAmounts) }, + ] +} diff --git a/src/modules/gold-land/castle-exam/castleState.ts b/src/modules/gold-land/castle-exam/castleState.ts new file mode 100644 index 0000000..51596f0 --- /dev/null +++ b/src/modules/gold-land/castle-exam/castleState.ts @@ -0,0 +1,11 @@ +export function sectionIds(questions: Array<{ sectionId: string }>): string[] { + return [...new Set(questions.map((question) => question.sectionId))] +} + +export function unansweredCount(questionIds: string[], answers: Record): number { + return questionIds.filter((id) => !answers[id]).length +} + +export function clearExamAnswers(): Record { + return {} +} diff --git a/src/modules/gold-land/components/budget/BudgetMissionPlayer.vue b/src/modules/gold-land/components/budget/BudgetMissionPlayer.vue new file mode 100644 index 0000000..9278e93 --- /dev/null +++ b/src/modules/gold-land/components/budget/BudgetMissionPlayer.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/src/modules/gold-land/components/castle-exam/CastleExamPlayer.vue b/src/modules/gold-land/components/castle-exam/CastleExamPlayer.vue new file mode 100644 index 0000000..b8223de --- /dev/null +++ b/src/modules/gold-land/components/castle-exam/CastleExamPlayer.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/src/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue b/src/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue new file mode 100644 index 0000000..035b8a0 --- /dev/null +++ b/src/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/src/modules/gold-land/components/lesson/LessonGrowthTableStep.vue b/src/modules/gold-land/components/lesson/LessonGrowthTableStep.vue new file mode 100644 index 0000000..b583355 --- /dev/null +++ b/src/modules/gold-land/components/lesson/LessonGrowthTableStep.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/modules/gold-land/components/lesson/LessonPlayer.vue b/src/modules/gold-land/components/lesson/LessonPlayer.vue index 560018a..27b41b4 100644 --- a/src/modules/gold-land/components/lesson/LessonPlayer.vue +++ b/src/modules/gold-land/components/lesson/LessonPlayer.vue @@ -28,6 +28,7 @@ const emit = defineEmits<{ const loading = ref(true) const error = ref('') +const submitError = ref('') const submitting = ref(false) const steps = ref([]) const levelOrder = ref(1) @@ -85,6 +86,7 @@ function initSortOrders(stepList: SanitizedLessonStep[]) { async function loadLesson() { loading.value = true error.value = '' + submitError.value = '' result.value = null currentIndex.value = 0 choiceAnswers.value = {} @@ -175,12 +177,12 @@ async function submitLesson() { }) if (missing) { - error.value = 'لطفاً به همه سؤال‌ها پاسخ بده.' + submitError.value = 'لطفاً به همه سؤال‌ها پاسخ بده.' return } submitting.value = true - error.value = '' + submitError.value = '' try { const durationMs = Date.now() - startedAt.value @@ -195,7 +197,7 @@ async function submitLesson() { levelsProgress.value = progress.levels } catch (err) { if (err instanceof StaleSessionError) return - error.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخ‌ها' + submitError.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخ‌ها' } finally { submitting.value = false } @@ -253,7 +255,10 @@ onMounted(loadLesson) @reorder-sort="reorderSort(currentStep.id, $event)" /> -

{{ error }}

+ + + ارسال دوباره +
() const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.result.stars) +const explanations = props.result.feedback && 'questions' in props.result.feedback ? props.result.feedback.questions : [] diff --git a/src/modules/gold-land/components/lesson/lessonImages.ts b/src/modules/gold-land/components/lesson/lessonImages.ts index 401c970..a870fd0 100644 --- a/src/modules/gold-land/components/lesson/lessonImages.ts +++ b/src/modules/gold-land/components/lesson/lessonImages.ts @@ -6,6 +6,7 @@ const IMAGE_MAP: Record = { coins: illustrations.coinsStack, 'gold-bar': illustrations.goldBar, piggy: illustrations.gamePiggy, + 'gold-chart': illustrations.gameGoldChart, market: illustrations.gameGoldChart, } diff --git a/src/modules/gold-land/components/portfolio/PortfolioPlayer.vue b/src/modules/gold-land/components/portfolio/PortfolioPlayer.vue new file mode 100644 index 0000000..9fa04ed --- /dev/null +++ b/src/modules/gold-land/components/portfolio/PortfolioPlayer.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/src/modules/gold-land/components/risk-return/RiskReturnPlayer.vue b/src/modules/gold-land/components/risk-return/RiskReturnPlayer.vue new file mode 100644 index 0000000..ecc2976 --- /dev/null +++ b/src/modules/gold-land/components/risk-return/RiskReturnPlayer.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/src/modules/gold-land/components/smart-shop/SmartShopPlayer.vue b/src/modules/gold-land/components/smart-shop/SmartShopPlayer.vue new file mode 100644 index 0000000..99b87be --- /dev/null +++ b/src/modules/gold-land/components/smart-shop/SmartShopPlayer.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/modules/gold-land/financial-goal/goalMath.ts b/src/modules/gold-land/financial-goal/goalMath.ts new file mode 100644 index 0000000..ef8811b --- /dev/null +++ b/src/modules/gold-land/financial-goal/goalMath.ts @@ -0,0 +1,18 @@ +export function initialPlan(current: number, goal: number, weekly: number, income: number, essential: number) { + const remaining = goal - current + const weeks = Math.ceil(remaining / weekly) + const totalSaved = current + weeks * weekly + return { remaining, weeks, totalSaved, extra: totalSaved - goal, optional: income - essential - weekly } +} + +export function revisedPlan(current: number, goal: number, initialWeekly: number, exceptionWeekly: number, continueWeekly: number) { + const balanceAfterTwo = current + initialWeekly + exceptionWeekly + const gap = Math.max(0, goal - balanceAfterTwo) + const remainingWeeks = gap === 0 ? 0 : Math.ceil(gap / continueWeekly) + const finalBalance = balanceAfterTwo + remainingWeeks * continueWeekly + return { balanceAfterTwo, remainingWeeks, totalWeeks: 2 + remainingWeeks, finalBalance, extra: finalBalance - goal } +} + +export function clonePlan(plan: T): T { + return JSON.parse(JSON.stringify(plan)) as T +} diff --git a/src/modules/gold-land/lesson/growthTable.ts b/src/modules/gold-land/lesson/growthTable.ts new file mode 100644 index 0000000..5220b8d --- /dev/null +++ b/src/modules/gold-land/lesson/growthTable.ts @@ -0,0 +1,21 @@ +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹' + +export function formatCoinTenths(tenths: number): string { + const abs = Math.abs(Math.trunc(tenths)) + const whole = Math.floor(abs / 10) + const fraction = abs % 10 + const plain = fraction === 0 ? String(whole) : `${whole}.${fraction}` + return plain.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit).replace('.', '٫') +} + +export function revealPeriods(periods: T[], shown: number): T[] { + return periods.slice(0, Math.max(0, Math.min(shown, periods.length))) +} + +export function nextShown(shown: number, total: number): number { + return Math.min(total, shown + 1) +} + +export function previousShown(shown: number): number { + return Math.max(1, shown - 1) +} diff --git a/src/modules/gold-land/portfolio/portfolioMath.ts b/src/modules/gold-land/portfolio/portfolioMath.ts new file mode 100644 index 0000000..46c6f17 --- /dev/null +++ b/src/modules/gold-land/portfolio/portfolioMath.ts @@ -0,0 +1,82 @@ +export interface FundAmounts { + sun: number + rain: number + wind: number +} + +export type FundId = keyof FundAmounts + +export function emptyFunds(): FundAmounts { + return { sun: 0, rain: 0, wind: 0 } +} + +export function fundSum(amounts: FundAmounts): number { + return amounts.sun + amounts.rain + amounts.wind +} + +export function remainingCoins(total: number, amounts: FundAmounts): number { + return total - fundSum(amounts) +} + +export function canIncreaseFund(amounts: FundAmounts, step: number, total: number): boolean { + return fundSum(amounts) + step <= total +} + +export function canDecreaseFund(amounts: FundAmounts, id: FundId, step: number): boolean { + return amounts[id] - step >= 0 +} + +export function adjustFund(amounts: FundAmounts, id: FundId, delta: number, step: number, total: number): FundAmounts { + if (delta === 0 || delta % step !== 0) return amounts + const next = amounts[id] + delta + if (next < 0 || fundSum(amounts) + delta > total) return amounts + return { ...amounts, [id]: next } +} + +export function applyPercent(amount: number, percent: number): number { + return (amount * (100 + percent)) / 100 +} + +export function changeLabel(total: number, baseline = 100): string { + const delta = total - baseline + if (delta > 0) return `سود ${delta} سکه` + if (delta < 0) return `زیان ${Math.abs(delta)} سکه` + return 'بدون تغییر' +} + +export interface ScenarioRule { + id: string + title: string + note: string + changes: Array<{ fundId: FundId; percent: number }> +} + +export function previewScenarios( + amounts: FundAmounts, + scenarios: ScenarioRule[], + funds: Array<{ id: FundId; label: string }>, +) { + const reference: FundAmounts = { sun: 100, rain: 0, wind: 0 } + return scenarios.map((scenario) => { + const percentOf = (id: FundId) => scenario.changes.find((change) => change.fundId === id)?.percent ?? 0 + const parts = funds.map((fund) => ({ + label: fund.label, + value: applyPercent(amounts[fund.id], percentOf(fund.id)), + })) + const total = parts.reduce((sum, part) => sum + part.value, 0) + const referenceSunTotal = (['sun', 'rain', 'wind'] as FundId[]).reduce( + (sum, id) => sum + applyPercent(reference[id], percentOf(id)), + 0, + ) + return { + id: scenario.id, + title: scenario.title, + note: scenario.note, + parts, + total, + changeLabel: changeLabel(total), + referenceSunTotal, + referenceChangeLabel: changeLabel(referenceSunTotal), + } + }) +} diff --git a/src/modules/gold-land/risk-return/riskChoices.ts b/src/modules/gold-land/risk-return/riskChoices.ts new file mode 100644 index 0000000..5cd3989 --- /dev/null +++ b/src/modules/gold-land/risk-return/riskChoices.ts @@ -0,0 +1,18 @@ +export interface RiskChoiceMap { + [questionId: string]: string +} + +export function setRiskAnswer(answers: RiskChoiceMap, questionId: string, optionId: string): RiskChoiceMap { + return { ...answers, [questionId]: optionId } +} + +export function riskSubmission(questionIds: string[], answers: RiskChoiceMap) { + return questionIds.map((questionId) => ({ + questionId, + answerId: answers[questionId] ?? '', + })) +} + +export function offerOutcomeLabels(label: string, outcomes: number[]) { + return outcomes.map((coins, index) => `${label}، کارت ${index + 1} از ${outcomes.length}: ${coins} سکه`) +} diff --git a/src/modules/gold-land/smart-shop/shopText.ts b/src/modules/gold-land/smart-shop/shopText.ts new file mode 100644 index 0000000..9752d97 --- /dev/null +++ b/src/modules/gold-land/smart-shop/shopText.ts @@ -0,0 +1,10 @@ +import { formatNumber } from '@/utils/storage' + +export function moneyText(value: number | null): string { + if (value === null) return 'نامشخص' + return `${formatNumber(value)} سکه` +} + +export function setShopAnswer(answers: Record, questionId: string, optionId: string): Record { + return { ...answers, [questionId]: optionId } +} diff --git a/src/modules/gold-land/types/goldLand.types.ts b/src/modules/gold-land/types/goldLand.types.ts index 89f000a..ac142f0 100644 --- a/src/modules/gold-land/types/goldLand.types.ts +++ b/src/modules/gold-land/types/goldLand.types.ts @@ -21,7 +21,7 @@ export interface LevelItem { rewardCoins: number } -export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' +export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' | 'growth_table' export interface LessonOption { id: string @@ -67,12 +67,35 @@ export interface SummaryStep { highlight?: string } +export interface GrowthTableStep { + id: string + type: 'growth_table' + title?: string + body: string + disclaimer: string + unitLabel: string + principalTenths: number + ratePercent: number + methods: Array<{ id: string; label: string; note: string }> + periods: Array<{ + index: number + rows: Array<{ + methodId: string + label: string + startTenths: number + growthTenths: number + endTenths: number + }> + }> +} + export type SanitizedLessonStep = | StoryStep | SingleChoiceStep | TrueFalseStep | SortStep | SummaryStep + | GrowthTableStep export interface SanitizedLesson { id: string @@ -99,6 +122,12 @@ export interface LevelDetailResponse { activity: { type: string lesson?: SanitizedLesson + budgeting?: BudgetMissionContent + riskReturn?: RiskReturnContent + portfolio?: PortfolioMissionContent + smartShop?: SmartShopContent + financialGoal?: FinancialGoalContent + castleExam?: CastleExamContent } | null } @@ -119,6 +148,193 @@ export interface CompleteLevelResponse { reward: { xp: number; coins: number } unlockedLevelId: number | null me: import('@/api/client').MeResponse + feedback?: + | { + plans: Array<{ + phase: 'initial' | 'final' + title: string + criteria: Array<{ id: string; label: string; earned: boolean; detail: string }> + }> + } + | { + summary: string + questions: Array<{ id: string; earned: boolean; explanation: string }> + } + | { + summary: string + scenarios: Array<{ + id: string + title: string + note: string + parts: Array<{ fundId: string; label: string; value: number }> + total: number + changeLabel: string + referenceSunTotal: number + referenceChangeLabel: string + }> + questions: Array<{ id: string; earned: boolean; explanation: string }> + } + | { + questions: Array<{ id: string; earned: boolean; explanation: string }> + } + | { + summary: string + purchases: Array<{ id: string; earned: boolean; explanation: string }> + } + | { + summary: string + timeline: { + initialWeeks: number + balanceAfterTwo: number + remainingWeeks: number + totalWeeks: number + finalBalance: number + } + questions: Array<{ id: string; earned: boolean; explanation: string }> + } + | { + summary: string + completed: boolean + questions: Array<{ + id: string + sourceLevelId: number + earned: boolean + selectedAnswerId: string + explanation: string + }> + } +} + +export interface PortfolioMissionContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + totalCoins: number + step: number + intro: string + rules: string[] + funds: Array<{ id: 'sun' | 'rain' | 'wind'; label: string; hint: string }> + scenarios: Array<{ + id: string + title: string + note: string + changes: Array<{ fundId: 'sun' | 'rain' | 'wind'; percent: number }> + }> + questions: Array<{ id: string; prompt: string; options: Array<{ id: string; label: string }> }> + summary: string +} + +export interface FinancialGoalContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + intro: string + rules: string[] + eventMessage: string + plan: { + goalPrice: number + currentSavings: number + weeklyIncome: number + normalEssential: number + exceptionEssential: number + initialRates: number[] + exceptionRates: number[] + continueRates: number[] + } + questions: Array<{ id: string; prompt: string; options: Array<{ id: string; label: string }> }> + summary: string +} + +export interface CastleExamContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + intro: string + sections: Array<{ id: string; title: string }> + questions: Array<{ + id: string + sourceLevelId: number + sectionId: string + sectionTitle: string + prompt: string + options: Array<{ id: string; label: string }> + }> +} + +export interface SmartShopContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + intro: string + rules: string[] + missions: Array<{ + id: string + title: string + need: string + budget: number + cards: Array<{ + id: string + label: string + facts: string[] + price: number | null + delivery: number | null + discountText: string | null + previousPrice: number | null + finalCost: number | null + }> + choice: { id: string; prompt: string; options: Array<{ id: string; label: string }> } + reason: { id: string; prompt: string; options: Array<{ id: string; label: string }> } + }> + summary: string +} + +export interface RiskReturnContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + stakeCoins: number + intro: string + disclaimer: string + exampleLimit: string + summary: string + offers: Array<{ id: 'a' | 'b'; label: string; outcomes: number[] }> + questions: Array<{ + id: string + prompt: string + options: Array<{ id: string; label: string }> + }> +} + +export interface BudgetMissionContent { + id: string + version: number + title: string + guideTitle: string + guideImageKey: string + ageRange: string + totalCoins: number + step: number + intro: string + rules: string[] + eventMessage: string + reserveLabel: string + categories: Array<{ + id: 'transport' | 'food' | 'savings' | 'fun' + label: string + hint: string + requiredInitial: number | null + requiredFinal: number | null + }> } export interface ChoiceLessonAnswer { @@ -131,7 +347,32 @@ export interface SortLessonAnswer { orderedItemIds: string[] } -export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer +export interface BudgetLevelAnswer { + questionId: 'initial' | 'final' + allocations: { + transport: number + food: number + savings: number + fun: number + } +} + +export interface PortfolioLevelAnswer { + questionId: 'portfolio' + holdings: { sun: number; rain: number; wind: number } +} + +export interface GoalLevelAnswer { + questionId: 'plan' + plan: { initialWeekly: number; exceptionWeekly: number; continueWeekly: number } +} + +export type LessonSubmissionAnswer = + | ChoiceLessonAnswer + | SortLessonAnswer + | BudgetLevelAnswer + | PortfolioLevelAnswer + | GoalLevelAnswer /** @deprecated Use LessonSubmissionAnswer */ export interface LessonAnswer { diff --git a/src/pages/LearningMapPage.vue b/src/pages/LearningMapPage.vue index 0f9619e..12c638e 100644 --- a/src/pages/LearningMapPage.vue +++ b/src/pages/LearningMapPage.vue @@ -153,6 +153,9 @@ onActivated(loadProgress) > + + + diff --git a/src/pages/LevelActivityPage.vue b/src/pages/LevelActivityPage.vue index 2af775d..2b0d108 100644 --- a/src/pages/LevelActivityPage.vue +++ b/src/pages/LevelActivityPage.vue @@ -4,6 +4,12 @@ import { useRoute, useRouter } from 'vue-router' import AppHeader from '@/components/common/AppHeader.vue' import PrimaryButton from '@/components/common/PrimaryButton.vue' import LessonPlayer from '@/modules/gold-land/components/lesson/LessonPlayer.vue' +import BudgetMissionPlayer from '@/modules/gold-land/components/budget/BudgetMissionPlayer.vue' +import RiskReturnPlayer from '@/modules/gold-land/components/risk-return/RiskReturnPlayer.vue' +import PortfolioPlayer from '@/modules/gold-land/components/portfolio/PortfolioPlayer.vue' +import SmartShopPlayer from '@/modules/gold-land/components/smart-shop/SmartShopPlayer.vue' +import FinancialGoalPlayer from '@/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue' +import CastleExamPlayer from '@/modules/gold-land/components/castle-exam/CastleExamPlayer.vue' import { goldLandApi } from '@/modules/gold-land/services/goldLand.api' const route = useRoute() @@ -13,6 +19,12 @@ const loading = ref(true) const error = ref('') const levelDetail = ref> | null>(null) const showPlayer = ref(false) +const showBudget = ref(false) +const showRisk = ref(false) +const showPortfolio = ref(false) +const showShop = ref(false) +const showGoal = ref(false) +const showCastle = ref(false) const levelId = computed(() => Number(route.params.levelId)) const level = computed(() => levelDetail.value?.level ?? null) @@ -21,6 +33,12 @@ async function loadLevel() { loading.value = true error.value = '' showPlayer.value = false + showBudget.value = false + showRisk.value = false + showPortfolio.value = false + showShop.value = false + showGoal.value = false + showCastle.value = false try { levelDetail.value = await goldLandApi.getLevel(levelId.value) @@ -34,10 +52,34 @@ async function loadLevel() { error.value = 'این مرحله به‌زودی باز می‌شود.' return } - if (item.activityType === 'lesson' && levelDetail.value.activity?.lesson) { + if (levelDetail.value.activity?.type === 'lesson' && levelDetail.value.activity.lesson) { showPlayer.value = true return } + if (levelDetail.value.activity?.type === 'budgeting' && levelDetail.value.activity.budgeting) { + showBudget.value = true + return + } + if (levelDetail.value.activity?.type === 'risk-return' && levelDetail.value.activity.riskReturn) { + showRisk.value = true + return + } + if (levelDetail.value.activity?.type === 'portfolio' && levelDetail.value.activity.portfolio) { + showPortfolio.value = true + return + } + if (levelDetail.value.activity?.type === 'smart-shop' && levelDetail.value.activity.smartShop) { + showShop.value = true + return + } + if (levelDetail.value.activity?.type === 'financial-goal' && levelDetail.value.activity.financialGoal) { + showGoal.value = true + return + } + if (levelDetail.value.activity?.type === 'castle-exam' && levelDetail.value.activity.castleExam) { + showCastle.value = true + return + } if (item.activityRoute) { router.replace(item.activityRoute) return @@ -94,6 +136,53 @@ watch(levelId, () => { @back-to-map="goToMap" @next-level="goToNextLevel" /> + + + + + + + + + + + +
diff --git a/test/budgeting-mission.test.ts b/test/budgeting-mission.test.ts new file mode 100644 index 0000000..42568b1 --- /dev/null +++ b/test/budgeting-mission.test.ts @@ -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) +}) diff --git a/test/castle-exam.test.ts b/test/castle-exam.test.ts new file mode 100644 index 0000000..372611a --- /dev/null +++ b/test/castle-exam.test.ts @@ -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 = { '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 = { '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, {}) +}) diff --git a/test/compound-interest.test.ts b/test/compound-interest.test.ts new file mode 100644 index 0000000..d19d222 --- /dev/null +++ b/test/compound-interest.test.ts @@ -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') +}) diff --git a/test/financial-goal.test.ts b/test/financial-goal.test.ts new file mode 100644 index 0000000..707df7a --- /dev/null +++ b/test/financial-goal.test.ts @@ -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) +}) diff --git a/test/portfolio.test.ts b/test/portfolio.test.ts new file mode 100644 index 0000000..cbad4d7 --- /dev/null +++ b/test/portfolio.test.ts @@ -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') +}) diff --git a/test/risk-return.test.ts b/test/risk-return.test.ts new file mode 100644 index 0000000..54a8b7e --- /dev/null +++ b/test/risk-return.test.ts @@ -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') +}) diff --git a/test/run-all-tests.mjs b/test/run-all-tests.mjs index 9f8e3e1..2f27586 100644 --- a/test/run-all-tests.mjs +++ b/test/run-all-tests.mjs @@ -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 diff --git a/test/smart-shop.test.ts b/test/smart-shop.test.ts new file mode 100644 index 0000000..5887332 --- /dev/null +++ b/test/smart-shop.test.ts @@ -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') +})