feat: complete the thirteen-stage learning experience and E2E journey
Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,6 @@
|
|||||||
"repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git",
|
"repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git",
|
||||||
"checkoutPath": "backend-nest",
|
"checkoutPath": "backend-nest",
|
||||||
"localRef": "",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||||
|
|
||||||
|
const perfectLessons: Record<number, Array<Record<string, unknown>>> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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<number, unknown> = {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||||
|
|
||||||
|
const lessons: Record<number, Array<Record<string, unknown>>> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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<number, unknown> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||||
|
|
||||||
|
const lessons: Record<number, Array<Record<string, unknown>>> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||||
|
|
||||||
|
const lessons: Record<number, Array<Record<string, unknown>>> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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<number, Array<Record<string, unknown>>> = {
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<string, string>): number {
|
||||||
|
return questionIds.filter((id) => !answers[id]).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearExamAnswers(): Record<string, string> {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import {
|
||||||
|
adjustBudget,
|
||||||
|
budgetReserve,
|
||||||
|
budgetSubmission,
|
||||||
|
canDecrease,
|
||||||
|
canIncrease,
|
||||||
|
cloneBudget,
|
||||||
|
emptyBudget,
|
||||||
|
missingEssentials,
|
||||||
|
type BudgetAmounts,
|
||||||
|
type BudgetKey,
|
||||||
|
} from '@/modules/gold-land/budgeting/budgetMath'
|
||||||
|
import type { BudgetMissionContent, CompleteLevelResponse, LevelItem } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
backToMap: [focusLevel?: number]
|
||||||
|
nextLevel: [levelId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'plan' | 'review' | 'event' | 'revise' | 'result'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const mission = ref<BudgetMissionContent | null>(null)
|
||||||
|
const levelTitle = ref('')
|
||||||
|
const draft = ref<BudgetAmounts>(emptyBudget())
|
||||||
|
const initialSnapshot = ref<BudgetAmounts>(emptyBudget())
|
||||||
|
const finalDraft = ref<BudgetAmounts>(emptyBudget())
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
const levels = ref<LevelItem[]>([])
|
||||||
|
const limitNote = ref('')
|
||||||
|
|
||||||
|
const categories = computed(() => mission.value?.categories ?? [])
|
||||||
|
const total = computed(() => mission.value?.totalCoins ?? 100)
|
||||||
|
const step = computed(() => mission.value?.step ?? 5)
|
||||||
|
const activeAmounts = computed(() => (phase.value === 'revise' ? finalDraft.value : draft.value))
|
||||||
|
const reserve = computed(() => budgetReserve(total.value, activeAmounts.value))
|
||||||
|
const finalGaps = computed(() => missingEssentials(finalDraft.value, categories.value, 'final'))
|
||||||
|
const budgetPlans = computed(() => {
|
||||||
|
const feedback = result.value?.feedback
|
||||||
|
if (!feedback || !('plans' in feedback)) return []
|
||||||
|
return feedback.plans
|
||||||
|
})
|
||||||
|
const guideImage = computed(() => illustrations.professor)
|
||||||
|
|
||||||
|
const nextPlayable = computed(() => {
|
||||||
|
if (!result.value?.unlockedLevelId) return null
|
||||||
|
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
|
||||||
|
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
function amountsForPhase(): BudgetAmounts {
|
||||||
|
return phase.value === 'revise' ? finalDraft.value : draft.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAmounts(next: BudgetAmounts) {
|
||||||
|
if (phase.value === 'revise') finalDraft.value = next
|
||||||
|
else draft.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(key: BudgetKey, direction: 1 | -1) {
|
||||||
|
limitNote.value = ''
|
||||||
|
const current = amountsForPhase()
|
||||||
|
if (direction > 0 && !canIncrease(current, step.value, total.value)) {
|
||||||
|
limitNote.value = 'بیشتر از ۱۰۰ سکه نمیشود. اول از بخش دیگری کم کن.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (direction < 0 && !canDecrease(current, key, step.value)) {
|
||||||
|
limitNote.value = 'این بخش از صفر کمتر نمیشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAmounts(adjustBudget(current, key, direction * step.value, step.value, total.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMission() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
submitError.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
draft.value = emptyBudget()
|
||||||
|
finalDraft.value = emptyBudget()
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'budgeting' || !detail.activity.budgeting) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
mission.value = detail.activity.budgeting
|
||||||
|
levelTitle.value = detail.level.title
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت مأموریت'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalNeedMessage(gaps: { label: string }[]) {
|
||||||
|
return `قبل از ثبت، این نیازها را خودت کامل کن: ${gaps.map((item) => item.label).join('، ')}. رفتوآمد باید حداقل ۳۰ سکه و آب و غذا حداقل ۲۵ سکه باشد.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goReview() {
|
||||||
|
phase.value = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToPlan() {
|
||||||
|
phase.value = 'plan'
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmInitial() {
|
||||||
|
const gaps = missingEssentials(draft.value, categories.value, 'initial')
|
||||||
|
if (gaps.length > 0) return
|
||||||
|
initialSnapshot.value = cloneBudget(draft.value)
|
||||||
|
finalDraft.value = cloneBudget(draft.value)
|
||||||
|
phase.value = 'event'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRevise() {
|
||||||
|
finalDraft.value = cloneBudget(initialSnapshot.value)
|
||||||
|
phase.value = 'revise'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMission() {
|
||||||
|
if (submitting.value || !mission.value) return
|
||||||
|
if (finalGaps.value.length > 0) {
|
||||||
|
submitError.value = finalNeedMessage(finalGaps.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() =>
|
||||||
|
goldLandApi.completeLevel(
|
||||||
|
props.levelId,
|
||||||
|
budgetSubmission(initialSnapshot.value, finalDraft.value),
|
||||||
|
),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
levels.value = (await goldLandApi.getProgress()).levels
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) return
|
||||||
|
submitError.value =
|
||||||
|
err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError && err.status === 422
|
||||||
|
? err.message
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? `برنامه پذیرفته نشد: ${err.message}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadMission)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="budget-mission">
|
||||||
|
<p v-if="loading" class="budget-mission__status">در حال بارگذاری مأموریت...</p>
|
||||||
|
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p class="budget-mission__status" role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'intro'">
|
||||||
|
<img :src="guideImage" alt="" class="budget-mission__guide" />
|
||||||
|
<p class="budget-mission__guide-name">{{ mission.guideTitle }}</p>
|
||||||
|
<h2>{{ mission.title }}</h2>
|
||||||
|
<p>{{ mission.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="rule in mission.rules" :key="rule">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'plan'">ساخت بودجه</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && (phase === 'plan' || phase === 'revise')">
|
||||||
|
<h2>{{ phase === 'plan' ? 'بودجهٔ اول' : 'بودجهٔ بعد از تغییر' }}</h2>
|
||||||
|
<p class="budget-mission__totals">
|
||||||
|
بودجهٔ کل {{ total }} سکه. {{ mission.reserveLabel }}: {{ reserve }} سکه.
|
||||||
|
</p>
|
||||||
|
<p v-if="limitNote" role="status">{{ limitNote }}</p>
|
||||||
|
<div v-for="category in categories" :key="category.id" class="budget-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ category.label }}</strong>
|
||||||
|
<p>{{ category.hint }}</p>
|
||||||
|
<p>مقدار: {{ activeAmounts[category.id] }} سکه</p>
|
||||||
|
</div>
|
||||||
|
<div class="budget-row__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="`کاهش ${category.label}`"
|
||||||
|
:disabled="!canDecrease(activeAmounts, category.id, step)"
|
||||||
|
@click="change(category.id, -1)"
|
||||||
|
>
|
||||||
|
کم کردن
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="`افزایش ${category.label}`"
|
||||||
|
:disabled="!canIncrease(activeAmounts, step, total)"
|
||||||
|
@click="change(category.id, 1)"
|
||||||
|
>
|
||||||
|
زیاد کردن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton
|
||||||
|
v-if="phase === 'plan'"
|
||||||
|
variant="purple"
|
||||||
|
full-width
|
||||||
|
@click="goReview"
|
||||||
|
>
|
||||||
|
بررسی برنامه
|
||||||
|
</PrimaryButton>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="finalGaps.length" role="status">
|
||||||
|
{{ finalNeedMessage(finalGaps) }}
|
||||||
|
</p>
|
||||||
|
<PrimaryButton
|
||||||
|
variant="gold"
|
||||||
|
full-width
|
||||||
|
:disabled="submitting || finalGaps.length > 0"
|
||||||
|
@click="submitMission"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton
|
||||||
|
v-if="submitError"
|
||||||
|
variant="purple"
|
||||||
|
full-width
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitMission"
|
||||||
|
>
|
||||||
|
ارسال دوباره
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'review'">
|
||||||
|
<h2>بررسی برنامه</h2>
|
||||||
|
<p v-if="missingEssentials(draft, categories, 'initial').length" role="status">
|
||||||
|
برنامهٔ اول هنوز تأیید نمیشود. رفتوآمد حداقل ۲۰ سکه و آب و غذا حداقل ۲۵ سکه لازم است.
|
||||||
|
کم است: {{ missingEssentials(draft, categories, 'initial').map((item) => item.label).join('، ') }}.
|
||||||
|
خودت مقدار را اصلاح کن. پسانداز، تفریح و ذخیره مانع تأیید نیستند.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>رفتوآمد و آب و غذا برای این گردش کافی است. پسانداز برای هدف جدا از این نیازهاست و تفریح اختیاری است.</p>
|
||||||
|
<p>{{ mission.reserveLabel }}: {{ budgetReserve(total, draft) }} سکه. صفر بودن ذخیره بهتنهایی اشکال ندارد.</p>
|
||||||
|
</template>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="backToPlan">اصلاح برنامه</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
variant="gold"
|
||||||
|
full-width
|
||||||
|
:disabled="missingEssentials(draft, categories, 'initial').length > 0"
|
||||||
|
@click="confirmInitial"
|
||||||
|
>
|
||||||
|
تأیید برنامه
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'event'">
|
||||||
|
<img :src="guideImage" alt="" class="budget-mission__guide" />
|
||||||
|
<p class="budget-mission__guide-name">{{ mission.guideTitle }}</p>
|
||||||
|
<h2>شرایط تغییر کرد</h2>
|
||||||
|
<p>{{ mission.eventMessage }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="startRevise">بازتنظیم بودجه</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="result && mission">
|
||||||
|
<h2>نتیجهٔ مأموریت</h2>
|
||||||
|
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">
|
||||||
|
پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.
|
||||||
|
</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<section v-for="plan in budgetPlans" :key="plan.phase">
|
||||||
|
<h3>{{ plan.title }}</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in plan.criteria" :key="item.id">
|
||||||
|
{{ item.earned ? 'انجام شد' : 'قابل بهتر شدن' }}: {{ item.label }}. {{ item.detail }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">
|
||||||
|
بازگشت به نقشه
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">
|
||||||
|
رفتن به مرحله بعد
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.budget-mission {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-mission__status,
|
||||||
|
.budget-mission p,
|
||||||
|
.budget-mission li {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-mission__guide {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-mission__guide-name {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-mission__totals {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row {
|
||||||
|
border: 1px solid rgba(118, 75, 190, 0.35);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row__actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import { clearExamAnswers, sectionIds, unansweredCount } from '@/modules/gold-land/castle-exam/castleState'
|
||||||
|
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
|
||||||
|
import type { CastleExamContent, CompleteLevelResponse } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
const emit = defineEmits<{ backToMap: [focusLevel?: number] }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'questions' | 'review' | 'result'
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const exam = ref<CastleExamContent | null>(null)
|
||||||
|
const sectionIndex = ref(0)
|
||||||
|
const questionIndex = ref(0)
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
|
||||||
|
const sections = computed(() => sectionIds(exam.value?.questions ?? []).map((id) => exam.value?.sections.find((section) => section.id === id) ?? { id, title: id }))
|
||||||
|
const sectionQuestions = computed(() => exam.value?.questions.filter((question) => question.sectionId === sections.value[sectionIndex.value]?.id) ?? [])
|
||||||
|
const current = computed(() => sectionQuestions.value[questionIndex.value])
|
||||||
|
const answered = computed(() => (exam.value?.questions.length ?? 0) - unansweredCount(exam.value?.questions.map((question) => question.id) ?? [], answers.value))
|
||||||
|
const feedback = computed(() => {
|
||||||
|
const value = result.value?.feedback
|
||||||
|
if (!value || !('completed' in value)) return null
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const passed = computed(() => (result.value?.score ?? 0) >= 60 && feedback.value?.completed === true)
|
||||||
|
|
||||||
|
function resetAnswers() {
|
||||||
|
answers.value = clearExamAnswers()
|
||||||
|
result.value = null
|
||||||
|
sectionIndex.value = 0
|
||||||
|
questionIndex.value = 0
|
||||||
|
phase.value = 'questions'
|
||||||
|
submitError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExam() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'castle-exam' || !detail.activity.castleExam) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
exam.value = detail.activity.castleExam
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت آزمون'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
if (questionIndex.value > 0) {
|
||||||
|
questionIndex.value -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sectionIndex.value === 0) return
|
||||||
|
sectionIndex.value -= 1
|
||||||
|
questionIndex.value = Math.max(0, sectionQuestions.value.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function editQuestion(questionId: string) {
|
||||||
|
const question = exam.value?.questions.find((item) => item.id === questionId)
|
||||||
|
if (!question) return
|
||||||
|
sectionIndex.value = sections.value.findIndex((section) => section.id === question.sectionId)
|
||||||
|
questionIndex.value = (exam.value?.questions.filter((item) => item.sectionId === question.sectionId).findIndex((item) => item.id === questionId)) ?? 0
|
||||||
|
phase.value = 'questions'
|
||||||
|
}
|
||||||
|
function nextQuestion() {
|
||||||
|
if (!current.value || !answers.value[current.value.id]) return
|
||||||
|
if (questionIndex.value < sectionQuestions.value.length - 1) {
|
||||||
|
questionIndex.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sectionIndex.value < sections.value.length - 1) {
|
||||||
|
sectionIndex.value += 1
|
||||||
|
questionIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phase.value = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitExam() {
|
||||||
|
if (!exam.value || submitting.value) return
|
||||||
|
if (unansweredCount(exam.value.questions.map((question) => question.id), answers.value) > 0) {
|
||||||
|
submitError.value = 'هنوز به همه سؤالها پاسخ ندادهای.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() => goldLandApi.completeLevel(props.levelId, exam.value!.questions.map((question) => ({
|
||||||
|
questionId: question.id,
|
||||||
|
answerId: answers.value[question.id] ?? '',
|
||||||
|
}))),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) {
|
||||||
|
resetAnswers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitError.value = err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. پاسخها حفظ شدهاند. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadExam)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="castle-exam">
|
||||||
|
<p v-if="loading">در حال بارگذاری آزمون...</p>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadExam">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="exam && phase === 'intro'">
|
||||||
|
<img :src="illustrations.professor" alt="" class="castle-exam__guide" />
|
||||||
|
<p class="castle-exam__guide-name">{{ exam.guideTitle }}</p>
|
||||||
|
<h2>{{ exam.title }}</h2>
|
||||||
|
<p>{{ exam.intro }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'questions'">شروع سؤالها</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="exam && phase === 'questions' && current">
|
||||||
|
<p>{{ sections[sectionIndex]?.title }}، سؤال {{ questionIndex + 1 }} از {{ sectionQuestions.length }}</p>
|
||||||
|
<p>پاسخدادهشده: {{ answered }} از {{ exam.questions.length }}. بیپاسخ: {{ exam.questions.length - answered }}</p>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ current.prompt }}</legend>
|
||||||
|
<label v-for="option in current.options" :key="option.id">
|
||||||
|
<input type="radio" :name="current.id" :value="option.id" :checked="answers[current.id] === option.id" @change="answers = setRiskAnswer(answers, current.id, option.id)" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="castle-exam__nav">
|
||||||
|
<PrimaryButton variant="purple" :disabled="sectionIndex === 0 && questionIndex === 0" @click="goPrev">قبلی</PrimaryButton>
|
||||||
|
<PrimaryButton variant="gold" :disabled="!answers[current.id]" @click="nextQuestion">{{ sectionIndex === sections.length - 1 && questionIndex === sectionQuestions.length - 1 ? 'مرور پاسخها' : 'بعدی' }}</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="exam && phase === 'review'">
|
||||||
|
<h2>مرور پاسخها</h2>
|
||||||
|
<p>پاسخدادهشده: {{ answered }} از {{ exam.questions.length }}</p>
|
||||||
|
<article v-for="question in exam.questions" :key="question.id">
|
||||||
|
<p>{{ question.sectionTitle }}: {{ question.prompt }}</p>
|
||||||
|
<p>{{ answers[question.id] ? question.options.find((option) => option.id === answers[question.id])?.label : 'هنوز پاسخی نیست' }}</p>
|
||||||
|
<button type="button" @click="editQuestion(question.id)">تغییر این پاسخ</button>
|
||||||
|
</article>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitExam">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitExam">ارسال دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result && feedback && exam">
|
||||||
|
<h2>نتیجه این تلاش</h2>
|
||||||
|
<p>امتیاز این تلاش: {{ result.score }} از ۱۰۰.</p>
|
||||||
|
<p v-if="passed">ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-else-if="result.previousStars > 0">این تلاش کامل نشد. ستاره ذخیرهشده همچنان {{ result.previousStars }} از ۳ است.</p>
|
||||||
|
<p v-else>این تلاش کامل نشد و هنوز ستارهای ثبت نشده.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<p v-if="passed">{{ feedback.summary }}</p>
|
||||||
|
<article v-for="item in feedback.questions" :key="item.id">
|
||||||
|
<p>{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ exam.questions.find((question) => question.id === item.id)?.options.find((option) => option.id === item.selectedAnswerId)?.label }}</p>
|
||||||
|
<p>{{ item.explanation }}</p>
|
||||||
|
<button v-if="!item.earned" type="button" @click="router.push(`/gold-land/levels/${item.sourceLevelId}`)">مرور مرحله {{ item.sourceLevelId }}</button>
|
||||||
|
</article>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="resetAnswers">تلاش مجدد</PrimaryButton>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', props.levelId)">بازگشت به نقشه</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.castle-exam { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
|
||||||
|
.castle-exam p, .castle-exam legend { line-height: 1.8; }
|
||||||
|
.castle-exam__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
|
||||||
|
.castle-exam__guide-name { text-align: center; font-weight: 800; margin: 0; }
|
||||||
|
.castle-exam__nav { display: flex; gap: 8px; }
|
||||||
|
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
|
||||||
|
label { display: flex; gap: 8px; }
|
||||||
|
button { min-height: 44px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import { clonePlan, initialPlan, revisedPlan } from '@/modules/gold-land/financial-goal/goalMath'
|
||||||
|
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
|
||||||
|
import type { CompleteLevelResponse, FinancialGoalContent, LevelItem } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'initial' | 'event' | 'questions' | 'review' | 'result'
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const mission = ref<FinancialGoalContent | null>(null)
|
||||||
|
const initialWeekly = ref<number | null>(null)
|
||||||
|
const initialSnapshot = ref<number | null>(null)
|
||||||
|
const exceptionWeekly = ref<number | null>(null)
|
||||||
|
const continueWeekly = ref<number | null>(null)
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const questionIndex = ref(0)
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
const levels = ref<LevelItem[]>([])
|
||||||
|
|
||||||
|
const plan = computed(() => mission.value?.plan)
|
||||||
|
const preview = computed(() => {
|
||||||
|
if (!plan.value || initialWeekly.value == null) return null
|
||||||
|
return initialPlan(plan.value.currentSavings, plan.value.goalPrice, initialWeekly.value, plan.value.weeklyIncome, plan.value.normalEssential)
|
||||||
|
})
|
||||||
|
const revised = computed(() => {
|
||||||
|
if (!plan.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return null
|
||||||
|
return revisedPlan(plan.value.currentSavings, plan.value.goalPrice, initialSnapshot.value, exceptionWeekly.value, continueWeekly.value)
|
||||||
|
})
|
||||||
|
const questions = computed(() => mission.value?.questions ?? [])
|
||||||
|
const currentQuestion = computed(() => questions.value[questionIndex.value])
|
||||||
|
const feedback = computed(() => {
|
||||||
|
const value = result.value?.feedback
|
||||||
|
if (!value || !('timeline' in value)) return null
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const nextPlayable = computed(() => {
|
||||||
|
if (!result.value?.unlockedLevelId) return null
|
||||||
|
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
|
||||||
|
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
function confirmInitial() {
|
||||||
|
if (initialWeekly.value == null) return
|
||||||
|
initialSnapshot.value = initialWeekly.value
|
||||||
|
exceptionWeekly.value = null
|
||||||
|
continueWeekly.value = null
|
||||||
|
phase.value = 'event'
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInitial() {
|
||||||
|
if (initialSnapshot.value == null) return
|
||||||
|
const saved = clonePlan(initialSnapshot.value)
|
||||||
|
phase.value = 'initial'
|
||||||
|
initialWeekly.value = saved
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMission() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'financial-goal' || !detail.activity.financialGoal) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
mission.value = detail.activity.financialGoal
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت چالش'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goReview() {
|
||||||
|
if (questions.value.some((question) => !answers.value[question.id])) {
|
||||||
|
submitError.value = 'اول به هر پنج سؤال پاسخ بده.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitError.value = ''
|
||||||
|
phase.value = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGoal() {
|
||||||
|
if (submitting.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() => goldLandApi.completeLevel(props.levelId, [
|
||||||
|
{
|
||||||
|
questionId: 'plan',
|
||||||
|
plan: {
|
||||||
|
initialWeekly: initialSnapshot.value!,
|
||||||
|
exceptionWeekly: exceptionWeekly.value!,
|
||||||
|
continueWeekly: continueWeekly.value!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...questions.value.map((question) => ({ questionId: question.id, answerId: answers.value[question.id] ?? '' })),
|
||||||
|
]),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
levels.value = (await goldLandApi.getProgress()).levels
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) return
|
||||||
|
submitError.value = err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. برنامه و پاسخها حفظ شدهاند. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadMission)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="goal-game">
|
||||||
|
<p v-if="loading">در حال بارگذاری چالش...</p>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mission && phase === 'intro'">
|
||||||
|
<img :src="illustrations.professor" alt="" class="goal-game__guide" />
|
||||||
|
<p class="goal-game__guide-name">{{ mission.guideTitle }}</p>
|
||||||
|
<h2>{{ mission.title }}</h2>
|
||||||
|
<p>{{ mission.intro }}</p>
|
||||||
|
<ul><li v-for="rule in mission.rules" :key="rule">{{ rule }}</li></ul>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'initial'">ساخت برنامه</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mission && plan && phase === 'initial'">
|
||||||
|
<h2>برنامه اولیه</h2>
|
||||||
|
<p>هدف {{ plan.goalPrice }} سکه. موجودی {{ plan.currentSavings }} سکه. پول هفتگی {{ plan.weeklyIncome }} و نیاز ضروری {{ plan.normalEssential }} سکه.</p>
|
||||||
|
<p>پسانداز هفتگی را انتخاب کن. سریعترین برنامه نمره اضافه ندارد.</p>
|
||||||
|
<div class="goal-game__choices">
|
||||||
|
<button v-for="rate in plan.initialRates" :key="rate" type="button" :aria-pressed="initialWeekly === rate" @click="initialWeekly = rate">{{ rate }} سکه در هفته</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="preview && initialWeekly != null">
|
||||||
|
<p>مبلغ باقیمانده تا هدف: {{ preview.remaining }} سکه.</p>
|
||||||
|
<p>برآورد زمان: {{ preview.weeks }} هفته. مجموع پسانداز در هفته رسیدن: {{ preview.totalSaved }} سکه.</p>
|
||||||
|
<p>{{ preview.extra > 0 ? `مبلغ اضافه بر هدف: ${preview.extra} سکه.` : 'مبلغ اضافه بر هدف نداری.' }}</p>
|
||||||
|
<p>خرج اختیاری یا ذخیره همان هفته: {{ preview.optional }} سکه. صفر بودن آن بهتنهایی خطا نیست.</p>
|
||||||
|
</template>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="initialWeekly == null" @click="confirmInitial">تأیید برنامه اولیه</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mission && plan && phase === 'event'">
|
||||||
|
<h2>تغییر شرایط</h2>
|
||||||
|
<p>{{ mission.eventMessage }}</p>
|
||||||
|
<p>هفته اول با {{ initialSnapshot }} سکه پسانداز ثبت شده و با تغییر بعدی عوض نمیشود.</p>
|
||||||
|
<p>برای هفته دوم یکی از این مقدارها را انتخاب کن. پسانداز نکردن هم معتبر است.</p>
|
||||||
|
<div class="goal-game__choices">
|
||||||
|
<button v-for="rate in plan.exceptionRates" :key="`ex-${rate}`" type="button" :aria-pressed="exceptionWeekly === rate" @click="exceptionWeekly = rate">هفته دوم: {{ rate }} سکه</button>
|
||||||
|
</div>
|
||||||
|
<p>از هفته سوم شرایط اولیه برمیگردد. نرخ ادامه را خودت انتخاب کن.</p>
|
||||||
|
<div class="goal-game__choices">
|
||||||
|
<button v-for="rate in plan.continueRates" :key="`go-${rate}`" type="button" :aria-pressed="continueWeekly === rate" @click="continueWeekly = rate">ادامه: {{ rate }} سکه</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="revised">
|
||||||
|
<p>موجودی پس از هفته دوم: {{ revised.balanceAfterTwo }} سکه.</p>
|
||||||
|
<p>هفتههای باقیمانده: {{ revised.remainingWeeks }}. کل زمان: {{ revised.totalWeeks }} هفته.</p>
|
||||||
|
<p>موجودی نهایی برآوردی: {{ revised.finalBalance }} سکه. {{ revised.extra > 0 ? `${revised.extra} سکه اضافه بر هدف.` : 'بدون مبلغ اضافه.' }}</p>
|
||||||
|
<p>خط زمان: هفته ۱ با برنامه اولیه، هفته ۲ با نیاز بیشتر، بعد نرخ ادامه تا رسیدن به هدف.</p>
|
||||||
|
</template>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="changeInitial">تغییر برنامه اولیه</PrimaryButton>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="exceptionWeekly == null || continueWeekly == null" @click="phase = 'questions'">رفتن به سؤالها</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mission && phase === 'questions' && currentQuestion">
|
||||||
|
<p>سؤال {{ questionIndex + 1 }} از {{ questions.length }}</p>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ currentQuestion.prompt }}</legend>
|
||||||
|
<label v-for="option in currentQuestion.options" :key="option.id">
|
||||||
|
<input type="radio" :name="currentQuestion.id" :value="option.id" :checked="answers[currentQuestion.id] === option.id" @change="answers = setRiskAnswer(answers, currentQuestion.id, option.id)" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="goal-game__nav">
|
||||||
|
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="questionIndex < questions.length - 1" variant="gold" :disabled="!answers[currentQuestion.id]" @click="questionIndex += 1">بعدی</PrimaryButton>
|
||||||
|
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">مرور پاسخها</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mission && phase === 'review'">
|
||||||
|
<h2>مرور</h2>
|
||||||
|
<p>برنامه اولیه {{ initialSnapshot }}، هفته دوم {{ exceptionWeekly }}، ادامه {{ continueWeekly }}. نمره از این عددها نمیآید.</p>
|
||||||
|
<article v-for="(question, index) in questions" :key="question.id">
|
||||||
|
<p>{{ question.prompt }}</p>
|
||||||
|
<p>انتخاب تو: {{ question.options.find((option) => option.id === answers[question.id])?.label }}</p>
|
||||||
|
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
|
||||||
|
</article>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitGoal">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitGoal">ارسال دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result && feedback">
|
||||||
|
<h2>نتیجه</h2>
|
||||||
|
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<p>برنامه اولیه {{ feedback.timeline.initialWeeks }} هفته. بعد از هفته دوم {{ feedback.timeline.balanceAfterTwo }} سکه، {{ feedback.timeline.remainingWeeks }} هفته دیگر، کل {{ feedback.timeline.totalWeeks }} هفته و موجودی نهایی {{ feedback.timeline.finalBalance }} سکه.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in feedback.questions" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{{ feedback.summary }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.goal-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
|
||||||
|
.goal-game p, .goal-game li, .goal-game legend { line-height: 1.8; }
|
||||||
|
.goal-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
|
||||||
|
.goal-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
|
||||||
|
.goal-game__choices, .goal-game__nav { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.goal-game button, label { min-height: 44px; }
|
||||||
|
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
|
||||||
|
label { display: flex; gap: 8px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import SectionCard from '@/components/common/SectionCard.vue'
|
||||||
|
import { formatCoinTenths, nextShown, previousShown, revealPeriods } from '@/modules/gold-land/lesson/growthTable'
|
||||||
|
import type { GrowthTableStep } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ step: GrowthTableStep }>()
|
||||||
|
const shown = ref(1)
|
||||||
|
const visible = computed(() => revealPeriods(props.step.periods, shown.value))
|
||||||
|
const current = computed(() => visible.value[visible.value.length - 1])
|
||||||
|
|
||||||
|
function equation(start: number, growth: number, end: number): string {
|
||||||
|
return `${formatCoinTenths(start)} + ${formatCoinTenths(growth)} = ${formatCoinTenths(end)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="growth-table">
|
||||||
|
<SectionCard>
|
||||||
|
<h2 v-if="step.title">{{ step.title }}</h2>
|
||||||
|
<p>{{ step.body }}</p>
|
||||||
|
<p>مبلغ اولیه {{ formatCoinTenths(step.principalTenths) }} سکه. نرخ فرضی {{ formatCoinTenths(step.ratePercent * 10) }}٪ در هر {{ step.unitLabel }}.</p>
|
||||||
|
</SectionCard>
|
||||||
|
<p v-for="method in step.methods" :key="method.id"><strong>{{ method.label }}:</strong> {{ method.note }}</p>
|
||||||
|
<div class="growth-table__nav">
|
||||||
|
<button type="button" :disabled="shown <= 1" @click="shown = previousShown(shown)">دوره قبل</button>
|
||||||
|
<button type="button" :disabled="shown >= step.periods.length" @click="shown = nextShown(shown, step.periods.length)">دوره بعد</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="current">{{ step.unitLabel }} {{ formatCoinTenths(current.index * 10) }}</p>
|
||||||
|
<table v-if="current">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">روش</th>
|
||||||
|
<th scope="col">مبلغ شروع</th>
|
||||||
|
<th scope="col">رشد این دوره</th>
|
||||||
|
<th scope="col">مبلغ پایان</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in current.rows" :key="row.methodId">
|
||||||
|
<th scope="row">{{ row.label }}</th>
|
||||||
|
<td>{{ formatCoinTenths(row.startTenths) }} سکه</td>
|
||||||
|
<td>{{ formatCoinTenths(row.growthTenths) }} سکه</td>
|
||||||
|
<td>{{ formatCoinTenths(row.endTenths) }} سکه</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-for="row in current?.rows ?? []" :key="`${row.methodId}-eq`">
|
||||||
|
{{ row.label }}: {{ equation(row.startTenths, row.growthTenths, row.endTenths) }}
|
||||||
|
</p>
|
||||||
|
<p>{{ step.disclaimer }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.growth-table { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.growth-table p, .growth-table h2 { line-height: 1.8; margin: 0; }
|
||||||
|
.growth-table__nav { display: flex; gap: 8px; }
|
||||||
|
.growth-table button { min-height: 44px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: right; padding: 6px 4px; }
|
||||||
|
</style>
|
||||||
@@ -28,6 +28,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const steps = ref<SanitizedLessonStep[]>([])
|
const steps = ref<SanitizedLessonStep[]>([])
|
||||||
const levelOrder = ref(1)
|
const levelOrder = ref(1)
|
||||||
@@ -85,6 +86,7 @@ function initSortOrders(stepList: SanitizedLessonStep[]) {
|
|||||||
async function loadLesson() {
|
async function loadLesson() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
submitError.value = ''
|
||||||
result.value = null
|
result.value = null
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
choiceAnswers.value = {}
|
choiceAnswers.value = {}
|
||||||
@@ -175,12 +177,12 @@ async function submitLesson() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (missing) {
|
if (missing) {
|
||||||
error.value = 'لطفاً به همه سؤالها پاسخ بده.'
|
submitError.value = 'لطفاً به همه سؤالها پاسخ بده.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
error.value = ''
|
submitError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const durationMs = Date.now() - startedAt.value
|
const durationMs = Date.now() - startedAt.value
|
||||||
@@ -195,7 +197,7 @@ async function submitLesson() {
|
|||||||
levelsProgress.value = progress.levels
|
levelsProgress.value = progress.levels
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof StaleSessionError) return
|
if (err instanceof StaleSessionError) return
|
||||||
error.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخها'
|
submitError.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخها'
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -253,7 +255,10 @@ onMounted(loadLesson)
|
|||||||
@reorder-sort="reorderSort(currentStep.id, $event)"
|
@reorder-sort="reorderSort(currentStep.id, $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p v-if="error" class="lesson-player__error">{{ error }}</p>
|
<p v-if="submitError" class="lesson-player__error" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitLesson">
|
||||||
|
ارسال دوباره
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
<div class="lesson-player__nav">
|
<div class="lesson-player__nav">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.result.stars)
|
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 : []
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,6 +45,10 @@ const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.re
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="explanations.length" class="lesson-result__notes">
|
||||||
|
<li v-for="item in explanations" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p v-if="result.bestScoreImproved && !result.isFirstCompletion" class="lesson-result__note">
|
<p v-if="result.bestScoreImproved && !result.isFirstCompletion" class="lesson-result__note">
|
||||||
رکورد امتیازت بهتر شد!
|
رکورد امتیازت بهتر شد!
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import LessonSingleChoiceStep from './LessonSingleChoiceStep.vue'
|
|||||||
import LessonTrueFalseStep from './LessonTrueFalseStep.vue'
|
import LessonTrueFalseStep from './LessonTrueFalseStep.vue'
|
||||||
import LessonSortStep from './LessonSortStep.vue'
|
import LessonSortStep from './LessonSortStep.vue'
|
||||||
import LessonSummaryStep from './LessonSummaryStep.vue'
|
import LessonSummaryStep from './LessonSummaryStep.vue'
|
||||||
|
import LessonGrowthTableStep from './LessonGrowthTableStep.vue'
|
||||||
import type { SanitizedLessonStep } from '@/modules/gold-land/types/goldLand.types'
|
import type { SanitizedLessonStep } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -48,4 +49,6 @@ const emit = defineEmits<{
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<LessonSummaryStep v-else-if="step.type === 'summary'" :step="step" />
|
<LessonSummaryStep v-else-if="step.type === 'summary'" :step="step" />
|
||||||
|
|
||||||
|
<LessonGrowthTableStep v-else-if="step.type === 'growth_table'" :step="step" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const IMAGE_MAP: Record<string, string> = {
|
|||||||
coins: illustrations.coinsStack,
|
coins: illustrations.coinsStack,
|
||||||
'gold-bar': illustrations.goldBar,
|
'gold-bar': illustrations.goldBar,
|
||||||
piggy: illustrations.gamePiggy,
|
piggy: illustrations.gamePiggy,
|
||||||
|
'gold-chart': illustrations.gameGoldChart,
|
||||||
market: illustrations.gameGoldChart,
|
market: illustrations.gameGoldChart,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import {
|
||||||
|
adjustFund,
|
||||||
|
canDecreaseFund,
|
||||||
|
canIncreaseFund,
|
||||||
|
emptyFunds,
|
||||||
|
previewScenarios,
|
||||||
|
remainingCoins,
|
||||||
|
type FundAmounts,
|
||||||
|
type FundId,
|
||||||
|
} from '@/modules/gold-land/portfolio/portfolioMath'
|
||||||
|
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
|
||||||
|
import type { CompleteLevelResponse, LevelItem, PortfolioMissionContent } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'build' | 'outcomes' | 'questions' | 'review' | 'result'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const mission = ref<PortfolioMissionContent | null>(null)
|
||||||
|
const funds = ref<FundAmounts>(emptyFunds())
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const questionIndex = ref(0)
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
const levels = ref<LevelItem[]>([])
|
||||||
|
const limitNote = ref('')
|
||||||
|
|
||||||
|
const total = computed(() => mission.value?.totalCoins ?? 100)
|
||||||
|
const step = computed(() => mission.value?.step ?? 10)
|
||||||
|
const left = computed(() => remainingCoins(total.value, funds.value))
|
||||||
|
const questions = computed(() => mission.value?.questions ?? [])
|
||||||
|
const currentQuestion = computed(() => questions.value[questionIndex.value])
|
||||||
|
const previews = computed(() => {
|
||||||
|
if (!mission.value) return []
|
||||||
|
return previewScenarios(funds.value, mission.value.scenarios, mission.value.funds)
|
||||||
|
})
|
||||||
|
const serverFeedback = computed(() => {
|
||||||
|
const feedback = result.value?.feedback
|
||||||
|
if (!feedback || !('scenarios' in feedback)) return null
|
||||||
|
return feedback
|
||||||
|
})
|
||||||
|
const nextPlayable = computed(() => {
|
||||||
|
if (!result.value?.unlockedLevelId) return null
|
||||||
|
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
|
||||||
|
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
function change(id: FundId, direction: 1 | -1) {
|
||||||
|
limitNote.value = ''
|
||||||
|
if (direction > 0 && !canIncreaseFund(funds.value, step.value, total.value)) {
|
||||||
|
limitNote.value = 'بیشتر از ۱۰۰ سکه نمیشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (direction < 0 && !canDecreaseFund(funds.value, id, step.value)) {
|
||||||
|
limitNote.value = 'این صندوق از صفر کمتر نمیشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
funds.value = adjustFund(funds.value, id, direction * step.value, step.value, total.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function seeOutcomes() {
|
||||||
|
if (left.value !== 0) return
|
||||||
|
phase.value = 'outcomes'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMission() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'portfolio' || !detail.activity.portfolio) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
mission.value = detail.activity.portfolio
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت بازی'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goReview() {
|
||||||
|
if (questions.value.some((question) => !answers.value[question.id])) {
|
||||||
|
submitError.value = 'اول به هر پنج سؤال پاسخ بده.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitError.value = ''
|
||||||
|
phase.value = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPortfolio() {
|
||||||
|
if (submitting.value || !mission.value || left.value !== 0) return
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() =>
|
||||||
|
goldLandApi.completeLevel(props.levelId, [
|
||||||
|
{ questionId: 'portfolio', holdings: { ...funds.value } },
|
||||||
|
...questions.value.map((question) => ({
|
||||||
|
questionId: question.id,
|
||||||
|
answerId: answers.value[question.id] ?? '',
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
levels.value = (await goldLandApi.getProgress()).levels
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) return
|
||||||
|
submitError.value =
|
||||||
|
err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. سبد و پاسخها حفظ شدهاند. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadMission)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="portfolio-game">
|
||||||
|
<p v-if="loading">در حال بارگذاری بازی...</p>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'intro'">
|
||||||
|
<img :src="illustrations.professor" alt="" class="portfolio-game__guide" />
|
||||||
|
<p class="portfolio-game__guide-name">{{ mission.guideTitle }}</p>
|
||||||
|
<h2>{{ mission.title }}</h2>
|
||||||
|
<p>{{ mission.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="rule in mission.rules" :key="rule">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'build'">ساخت سبد</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'build'">
|
||||||
|
<h2>تقسیم {{ total }} سکه</h2>
|
||||||
|
<p>مانده برای تخصیص: {{ left }} سکه. ادامه فقط وقتی مانده صفر است.</p>
|
||||||
|
<p v-if="limitNote" role="status">{{ limitNote }}</p>
|
||||||
|
<p v-if="left !== 0" role="status">مجموع هنوز ۱۰۰ نیست؛ برای دیدن پیامدها باید دقیقاً ۱۰۰ سکه تقسیم شود.</p>
|
||||||
|
<div v-for="fund in mission.funds" :key="fund.id" class="portfolio-row">
|
||||||
|
<strong>{{ fund.label }}</strong>
|
||||||
|
<p>{{ fund.hint }}</p>
|
||||||
|
<p>مقدار: {{ funds[fund.id] }} سکه</p>
|
||||||
|
<div class="portfolio-row__actions">
|
||||||
|
<button type="button" :aria-label="`کاهش ${fund.label}`" :disabled="!canDecreaseFund(funds, fund.id, step)" @click="change(fund.id, -1)">کم کردن</button>
|
||||||
|
<button type="button" :aria-label="`افزایش ${fund.label}`" :disabled="!canIncreaseFund(funds, step, total)" @click="change(fund.id, 1)">زیاد کردن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="left !== 0" @click="seeOutcomes">دیدن سه سناریو</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'outcomes'">
|
||||||
|
<h2>سه مثال ساختگی</h2>
|
||||||
|
<p>قرعهکشی نشده. هیچ سبدی بهخاطر یک سناریو بهترین نیست. سبد مرجع، ۱۰۰ سکه فقط در آفتاب است.</p>
|
||||||
|
<section v-for="scenario in previews" :key="scenario.id" class="portfolio-scenario">
|
||||||
|
<h3>{{ scenario.title }}</h3>
|
||||||
|
<p>{{ scenario.note }}</p>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="part in scenario.parts" :key="part.label">
|
||||||
|
<th scope="row">{{ part.label }}</th>
|
||||||
|
<td>{{ part.value }} سکه</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">مجموع سبد تو</th>
|
||||||
|
<td>{{ scenario.total }} سکه، {{ scenario.changeLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">سبد مرجع آفتاب</th>
|
||||||
|
<td>{{ scenario.referenceSunTotal }} سکه، {{ scenario.referenceChangeLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="phase = 'build'">تغییر ترکیب</PrimaryButton>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'questions'">رفتن به سؤالها</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'questions' && currentQuestion">
|
||||||
|
<p>سؤال {{ questionIndex + 1 }} از {{ questions.length }}</p>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ currentQuestion.prompt }}</legend>
|
||||||
|
<label v-for="option in currentQuestion.options" :key="option.id" class="portfolio-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:name="currentQuestion.id"
|
||||||
|
:value="option.id"
|
||||||
|
:checked="answers[currentQuestion.id] === option.id"
|
||||||
|
@change="answers = setRiskAnswer(answers, currentQuestion.id, option.id)"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="portfolio-game__nav">
|
||||||
|
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="questionIndex < questions.length - 1" variant="gold" :disabled="!answers[currentQuestion.id]" @click="questionIndex += 1">بعدی</PrimaryButton>
|
||||||
|
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">مرور پاسخها</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'review'">
|
||||||
|
<h2>مرور پاسخها</h2>
|
||||||
|
<p>سبد: آفتاب {{ funds.sun }}، باران {{ funds.rain }}، باد {{ funds.wind }}. نمره از ترکیب سبد نمیآید.</p>
|
||||||
|
<article v-for="(question, index) in questions" :key="question.id">
|
||||||
|
<p>{{ question.prompt }}</p>
|
||||||
|
<p>انتخاب تو: {{ question.options.find((option) => option.id === answers[question.id])?.label ?? 'هنوز انتخاب نشده' }}</p>
|
||||||
|
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
|
||||||
|
</article>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitPortfolio">
|
||||||
|
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
|
||||||
|
</PrimaryButton>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitPortfolio">ارسال دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="result && serverFeedback">
|
||||||
|
<h2>نتیجه</h2>
|
||||||
|
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<section v-for="scenario in serverFeedback.scenarios" :key="scenario.id">
|
||||||
|
<h3>{{ scenario.title }}</h3>
|
||||||
|
<p>مجموع {{ scenario.total }} سکه، {{ scenario.changeLabel }}. مرجع آفتاب: {{ scenario.referenceSunTotal }} سکه، {{ scenario.referenceChangeLabel }}.</p>
|
||||||
|
</section>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in serverFeedback.questions" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{{ serverFeedback.summary }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.portfolio-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
|
||||||
|
.portfolio-game p, .portfolio-game li, .portfolio-game legend { line-height: 1.8; }
|
||||||
|
.portfolio-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
|
||||||
|
.portfolio-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
|
||||||
|
.portfolio-row, .portfolio-scenario, .portfolio-option, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
|
||||||
|
.portfolio-row__actions, .portfolio-game__nav { display: flex; gap: 8px; }
|
||||||
|
.portfolio-option { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
.portfolio-game button, .portfolio-row button { min-height: 44px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: right; padding: 4px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import { offerOutcomeLabels, riskSubmission, setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
|
||||||
|
import type { CompleteLevelResponse, LevelItem, RiskReturnContent } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
backToMap: [focusLevel?: number]
|
||||||
|
nextLevel: [levelId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'compare' | 'questions' | 'review' | 'result'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const mission = ref<RiskReturnContent | null>(null)
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const questionIndex = ref(0)
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
const levels = ref<LevelItem[]>([])
|
||||||
|
|
||||||
|
const questions = computed(() => mission.value?.questions ?? [])
|
||||||
|
const currentQuestion = computed(() => questions.value[questionIndex.value])
|
||||||
|
const guideImage = computed(() => illustrations.professor)
|
||||||
|
const riskFeedback = computed(() => {
|
||||||
|
const feedback = result.value?.feedback
|
||||||
|
if (!feedback || !('questions' in feedback) || !('summary' in feedback) || 'scenarios' in feedback || 'timeline' in feedback) return null
|
||||||
|
return feedback
|
||||||
|
})
|
||||||
|
const nextPlayable = computed(() => {
|
||||||
|
if (!result.value?.unlockedLevelId) return null
|
||||||
|
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
|
||||||
|
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
function choose(questionId: string, optionId: string) {
|
||||||
|
answers.value = setRiskAnswer(answers.value, questionId, optionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLabel(questionId: string) {
|
||||||
|
const question = questions.value.find((item) => item.id === questionId)
|
||||||
|
const optionId = answers.value[questionId]
|
||||||
|
return question?.options.find((option) => option.id === optionId)?.label ?? 'هنوز انتخاب نشده'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMission() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
submitError.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'risk-return' || !detail.activity.riskReturn) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
mission.value = detail.activity.riskReturn
|
||||||
|
answers.value = {}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت چالش'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goReview() {
|
||||||
|
if (questions.value.some((question) => !answers.value[question.id])) {
|
||||||
|
submitError.value = 'اول به هر پنج فعالیت پاسخ بده.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitError.value = ''
|
||||||
|
phase.value = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChallenge() {
|
||||||
|
if (submitting.value || !mission.value) return
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() =>
|
||||||
|
goldLandApi.completeLevel(
|
||||||
|
props.levelId,
|
||||||
|
riskSubmission(questions.value.map((question) => question.id), answers.value),
|
||||||
|
),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
levels.value = (await goldLandApi.getProgress()).levels
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) return
|
||||||
|
submitError.value =
|
||||||
|
err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. پاسخهایت همینجا مانده. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadMission)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="risk-challenge">
|
||||||
|
<p v-if="loading">در حال بارگذاری چالش...</p>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'intro'">
|
||||||
|
<img :src="guideImage" alt="" class="risk-challenge__guide" />
|
||||||
|
<p class="risk-challenge__guide-name">{{ mission.guideTitle }}</p>
|
||||||
|
<h2>{{ mission.title }}</h2>
|
||||||
|
<p>{{ mission.intro }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'compare'">دیدن دو پیشنهاد</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'compare'">
|
||||||
|
<h2>دو پیشنهاد خیالی برای {{ mission.stakeCoins }} سکه</h2>
|
||||||
|
<p>{{ mission.disclaimer }}</p>
|
||||||
|
<p>{{ mission.exampleLimit }}</p>
|
||||||
|
<section v-for="offer in mission.offers" :key="offer.id" class="risk-offer">
|
||||||
|
<h3>{{ offer.label }}</h3>
|
||||||
|
<p>چهار نتیجهٔ هماحتمال. شانس هر کارت در این مثال یکسان است.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="label in offerOutcomeLabels(offer.label, offer.outcomes)" :key="label">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="phase = 'questions'">رفتن به فعالیتها</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'questions' && currentQuestion">
|
||||||
|
<p>فعالیت {{ questionIndex + 1 }} از {{ questions.length }}</p>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ currentQuestion.prompt }}</legend>
|
||||||
|
<label v-for="option in currentQuestion.options" :key="option.id" class="risk-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:name="currentQuestion.id"
|
||||||
|
:value="option.id"
|
||||||
|
:checked="answers[currentQuestion.id] === option.id"
|
||||||
|
@change="choose(currentQuestion.id, option.id)"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="risk-challenge__nav">
|
||||||
|
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
v-if="questionIndex < questions.length - 1"
|
||||||
|
variant="gold"
|
||||||
|
:disabled="!answers[currentQuestion.id]"
|
||||||
|
@click="questionIndex += 1"
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">
|
||||||
|
مرور پاسخها
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mission && phase === 'review'">
|
||||||
|
<h2>مرور پاسخها</h2>
|
||||||
|
<p>قبل از ارسال میتوانی هر پاسخ را عوض کنی. امتیاز از نتیجهٔ شانسی نمیآید.</p>
|
||||||
|
<article v-for="(question, index) in questions" :key="question.id">
|
||||||
|
<p>{{ question.prompt }}</p>
|
||||||
|
<p>انتخاب تو: {{ selectedLabel(question.id) }}</p>
|
||||||
|
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
|
||||||
|
</article>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitChallenge">
|
||||||
|
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
|
||||||
|
</PrimaryButton>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitChallenge">
|
||||||
|
ارسال دوباره
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="result && riskFeedback">
|
||||||
|
<h2>نتیجهٔ چالش</h2>
|
||||||
|
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">
|
||||||
|
پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.
|
||||||
|
</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in riskFeedback.questions" :key="item.id">
|
||||||
|
{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>{{ riskFeedback.summary }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">
|
||||||
|
بازگشت به نقشه
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">
|
||||||
|
رفتن به مرحله بعد
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.risk-challenge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-challenge p,
|
||||||
|
.risk-challenge li,
|
||||||
|
.risk-challenge legend {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-challenge__guide {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-challenge__guide-name {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-offer,
|
||||||
|
.risk-option,
|
||||||
|
article {
|
||||||
|
border: 1px solid rgba(118, 75, 190, 0.35);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-option {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-challenge__nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-challenge button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
|
import { illustrations } from '@/assets/illustrations'
|
||||||
|
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
|
||||||
|
import { runMeMutation } from '@/api/sync'
|
||||||
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
import { moneyText, setShopAnswer } from '@/modules/gold-land/smart-shop/shopText'
|
||||||
|
import type { CompleteLevelResponse, LevelItem, SmartShopContent } from '@/modules/gold-land/types/goldLand.types'
|
||||||
|
|
||||||
|
const props = defineProps<{ levelId: number }>()
|
||||||
|
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'mission' | 'review' | 'result'
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const phase = ref<Phase>('intro')
|
||||||
|
const missionIndex = ref(0)
|
||||||
|
const shop = ref<SmartShopContent | null>(null)
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const result = ref<CompleteLevelResponse | null>(null)
|
||||||
|
const levels = ref<LevelItem[]>([])
|
||||||
|
|
||||||
|
const mission = computed(() => shop.value?.missions[missionIndex.value] ?? null)
|
||||||
|
const feedback = computed(() => {
|
||||||
|
const value = result.value?.feedback
|
||||||
|
if (!value || !('purchases' in value)) return null
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
const nextPlayable = computed(() => {
|
||||||
|
if (!result.value?.unlockedLevelId) return null
|
||||||
|
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
|
||||||
|
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
function choose(questionId: string, optionId: string) {
|
||||||
|
answers.value = setShopAnswer(answers.value, questionId, optionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMission() {
|
||||||
|
if (!mission.value) return
|
||||||
|
if (!answers.value[mission.value.choice.id] || !answers.value[mission.value.reason.id]) {
|
||||||
|
submitError.value = 'اول انتخاب و دلیل این مأموریت را مشخص کن.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitError.value = ''
|
||||||
|
if (missionIndex.value >= (shop.value?.missions.length ?? 1) - 1) {
|
||||||
|
phase.value = 'review'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
missionIndex.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShop() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
phase.value = 'intro'
|
||||||
|
try {
|
||||||
|
const detail = await goldLandApi.getLevel(props.levelId)
|
||||||
|
if (detail.level.status === 'locked') {
|
||||||
|
error.value = 'این مرحله هنوز قفل است.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.level.status === 'coming_soon') {
|
||||||
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (detail.activity?.type !== 'smart-shop' || !detail.activity.smartShop) {
|
||||||
|
error.value = 'محتوای این مرحله در دسترس نیست.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goldLandApi.startLevel(props.levelId)
|
||||||
|
shop.value = detail.activity.smartShop
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'خطا در دریافت بازی'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShop() {
|
||||||
|
if (submitting.value || !shop.value) return
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await runMeMutation(
|
||||||
|
() => goldLandApi.completeLevel(
|
||||||
|
props.levelId,
|
||||||
|
shop.value!.missions.flatMap((item) => [
|
||||||
|
{ questionId: item.choice.id, answerId: answers.value[item.choice.id] ?? '' },
|
||||||
|
{ questionId: item.reason.id, answerId: answers.value[item.reason.id] ?? '' },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
(value) => value.me,
|
||||||
|
)
|
||||||
|
result.value = response
|
||||||
|
phase.value = 'result'
|
||||||
|
levels.value = (await goldLandApi.getProgress()).levels
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StaleSessionError) return
|
||||||
|
submitError.value = err instanceof ApiNetworkError
|
||||||
|
? 'اتصال برقرار نشد. انتخابها حفظ شدهاند. دوباره بفرست.'
|
||||||
|
: err instanceof ApiClientError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'ارسال نتیجه ناموفق بود'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadShop)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shop-game">
|
||||||
|
<p v-if="loading">در حال بارگذاری بازی...</p>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<p role="alert">{{ error }}</p>
|
||||||
|
<PrimaryButton variant="purple" full-width @click="loadShop">تلاش دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="shop && phase === 'intro'">
|
||||||
|
<img :src="illustrations.professor" alt="" class="shop-game__guide" />
|
||||||
|
<p class="shop-game__guide-name">{{ shop.guideTitle }}</p>
|
||||||
|
<h2>{{ shop.title }}</h2>
|
||||||
|
<p>{{ shop.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="rule in shop.rules" :key="rule">{{ rule }}</li>
|
||||||
|
</ul>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="phase = 'mission'">شروع خریدها</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="shop && mission && phase === 'mission'">
|
||||||
|
<h2>{{ mission.title }}</h2>
|
||||||
|
<p>نیاز: {{ mission.need }}</p>
|
||||||
|
<p>بودجه: {{ moneyText(mission.budget) }}</p>
|
||||||
|
<article v-for="card in mission.cards" :key="card.id" class="shop-card">
|
||||||
|
<h3>{{ card.label }}</h3>
|
||||||
|
<p v-for="fact in card.facts" :key="fact">{{ fact }}</p>
|
||||||
|
<p v-if="card.previousPrice !== null">قیمت قبلی: {{ moneyText(card.previousPrice) }}</p>
|
||||||
|
<p v-if="card.discountText">تخفیف: {{ card.discountText }}</p>
|
||||||
|
<p>قیمت: {{ moneyText(card.price) }}</p>
|
||||||
|
<p>تحویل: {{ card.delivery === 0 ? 'رایگان' : moneyText(card.delivery) }}</p>
|
||||||
|
<p>جمع نهایی: {{ moneyText(card.finalCost) }}</p>
|
||||||
|
</article>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ mission.choice.prompt }}</legend>
|
||||||
|
<label v-for="option in mission.choice.options" :key="option.id">
|
||||||
|
<input type="radio" :name="mission.choice.id" :value="option.id" :checked="answers[mission.choice.id] === option.id" @change="choose(mission.choice.id, option.id)" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ mission.reason.prompt }}</legend>
|
||||||
|
<label v-for="option in mission.reason.options" :key="option.id">
|
||||||
|
<input type="radio" :name="mission.reason.id" :value="option.id" :checked="answers[mission.reason.id] === option.id" @change="choose(mission.reason.id, option.id)" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="nextMission">{{ missionIndex === shop.missions.length - 1 ? 'مرور انتخابها' : 'مأموریت بعد' }}</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="shop && phase === 'review'">
|
||||||
|
<h2>مرور خریدها</h2>
|
||||||
|
<article v-for="(item, index) in shop.missions" :key="item.id">
|
||||||
|
<h3>{{ item.title }}</h3>
|
||||||
|
<p>انتخاب: {{ item.choice.options.find((option) => option.id === answers[item.choice.id])?.label }}</p>
|
||||||
|
<p>دلیل: {{ item.reason.options.find((option) => option.id === answers[item.reason.id])?.label }}</p>
|
||||||
|
<button type="button" @click="missionIndex = index; phase = 'mission'">تغییر این مأموریت</button>
|
||||||
|
</article>
|
||||||
|
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitShop">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
|
||||||
|
<p v-if="submitError" role="alert">{{ submitError }}</p>
|
||||||
|
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitShop">ارسال دوباره</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result && feedback">
|
||||||
|
<h2>نتیجه</h2>
|
||||||
|
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
|
||||||
|
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
|
||||||
|
<p v-else>این بار پاداش تازهای اضافه نشد.</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in feedback.purchases" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{{ feedback.summary }}</p>
|
||||||
|
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
|
||||||
|
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shop-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
|
||||||
|
.shop-game p, .shop-game li, .shop-game legend { line-height: 1.8; }
|
||||||
|
.shop-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
|
||||||
|
.shop-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
|
||||||
|
.shop-card, article, label { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
|
||||||
|
label { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
button, .shop-card { min-height: 44px; }
|
||||||
|
</style>
|
||||||
@@ -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<T>(plan: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(plan)) as T
|
||||||
|
}
|
||||||
@@ -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<T>(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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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} سکه`)
|
||||||
|
}
|
||||||
@@ -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<string, string>, questionId: string, optionId: string): Record<string, string> {
|
||||||
|
return { ...answers, [questionId]: optionId }
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export interface LevelItem {
|
|||||||
rewardCoins: number
|
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 {
|
export interface LessonOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -67,12 +67,35 @@ export interface SummaryStep {
|
|||||||
highlight?: string
|
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 =
|
export type SanitizedLessonStep =
|
||||||
| StoryStep
|
| StoryStep
|
||||||
| SingleChoiceStep
|
| SingleChoiceStep
|
||||||
| TrueFalseStep
|
| TrueFalseStep
|
||||||
| SortStep
|
| SortStep
|
||||||
| SummaryStep
|
| SummaryStep
|
||||||
|
| GrowthTableStep
|
||||||
|
|
||||||
export interface SanitizedLesson {
|
export interface SanitizedLesson {
|
||||||
id: string
|
id: string
|
||||||
@@ -99,6 +122,12 @@ export interface LevelDetailResponse {
|
|||||||
activity: {
|
activity: {
|
||||||
type: string
|
type: string
|
||||||
lesson?: SanitizedLesson
|
lesson?: SanitizedLesson
|
||||||
|
budgeting?: BudgetMissionContent
|
||||||
|
riskReturn?: RiskReturnContent
|
||||||
|
portfolio?: PortfolioMissionContent
|
||||||
|
smartShop?: SmartShopContent
|
||||||
|
financialGoal?: FinancialGoalContent
|
||||||
|
castleExam?: CastleExamContent
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +148,193 @@ export interface CompleteLevelResponse {
|
|||||||
reward: { xp: number; coins: number }
|
reward: { xp: number; coins: number }
|
||||||
unlockedLevelId: number | null
|
unlockedLevelId: number | null
|
||||||
me: import('@/api/client').MeResponse
|
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 {
|
export interface ChoiceLessonAnswer {
|
||||||
@@ -131,7 +347,32 @@ export interface SortLessonAnswer {
|
|||||||
orderedItemIds: string[]
|
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 */
|
/** @deprecated Use LessonSubmissionAnswer */
|
||||||
export interface LessonAnswer {
|
export interface LessonAnswer {
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ onActivated(loadProgress)
|
|||||||
>
|
>
|
||||||
<template v-if="selectedLevel?.status === 'coming_soon'">بهزودی</template>
|
<template v-if="selectedLevel?.status === 'coming_soon'">بهزودی</template>
|
||||||
<template v-else-if="isLevelCompleted(selectedLevel!)">مرور مجدد</template>
|
<template v-else-if="isLevelCompleted(selectedLevel!)">مرور مجدد</template>
|
||||||
|
<template v-else-if="selectedLevel?.activityType === 'game'">شروع بازی</template>
|
||||||
|
<template v-else-if="selectedLevel?.activityType === 'challenge'">شروع چالش</template>
|
||||||
|
<template v-else-if="selectedLevel?.activityType === 'final_exam'">شروع آزمون</template>
|
||||||
<template v-else>شروع درس</template>
|
<template v-else>شروع درس</template>
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import AppHeader from '@/components/common/AppHeader.vue'
|
import AppHeader from '@/components/common/AppHeader.vue'
|
||||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||||
import LessonPlayer from '@/modules/gold-land/components/lesson/LessonPlayer.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'
|
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -13,6 +19,12 @@ const loading = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const levelDetail = ref<Awaited<ReturnType<typeof goldLandApi.getLevel>> | null>(null)
|
const levelDetail = ref<Awaited<ReturnType<typeof goldLandApi.getLevel>> | null>(null)
|
||||||
const showPlayer = ref(false)
|
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 levelId = computed(() => Number(route.params.levelId))
|
||||||
const level = computed(() => levelDetail.value?.level ?? null)
|
const level = computed(() => levelDetail.value?.level ?? null)
|
||||||
@@ -21,6 +33,12 @@ async function loadLevel() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showPlayer.value = false
|
showPlayer.value = false
|
||||||
|
showBudget.value = false
|
||||||
|
showRisk.value = false
|
||||||
|
showPortfolio.value = false
|
||||||
|
showShop.value = false
|
||||||
|
showGoal.value = false
|
||||||
|
showCastle.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
levelDetail.value = await goldLandApi.getLevel(levelId.value)
|
levelDetail.value = await goldLandApi.getLevel(levelId.value)
|
||||||
@@ -34,10 +52,34 @@ async function loadLevel() {
|
|||||||
error.value = 'این مرحله بهزودی باز میشود.'
|
error.value = 'این مرحله بهزودی باز میشود.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (item.activityType === 'lesson' && levelDetail.value.activity?.lesson) {
|
if (levelDetail.value.activity?.type === 'lesson' && levelDetail.value.activity.lesson) {
|
||||||
showPlayer.value = true
|
showPlayer.value = true
|
||||||
return
|
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) {
|
if (item.activityRoute) {
|
||||||
router.replace(item.activityRoute)
|
router.replace(item.activityRoute)
|
||||||
return
|
return
|
||||||
@@ -94,6 +136,53 @@ watch(levelId, () => {
|
|||||||
@back-to-map="goToMap"
|
@back-to-map="goToMap"
|
||||||
@next-level="goToNextLevel"
|
@next-level="goToNextLevel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BudgetMissionPlayer
|
||||||
|
v-else-if="showBudget"
|
||||||
|
:key="`budget-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
@next-level="goToNextLevel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RiskReturnPlayer
|
||||||
|
v-else-if="showRisk"
|
||||||
|
:key="`risk-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
@next-level="goToNextLevel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PortfolioPlayer
|
||||||
|
v-else-if="showPortfolio"
|
||||||
|
:key="`portfolio-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
@next-level="goToNextLevel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SmartShopPlayer
|
||||||
|
v-else-if="showShop"
|
||||||
|
:key="`shop-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
@next-level="goToNextLevel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FinancialGoalPlayer
|
||||||
|
v-else-if="showGoal"
|
||||||
|
:key="`goal-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
@next-level="goToNextLevel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CastleExamPlayer
|
||||||
|
v-else-if="showCastle"
|
||||||
|
:key="`castle-${levelId}`"
|
||||||
|
:level-id="levelId"
|
||||||
|
@back-to-map="goToMap"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
adjustBudget,
|
||||||
|
budgetReserve,
|
||||||
|
canDecrease,
|
||||||
|
canIncrease,
|
||||||
|
budgetSubmission,
|
||||||
|
cloneBudget,
|
||||||
|
emptyBudget,
|
||||||
|
missingEssentials,
|
||||||
|
} from '../src/modules/gold-land/budgeting/budgetMath'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ id: 'transport' as const, label: 'رفتوآمد', requiredInitial: 20, requiredFinal: 30 },
|
||||||
|
{ id: 'food' as const, label: 'آب و غذا', requiredInitial: 25, requiredFinal: 25 },
|
||||||
|
{ id: 'savings' as const, label: 'پسانداز', requiredInitial: null, requiredFinal: null },
|
||||||
|
{ id: 'fun' as const, label: 'تفریح', requiredInitial: null, requiredFinal: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('reserve and button limits stay inside 100 coins', () => {
|
||||||
|
let amounts = emptyBudget()
|
||||||
|
assert.equal(budgetReserve(100, amounts), 100)
|
||||||
|
assert.equal(canDecrease(amounts, 'fun', 5), false)
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
amounts = adjustBudget(amounts, 'fun', 5, 5, 100)
|
||||||
|
}
|
||||||
|
assert.equal(budgetReserve(100, amounts), 0)
|
||||||
|
assert.equal(canIncrease(amounts, 5, 100), false)
|
||||||
|
const unchanged = adjustBudget(amounts, 'food', 5, 5, 100)
|
||||||
|
assert.equal(unchanged.food, 0)
|
||||||
|
assert.equal(unchanged.fun, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('editing the final budget does not change the submitted initial allocations', () => {
|
||||||
|
const initial = { transport: 20, food: 25, savings: 20, fun: 0 }
|
||||||
|
const finalPlan = cloneBudget(initial)
|
||||||
|
const payload = budgetSubmission(initial, finalPlan)
|
||||||
|
finalPlan.transport += 10
|
||||||
|
finalPlan.fun = 15
|
||||||
|
assert.deepEqual(payload[0]?.allocations, { transport: 20, food: 25, savings: 20, fun: 0 })
|
||||||
|
assert.equal(payload[1]?.allocations.transport, 20)
|
||||||
|
assert.equal(missingEssentials(initial, categories, 'initial').length, 0)
|
||||||
|
assert.equal(missingEssentials(finalPlan, categories, 'final').some((item) => item.id === 'food'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('network and late session responses do not apply a new account', async () => {
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => runMeMutation(async () => {
|
||||||
|
throw new ApiNetworkError()
|
||||||
|
}),
|
||||||
|
ApiNetworkError,
|
||||||
|
)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 999 } } }
|
||||||
|
}),
|
||||||
|
StaleSessionError,
|
||||||
|
)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import { clearExamAnswers, sectionIds, unansweredCount } from '../src/modules/gold-land/castle-exam/castleState'
|
||||||
|
|
||||||
|
const questions = [
|
||||||
|
{ id: 'castle-q1', sectionId: 'basics' },
|
||||||
|
{ id: 'castle-q2', sectionId: 'basics' },
|
||||||
|
{ id: 'castle-q5', sectionId: 'market' },
|
||||||
|
{ id: 'castle-q9', sectionId: 'choices' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('sections keep answers while moving and incomplete exams do not submit', () => {
|
||||||
|
assert.deepEqual(sectionIds(questions), ['basics', 'market', 'choices'])
|
||||||
|
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
|
||||||
|
answers = { ...answers, 'castle-q5': 'no' }
|
||||||
|
assert.equal(answers['castle-q1'], 'exchange')
|
||||||
|
assert.equal(unansweredCount(questions.map((question) => question.id), answers), 2)
|
||||||
|
assert.equal(unansweredCount(questions.map((question) => question.id), answers) === 0, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('network and session failures keep or clear exam answers without applying me', async () => {
|
||||||
|
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
|
||||||
|
assert.equal(answers['castle-q1'], 'exchange')
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
answers = clearExamAnswers()
|
||||||
|
return { me: { user: { coins: 1 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.deepEqual(answers, {})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import { formatCoinTenths, nextShown, previousShown, revealPeriods } from '../src/modules/gold-land/lesson/growthTable'
|
||||||
|
|
||||||
|
const periods = [1000, 1100, 1210, 1331]
|
||||||
|
|
||||||
|
test('period reveal can move forward and back without changing amounts', () => {
|
||||||
|
const snapshot = periods.join(',')
|
||||||
|
let shown = 1
|
||||||
|
assert.deepEqual(revealPeriods(periods, shown), [1000])
|
||||||
|
shown = nextShown(shown, periods.length)
|
||||||
|
shown = nextShown(shown, periods.length)
|
||||||
|
shown = nextShown(shown, periods.length)
|
||||||
|
assert.deepEqual(revealPeriods(periods, shown), periods)
|
||||||
|
shown = previousShown(shown)
|
||||||
|
assert.equal(revealPeriods(periods, shown).at(-1), 1210)
|
||||||
|
assert.equal(periods.join(','), snapshot)
|
||||||
|
assert.equal(formatCoinTenths(1331), '۱۳۳٫۱')
|
||||||
|
assert.equal(formatCoinTenths(1210), '۱۲۱')
|
||||||
|
assert.equal(formatCoinTenths(1300), '۱۳۰')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('review keeps the chosen answer and a failed submit does not apply me', async () => {
|
||||||
|
let answer = '120'
|
||||||
|
answer = '121'
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
throw new ApiNetworkError()
|
||||||
|
}), ApiNetworkError)
|
||||||
|
assert.equal(answer, '121')
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 9 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(answer, '121')
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import { clonePlan, initialPlan, revisedPlan } from '../src/modules/gold-land/financial-goal/goalMath'
|
||||||
|
|
||||||
|
test('changing the later rate does not change the initial snapshot', () => {
|
||||||
|
const initial = initialPlan(30, 120, 15, 30, 10)
|
||||||
|
assert.equal(initial.weeks, 6)
|
||||||
|
assert.equal(initial.remaining, 90)
|
||||||
|
const snapshot = clonePlan(15)
|
||||||
|
const later = revisedPlan(30, 120, snapshot, 5, 20)
|
||||||
|
assert.equal(snapshot, 15)
|
||||||
|
assert.equal(later.balanceAfterTwo, 50)
|
||||||
|
assert.equal(later.totalWeeks, 2 + Math.ceil(70 / 20))
|
||||||
|
const paused = revisedPlan(30, 120, snapshot, 0, 15)
|
||||||
|
assert.equal(paused.balanceAfterTwo, 45)
|
||||||
|
assert.equal(snapshot, 15)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('review and a failed submit keep the chosen plan', async () => {
|
||||||
|
let answers = { 'q-left': '120' }
|
||||||
|
answers = { ...answers, 'q-left': '90' }
|
||||||
|
const plan = clonePlan({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 })
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 1 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(answers['q-left'], '90')
|
||||||
|
assert.equal(plan.exceptionWeekly, 5)
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import {
|
||||||
|
adjustFund,
|
||||||
|
canIncreaseFund,
|
||||||
|
changeLabel,
|
||||||
|
emptyFunds,
|
||||||
|
fundSum,
|
||||||
|
previewScenarios,
|
||||||
|
remainingCoins,
|
||||||
|
} from '../src/modules/gold-land/portfolio/portfolioMath'
|
||||||
|
import { setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
id: 'sunny',
|
||||||
|
title: 'هوای آفتابی',
|
||||||
|
note: 'مثال',
|
||||||
|
changes: [
|
||||||
|
{ fundId: 'sun' as const, percent: 20 },
|
||||||
|
{ fundId: 'rain' as const, percent: -10 },
|
||||||
|
{ fundId: 'wind' as const, percent: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drop',
|
||||||
|
title: 'افت همگانی',
|
||||||
|
note: 'مثال',
|
||||||
|
changes: [
|
||||||
|
{ fundId: 'sun' as const, percent: -10 },
|
||||||
|
{ fundId: 'rain' as const, percent: -10 },
|
||||||
|
{ fundId: 'wind' as const, percent: -10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const funds = [
|
||||||
|
{ id: 'sun' as const, label: 'آفتاب' },
|
||||||
|
{ id: 'rain' as const, label: 'باران' },
|
||||||
|
{ id: 'wind' as const, label: 'باد' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('allocation stays on step 10 and cannot pass 100 or go negative', () => {
|
||||||
|
let amounts = emptyFunds()
|
||||||
|
assert.equal(remainingCoins(100, amounts), 100)
|
||||||
|
assert.equal(canIncreaseFund(amounts, 10, 100), true)
|
||||||
|
for (let index = 0; index < 4; index += 1) amounts = adjustFund(amounts, 'sun', 10, 10, 100)
|
||||||
|
amounts = adjustFund(amounts, 'rain', 10, 10, 100)
|
||||||
|
amounts = adjustFund(amounts, 'wind', 50, 10, 100)
|
||||||
|
assert.equal(fundSum(amounts), 100)
|
||||||
|
assert.equal(remainingCoins(100, amounts), 0)
|
||||||
|
assert.deepEqual(adjustFund(amounts, 'sun', 10, 10, 100), amounts)
|
||||||
|
assert.deepEqual(adjustFund(amounts, 'rain', -20, 10, 100), amounts)
|
||||||
|
const reduced = adjustFund(amounts, 'rain', -10, 10, 100)
|
||||||
|
assert.equal(reduced.rain, 0)
|
||||||
|
assert.equal(remainingCoins(100, reduced), 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changing the mix refreshes all shown outcomes and loss is written in words', () => {
|
||||||
|
const first = previewScenarios({ sun: 100, rain: 0, wind: 0 }, scenarios, funds)
|
||||||
|
assert.equal(first[0]?.changeLabel, 'سود 20 سکه')
|
||||||
|
const drop = first.find((item) => item.id === 'drop')
|
||||||
|
assert.equal(drop?.total, 90)
|
||||||
|
assert.equal(drop?.changeLabel, changeLabel(90))
|
||||||
|
assert.equal(drop?.changeLabel.includes('زیان'), true)
|
||||||
|
const second = previewScenarios({ sun: 40, rain: 30, wind: 30 }, scenarios, funds)
|
||||||
|
assert.equal(second[0]?.total, 105)
|
||||||
|
assert.notEqual(second[0]?.total, first[0]?.total)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('review keeps answers and a network or stale session does not apply me', async () => {
|
||||||
|
let answers = setRiskAnswer({}, 'q-one', 'safe')
|
||||||
|
answers = setRiskAnswer(answers, 'q-one', 'depend')
|
||||||
|
assert.equal(answers['q-one'], 'depend')
|
||||||
|
const mix = { sun: 40, rain: 30, wind: 30 }
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
throw new ApiNetworkError()
|
||||||
|
}), ApiNetworkError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(mix.sun, 40)
|
||||||
|
assert.equal(answers['q-one'], 'depend')
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 1 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(answers['q-one'], 'depend')
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import { offerOutcomeLabels, riskSubmission, setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
|
||||||
|
|
||||||
|
const offers = [
|
||||||
|
{ label: 'پیشنهاد الف', outcomes: [20, 20, 22, 22] },
|
||||||
|
{ label: 'پیشنهاد ب', outcomes: [8, 16, 28, 36] },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('both offers show all four outcomes in text', () => {
|
||||||
|
const labels = offers.flatMap((offer) => offerOutcomeLabels(offer.label, offer.outcomes))
|
||||||
|
assert.equal(labels.length, 8)
|
||||||
|
assert.equal(labels.filter((label) => label.includes('20 سکه')).length, 2)
|
||||||
|
assert.equal(labels.some((label) => label.includes('کارت 1 از 4: 8 سکه')), true)
|
||||||
|
assert.equal(labels.some((label) => label.includes('36 سکه')), true)
|
||||||
|
assert.equal(labels.some((label) => label.includes('22 سکه')), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('review can change an answer before submit', () => {
|
||||||
|
let answers = setRiskAnswer({}, 'q-loss', 'a')
|
||||||
|
answers = setRiskAnswer(answers, 'q-loss', 'b')
|
||||||
|
const payload = riskSubmission(['q-loss', 'q-certain'], answers)
|
||||||
|
assert.equal(payload[0]?.answerId, 'b')
|
||||||
|
assert.equal(payload[1]?.answerId, '')
|
||||||
|
answers = setRiskAnswer(answers, 'q-certain', 'no')
|
||||||
|
assert.equal(riskSubmission(['q-certain'], answers)[0]?.answerId, 'no')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('network failure and a late session do not clear local answers or apply me', async () => {
|
||||||
|
let answers = setRiskAnswer({}, 'q-loss', 'b')
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
throw new ApiNetworkError()
|
||||||
|
}), ApiNetworkError)
|
||||||
|
assert.equal(answers['q-loss'], 'b')
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 1 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(answers['q-loss'], 'b')
|
||||||
|
})
|
||||||
@@ -11,6 +11,13 @@ const files = [
|
|||||||
'catalog-api.test.ts',
|
'catalog-api.test.ts',
|
||||||
'mutation-queue.test.ts',
|
'mutation-queue.test.ts',
|
||||||
'gold-guess-rewards.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
|
let failed = false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
|
||||||
|
import { invalidateSession } from '../src/api/authSession'
|
||||||
|
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
|
||||||
|
import { moneyText, setShopAnswer } from '../src/modules/gold-land/smart-shop/shopText'
|
||||||
|
|
||||||
|
test('need budget and cost parts stay distinct, and unknown is not zero', () => {
|
||||||
|
assert.equal(moneyText(50).includes('۵۰'), true)
|
||||||
|
assert.equal(moneyText(45).includes('۴۵'), true)
|
||||||
|
assert.equal(moneyText(null), 'نامشخص')
|
||||||
|
assert.notEqual(moneyText(null), moneyText(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('review can change a choice and a failed submit keeps it', async () => {
|
||||||
|
let answers = setShopAnswer({}, 'notebook-choice', 'a')
|
||||||
|
answers = setShopAnswer(answers, 'notebook-choice', 'b')
|
||||||
|
assert.equal(answers['notebook-choice'], 'b')
|
||||||
|
resetMeMutationQueueForTests()
|
||||||
|
let applied = 0
|
||||||
|
bindMeMutationApply(() => {
|
||||||
|
applied += 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
throw new ApiNetworkError()
|
||||||
|
}), ApiNetworkError)
|
||||||
|
await assert.rejects(() => runMeMutation(async () => {
|
||||||
|
invalidateSession()
|
||||||
|
return { me: { user: { coins: 1 } } }
|
||||||
|
}), StaleSessionError)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
assert.equal(answers['notebook-choice'], 'b')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user