stuff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
fface20519
commit
9dd7662b3d
31 changed files with 1967 additions and 609 deletions
13
PROGRESS.md
13
PROGRESS.md
|
|
@ -37,11 +37,15 @@
|
||||||
- Ensured voices list still loads while the form is disabled during save
|
- Ensured voices list still loads while the form is disabled during save
|
||||||
- Added unit tests for the upsert+voice flow (`mnemo_cards_admin`)
|
- Added unit tests for the upsert+voice flow (`mnemo_cards_admin`)
|
||||||
- **Admin Packs: Color Palette Picker**: Added palette-based color selection for packs
|
- **Admin Packs: Color Palette Picker**: Added palette-based color selection for packs
|
||||||
- Replaced free-text color input with swatches + native color picker
|
- Replaced free-text color input with native color picker (palette)
|
||||||
|
- Removed hardcoded default swatch colors (keep only palette)
|
||||||
- Added unit tests for the new color picker component (`mnemo_cards_admin`)
|
- Added unit tests for the new color picker component (`mnemo_cards_admin`)
|
||||||
- **Admin Tests: Pack Linking**: Added ability to link tests to packs in admin
|
- **Admin Tests: Pack Linking**: Added ability to link tests to packs in admin
|
||||||
- Test editor now supports selecting packs and syncing links on Save
|
- Test editor now supports selecting packs and syncing links on Save
|
||||||
- Added unit test coverage for the new pack-linking component (`mnemo_cards_admin`)
|
- Added unit test coverage for the new pack-linking component (`mnemo_cards_admin`)
|
||||||
|
- **Admin Tests: Color Picker + Pack Color Sync**: Added palette-based test color selection
|
||||||
|
- Test editor now uses the same palette picker as packs
|
||||||
|
- When selecting/changing a pack in test editor, test color auto-updates to the pack color
|
||||||
|
|
||||||
- **Service Reliability**: Enhanced service management and monitoring
|
- **Service Reliability**: Enhanced service management and monitoring
|
||||||
- Port conflict detection and resolution
|
- Port conflict detection and resolution
|
||||||
|
|
@ -79,6 +83,7 @@
|
||||||
- Card UI: moved voice play icon into a `Stack` overlay so it doesn't take an extra row on the card (with widget test coverage)
|
- Card UI: moved voice play icon into a `Stack` overlay so it doesn't take an extra row on the card (with widget test coverage)
|
||||||
- CardViewer UI: navigation arrows are now constrained under the card on wide screens; favorites (heart) button is positioned closer to the card
|
- CardViewer UI: navigation arrows are now constrained under the card on wide screens; favorites (heart) button is positioned closer to the card
|
||||||
- Added widget test coverage for wide-screen navigation button positioning
|
- Added widget test coverage for wide-screen navigation button positioning
|
||||||
|
- Pack details: "Перемешать" button no longer has an active (pressed) state (widget test updated)
|
||||||
- Added app version display on authentication page
|
- Added app version display on authentication page
|
||||||
- **UI Components Refactoring**: Extracted authentication components
|
- **UI Components Refactoring**: Extracted authentication components
|
||||||
- Created `SignInWithGoogleButton` component for Google authentication
|
- Created `SignInWithGoogleButton` component for Google authentication
|
||||||
|
|
@ -98,6 +103,7 @@
|
||||||
- Added SafeArea wrapping and themed question cards on `game_page.dart`
|
- Added SafeArea wrapping and themed question cards on `game_page.dart`
|
||||||
- Added widget tests for themed question cards, exit control, and interactive-only notice
|
- Added widget tests for themed question cards, exit control, and interactive-only notice
|
||||||
- Timer/progress now sourced from live session state on `game_page.dart`
|
- Timer/progress now sourced from live session state on `game_page.dart`
|
||||||
|
- Fixed broken game navigation: `TestPage` now pushes `/game/:id` so Exit/Finish/Back work (incl. deep-link fallback) + widget tests
|
||||||
- **Game Tests Package**: Started shared package `games/packages/game_tests`
|
- **Game Tests Package**: Started shared package `games/packages/game_tests`
|
||||||
- Added configurable `GameTestSettings` (sounds/haptics/delay)
|
- Added configurable `GameTestSettings` (sounds/haptics/delay)
|
||||||
- Integrated into `mnemo_cards_web_v2` via `TestsModule`/`TestsStateManager`
|
- Integrated into `mnemo_cards_web_v2` via `TestsModule`/`TestsStateManager`
|
||||||
|
|
@ -132,6 +138,11 @@
|
||||||
- Admin voice uploads are persisted to `data/voice/` and DB stores only a file name (no base64)
|
- Admin voice uploads are persisted to `data/voice/` and DB stores only a file name (no base64)
|
||||||
- Public voice list heals legacy base64-in-DB by migrating to file on first read
|
- Public voice list heals legacy base64-in-DB by migrating to file on first read
|
||||||
- `/api/v2/voice/<voiceId>` serves bytes (or redirects for remote URLs)
|
- `/api/v2/voice/<voiceId>` serves bytes (or redirects for remote URLs)
|
||||||
|
- **Admin Tests: Stop Returning base64 Images**: `/api/v2/admin/tests/<id>` now returns image **links**, never base64
|
||||||
|
- Self-heals legacy base64/data-url values on read by persisting them into `data/cards/` and storing only a `cardId` in DB
|
||||||
|
- Normalizes admin round-trip values (`/api/.../cards/<id>/image` → `<cardId>`) so editing tests doesn't pollute DB with API URLs
|
||||||
|
- When linking a test to a pack, automatically links all referenced image cards to that pack so `/api/v2/packs/<packId>/cards/<cardId>/image` works
|
||||||
|
- Added focused backend unit test: `mnemo_cards_backend/test/api/v2/admin_tests_api_v2_image_urls_test.dart`
|
||||||
- **User Telegram Field**: Added telegram field to user model
|
- **User Telegram Field**: Added telegram field to user model
|
||||||
- Added `telegram` field to `UserModel` and `UserDto` alongside `email`
|
- Added `telegram` field to `UserModel` and `UserDto` alongside `email`
|
||||||
- Added `telegram` column to `users` table in database (migration v2→v3)
|
- Added `telegram` column to `users` table in database (migration v2→v3)
|
||||||
|
|
|
||||||
9
TODO.md
9
TODO.md
|
|
@ -53,6 +53,7 @@
|
||||||
- Common libraries: Target 90% coverage
|
- Common libraries: Target 90% coverage
|
||||||
- ✅ Added unit test for version display on auth page
|
- ✅ Added unit test for version display on auth page
|
||||||
- ✅ Web: CardViewer navigation controls are constrained under the card on wide screens (widget test added)
|
- ✅ Web: CardViewer navigation controls are constrained under the card on wide screens (widget test added)
|
||||||
|
- ✅ Web: Pack details "Перемешать" button has no active (pressed) state (widget test updated)
|
||||||
- ✅ **User Telegram Field Tests**: Added comprehensive unit tests for telegram field
|
- ✅ **User Telegram Field Tests**: Added comprehensive unit tests for telegram field
|
||||||
- Created `test/models/user_model_telegram_test.dart` with 6 test cases
|
- Created `test/models/user_model_telegram_test.dart` with 6 test cases
|
||||||
- Created `test/user_dto_telegram_test.dart` with 7 test cases
|
- Created `test/user_dto_telegram_test.dart` with 7 test cases
|
||||||
|
|
@ -60,6 +61,7 @@
|
||||||
- ✅ Refactored authentication components - need to add unit tests for SignInWithGoogleButton and SignInWithTelegram
|
- ✅ Refactored authentication components - need to add unit tests for SignInWithGoogleButton and SignInWithTelegram
|
||||||
- ✅ Added comprehensive unit tests for ThemeToggleWidget (7 test cases covering all functionality)
|
- ✅ Added comprehensive unit tests for ThemeToggleWidget (7 test cases covering all functionality)
|
||||||
- ✅ Game/Test flow cleanup: removed traditional test entry, themed game page, added widget coverage for question card surfaces, exit control, and interactive-only notice; timer/progress pulled from live session state
|
- ✅ Game/Test flow cleanup: removed traditional test entry, themed game page, added widget coverage for question card surfaces, exit control, and interactive-only notice; timer/progress pulled from live session state
|
||||||
|
- ✅ Web: fixed game navigation actions (Exit/Finish/Back) by using push to `/game/:id` and adding deep-link fallback + widget tests
|
||||||
- ✅ Game Tests package: created `games/packages/game_tests`, added `GameTestSettings`, integrated into `mnemo_cards_web_v2` settings and state manager
|
- ✅ Game Tests package: created `games/packages/game_tests`, added `GameTestSettings`, integrated into `mnemo_cards_web_v2` settings and state manager
|
||||||
- ✅ **Test Page Code Quality Fix**: Fixed critical code duplication in `test_page.dart`
|
- ✅ **Test Page Code Quality Fix**: Fixed critical code duplication in `test_page.dart`
|
||||||
- Removed 700+ lines of duplicate code
|
- Removed 700+ lines of duplicate code
|
||||||
|
|
@ -110,6 +112,7 @@
|
||||||
- Response time optimization
|
- Response time optimization
|
||||||
- [x] **Card Images Storage**: Stop storing base64 in DB; always store path/file name and serve via image endpoints (фикс 500 на картинках)
|
- [x] **Card Images Storage**: Stop storing base64 in DB; always store path/file name and serve via image endpoints (фикс 500 на картинках)
|
||||||
- [x] **Card Voices Storage**: Stop storing base64 in DB; store voice file name and serve via `/api/v2/voice/<id>` (voices list returns direct `url`)
|
- [x] **Card Voices Storage**: Stop storing base64 in DB; store voice file name and serve via `/api/v2/voice/<id>` (voices list returns direct `url`)
|
||||||
|
- [x] **Tests Images (Admin API)**: Stop returning base64 in `/api/v2/admin/tests/<id>`; always return image links and heal legacy base64 on read
|
||||||
- [x] **Generated Tests Cleanup**: Fix orphan generated tests and add TTL purge (cron/DB cleanup)
|
- [x] **Generated Tests Cleanup**: Fix orphan generated tests and add TTL purge (cron/DB cleanup)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
@ -123,10 +126,14 @@
|
||||||
- Added voice uploader to card editor (upload on Save)
|
- Added voice uploader to card editor (upload on Save)
|
||||||
- Added unit tests for upsert+voice flow
|
- Added unit tests for upsert+voice flow
|
||||||
- [x] **Admin Packs: Color Palette Picker**: Add palette-based pack color selection
|
- [x] **Admin Packs: Color Palette Picker**: Add palette-based pack color selection
|
||||||
- Replace free-text hex input with swatches + native picker
|
- Replace free-text hex input with native picker (palette)
|
||||||
|
- Remove hardcoded default swatch colors (keep only palette)
|
||||||
- Add unit tests for the picker component
|
- Add unit tests for the picker component
|
||||||
- [x] **Admin Tests: Link to Packs**: Add pack selection in test editor and sync test↔pack links on Save
|
- [x] **Admin Tests: Link to Packs**: Add pack selection in test editor and sync test↔pack links on Save
|
||||||
- Add unit test coverage for pack-linking UI
|
- Add unit test coverage for pack-linking UI
|
||||||
|
- [x] **Admin Tests: Color Picker + Pack Color Sync**: Use palette picker for test color and sync from selected pack
|
||||||
|
- Replace free-text test color input with `ColorPaletteInput`
|
||||||
|
- Auto-update test color when selecting/changing a pack in test editor
|
||||||
- [x] **Matrix Test (image selection)**: Add matrix question type end-to-end (backend + web + admin)
|
- [x] **Matrix Test (image selection)**: Add matrix question type end-to-end (backend + web + admin)
|
||||||
- Auto-generate matrix images from pack card pool (generator framework)
|
- Auto-generate matrix images from pack card pool (generator framework)
|
||||||
- Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
|
- Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@ import { Search, Check } from 'lucide-react'
|
||||||
interface TestPacksManagerProps {
|
interface TestPacksManagerProps {
|
||||||
currentPackIds: string[]
|
currentPackIds: string[]
|
||||||
onPacksChange: (addIds: string[], removeIds: string[]) => void
|
onPacksChange: (addIds: string[], removeIds: string[]) => void
|
||||||
|
onSelectedPackColorChange?: (color: string) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestPacksManager({
|
export function TestPacksManager({
|
||||||
currentPackIds,
|
currentPackIds,
|
||||||
onPacksChange,
|
onPacksChange,
|
||||||
|
onSelectedPackColorChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: TestPacksManagerProps) {
|
}: TestPacksManagerProps) {
|
||||||
// Reset internal state when switching tests / pack list.
|
// Reset internal state when switching tests / pack list.
|
||||||
|
|
@ -35,6 +37,7 @@ export function TestPacksManager({
|
||||||
key={stateKey}
|
key={stateKey}
|
||||||
currentPackIds={currentPackIds}
|
currentPackIds={currentPackIds}
|
||||||
onPacksChange={onPacksChange}
|
onPacksChange={onPacksChange}
|
||||||
|
onSelectedPackColorChange={onSelectedPackColorChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -43,6 +46,7 @@ export function TestPacksManager({
|
||||||
function TestPacksManagerInner({
|
function TestPacksManagerInner({
|
||||||
currentPackIds,
|
currentPackIds,
|
||||||
onPacksChange,
|
onPacksChange,
|
||||||
|
onSelectedPackColorChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: TestPacksManagerProps) {
|
}: TestPacksManagerProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
@ -72,6 +76,11 @@ function TestPacksManagerInner({
|
||||||
enabled: !disabled,
|
enabled: !disabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allPacks: CardPackPreviewDto[] = packsData?.items || []
|
||||||
|
const packsById = useMemo(() => {
|
||||||
|
return new Map(allPacks.map((pack) => [String(pack.id), pack]))
|
||||||
|
}, [allPacks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toAdd = selectedList.filter(
|
const toAdd = selectedList.filter(
|
||||||
(id) => !currentPackIds.includes(id) && !removedList.includes(id),
|
(id) => !currentPackIds.includes(id) && !removedList.includes(id),
|
||||||
|
|
@ -93,28 +102,35 @@ function TestPacksManagerInner({
|
||||||
selectedPacks.size,
|
selectedPacks.size,
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleTogglePack = (packId: string) => {
|
const handleTogglePack = (pack: CardPackPreviewDto) => {
|
||||||
if (disabled || !packId) return
|
if (disabled || !pack?.id) return
|
||||||
|
|
||||||
const packIdStr = String(packId)
|
const packIdStr = String(pack.id)
|
||||||
const isCurrentlySelected =
|
const isCurrentlySelected =
|
||||||
selectedPacks.has(packIdStr) && !removedPacks.has(packIdStr)
|
selectedPacks.has(packIdStr) && !removedPacks.has(packIdStr)
|
||||||
const isInCurrentTest = currentPackIds.includes(packIdStr)
|
const isInCurrentTest = currentPackIds.includes(packIdStr)
|
||||||
|
|
||||||
if (isCurrentlySelected) {
|
if (isCurrentlySelected) {
|
||||||
const newSelected = new Set(selectedPacks)
|
const nextSelected = new Set(selectedPacks)
|
||||||
newSelected.delete(packIdStr)
|
nextSelected.delete(packIdStr)
|
||||||
setSelectedPacks(newSelected)
|
setSelectedPacks(nextSelected)
|
||||||
|
|
||||||
if (isInCurrentTest) {
|
if (isInCurrentTest) {
|
||||||
setRemovedPacks((prev) => new Set([...prev, packIdStr]))
|
setRemovedPacks((prev) => new Set([...prev, packIdStr]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onSelectedPackColorChange) {
|
||||||
|
const fallbackId = Array.from(nextSelected)[0]
|
||||||
|
const fallback = fallbackId ? packsById.get(fallbackId) : undefined
|
||||||
|
if (fallback?.color) onSelectedPackColorChange(fallback.color)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSelected = new Set(selectedPacks)
|
const nextSelected = new Set(selectedPacks)
|
||||||
newSelected.add(packIdStr)
|
nextSelected.add(packIdStr)
|
||||||
setSelectedPacks(newSelected)
|
setSelectedPacks(nextSelected)
|
||||||
|
|
||||||
if (removedPacks.has(packIdStr)) {
|
if (removedPacks.has(packIdStr)) {
|
||||||
setRemovedPacks((prev) => {
|
setRemovedPacks((prev) => {
|
||||||
|
|
@ -123,6 +139,10 @@ function TestPacksManagerInner({
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onSelectedPackColorChange) {
|
||||||
|
onSelectedPackColorChange(pack.color ?? '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPackSelected = (packId: string | number) => {
|
const isPackSelected = (packId: string | number) => {
|
||||||
|
|
@ -134,7 +154,6 @@ function TestPacksManagerInner({
|
||||||
return currentPackIds.includes(String(packId))
|
return currentPackIds.includes(String(packId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPacks: CardPackPreviewDto[] = packsData?.items || []
|
|
||||||
const filteredPacks = search
|
const filteredPacks = search
|
||||||
? allPacks.filter(
|
? allPacks.filter(
|
||||||
(pack) =>
|
(pack) =>
|
||||||
|
|
@ -197,7 +216,7 @@ function TestPacksManagerInner({
|
||||||
<TableRow
|
<TableRow
|
||||||
key={pack.id}
|
key={pack.id}
|
||||||
className={selected ? 'bg-muted/50' : ''}
|
className={selected ? 'bg-muted/50' : ''}
|
||||||
onClick={() => handleTogglePack(packIdStr)}
|
onClick={() => handleTogglePack(pack)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,19 @@ describe('ColorPaletteInput', () => {
|
||||||
expect(screen.getByLabelText('Color')).toHaveValue('#FF0000')
|
expect(screen.getByLabelText('Color')).toHaveValue('#FF0000')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onChange when a swatch is clicked', () => {
|
it('calls onChange when text input changes', () => {
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ColorPaletteInput
|
<>
|
||||||
id="color"
|
<label htmlFor="color">Color</label>
|
||||||
value=""
|
<ColorPaletteInput id="color" value="" onChange={onChange} />
|
||||||
onChange={onChange}
|
</>,
|
||||||
palette={['#123456', '#ABCDEF']}
|
|
||||||
/>,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Set color #ABCDEF' }))
|
fireEvent.change(screen.getByLabelText('Color'), {
|
||||||
|
target: { value: '#ABCDEF' },
|
||||||
|
})
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith('#ABCDEF')
|
expect(onChange).toHaveBeenCalledWith('#ABCDEF')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,6 @@ import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
const DEFAULT_COLOR_PALETTE = [
|
|
||||||
'#EF4444', // red-500
|
|
||||||
'#F97316', // orange-500
|
|
||||||
'#F59E0B', // amber-500
|
|
||||||
'#EAB308', // yellow-500
|
|
||||||
'#22C55E', // green-500
|
|
||||||
'#10B981', // emerald-500
|
|
||||||
'#14B8A6', // teal-500
|
|
||||||
'#06B6D4', // cyan-500
|
|
||||||
'#3B82F6', // blue-500
|
|
||||||
'#6366F1', // indigo-500
|
|
||||||
'#A855F7', // purple-500
|
|
||||||
'#EC4899', // pink-500
|
|
||||||
'#6B7280', // gray-500
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const isHexColor = (value: string): boolean => {
|
const isHexColor = (value: string): boolean => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(trimmed)
|
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(trimmed)
|
||||||
|
|
@ -28,7 +12,6 @@ export type ColorPaletteInputProps = {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
palette?: readonly string[]
|
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +20,6 @@ export const ColorPaletteInput = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
palette = DEFAULT_COLOR_PALETTE,
|
|
||||||
placeholder = '#FF0000',
|
placeholder = '#FF0000',
|
||||||
}: ColorPaletteInputProps) => {
|
}: ColorPaletteInputProps) => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|
@ -84,27 +66,6 @@ export const ColorPaletteInput = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{palette.map((color) => {
|
|
||||||
const isSelected = trimmed.toLowerCase() === color.toLowerCase()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
aria-label={`Set color ${color}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(color)}
|
|
||||||
className={cn(
|
|
||||||
'h-8 w-8 rounded-md border border-input transition-shadow',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
||||||
disabled && 'cursor-not-allowed opacity-50',
|
|
||||||
isSelected && 'ring-2 ring-ring ring-offset-2',
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { questionFromJson, questionToJson } from '@/types/questions'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -481,10 +482,13 @@ export default function TestsPage() {
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="color">Color</Label>
|
<Label htmlFor="color">Color</Label>
|
||||||
<Input
|
<ColorPaletteInput
|
||||||
id="color"
|
id="color"
|
||||||
value={formData.color}
|
value={formData.color}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
|
onChange={(value) =>
|
||||||
|
setFormData((prev) => ({ ...prev, color: value }))
|
||||||
|
}
|
||||||
|
disabled={isSaving}
|
||||||
placeholder="#FF0000"
|
placeholder="#FF0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -533,6 +537,11 @@ export default function TestsPage() {
|
||||||
<TestPacksManager
|
<TestPacksManager
|
||||||
currentPackIds={currentPackIds}
|
currentPackIds={currentPackIds}
|
||||||
onPacksChange={handlePacksChange}
|
onPacksChange={handlePacksChange}
|
||||||
|
onSelectedPackColorChange={(color) => {
|
||||||
|
const trimmed = color.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setFormData((prev) => ({ ...prev, color: trimmed }))
|
||||||
|
}}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
@ -21,6 +22,202 @@ class AdminPacksApiV2 {
|
||||||
|
|
||||||
AdminPacksApiV2(this._db);
|
AdminPacksApiV2(this._db);
|
||||||
|
|
||||||
|
static final _uuidRegex = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
|
||||||
|
|
||||||
|
bool _isBase64OrDataUrlImage(String value) =>
|
||||||
|
CardImageStorage.tryParseBase64Image(value) != null;
|
||||||
|
|
||||||
|
String? _extractCardIdFromApiImageUrl(String value) {
|
||||||
|
final v = value.trim();
|
||||||
|
if (!CardImageStorage.isApiImageUrl(v)) return null;
|
||||||
|
final match =
|
||||||
|
RegExp(r'/cards/([^/]+)/image(?:Back)?$').firstMatch(v)?.group(1);
|
||||||
|
if (match == null) return null;
|
||||||
|
return _isUuid(match) ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tryLinkCardToPack({
|
||||||
|
required String packId,
|
||||||
|
required String cardId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final card = await _db.packDao.getCardById(cardId);
|
||||||
|
if (card == null) return;
|
||||||
|
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _convertBase64ToCard(String base64Image, String packId) async {
|
||||||
|
try {
|
||||||
|
final companion = GameCardsCompanion.insert(
|
||||||
|
original: 'test_image',
|
||||||
|
translation: 'test_image',
|
||||||
|
image: '',
|
||||||
|
mnemo: const drift.Value('test_image'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final cardId = await _db.packDao.createCard(companion);
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||||
|
|
||||||
|
final stored = await CardImageStorage.persistFromBase64(
|
||||||
|
cardId: cardId,
|
||||||
|
imageValue: base64Image,
|
||||||
|
preferredFileName: null,
|
||||||
|
isBack: false,
|
||||||
|
);
|
||||||
|
if (stored == null) return null;
|
||||||
|
|
||||||
|
final created = await _db.packDao.getCardById(cardId);
|
||||||
|
if (created == null) return null;
|
||||||
|
|
||||||
|
await _db.packDao.updateCard(
|
||||||
|
created.copyWith(
|
||||||
|
image: stored.fileName,
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return cardId;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _normalizeImageValueForDb(
|
||||||
|
String? value, {
|
||||||
|
required String packId,
|
||||||
|
}) async {
|
||||||
|
if (value == null) return null;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
|
||||||
|
if (CardImageStorage.isRemoteUrl(v)) return v;
|
||||||
|
|
||||||
|
final fromApi = _extractCardIdFromApiImageUrl(v);
|
||||||
|
if (fromApi != null) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: fromApi);
|
||||||
|
return fromApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isUuid(v)) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isBase64OrDataUrlImage(v)) {
|
||||||
|
return _convertBase64ToCard(v, packId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureTestImagesLinkedToPack({
|
||||||
|
required String testId,
|
||||||
|
required String packId,
|
||||||
|
}) async {
|
||||||
|
// Ensure test cover is normalized and linked (if cover is an image ref).
|
||||||
|
final test = await _db.testDao.getTestById(testId);
|
||||||
|
if (test != null && test.cover != null && test.cover!.trim().isNotEmpty) {
|
||||||
|
final normalizedCover =
|
||||||
|
await _normalizeImageValueForDb(test.cover, packId: packId);
|
||||||
|
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
|
||||||
|
await _db.testDao.updateTest(
|
||||||
|
test.copyWith(
|
||||||
|
cover: drift.Value(normalizedCover),
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (normalizedCover != null && _isUuid(normalizedCover)) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: normalizedCover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all question/button images are normalized and cards linked to pack.
|
||||||
|
final questions = await _db.testDao.getTestQuestions(testId);
|
||||||
|
for (final q in questions) {
|
||||||
|
// Parse options/buttons
|
||||||
|
List<dynamic> buttons;
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(q.options);
|
||||||
|
buttons = decoded is List ? decoded : <dynamic>[];
|
||||||
|
} catch (_) {
|
||||||
|
buttons = <dynamic>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse uiData
|
||||||
|
Map<String, dynamic> uiData;
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(q.uiData);
|
||||||
|
uiData =
|
||||||
|
decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||||
|
} catch (_) {
|
||||||
|
uiData = <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutated = false;
|
||||||
|
|
||||||
|
if (uiData['image'] != null) {
|
||||||
|
final raw = uiData['image']?.toString();
|
||||||
|
final normalized = await _normalizeImageValueForDb(
|
||||||
|
raw,
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
if (normalized == null) {
|
||||||
|
uiData.remove('image');
|
||||||
|
} else {
|
||||||
|
uiData['image'] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedButtons = <dynamic>[];
|
||||||
|
for (final b in buttons) {
|
||||||
|
if (b is Map) {
|
||||||
|
final buttonMap = Map<String, dynamic>.from(
|
||||||
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
if (buttonMap['image'] != null) {
|
||||||
|
final raw = buttonMap['image']?.toString();
|
||||||
|
final normalized = await _normalizeImageValueForDb(
|
||||||
|
raw,
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
if (normalized == null) {
|
||||||
|
buttonMap.remove('image');
|
||||||
|
} else {
|
||||||
|
buttonMap['image'] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedButtons.add(buttonMap);
|
||||||
|
} else {
|
||||||
|
normalizedButtons.add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutated) {
|
||||||
|
await _db.testDao.updateTestQuestion(
|
||||||
|
q.copyWith(
|
||||||
|
options: jsonEncode(normalizedButtons),
|
||||||
|
uiData: jsonEncode(uiData),
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Response _json(
|
Response _json(
|
||||||
Object? data, {
|
Object? data, {
|
||||||
int statusCode = 200,
|
int statusCode = 200,
|
||||||
|
|
@ -372,6 +569,10 @@ class AdminPacksApiV2 {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _db.testDao.linkTestToPack(testId, packId);
|
await _db.testDao.linkTestToPack(testId, packId);
|
||||||
|
// Important: tests may reference cardIds (or even base64) in
|
||||||
|
// question/button images. Ensure those cards are linked to this pack
|
||||||
|
// so `/api/v2/packs/<packId>/cards/<cardId>/image` works.
|
||||||
|
await _ensureTestImagesLinkedToPack(testId: testId, packId: packId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle duplicate or constraint errors
|
// Handle duplicate or constraint errors
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
|
@ -21,14 +20,23 @@ class AdminTestsApiV2 {
|
||||||
|
|
||||||
AdminTestsApiV2(this._db);
|
AdminTestsApiV2(this._db);
|
||||||
|
|
||||||
// Helper function to check if string is base64 encoded
|
static final _uuidRegex = RegExp(
|
||||||
bool _isBase64(String value) {
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
if (value.isEmpty) return false;
|
caseSensitive: false,
|
||||||
// Base64 strings are typically long and contain only base64 characters
|
);
|
||||||
// Check length (base64 images are usually > 100 chars) and character set
|
|
||||||
if (value.length < 50) return false;
|
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
|
||||||
final base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$');
|
|
||||||
return base64Regex.hasMatch(value) && value.length > 100;
|
bool _isBase64OrDataUrlImage(String value) =>
|
||||||
|
CardImageStorage.tryParseBase64Image(value) != null;
|
||||||
|
|
||||||
|
String? _extractCardIdFromApiImageUrl(String value) {
|
||||||
|
final v = value.trim();
|
||||||
|
if (!CardImageStorage.isApiImageUrl(v)) return null;
|
||||||
|
final match =
|
||||||
|
RegExp(r'/cards/([^/]+)/image(?:Back)?$').firstMatch(v)?.group(1);
|
||||||
|
if (match == null) return null;
|
||||||
|
return _isUuid(match) ? match : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert base64 image to card and return card ID
|
// Helper function to convert base64 image to card and return card ID
|
||||||
|
|
@ -78,6 +86,86 @@ class AdminTestsApiV2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureCardLinkedToPack({
|
||||||
|
required String cardId,
|
||||||
|
required String packId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final card = await _db.packDao.getCardById(cardId);
|
||||||
|
if (card == null) return;
|
||||||
|
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort: linking is not critical for response serialization.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes an incoming image value to a stable DB reference:
|
||||||
|
/// - `null` / empty -> `null`
|
||||||
|
/// - remote URL -> remote URL
|
||||||
|
/// - `/api/v2/packs/.../cards/<cardId>/image` -> `<cardId>`
|
||||||
|
/// - base64 / data URL -> create card (and link to pack if provided) -> `<cardId>`
|
||||||
|
/// - UUID -> UUID
|
||||||
|
/// - other -> returned as-is (legacy)
|
||||||
|
Future<String?> _normalizeImageValueForDb(
|
||||||
|
String? value, {
|
||||||
|
required String? packId,
|
||||||
|
}) async {
|
||||||
|
if (value == null) return null;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
|
||||||
|
if (CardImageStorage.isRemoteUrl(v)) return v;
|
||||||
|
|
||||||
|
final fromApi = _extractCardIdFromApiImageUrl(v);
|
||||||
|
if (fromApi != null) {
|
||||||
|
if (packId != null) {
|
||||||
|
await _ensureCardLinkedToPack(cardId: fromApi, packId: packId);
|
||||||
|
}
|
||||||
|
return fromApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isUuid(v)) {
|
||||||
|
if (packId != null) {
|
||||||
|
await _ensureCardLinkedToPack(cardId: v, packId: packId);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isBase64OrDataUrlImage(v)) {
|
||||||
|
final cardId = await _convertBase64ToCard(v, packId);
|
||||||
|
if (cardId != null && packId != null) {
|
||||||
|
await _ensureCardLinkedToPack(cardId: cardId, packId: packId);
|
||||||
|
}
|
||||||
|
return cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _imageValueToApiUrl(
|
||||||
|
String? value, {
|
||||||
|
required String? packId,
|
||||||
|
}) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
|
||||||
|
if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cardId = _isUuid(v) ? v : _extractCardIdFromApiImageUrl(v);
|
||||||
|
if (cardId != null && packId != null) {
|
||||||
|
return '/api/v2/packs/$packId/cards/$cardId/image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't build a URL (no packId), don't leak base64: return null.
|
||||||
|
if (_isBase64OrDataUrlImage(v)) return null;
|
||||||
|
|
||||||
|
// Legacy: unknown, keep as-is.
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
Response _json(
|
Response _json(
|
||||||
Object? data, {
|
Object? data, {
|
||||||
int statusCode = 200,
|
int statusCode = 200,
|
||||||
|
|
@ -143,6 +231,21 @@ class AdminTestsApiV2 {
|
||||||
for (final test in allTests) {
|
for (final test in allTests) {
|
||||||
final questions = await _db.testDao.getTestQuestions(test.id);
|
final questions = await _db.testDao.getTestQuestions(test.id);
|
||||||
|
|
||||||
|
// Use first pack (if any) to build cover URL.
|
||||||
|
final packIdForCover = await _db.testDao.getPackIdForTest(test.id);
|
||||||
|
final normalizedCover = await _normalizeImageValueForDb(
|
||||||
|
test.cover,
|
||||||
|
packId: packIdForCover,
|
||||||
|
);
|
||||||
|
if ((test.cover ?? '').trim() != (normalizedCover ?? '')) {
|
||||||
|
await _db.testDao.updateTest(
|
||||||
|
test.copyWith(
|
||||||
|
cover: drift.Value(normalizedCover),
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get pack information for this test
|
// Get pack information for this test
|
||||||
final packIds = await _db.testDao.getPackIdsForTest(test.id);
|
final packIds = await _db.testDao.getPackIdsForTest(test.id);
|
||||||
final packs = <Map<String, dynamic>>[];
|
final packs = <Map<String, dynamic>>[];
|
||||||
|
|
@ -160,7 +263,7 @@ class AdminTestsApiV2 {
|
||||||
'id': test.id,
|
'id': test.id,
|
||||||
'name': test.name,
|
'name': test.name,
|
||||||
'color': test.color,
|
'color': test.color,
|
||||||
'cover': test.cover,
|
'cover': _imageValueToApiUrl(normalizedCover, packId: packIdForCover),
|
||||||
'version': test.version ?? '1.0',
|
'version': test.version ?? '1.0',
|
||||||
'time': test.time,
|
'time': test.time,
|
||||||
'timeSubtitle': test.timeSubtitle,
|
'timeSubtitle': test.timeSubtitle,
|
||||||
|
|
@ -257,115 +360,142 @@ class AdminTestsApiV2 {
|
||||||
|
|
||||||
// Get questions
|
// Get questions
|
||||||
final questions = await _db.testDao.getTestQuestions(testId);
|
final questions = await _db.testDao.getTestQuestions(testId);
|
||||||
final questionsList = questions.map((q) {
|
final questionsWithUrls = <Map<String, dynamic>>[];
|
||||||
// Новая структура: собираем вопрос из отдельных полей
|
|
||||||
Map<String, dynamic> questionJson = {
|
|
||||||
'questionType': q.questionType,
|
|
||||||
'id': q.id,
|
|
||||||
'word': q.word,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Парсим options (JSON array кнопок)
|
for (final q in questions) {
|
||||||
|
// Parse options/buttons
|
||||||
|
List<dynamic> buttons;
|
||||||
try {
|
try {
|
||||||
final options = json.decode(q.options) as List<dynamic>;
|
final decoded = json.decode(q.options);
|
||||||
questionJson['buttons'] = options;
|
buttons = decoded is List ? decoded : <dynamic>[];
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
questionJson['buttons'] = [];
|
buttons = <dynamic>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем answer
|
// Parse uiData
|
||||||
questionJson['answer'] = q.answer;
|
Map<String, dynamic> uiData;
|
||||||
|
|
||||||
// Парсим uiData (image, text, audio, template)
|
|
||||||
try {
|
try {
|
||||||
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
final decoded = json.decode(q.uiData);
|
||||||
questionJson.addAll(uiData);
|
uiData = decoded is Map<String, dynamic>
|
||||||
} catch (e) {
|
? decoded
|
||||||
// Если uiData пустой или невалидный, игнорируем
|
: <String, dynamic>{};
|
||||||
|
} catch (_) {
|
||||||
|
uiData = <String, dynamic>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
return AbstractTestQuestion.fromJson(questionJson);
|
var mutated = false;
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Helper function to convert image ID to URL
|
// Normalize question image in uiData
|
||||||
String? _convertImageToUrl(String? imageValue, String? packId) {
|
if (uiData['image'] != null) {
|
||||||
if (imageValue == null) return imageValue;
|
final raw = uiData['image']?.toString();
|
||||||
|
final normalized =
|
||||||
// If it's already a proper URL, return as is
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
if (imageValue.startsWith('http://') ||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
imageValue.startsWith('https://') ||
|
mutated = true;
|
||||||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
|
}
|
||||||
return imageValue;
|
if (normalized == null) {
|
||||||
}
|
uiData.remove('image');
|
||||||
|
} else {
|
||||||
// If packId is null, we can't convert to URL, return as is
|
uiData['image'] = normalized;
|
||||||
if (packId == null) return imageValue;
|
|
||||||
|
|
||||||
// Check if it's base64 encoded image
|
|
||||||
if (_isBase64(imageValue)) {
|
|
||||||
// Base64 images should be converted to card IDs before saving
|
|
||||||
// If we see base64 here, it means it wasn't converted during save
|
|
||||||
// For now, return as is (will be handled during save)
|
|
||||||
return imageValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's base64 data URL, extract card ID if possible
|
|
||||||
// Format: /api/v2/packs/{packId}/cards/{base64}/image or just base64
|
|
||||||
if (imageValue.contains('/cards/')) {
|
|
||||||
// Extract card ID from path like /api/v2/packs/{packId}/cards/{cardId}/image
|
|
||||||
final parts = imageValue.split('/cards/');
|
|
||||||
if (parts.length == 2) {
|
|
||||||
final cardIdPart = parts[1].split('/')[0];
|
|
||||||
// If it's a valid UUID format, use it; otherwise it might be base64
|
|
||||||
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(cardIdPart)) {
|
|
||||||
return '/api/v2/packs/$packId/cards/$cardIdPart/image';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it looks like a UUID (card ID), convert to URL
|
// Normalize button images
|
||||||
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
|
final normalizedButtons = <dynamic>[];
|
||||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
for (final b in buttons) {
|
||||||
|
if (b is Map) {
|
||||||
|
final buttonMap = Map<String, dynamic>.from(
|
||||||
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
if (buttonMap['image'] != null) {
|
||||||
|
final raw = buttonMap['image']?.toString();
|
||||||
|
final normalized =
|
||||||
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
if (normalized == null) {
|
||||||
|
buttonMap.remove('image');
|
||||||
|
} else {
|
||||||
|
buttonMap['image'] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedButtons.add(buttonMap);
|
||||||
|
} else {
|
||||||
|
normalizedButtons.add(b);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, assume it's already a card ID and convert
|
if (mutated) {
|
||||||
// This handles cases where the value is stored as card ID
|
await _db.testDao.updateTestQuestion(
|
||||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
q.copyWith(
|
||||||
}
|
options: jsonEncode(normalizedButtons),
|
||||||
|
uiData: jsonEncode(uiData),
|
||||||
// Convert image IDs to URLs in questions
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
final questionsWithUrls = questionsList.map((q) {
|
),
|
||||||
final questionJson = q.toJson();
|
|
||||||
|
|
||||||
// Convert image in question itself
|
|
||||||
if (questionJson['image'] != null) {
|
|
||||||
questionJson['image'] = _convertImageToUrl(
|
|
||||||
questionJson['image'] as String?,
|
|
||||||
packId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert images in buttons
|
// Build response question map (images as URLs)
|
||||||
if (questionJson['buttons'] != null) {
|
final questionJson = <String, dynamic>{
|
||||||
final buttons = questionJson['buttons'] as List<dynamic>;
|
'questionType': q.questionType,
|
||||||
for (final button in buttons) {
|
'id': q.id,
|
||||||
if (button is Map<String, dynamic> && button['image'] != null) {
|
'word': q.word,
|
||||||
button['image'] = _convertImageToUrl(
|
'answer': q.answer,
|
||||||
button['image'] as String?,
|
'buttons': normalizedButtons.map((b) {
|
||||||
packId,
|
if (b is Map<String, dynamic> && b['image'] != null) {
|
||||||
|
final updated = Map<String, dynamic>.from(b);
|
||||||
|
updated['image'] = _imageValueToApiUrl(
|
||||||
|
updated['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
);
|
);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
if (b is Map) {
|
||||||
}
|
final updated = Map<String, dynamic>.from(
|
||||||
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
if (updated['image'] != null) {
|
||||||
|
updated['image'] = _imageValueToApiUrl(
|
||||||
|
updated['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
return questionJson;
|
final uiDataForResponse = Map<String, dynamic>.from(uiData);
|
||||||
}).toList();
|
if (uiDataForResponse['image'] != null) {
|
||||||
|
uiDataForResponse['image'] = _imageValueToApiUrl(
|
||||||
|
uiDataForResponse['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
questionJson.addAll(uiDataForResponse);
|
||||||
|
|
||||||
|
questionsWithUrls.add(questionJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and convert cover to URL too (never return base64).
|
||||||
|
final normalizedCover =
|
||||||
|
await _normalizeImageValueForDb(test.cover, packId: packId);
|
||||||
|
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
|
||||||
|
await _db.testDao.updateTest(
|
||||||
|
test.copyWith(
|
||||||
|
cover: drift.Value(normalizedCover),
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
'id': test.id,
|
'id': test.id,
|
||||||
'name': test.name,
|
'name': test.name,
|
||||||
'color': test.color,
|
'color': test.color,
|
||||||
'cover': test.cover,
|
'cover': _imageValueToApiUrl(normalizedCover, packId: packId),
|
||||||
'version': test.version ?? '1.0',
|
'version': test.version ?? '1.0',
|
||||||
'time': test.time,
|
'time': test.time,
|
||||||
'timeSubtitle': test.timeSubtitle,
|
'timeSubtitle': test.timeSubtitle,
|
||||||
|
|
@ -447,10 +577,16 @@ class AdminTestsApiV2 {
|
||||||
// Update test
|
// Update test
|
||||||
final testToUpdate = await _db.testDao.getTestById(testId);
|
final testToUpdate = await _db.testDao.getTestById(testId);
|
||||||
if (testToUpdate != null) {
|
if (testToUpdate != null) {
|
||||||
|
final packIdForTest = await _db.testDao.getPackIdForTest(testId);
|
||||||
|
final normalizedCover =
|
||||||
|
await _normalizeImageValueForDb(cover, packId: packIdForTest);
|
||||||
|
|
||||||
final updatedTest = testToUpdate.copyWith(
|
final updatedTest = testToUpdate.copyWith(
|
||||||
name: name,
|
name: name,
|
||||||
color: color != null ? drift.Value(color) : const drift.Value.absent(),
|
color: color != null ? drift.Value(color) : const drift.Value.absent(),
|
||||||
cover: cover != null ? drift.Value(cover) : const drift.Value.absent(),
|
cover: normalizedCover != null
|
||||||
|
? drift.Value(normalizedCover)
|
||||||
|
: const drift.Value.absent(),
|
||||||
version: version != null ? drift.Value(version) : const drift.Value.absent(),
|
version: version != null ? drift.Value(version) : const drift.Value.absent(),
|
||||||
time: time != null ? drift.Value(time) : const drift.Value.absent(),
|
time: time != null ? drift.Value(time) : const drift.Value.absent(),
|
||||||
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
|
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
|
||||||
|
|
@ -467,7 +603,7 @@ class AdminTestsApiV2 {
|
||||||
await _db.testDao.softDeleteTestQuestion(q.id);
|
await _db.testDao.softDeleteTestQuestion(q.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get packId for the test to convert base64 images to cards
|
// Get packId for the test to normalize/convert images
|
||||||
final packId = await _db.testDao.getPackIdForTest(testId);
|
final packId = await _db.testDao.getPackIdForTest(testId);
|
||||||
|
|
||||||
// Add new questions
|
// Add new questions
|
||||||
|
|
@ -481,21 +617,20 @@ class AdminTestsApiV2 {
|
||||||
final answer = questionJson['answer'] as String? ?? '';
|
final answer = questionJson['answer'] as String? ?? '';
|
||||||
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
// Convert base64 images in buttons to card IDs
|
// Normalize images in buttons to stable DB refs (cardId/remote URL).
|
||||||
if (buttons.isNotEmpty) {
|
if (buttons.isNotEmpty) {
|
||||||
final convertedButtons = <Map<String, dynamic>>[];
|
final convertedButtons = <Map<String, dynamic>>[];
|
||||||
for (final button in buttons) {
|
for (final button in buttons) {
|
||||||
if (button is Map<String, dynamic>) {
|
if (button is Map<String, dynamic>) {
|
||||||
final buttonMap = Map<String, dynamic>.from(button);
|
final buttonMap = Map<String, dynamic>.from(button);
|
||||||
if (buttonMap['image'] != null) {
|
if (buttonMap['image'] != null) {
|
||||||
final imageValue = buttonMap['image'] as String;
|
final raw = buttonMap['image']?.toString();
|
||||||
// Check if it's base64
|
final normalized =
|
||||||
if (_isBase64(imageValue)) {
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
// Convert base64 to card
|
if (normalized == null) {
|
||||||
final cardId = await _convertBase64ToCard(imageValue, packId);
|
buttonMap.remove('image');
|
||||||
if (cardId != null) {
|
} else {
|
||||||
buttonMap['image'] = cardId;
|
buttonMap['image'] = normalized;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
convertedButtons.add(buttonMap);
|
convertedButtons.add(buttonMap);
|
||||||
|
|
@ -509,17 +644,11 @@ class AdminTestsApiV2 {
|
||||||
// UI данные (image, text, audio, template)
|
// UI данные (image, text, audio, template)
|
||||||
final uiData = <String, dynamic>{};
|
final uiData = <String, dynamic>{};
|
||||||
if (questionJson['image'] != null) {
|
if (questionJson['image'] != null) {
|
||||||
final imageValue = questionJson['image'] as String;
|
final raw = questionJson['image']?.toString();
|
||||||
// Convert base64 image to card if needed
|
final normalized =
|
||||||
if (_isBase64(imageValue)) {
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
final cardId = await _convertBase64ToCard(imageValue, packId);
|
if (normalized != null) {
|
||||||
if (cardId != null) {
|
uiData['image'] = normalized;
|
||||||
uiData['image'] = cardId;
|
|
||||||
} else {
|
|
||||||
uiData['image'] = imageValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uiData['image'] = imageValue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
||||||
|
|
@ -564,11 +693,17 @@ class AdminTestsApiV2 {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new test
|
// Create new test
|
||||||
|
final normalizedCover = await _normalizeImageValueForDb(
|
||||||
|
cover,
|
||||||
|
packId: null,
|
||||||
|
);
|
||||||
final newTest = await _db.testDao.createTest(
|
final newTest = await _db.testDao.createTest(
|
||||||
TestsCompanion.insert(
|
TestsCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
color: color != null ? drift.Value(color) : const drift.Value.absent(),
|
color: color != null ? drift.Value(color) : const drift.Value.absent(),
|
||||||
cover: cover != null ? drift.Value(cover) : const drift.Value.absent(),
|
cover: normalizedCover != null
|
||||||
|
? drift.Value(normalizedCover)
|
||||||
|
: const drift.Value.absent(),
|
||||||
version: version != null ? drift.Value(version) : const drift.Value.absent(),
|
version: version != null ? drift.Value(version) : const drift.Value.absent(),
|
||||||
time: time != null ? drift.Value(time) : const drift.Value.absent(),
|
time: time != null ? drift.Value(time) : const drift.Value.absent(),
|
||||||
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
|
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
|
||||||
|
|
@ -577,14 +712,10 @@ class AdminTestsApiV2 {
|
||||||
|
|
||||||
// Add questions if provided
|
// Add questions if provided
|
||||||
if (questions != null) {
|
if (questions != null) {
|
||||||
// Get packId if test is linked to a pack
|
// Test can be linked to packs only after save (admin UI does it).
|
||||||
String? packId;
|
// We'll persist images as cards even without packId and later link
|
||||||
try {
|
// those cards to packs when the test is linked.
|
||||||
packId = await _db.testDao.getPackIdForTest(newTest);
|
final packId = await _db.testDao.getPackIdForTest(newTest);
|
||||||
} catch (e) {
|
|
||||||
// Test might not be linked to a pack yet
|
|
||||||
packId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int orderIndex = 0;
|
int orderIndex = 0;
|
||||||
for (final q in questions) {
|
for (final q in questions) {
|
||||||
|
|
@ -596,21 +727,20 @@ class AdminTestsApiV2 {
|
||||||
final answer = questionJson['answer'] as String? ?? '';
|
final answer = questionJson['answer'] as String? ?? '';
|
||||||
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
// Convert base64 images in buttons to card IDs
|
// Normalize images in buttons to stable DB refs (cardId/remote URL).
|
||||||
if (buttons.isNotEmpty) {
|
if (buttons.isNotEmpty) {
|
||||||
final convertedButtons = <Map<String, dynamic>>[];
|
final convertedButtons = <Map<String, dynamic>>[];
|
||||||
for (final button in buttons) {
|
for (final button in buttons) {
|
||||||
if (button is Map<String, dynamic>) {
|
if (button is Map<String, dynamic>) {
|
||||||
final buttonMap = Map<String, dynamic>.from(button);
|
final buttonMap = Map<String, dynamic>.from(button);
|
||||||
if (buttonMap['image'] != null) {
|
if (buttonMap['image'] != null) {
|
||||||
final imageValue = buttonMap['image'] as String;
|
final raw = buttonMap['image']?.toString();
|
||||||
// Check if it's base64
|
final normalized =
|
||||||
if (_isBase64(imageValue)) {
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
// Convert base64 to card
|
if (normalized == null) {
|
||||||
final cardId = await _convertBase64ToCard(imageValue, packId);
|
buttonMap.remove('image');
|
||||||
if (cardId != null) {
|
} else {
|
||||||
buttonMap['image'] = cardId;
|
buttonMap['image'] = normalized;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
convertedButtons.add(buttonMap);
|
convertedButtons.add(buttonMap);
|
||||||
|
|
@ -624,17 +754,11 @@ class AdminTestsApiV2 {
|
||||||
// UI данные (image, text, audio, template)
|
// UI данные (image, text, audio, template)
|
||||||
final uiData = <String, dynamic>{};
|
final uiData = <String, dynamic>{};
|
||||||
if (questionJson['image'] != null) {
|
if (questionJson['image'] != null) {
|
||||||
final imageValue = questionJson['image'] as String;
|
final raw = questionJson['image']?.toString();
|
||||||
// Convert base64 image to card if needed
|
final normalized =
|
||||||
if (_isBase64(imageValue)) {
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
final cardId = await _convertBase64ToCard(imageValue, packId);
|
if (normalized != null) {
|
||||||
if (cardId != null) {
|
uiData['image'] = normalized;
|
||||||
uiData['image'] = cardId;
|
|
||||||
} else {
|
|
||||||
uiData['image'] = imageValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
uiData['image'] = imageValue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
@ -16,6 +18,130 @@ class TestManager {
|
||||||
|
|
||||||
TestManager(this._db);
|
TestManager(this._db);
|
||||||
|
|
||||||
|
static final _uuidRegex = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
|
||||||
|
|
||||||
|
bool _isBase64OrDataUrlImage(String value) =>
|
||||||
|
CardImageStorage.tryParseBase64Image(value) != null;
|
||||||
|
|
||||||
|
String? _extractCardIdFromApiImageUrl(String value) {
|
||||||
|
final v = value.trim();
|
||||||
|
if (!CardImageStorage.isApiImageUrl(v)) return null;
|
||||||
|
final match =
|
||||||
|
RegExp(r'/cards/([^/]+)/image(?:Back)?$').firstMatch(v)?.group(1);
|
||||||
|
if (match == null) return null;
|
||||||
|
return _isUuid(match) ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tryLinkCardToPack({
|
||||||
|
required String packId,
|
||||||
|
required String cardId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final card = await _db.packDao.getCardById(cardId);
|
||||||
|
if (card == null) return;
|
||||||
|
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _convertBase64ToCard(String base64Image, String? packId) async {
|
||||||
|
try {
|
||||||
|
final companion = GameCardsCompanion.insert(
|
||||||
|
original: 'test_image',
|
||||||
|
translation: 'test_image',
|
||||||
|
image: '',
|
||||||
|
mnemo: const drift.Value('test_image'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final cardId = await _db.packDao.createCard(companion);
|
||||||
|
if (packId != null) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stored = await CardImageStorage.persistFromBase64(
|
||||||
|
cardId: cardId,
|
||||||
|
imageValue: base64Image,
|
||||||
|
preferredFileName: null,
|
||||||
|
isBack: false,
|
||||||
|
);
|
||||||
|
if (stored == null) return null;
|
||||||
|
|
||||||
|
final created = await _db.packDao.getCardById(cardId);
|
||||||
|
if (created == null) return null;
|
||||||
|
|
||||||
|
await _db.packDao.updateCard(
|
||||||
|
created.copyWith(
|
||||||
|
image: stored.fileName,
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return cardId;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _normalizeImageValueForDb(
|
||||||
|
String? value, {
|
||||||
|
required String? packId,
|
||||||
|
}) async {
|
||||||
|
if (value == null) return null;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
|
||||||
|
if (CardImageStorage.isRemoteUrl(v)) return v;
|
||||||
|
|
||||||
|
final fromApi = _extractCardIdFromApiImageUrl(v);
|
||||||
|
if (fromApi != null) {
|
||||||
|
if (packId != null) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: fromApi);
|
||||||
|
}
|
||||||
|
return fromApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isUuid(v)) {
|
||||||
|
if (packId != null) {
|
||||||
|
await _tryLinkCardToPack(packId: packId, cardId: v);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isBase64OrDataUrlImage(v)) {
|
||||||
|
return _convertBase64ToCard(v, packId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _imageValueToApiUrl(
|
||||||
|
String? value, {
|
||||||
|
required String? packId,
|
||||||
|
}) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final v = value.trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
|
||||||
|
if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cardId = _isUuid(v) ? v : _extractCardIdFromApiImageUrl(v);
|
||||||
|
if (cardId != null && packId != null) {
|
||||||
|
return '/api/v2/packs/$packId/cards/$cardId/image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't leak base64 through user API.
|
||||||
|
if (_isBase64OrDataUrlImage(v)) return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
Future<TestStatisticsDto?> _testStatisticsDto(
|
Future<TestStatisticsDto?> _testStatisticsDto(
|
||||||
String userId, String testId) async {
|
String userId, String testId) async {
|
||||||
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
||||||
|
|
@ -56,26 +182,6 @@ class TestManager {
|
||||||
// Get packId for the test to convert image IDs to URLs
|
// Get packId for the test to convert image IDs to URLs
|
||||||
final packId = await _db.testDao.getPackIdForTest(testId);
|
final packId = await _db.testDao.getPackIdForTest(testId);
|
||||||
|
|
||||||
// Helper function to convert image ID to URL
|
|
||||||
String? _convertImageToUrl(String? imageValue, String? packId) {
|
|
||||||
if (imageValue == null || packId == null) return imageValue;
|
|
||||||
|
|
||||||
// If it's already a proper URL, return as is
|
|
||||||
if (imageValue.startsWith('http://') ||
|
|
||||||
imageValue.startsWith('https://') ||
|
|
||||||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
|
|
||||||
return imageValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it looks like a UUID (card ID), convert to URL
|
|
||||||
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
|
|
||||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, assume it's already a card ID and convert
|
|
||||||
return '/api/v2/packs/$packId/cards/$imageValue/image';
|
|
||||||
}
|
|
||||||
|
|
||||||
final questions = await _db.testDao.getTestQuestions(testId);
|
final questions = await _db.testDao.getTestQuestions(testId);
|
||||||
final statistics = await _testStatisticsDto(user.id!, testId);
|
final statistics = await _testStatisticsDto(user.id!, testId);
|
||||||
|
|
||||||
|
|
@ -107,14 +213,94 @@ class TestManager {
|
||||||
uiData = {};
|
uiData = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert question image to URL
|
var mutated = false;
|
||||||
|
|
||||||
if (uiData['image'] != null) {
|
if (uiData['image'] != null) {
|
||||||
uiData['image'] = _convertImageToUrl(
|
final raw = uiData['image']?.toString();
|
||||||
uiData['image'] as String?,
|
final normalized =
|
||||||
packId,
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
if (normalized == null) {
|
||||||
|
uiData.remove('image');
|
||||||
|
} else {
|
||||||
|
uiData['image'] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize button images too (so response is always URLs, never base64).
|
||||||
|
final normalizedButtons = <dynamic>[];
|
||||||
|
for (final b in buttons) {
|
||||||
|
if (b is Map) {
|
||||||
|
final buttonMap = Map<String, dynamic>.from(
|
||||||
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
if (buttonMap['image'] != null) {
|
||||||
|
final raw = buttonMap['image']?.toString();
|
||||||
|
final normalized =
|
||||||
|
await _normalizeImageValueForDb(raw, packId: packId);
|
||||||
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
if (normalized == null) {
|
||||||
|
buttonMap.remove('image');
|
||||||
|
} else {
|
||||||
|
buttonMap['image'] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedButtons.add(buttonMap);
|
||||||
|
} else {
|
||||||
|
normalizedButtons.add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
questionJson['buttons'] = normalizedButtons;
|
||||||
|
|
||||||
|
if (mutated) {
|
||||||
|
await _db.testDao.updateTestQuestion(
|
||||||
|
q.copyWith(
|
||||||
|
options: jsonEncode(normalizedButtons),
|
||||||
|
uiData: jsonEncode(uiData),
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
questionJson.addAll(uiData);
|
|
||||||
|
// Convert to URLs for response
|
||||||
|
final uiDataForResponse = Map<String, dynamic>.from(uiData);
|
||||||
|
if (uiDataForResponse['image'] != null) {
|
||||||
|
uiDataForResponse['image'] = _imageValueToApiUrl(
|
||||||
|
uiDataForResponse['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final buttonsForResponse = normalizedButtons.map((b) {
|
||||||
|
if (b is Map<String, dynamic> && b['image'] != null) {
|
||||||
|
final updated = Map<String, dynamic>.from(b);
|
||||||
|
updated['image'] = _imageValueToApiUrl(
|
||||||
|
updated['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
if (b is Map) {
|
||||||
|
final updated = Map<String, dynamic>.from(
|
||||||
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
if (updated['image'] != null) {
|
||||||
|
updated['image'] = _imageValueToApiUrl(
|
||||||
|
updated['image']?.toString(),
|
||||||
|
packId: packId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
questionJson['buttons'] = buttonsForResponse;
|
||||||
|
questionJson.addAll(uiDataForResponse);
|
||||||
|
|
||||||
// Matrix question: allow storing only config (matrixSize) and generate
|
// Matrix question: allow storing only config (matrixSize) and generate
|
||||||
// actual matrix cards from pack pool on-the-fly if buttons are missing.
|
// actual matrix cards from pack pool on-the-fly if buttons are missing.
|
||||||
|
|
@ -185,9 +371,9 @@ class TestManager {
|
||||||
.map((button) {
|
.map((button) {
|
||||||
if (button is Map<String, dynamic> && button['image'] != null) {
|
if (button is Map<String, dynamic> && button['image'] != null) {
|
||||||
final buttonMap = Map<String, dynamic>.from(button);
|
final buttonMap = Map<String, dynamic>.from(button);
|
||||||
buttonMap['image'] = _convertImageToUrl(
|
buttonMap['image'] = _imageValueToApiUrl(
|
||||||
buttonMap['image'] as String?,
|
buttonMap['image']?.toString(),
|
||||||
packId,
|
packId: packId,
|
||||||
);
|
);
|
||||||
return buttonMap;
|
return buttonMap;
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +388,10 @@ class TestManager {
|
||||||
id: testId.toString(),
|
id: testId.toString(),
|
||||||
name: test.name,
|
name: test.name,
|
||||||
color: test.color,
|
color: test.color,
|
||||||
cover: test.cover,
|
cover: _imageValueToApiUrl(
|
||||||
|
await _normalizeImageValueForDb(test.cover, packId: packId),
|
||||||
|
packId: packId,
|
||||||
|
),
|
||||||
version: test.version ?? '1.0',
|
version: test.version ?? '1.0',
|
||||||
time: test.time,
|
time: test.time,
|
||||||
timeSubtitle: test.timeSubtitle,
|
timeSubtitle: test.timeSubtitle,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||||
|
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/authorize/policies/admin_policy.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/authorize/policies/pack_policy.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/authorize/resource_loader.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/v2/admin_packs_api_v2.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/v2/admin_tests_api_v2.dart';
|
||||||
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/pack_dto_converter.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const oneByOnePngBase64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5n5p0AAAAASUVORK5CYII=';
|
||||||
|
|
||||||
|
late AppDatabase db;
|
||||||
|
late AdminTestsApiV2 adminTestsApi;
|
||||||
|
late AdminPacksApiV2 adminPacksApi;
|
||||||
|
|
||||||
|
late AccessService accessService;
|
||||||
|
late UserModel adminUser;
|
||||||
|
|
||||||
|
Request buildAdminRequest(
|
||||||
|
String method,
|
||||||
|
String url, {
|
||||||
|
Object? body,
|
||||||
|
}) {
|
||||||
|
return Request(
|
||||||
|
method,
|
||||||
|
Uri.parse(url),
|
||||||
|
body: body == null ? null : jsonEncode(body),
|
||||||
|
).change(
|
||||||
|
context: {
|
||||||
|
'user': adminUser,
|
||||||
|
'accessService': accessService,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||||
|
final port =
|
||||||
|
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||||
|
final database =
|
||||||
|
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||||
|
final username = Platform.environment['TEST_DB_USER'] ??
|
||||||
|
Platform.environment['DB_USER'] ??
|
||||||
|
'mnemo_user';
|
||||||
|
final password = Platform.environment['TEST_DB_PASSWORD'] ??
|
||||||
|
Platform.environment['DB_PASSWORD'] ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
db = AppDatabase.connect(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
database: database,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure pgcrypto is available for gen_random_uuid().
|
||||||
|
await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||||
|
await Migrator(db).createAll();
|
||||||
|
|
||||||
|
adminTestsApi = AdminTestsApiV2(db);
|
||||||
|
adminPacksApi = AdminPacksApiV2(db);
|
||||||
|
|
||||||
|
// Access service (pack policy is unused for admin-only checks).
|
||||||
|
final discountsManager = DiscountsManager(db);
|
||||||
|
final productsPriceResolver = ProductsPriceResolver(discountsManager, db);
|
||||||
|
final adsManager = AdsManager();
|
||||||
|
final packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
|
||||||
|
final packManager = PackManager(db, packDtoConverter);
|
||||||
|
|
||||||
|
final resourceLoader = ResourceLoader(packManager);
|
||||||
|
accessService = AccessService(
|
||||||
|
PackAccessPolicy(resourceLoader),
|
||||||
|
AdminAccessPolicy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminUser = UserModel(
|
||||||
|
id: 'admin',
|
||||||
|
admin: true,
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AdminTestsApiV2 images', () {
|
||||||
|
String? packId;
|
||||||
|
String? testId;
|
||||||
|
final createdCardIds = <String>{};
|
||||||
|
|
||||||
|
Future<void> cleanup() async {
|
||||||
|
// Delete relations first.
|
||||||
|
if (testId != null) {
|
||||||
|
await (db.delete(db.testPackRelations)
|
||||||
|
..where((r) => r.testId.equals(testId!)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
await (db.delete(db.testQuestions)
|
||||||
|
..where((q) => q.testId.equals(testId!)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
await (db.delete(db.tests)..where((t) => t.id.equals(testId!))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packId != null) {
|
||||||
|
await (db.delete(db.cardPackCards)
|
||||||
|
..where((c) => c.packId.equals(packId!)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final cardId in createdCardIds) {
|
||||||
|
// Remove card DB row
|
||||||
|
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId))).go();
|
||||||
|
|
||||||
|
// Remove possible image files in data/cards
|
||||||
|
final cardsDir = Directory('${PackManagerUtils.assetsDirectory.path}/cards');
|
||||||
|
final candidates = [
|
||||||
|
File('${cardsDir.path}/$cardId.png'),
|
||||||
|
File('${cardsDir.path}/$cardId.webp'),
|
||||||
|
File('${cardsDir.path}/$cardId.jpg'),
|
||||||
|
File('${cardsDir.path}/$cardId.jpeg'),
|
||||||
|
File('${cardsDir.path}/$cardId.gif'),
|
||||||
|
];
|
||||||
|
for (final f in candidates) {
|
||||||
|
if (f.existsSync()) {
|
||||||
|
try {
|
||||||
|
await f.delete();
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createdCardIds.clear();
|
||||||
|
packId = null;
|
||||||
|
testId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTest returns image URLs (no base64) and cards are linked to pack',
|
||||||
|
() async {
|
||||||
|
// Create pack
|
||||||
|
packId = await db.packDao.createPack(
|
||||||
|
CardPacksCompanion.insert(
|
||||||
|
title: 'pack for admin tests images',
|
||||||
|
subtitle: 'subtitle',
|
||||||
|
size: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create test with base64 cover + base64 question/button images
|
||||||
|
final createRequest = buildAdminRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/admin/tests',
|
||||||
|
body: {
|
||||||
|
'name': 'test with images',
|
||||||
|
'cover': oneByOnePngBase64,
|
||||||
|
'questions': [
|
||||||
|
{
|
||||||
|
'questionType': 'simple',
|
||||||
|
'word': 'word',
|
||||||
|
'answer': 'btn1',
|
||||||
|
'image': oneByOnePngBase64,
|
||||||
|
'buttons': [
|
||||||
|
{
|
||||||
|
'id': 'btn1',
|
||||||
|
'text': 'ok',
|
||||||
|
'image': oneByOnePngBase64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final createResp = await adminTestsApi.upsertTest(createRequest);
|
||||||
|
expect(createResp.statusCode, anyOf(equals(201), equals(200)));
|
||||||
|
final createBody = jsonDecode(await createResp.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
testId = (createBody['test'] as Map<String, dynamic>)['id'] as String;
|
||||||
|
expect(testId, isNotEmpty);
|
||||||
|
|
||||||
|
// Link test to pack via admin packs API (this must also link referenced cards)
|
||||||
|
final linkRequest = buildAdminRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/admin/packs',
|
||||||
|
body: {
|
||||||
|
'id': packId,
|
||||||
|
'title': 'pack for admin tests images',
|
||||||
|
'addTestIds': [testId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final linkResp = await adminPacksApi.upsertPack(linkRequest);
|
||||||
|
expect(linkResp.statusCode, equals(200));
|
||||||
|
|
||||||
|
// Fetch test and verify URLs
|
||||||
|
final getReq = buildAdminRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/admin/tests/$testId',
|
||||||
|
);
|
||||||
|
final getResp = await adminTestsApi.getTest(getReq, testId!);
|
||||||
|
expect(getResp.statusCode, equals(200));
|
||||||
|
|
||||||
|
final body = jsonDecode(await getResp.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final cover = body['cover'] as String?;
|
||||||
|
expect(cover, isNotNull);
|
||||||
|
expect(cover, isNot(contains(oneByOnePngBase64)));
|
||||||
|
expect(cover, startsWith('/api/v2/packs/$packId/cards/'));
|
||||||
|
expect(cover, endsWith('/image'));
|
||||||
|
|
||||||
|
final questions = body['questions'] as List<dynamic>;
|
||||||
|
expect(questions, hasLength(1));
|
||||||
|
final q0 = questions.first as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final qImage = q0['image'] as String?;
|
||||||
|
expect(qImage, isNotNull);
|
||||||
|
expect(qImage, isNot(contains(oneByOnePngBase64)));
|
||||||
|
expect(qImage, startsWith('/api/v2/packs/$packId/cards/'));
|
||||||
|
expect(qImage, endsWith('/image'));
|
||||||
|
|
||||||
|
final buttons = q0['buttons'] as List<dynamic>;
|
||||||
|
expect(buttons, hasLength(1));
|
||||||
|
final b0 = buttons.first as Map<String, dynamic>;
|
||||||
|
final bImage = b0['image'] as String?;
|
||||||
|
expect(bImage, isNotNull);
|
||||||
|
expect(bImage, isNot(contains(oneByOnePngBase64)));
|
||||||
|
expect(bImage, startsWith('/api/v2/packs/$packId/cards/'));
|
||||||
|
expect(bImage, endsWith('/image'));
|
||||||
|
|
||||||
|
// Extract card ids from URLs and ensure they are linked to the pack.
|
||||||
|
final extractCardId = (String url) {
|
||||||
|
final match = RegExp(r'/cards/([^/]+)/image$').firstMatch(url);
|
||||||
|
return match?.group(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
final coverCardId = extractCardId(cover!);
|
||||||
|
final qCardId = extractCardId(qImage!);
|
||||||
|
final bCardId = extractCardId(bImage!);
|
||||||
|
|
||||||
|
expect(coverCardId, isNotNull);
|
||||||
|
expect(qCardId, isNotNull);
|
||||||
|
expect(bCardId, isNotNull);
|
||||||
|
|
||||||
|
createdCardIds.addAll([coverCardId!, qCardId!, bCardId!]);
|
||||||
|
|
||||||
|
final linked = await (db.select(db.cardPackCards)
|
||||||
|
..where((c) => c.packId.equals(packId!) &
|
||||||
|
c.cardId.isIn([coverCardId, qCardId, bCardId])))
|
||||||
|
.get();
|
||||||
|
expect(linked.map((e) => e.cardId).toSet(),
|
||||||
|
containsAll([coverCardId, qCardId, bCardId]));
|
||||||
|
|
||||||
|
// Also ensure DB stores cardIds, not API URLs/base64.
|
||||||
|
final dbQuestions = await db.testDao.getTestQuestions(testId!);
|
||||||
|
expect(dbQuestions, hasLength(1));
|
||||||
|
final qDb = dbQuestions.single;
|
||||||
|
final options = jsonDecode(qDb.options) as List<dynamic>;
|
||||||
|
final uiData = jsonDecode(qDb.uiData) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final storedUiImage = uiData['image']?.toString();
|
||||||
|
expect(storedUiImage, isNotNull);
|
||||||
|
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||||
|
.hasMatch(storedUiImage!), isTrue);
|
||||||
|
|
||||||
|
final storedBtnImage =
|
||||||
|
(options.first as Map<String, dynamic>)['image']?.toString();
|
||||||
|
expect(storedBtnImage, isNotNull);
|
||||||
|
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||||
|
.hasMatch(storedBtnImage!), isTrue);
|
||||||
|
|
||||||
|
final storedCover = (await db.testDao.getTestById(testId!))!.cover;
|
||||||
|
expect(storedCover, isNotNull);
|
||||||
|
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||||
|
.hasMatch(storedCover!), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' hide isNull;
|
||||||
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late TestManager testManager;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||||
|
final port =
|
||||||
|
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||||
|
final database =
|
||||||
|
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||||
|
final username = Platform.environment['TEST_DB_USER'] ??
|
||||||
|
Platform.environment['DB_USER'] ??
|
||||||
|
'mnemo_user';
|
||||||
|
final password = Platform.environment['TEST_DB_PASSWORD'] ??
|
||||||
|
Platform.environment['DB_PASSWORD'] ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
db = AppDatabase.connect(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
database: database,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure pgcrypto is available for gen_random_uuid().
|
||||||
|
await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||||
|
|
||||||
|
// Create tables if needed (idempotent in drift for Postgres).
|
||||||
|
await Migrator(db).createAll();
|
||||||
|
|
||||||
|
testManager = TestManager(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TestManager.updateGeneratedTests', () {
|
||||||
|
String? packId;
|
||||||
|
String? cardId;
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
// Cleanup is scoped to the entities created by this test.
|
||||||
|
// Delete generated tests to avoid leaving orphans if pack is removed.
|
||||||
|
final generatedTests = await (db.select(db.tests)
|
||||||
|
..where((t) => t.version.equals('generated')))
|
||||||
|
.get();
|
||||||
|
for (final t in generatedTests) {
|
||||||
|
await (db.delete(db.tests)..where((x) => x.id.equals(t.id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packId != null) {
|
||||||
|
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
if (cardId != null) {
|
||||||
|
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId!)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
packId = null;
|
||||||
|
cardId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates generated test and links it to pack', () async {
|
||||||
|
packId = await db.packDao.createPack(
|
||||||
|
CardPacksCompanion.insert(
|
||||||
|
title: 'pack for generated test link',
|
||||||
|
subtitle: 'subtitle',
|
||||||
|
size: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cardId = await db.packDao.createCard(
|
||||||
|
GameCardsCompanion.insert(
|
||||||
|
original: 'hello',
|
||||||
|
translation: 'привет',
|
||||||
|
image: 'image_1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.packDao.addCardToPack(
|
||||||
|
packId: packId!,
|
||||||
|
cardId: cardId!,
|
||||||
|
order: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final packModel = CardPackModel(
|
||||||
|
id: packId!,
|
||||||
|
title: 'ignored',
|
||||||
|
subtitle: 'ignored',
|
||||||
|
size: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await testManager.updateGeneratedTests(packModel);
|
||||||
|
|
||||||
|
final packTests = await db.testDao.getTestsByPackId(packId!);
|
||||||
|
final generated =
|
||||||
|
packTests.where((t) => t.version == 'generated').toList();
|
||||||
|
|
||||||
|
expect(generated, hasLength(1));
|
||||||
|
|
||||||
|
final linkedPackId =
|
||||||
|
await db.testDao.getPackIdForTest(generated.single.id);
|
||||||
|
expect(linkedPackId, equals(packId));
|
||||||
|
|
||||||
|
final relations = await (db.select(db.testPackRelations)
|
||||||
|
..where((r) => r.packId.equals(packId!)))
|
||||||
|
.get();
|
||||||
|
expect(relations, hasLength(1));
|
||||||
|
expect(relations.single.testId, equals(generated.single.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
67
mnemo_cards_backend/test/packs/voice_storage_test.dart
Normal file
67
mnemo_cards_backend/test/packs/voice_storage_test.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('VoiceStorage', () {
|
||||||
|
test('sanitizeVoiceFileName strips voice/ prefix and rejects traversal', () {
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('voice/a.mp3'), 'a.mp3');
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('/voice/a.mp3'), 'a.mp3');
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('a.mp3'), 'a.mp3');
|
||||||
|
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('../a.mp3'), isNull);
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('voice/../a.mp3'), isNull);
|
||||||
|
expect(VoiceStorage.sanitizeVoiceFileName('voice/a/b.mp3'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryParseBase64Audio parses data: url and detects mp3', () {
|
||||||
|
final bytes = Uint8List.fromList([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
|
||||||
|
final b64 = base64Encode(bytes);
|
||||||
|
|
||||||
|
final parsed = VoiceStorage.tryParseBase64Audio(
|
||||||
|
'data:audio/mpeg;base64,$b64',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.contentType, 'audio/mpeg');
|
||||||
|
expect(parsed.ext, 'mp3');
|
||||||
|
expect(parsed.bytes, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistFromBase64 writes file into voice/ and resolves it', () async {
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('voice_assets_');
|
||||||
|
addTearDown(() async {
|
||||||
|
if (tempDir.existsSync()) {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const voiceId = 'f00b6be5-867a-43b4-979f-075e1ccf0f22';
|
||||||
|
final bytes = Uint8List.fromList([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
|
||||||
|
final b64 = base64Encode(bytes);
|
||||||
|
|
||||||
|
final stored = await VoiceStorage.persistFromBase64(
|
||||||
|
voiceId: voiceId,
|
||||||
|
voiceValue: 'data:audio/mpeg;base64,$b64',
|
||||||
|
assetsDirectory: tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stored, isNotNull);
|
||||||
|
expect(stored!.fileName, '$voiceId.mp3');
|
||||||
|
|
||||||
|
final resolved = await VoiceStorage.tryResolveLocalFile(
|
||||||
|
voiceValue: stored.fileName,
|
||||||
|
assetsDirectory: tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved, isNotNull);
|
||||||
|
expect(resolved!.fileName, stored.fileName);
|
||||||
|
expect(resolved.contentType, 'audio/mpeg');
|
||||||
|
expect(resolved.bytes, isNotEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||||
|
import 'package:mnemo_cards_backend/tests/generators/question_generators/matrix_question_generator.dart';
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('MatrixQuestionGenerator', () {
|
||||||
|
test('generates matrix question with fixed size', () async {
|
||||||
|
final data = CreationTestData(
|
||||||
|
packId: 'p1',
|
||||||
|
title: 'Pack',
|
||||||
|
color: '#fff',
|
||||||
|
items: List.generate(
|
||||||
|
12,
|
||||||
|
(i) => TestDataItem(
|
||||||
|
id: 'card_$i',
|
||||||
|
original: 'orig_$i',
|
||||||
|
translation: 'tr_$i',
|
||||||
|
image: 'card_$i',
|
||||||
|
audio: 'orig_$i',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final generator = MatrixQuestionGenerator(
|
||||||
|
data,
|
||||||
|
seed: 1,
|
||||||
|
fixedMatrixSize: 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
final answerCard = data.items.first;
|
||||||
|
final q = await generator.generate(answerCard);
|
||||||
|
|
||||||
|
expect(q, isA<MatrixTestQuestionBody>());
|
||||||
|
final mq = q as MatrixTestQuestionBody;
|
||||||
|
|
||||||
|
expect(mq.questionType, TestQuestionType.matrix);
|
||||||
|
expect(mq.matrixSize, 3);
|
||||||
|
expect(mq.cards.length, 9);
|
||||||
|
|
||||||
|
final ids = mq.cards.map((c) => c.id).toSet();
|
||||||
|
expect(ids.length, 9, reason: 'Matrix must contain unique cards');
|
||||||
|
|
||||||
|
expect(mq.answer, answerCard.id);
|
||||||
|
expect(mq.word, answerCard.original);
|
||||||
|
expect(ids.contains(answerCard.id), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps matrix size down when pool is too small', () async {
|
||||||
|
final data = CreationTestData(
|
||||||
|
packId: 'p1',
|
||||||
|
title: 'Pack',
|
||||||
|
color: '#fff',
|
||||||
|
items: List.generate(
|
||||||
|
5,
|
||||||
|
(i) => TestDataItem(
|
||||||
|
id: 'card_$i',
|
||||||
|
original: 'orig_$i',
|
||||||
|
translation: 'tr_$i',
|
||||||
|
image: 'card_$i',
|
||||||
|
audio: 'orig_$i',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final generator = MatrixQuestionGenerator(
|
||||||
|
data,
|
||||||
|
seed: 1,
|
||||||
|
fixedMatrixSize: 4, // impossible for pool=5
|
||||||
|
);
|
||||||
|
|
||||||
|
final q = await generator.generate(data.items.first);
|
||||||
|
final mq = q as MatrixTestQuestionBody;
|
||||||
|
|
||||||
|
// sqrt(5) == 2 -> 2x2
|
||||||
|
expect(mq.matrixSize, 2);
|
||||||
|
expect(mq.cards.length, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:google_sign_in/google_sign_in.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/models/telegram_auth_code_status.dart';
|
import 'package:mnemo_cards_web_v2/domain/models/telegram_auth_code_status.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||||
import 'package:telegram_web_app/telegram_web_app.dart';
|
import 'package:mnemo_cards_web_v2/utils/telegram_web_app_compat.dart';
|
||||||
|
|
||||||
/// Service for user authentication
|
/// Service for user authentication
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ import '../../../presentation/widgets/loading_view.dart';
|
||||||
class GamePage extends StatefulWidget {
|
class GamePage extends StatefulWidget {
|
||||||
const GamePage({
|
const GamePage({
|
||||||
required this.testId,
|
required this.testId,
|
||||||
|
this.returnToLocation,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String testId;
|
final String testId;
|
||||||
|
final String? returnToLocation;
|
||||||
static const questionCardKey = Key('game_question_card');
|
static const questionCardKey = Key('game_question_card');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -406,40 +408,45 @@ class _GamePageState extends State<GamePage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: FittedBox(
|
||||||
children: [
|
fit: BoxFit.scaleDown,
|
||||||
TweenAnimationBuilder<int>(
|
child: Column(
|
||||||
tween: Tween<int>(begin: 0, end: accuracy),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
duration: const Duration(milliseconds: 1200),
|
children: [
|
||||||
builder: (context, animatedAccuracy, child) {
|
TweenAnimationBuilder<int>(
|
||||||
return Text(
|
tween: Tween<int>(begin: 0, end: accuracy),
|
||||||
'$animatedAccuracy%',
|
duration: const Duration(milliseconds: 1200),
|
||||||
style: TextStyle(
|
builder: (context, animatedAccuracy, child) {
|
||||||
fontSize: 32.sp,
|
return Text(
|
||||||
fontWeight: FontWeight.bold,
|
'$animatedAccuracy%',
|
||||||
color: scoreColor,
|
style: TextStyle(
|
||||||
|
fontSize: 32.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: scoreColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
FadeTransition(
|
||||||
|
opacity: Tween<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: ModalRoute.of(context)?.animation ?? const AlwaysStoppedAnimation(1),
|
||||||
|
curve: const Interval(0.5, 1.0, curve: Curves.easeIn),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Score',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: scoreColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: 4.h),
|
|
||||||
FadeTransition(
|
|
||||||
opacity: Tween<double>(begin: 0, end: 1).animate(
|
|
||||||
CurvedAnimation(
|
|
||||||
parent: ModalRoute.of(context)?.animation ?? const AlwaysStoppedAnimation(1),
|
|
||||||
curve: const Interval(0.5, 1.0, curve: Curves.easeIn),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
child: Text(
|
|
||||||
'Score',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.sp,
|
|
||||||
color: scoreColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -480,9 +487,9 @@ class _GamePageState extends State<GamePage> {
|
||||||
),
|
),
|
||||||
SizedBox(width: 16.w),
|
SizedBox(width: 16.w),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.pop(),
|
onPressed: _leaveGame,
|
||||||
icon: const Icon(Icons.home),
|
icon: const Icon(Icons.arrow_back),
|
||||||
label: const Text('Back to Tests'),
|
label: const Text('Back'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
|
@ -537,6 +544,49 @@ class _GamePageState extends State<GamePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _leaveGame() async {
|
||||||
|
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||||
|
final userScope = appScope?.userScopeHolder.scope;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exit means "discard progress" (as per confirmation dialog text).
|
||||||
|
// Fire-and-forget: navigation should not wait for state cleanup.
|
||||||
|
userScope?.testsModule.testsStateManager
|
||||||
|
.resetGameSession()
|
||||||
|
.catchError((Object e, StackTrace s) {
|
||||||
|
log(
|
||||||
|
'Failed to reset game session',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
name: 'GamePage',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Failed to reset game session', error: e, stackTrace: s, name: 'GamePage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
|
||||||
|
final before = router.routeInformationProvider.value.uri.toString();
|
||||||
|
|
||||||
|
// Prefer popping when we have a real back stack (normal flow: TestPage -> GamePage via push).
|
||||||
|
// Note: on web, go_router's `canPop()` may be true even when popping is a no-op; so we
|
||||||
|
// verify by comparing location after pop and fall back to go().
|
||||||
|
if (router.canPop()) {
|
||||||
|
router.pop();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final after = router.routeInformationProvider.value.uri.toString();
|
||||||
|
if (after != before) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep-link (or no-op pop) fallback.
|
||||||
|
router.go(widget.returnToLocation ?? '/home');
|
||||||
|
}
|
||||||
|
|
||||||
void _restartGame() {
|
void _restartGame() {
|
||||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||||
final userScope = appScope?.userScopeHolder.scope;
|
final userScope = appScope?.userScopeHolder.scope;
|
||||||
|
|
@ -587,18 +637,21 @@ class _GamePageState extends State<GamePage> {
|
||||||
void _showExitConfirmation(BuildContext context) {
|
void _showExitConfirmation(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Exit Game'),
|
title: const Text('Exit Game'),
|
||||||
content: const Text('Are you sure you want to exit? Your progress will be lost.'),
|
content: const Text('Are you sure you want to exit? Your progress will be lost.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
context.pop();
|
// Let the dialog route actually get removed from the Navigator
|
||||||
|
// before we decide whether we can pop the game route.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await _leaveGame();
|
||||||
},
|
},
|
||||||
child: const Text('Exit'),
|
child: const Text('Exit'),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,10 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Launching test: ${test.name}', name: 'PackDetailsPage');
|
log('Launching test: ${test.name}', name: 'PackDetailsPage');
|
||||||
context.push('/test/${test.id}');
|
context.push(
|
||||||
|
'/game/${test.id}',
|
||||||
|
extra: '/pack/${widget.packId}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -326,7 +329,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await context.push('/purchase/${widget.packId}');
|
await context.push('/purchase/${widget.packId}');
|
||||||
}
|
}
|
||||||
|
|
@ -401,7 +404,6 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isFavoritesMode: _isFavoritesMode,
|
isFavoritesMode: _isFavoritesMode,
|
||||||
isShuffleActive: _isShuffled,
|
|
||||||
shuffleTurns: _shuffleAnimationTurns,
|
shuffleTurns: _shuffleAnimationTurns,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -451,6 +453,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Модуль "проверка знаний" для мобильных экранов
|
/// Модуль "проверка знаний" для мобильных экранов
|
||||||
|
// ignore: unused_element
|
||||||
Widget _buildMobileTestsSection(Color packColor) {
|
Widget _buildMobileTestsSection(Color packColor) {
|
||||||
final tests = _getTests();
|
final tests = _getTests();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ class _TestPageState extends State<TestPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playGame(BuildContext context) {
|
void _playGame(BuildContext context) {
|
||||||
context.go('/game/${widget.testId}');
|
context.push('/game/${widget.testId}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleBack() {
|
void _handleBack() {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import '../pages/purchase/purchase_page.dart';
|
||||||
import '../pages/statistics/statistics_page.dart';
|
import '../pages/statistics/statistics_page.dart';
|
||||||
import '../pages/tasks/create_task_page.dart';
|
import '../pages/tasks/create_task_page.dart';
|
||||||
import '../pages/tasks/tasks_page.dart';
|
import '../pages/tasks/tasks_page.dart';
|
||||||
import '../pages/test/test_page.dart';
|
|
||||||
import '../widgets/main_shell.dart';
|
import '../widgets/main_shell.dart';
|
||||||
|
|
||||||
/// Создает конфигурацию роутера приложения
|
/// Создает конфигурацию роутера приложения
|
||||||
|
|
@ -132,12 +131,14 @@ GoRouter createAppRouter({
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/test/:testId',
|
path: '/test/:testId',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
pageBuilder: (context, state) {
|
redirect: (context, state) {
|
||||||
final testId = state.pathParameters['testId']!;
|
final testId = state.pathParameters['testId'];
|
||||||
return MaterialPage(
|
if (testId == null || testId.isEmpty) return '/home';
|
||||||
child: TestPage(testId: testId),
|
return '/game/$testId';
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
pageBuilder: (context, state) => const NoTransitionPage(
|
||||||
|
child: SizedBox.shrink(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Game Page
|
// Game Page
|
||||||
|
|
@ -147,7 +148,10 @@ GoRouter createAppRouter({
|
||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
final testId = state.pathParameters['testId']!;
|
final testId = state.pathParameters['testId']!;
|
||||||
return MaterialPage(
|
return MaterialPage(
|
||||||
child: GamePage(testId: testId),
|
child: GamePage(
|
||||||
|
testId: testId,
|
||||||
|
returnToLocation: state.extra is String ? state.extra as String : null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ class PackDetailsControls extends StatelessWidget {
|
||||||
required this.onShuffle,
|
required this.onShuffle,
|
||||||
required this.onToggleFavorites,
|
required this.onToggleFavorites,
|
||||||
this.isFavoritesMode = false,
|
this.isFavoritesMode = false,
|
||||||
this.isShuffleActive = false,
|
|
||||||
this.shuffleTurns = 0.0,
|
this.shuffleTurns = 0.0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
@ -25,7 +24,6 @@ class PackDetailsControls extends StatelessWidget {
|
||||||
final VoidCallback onShuffle;
|
final VoidCallback onShuffle;
|
||||||
final VoidCallback onToggleFavorites;
|
final VoidCallback onToggleFavorites;
|
||||||
final bool isFavoritesMode;
|
final bool isFavoritesMode;
|
||||||
final bool isShuffleActive;
|
|
||||||
final double shuffleTurns;
|
final double shuffleTurns;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -49,7 +47,6 @@ class PackDetailsControls extends StatelessWidget {
|
||||||
icon: Icons.shuffle,
|
icon: Icons.shuffle,
|
||||||
label: 'Перемешать',
|
label: 'Перемешать',
|
||||||
onTap: onShuffle,
|
onTap: onShuffle,
|
||||||
isActive: isShuffleActive,
|
|
||||||
rotationTurns: shuffleTurns,
|
rotationTurns: shuffleTurns,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export 'telegram_web_app_compat_stub.dart'
|
||||||
|
if (dart.library.html) 'package:telegram_web_app/telegram_web_app.dart';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/// Minimal stub for `package:telegram_web_app` used in non-web platforms.
|
||||||
|
///
|
||||||
|
/// This project is web-only at runtime, but some tests/tools still compile on
|
||||||
|
/// the Dart VM. The real `telegram_web_app` package depends on `dart:js_interop`,
|
||||||
|
/// which is unavailable on the VM.
|
||||||
|
///
|
||||||
|
/// Keep this API surface to what we use in `AuthService`.
|
||||||
|
class TelegramWebApp {
|
||||||
|
TelegramWebApp._();
|
||||||
|
|
||||||
|
static final TelegramWebApp instance = TelegramWebApp._();
|
||||||
|
|
||||||
|
bool get isSupported => false;
|
||||||
|
|
||||||
|
dynamic get initData => null;
|
||||||
|
|
||||||
|
dynamic get initDataUnsafe => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:mnemo_cards_chat/mnemo_cards_chat.dart';
|
import 'package:mnemo_cards_chat/mnemo_cards_chat.dart';
|
||||||
|
|
|
||||||
|
|
@ -123,8 +123,6 @@ void main() {
|
||||||
paymentSystem: PaymentSystem.yookassa,
|
paymentSystem: PaymentSystem.yookassa,
|
||||||
externalToken: 'token',
|
externalToken: 'token',
|
||||||
meta: '{}',
|
meta: '{}',
|
||||||
packs: const [],
|
|
||||||
subscription: false,
|
|
||||||
products: const [
|
products: const [
|
||||||
MnemoCardsProductDto(
|
MnemoCardsProductDto(
|
||||||
id: 'pack-001',
|
id: 'pack-001',
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ void main() {
|
||||||
group('Legacy getStatistics method', () {
|
group('Legacy getStatistics method', () {
|
||||||
test('still works for backward compatibility', () {
|
test('still works for backward compatibility', () {
|
||||||
final user = UserDto(
|
final user = UserDto(
|
||||||
id: 1,
|
id: '1',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
packs: ['pack1', 'pack2'],
|
packs: ['pack1', 'pack2'],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/services/game_session_manager.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/services/game_sound_service.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/presentation/pages/game/game_page.dart';
|
||||||
|
import 'package:yx_scope/yx_scope.dart';
|
||||||
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
|
||||||
|
class _MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
||||||
|
|
||||||
|
class _MockUserScopeHolder extends Mock implements UserScopeHolder {}
|
||||||
|
|
||||||
|
class _MockUserScope extends Mock implements UserScope {}
|
||||||
|
|
||||||
|
class _MockTestsModule extends Mock implements TestsModule {}
|
||||||
|
|
||||||
|
class _MockTestManager extends Mock implements TestManager {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _MockAppScopeContainer mockAppScope;
|
||||||
|
late _MockUserScopeHolder mockUserScopeHolder;
|
||||||
|
late _MockUserScope mockUserScope;
|
||||||
|
late _MockTestsModule mockTestsModule;
|
||||||
|
late _MockTestManager mockTestManager;
|
||||||
|
|
||||||
|
late GameSessionManager gameSessionManager;
|
||||||
|
late GameSoundService gameSoundService;
|
||||||
|
late TestsStateManager testsStateManager;
|
||||||
|
|
||||||
|
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockAppScope = _MockAppScopeContainer();
|
||||||
|
mockUserScopeHolder = _MockUserScopeHolder();
|
||||||
|
mockUserScope = _MockUserScope();
|
||||||
|
mockTestsModule = _MockTestsModule();
|
||||||
|
mockTestManager = _MockTestManager();
|
||||||
|
|
||||||
|
gameSessionManager = GameSessionManager();
|
||||||
|
gameSoundService = GameSoundService();
|
||||||
|
testsStateManager = TestsStateManager(
|
||||||
|
testManager: mockTestManager,
|
||||||
|
gameSessionManager: gameSessionManager,
|
||||||
|
gameSoundService: gameSoundService,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockTestManager.loadTest(any())).thenAnswer(
|
||||||
|
(_) async => TestDto(
|
||||||
|
id: '42',
|
||||||
|
name: 'Sample Test',
|
||||||
|
questions: [
|
||||||
|
SimpleTestQuestionBody(
|
||||||
|
word: 'four',
|
||||||
|
answer: '4',
|
||||||
|
text: '2+2?',
|
||||||
|
buttons: [
|
||||||
|
TestButtonDto.text('3', '3'),
|
||||||
|
TestButtonDto.text('4', '4'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
|
||||||
|
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
|
||||||
|
when(() => mockUserScope.testsModule).thenReturn(mockTestsModule);
|
||||||
|
|
||||||
|
when(() => mockTestsModule.testsStateManager).thenReturn(testsStateManager);
|
||||||
|
when(() => mockTestsModule.gameSoundService).thenReturn(gameSoundService);
|
||||||
|
when(() => mockTestsModule.gameSessionManager).thenReturn(gameSessionManager);
|
||||||
|
when(() => mockTestsModule.testManager).thenReturn(mockTestManager);
|
||||||
|
|
||||||
|
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
|
||||||
|
ScopeState.available(scope: mockAppScope),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Exit confirmation navigates to /home when cannot pop',
|
||||||
|
(tester) async {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/game/42',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/home',
|
||||||
|
builder: (context, state) => Scaffold(
|
||||||
|
body: const Text('Home'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/game/:testId',
|
||||||
|
builder: (context, state) => GamePage(
|
||||||
|
testId: state.pathParameters['testId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
holder: appScopeHolder,
|
||||||
|
child: ScreenUtilInit(
|
||||||
|
designSize: const Size(390, 844),
|
||||||
|
builder: (context, child) => MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpUntil(tester, find.byType(GamePage));
|
||||||
|
|
||||||
|
// Tap close -> dialog
|
||||||
|
await tester.tap(find.byIcon(Icons.close).first);
|
||||||
|
await _pumpUntil(tester, find.text('Exit Game'));
|
||||||
|
|
||||||
|
expect(find.text('Exit Game'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Exit'));
|
||||||
|
await _pumpUntil(tester, find.text('Home'));
|
||||||
|
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, equals('/home'));
|
||||||
|
expect(find.text('Home'), findsOneWidget);
|
||||||
|
expect(testsStateManager.state, const TestsState.loading());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Completed screen Back navigates to /home when cannot pop',
|
||||||
|
(tester) async {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/game/42',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/home',
|
||||||
|
builder: (context, state) => Scaffold(
|
||||||
|
body: const Text('Home'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/game/:testId',
|
||||||
|
builder: (context, state) => GamePage(
|
||||||
|
testId: state.pathParameters['testId']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
holder: appScopeHolder,
|
||||||
|
child: ScreenUtilInit(
|
||||||
|
designSize: const Size(390, 844),
|
||||||
|
builder: (context, child) => MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpUntil(tester, find.byType(GamePage));
|
||||||
|
|
||||||
|
// Drive session to completed.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await testsStateManager.completeGameSession();
|
||||||
|
});
|
||||||
|
await _pumpUntil(tester, find.text('Game Completed!'));
|
||||||
|
|
||||||
|
expect(find.text('Game Completed!'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Back'));
|
||||||
|
await _pumpUntil(tester, find.text('Home'));
|
||||||
|
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, equals('/home'));
|
||||||
|
expect(find.text('Home'), findsOneWidget);
|
||||||
|
expect(testsStateManager.state, const TestsState.loading());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(WidgetTester tester, Finder finder) async {
|
||||||
|
for (var i = 0; i < 150; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
throw TestFailure('Timed out waiting for: $finder');
|
||||||
|
}
|
||||||
|
|
@ -2,34 +2,36 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart';
|
import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/models/game_question.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/services/game_session_manager.dart';
|
import 'package:mnemo_cards_web_v2/domain/services/game_session_manager.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/services/game_sound_service.dart';
|
import 'package:mnemo_cards_web_v2/domain/services/game_sound_service.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart';
|
import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart';
|
||||||
import 'package:mnemo_cards_web_v2/domain/models/game_question.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.dart';
|
import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.dart';
|
||||||
import 'package:mnemo_cards_web_v2/presentation/pages/game/game_page.dart';
|
import 'package:mnemo_cards_web_v2/presentation/pages/game/game_page.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:yx_scope/yx_scope.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
|
||||||
// Mock classes
|
class _MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
||||||
class MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
|
||||||
|
|
||||||
class MockUserScopeHolder extends Mock implements UserScopeHolder {}
|
class _MockUserScopeHolder extends Mock implements UserScopeHolder {}
|
||||||
|
|
||||||
class MockUserScope extends Mock implements UserScope {}
|
class _MockUserScope extends Mock implements UserScope {}
|
||||||
|
|
||||||
class MockGameSoundService extends Mock implements GameSoundService {}
|
class _MockGameSoundService extends Mock implements GameSoundService {}
|
||||||
|
|
||||||
class MockTestsStateManager extends Mock implements TestsStateManager {}
|
class _MockTestManager extends Mock implements TestManager {}
|
||||||
|
|
||||||
class FakeTestsModule extends Fake implements TestsModule {
|
class _FakeTestsModule extends Fake implements TestsModule {
|
||||||
FakeTestsModule({
|
_FakeTestsModule({
|
||||||
required this.testsStateManager,
|
required this.testsStateManager,
|
||||||
required this.gameSoundService,
|
required this.gameSoundService,
|
||||||
|
required this.gameSessionManager,
|
||||||
|
required this.testManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -39,70 +41,137 @@ class FakeTestsModule extends Fake implements TestsModule {
|
||||||
final GameSoundService gameSoundService;
|
final GameSoundService gameSoundService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GameSessionManager get gameSessionManager => throw UnimplementedError();
|
final GameSessionManager gameSessionManager;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TestManager get testManager => throw UnimplementedError();
|
final TestManager testManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpyTestsStateManager extends TestsStateManager {
|
||||||
|
_SpyTestsStateManager({
|
||||||
|
required super.testManager,
|
||||||
|
required super.gameSessionManager,
|
||||||
|
required super.gameSoundService,
|
||||||
|
});
|
||||||
|
|
||||||
|
int startCalls = 0;
|
||||||
|
int resumeCalls = 0;
|
||||||
|
|
||||||
|
Future<void> setStateForTest(TestsState newState) {
|
||||||
|
return handle((emit) async {
|
||||||
|
emit(newState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> startGameSession(String testId) {
|
||||||
|
startCalls++;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void resumeGameSession() {
|
||||||
|
resumeCalls++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MockAppScopeContainer mockAppScope;
|
late _MockAppScopeContainer mockAppScope;
|
||||||
late MockUserScopeHolder mockUserScopeHolder;
|
late _MockUserScopeHolder mockUserScopeHolder;
|
||||||
late MockUserScope mockUserScope;
|
late _MockUserScope mockUserScope;
|
||||||
late MockTestsStateManager mockTestsStateManager;
|
late _MockGameSoundService mockGameSoundService;
|
||||||
late MockGameSoundService mockGameSoundService;
|
late _MockTestManager mockTestManager;
|
||||||
late FakeTestsModule fakeTestsModule;
|
|
||||||
|
|
||||||
setUp(() {
|
late GameSessionManager gameSessionManager;
|
||||||
mockAppScope = MockAppScopeContainer();
|
late _SpyTestsStateManager testsStateManager;
|
||||||
mockUserScopeHolder = MockUserScopeHolder();
|
late _FakeTestsModule fakeTestsModule;
|
||||||
mockUserScope = MockUserScope();
|
|
||||||
mockTestsStateManager = MockTestsStateManager();
|
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
|
||||||
mockGameSoundService = MockGameSoundService();
|
|
||||||
fakeTestsModule = FakeTestsModule(
|
setUp(() async {
|
||||||
testsStateManager: mockTestsStateManager,
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
ScreenUtil.ensureScreenSize();
|
||||||
|
|
||||||
|
mockAppScope = _MockAppScopeContainer();
|
||||||
|
mockUserScopeHolder = _MockUserScopeHolder();
|
||||||
|
mockUserScope = _MockUserScope();
|
||||||
|
mockGameSoundService = _MockGameSoundService();
|
||||||
|
mockTestManager = _MockTestManager();
|
||||||
|
|
||||||
|
gameSessionManager = GameSessionManager();
|
||||||
|
|
||||||
|
testsStateManager = _SpyTestsStateManager(
|
||||||
|
testManager: mockTestManager,
|
||||||
|
gameSessionManager: gameSessionManager,
|
||||||
gameSoundService: mockGameSoundService,
|
gameSoundService: mockGameSoundService,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Setup the mock chain
|
fakeTestsModule = _FakeTestsModule(
|
||||||
|
testsStateManager: testsStateManager,
|
||||||
|
gameSoundService: mockGameSoundService,
|
||||||
|
gameSessionManager: gameSessionManager,
|
||||||
|
testManager: mockTestManager,
|
||||||
|
);
|
||||||
|
|
||||||
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
|
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
|
||||||
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
|
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
|
||||||
when(() => mockUserScope.testsModule).thenReturn(fakeTestsModule);
|
when(() => mockUserScope.testsModule).thenReturn(fakeTestsModule);
|
||||||
when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading());
|
|
||||||
when(() => mockTestsStateManager.startGameSession(any<String>())).thenAnswer((_) async {});
|
|
||||||
when(() => mockTestsStateManager.resetGameSession()).thenAnswer((_) async {});
|
|
||||||
when(() => mockTestsStateManager.submitAnswer(any<String>())).thenAnswer((_) async {});
|
|
||||||
when(() => mockTestsStateManager.nextQuestion()).thenAnswer((_) async {});
|
|
||||||
when(() => mockTestsStateManager.previousQuestion()).thenAnswer((_) async {});
|
|
||||||
when(() => mockGameSoundService.initialize()).thenAnswer((_) async {});
|
when(() => mockGameSoundService.initialize()).thenAnswer((_) async {});
|
||||||
when(() => mockGameSoundService.playGameStart()).thenAnswer((_) async {});
|
when(() => mockGameSoundService.playGameStart()).thenAnswer((_) async {});
|
||||||
|
|
||||||
// Initialize screen util
|
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
|
||||||
_initScreenUtil();
|
ScopeState.available(scope: mockAppScope),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> pumpGamePage(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required Widget child,
|
||||||
|
ThemeData? theme,
|
||||||
|
}) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
holder: appScopeHolder,
|
||||||
|
child: ScreenUtilInit(
|
||||||
|
designSize: const Size(390, 844),
|
||||||
|
builder: (context, _) => MaterialApp(
|
||||||
|
theme: theme,
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
int maxPumps = 60,
|
||||||
|
Duration step = const Duration(milliseconds: 50),
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < maxPumps; i++) {
|
||||||
|
await tester.pump(step);
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
throw TestFailure('Timed out waiting for: $finder');
|
||||||
|
}
|
||||||
|
|
||||||
group('GamePage', () {
|
group('GamePage', () {
|
||||||
testWidgets('should display preparing state', (tester) async {
|
testWidgets('should display preparing state', (tester) async {
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionPreparing(
|
TestsState.gameSessionPreparing(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: [],
|
questions: const [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Ready to Start?'), findsOneWidget);
|
expect(find.text('Ready to Start?'), findsOneWidget);
|
||||||
expect(find.text('Start Game'), findsOneWidget);
|
expect(find.text('Start Game'), findsOneWidget);
|
||||||
|
expect(testsStateManager.startCalls, equals(0));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('does not restart when session already active', (tester) async {
|
testWidgets('does not restart when session already active', (tester) async {
|
||||||
|
|
@ -111,39 +180,30 @@ void main() {
|
||||||
MultipleChoiceQuestion(
|
MultipleChoiceQuestion(
|
||||||
id: 'q1',
|
id: 'q1',
|
||||||
question: '2+2?',
|
question: '2+2?',
|
||||||
options: ['3', '4'],
|
options: const ['3', '4'],
|
||||||
correctAnswer: '4',
|
correctAnswer: '4',
|
||||||
word: 'four',
|
word: 'four',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionActive(
|
TestsState.gameSessionActive(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: questions,
|
questions: questions,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
currentResult: null,
|
currentResult: null,
|
||||||
questionResults: {},
|
questionResults: const {},
|
||||||
isAnswerSubmitted: false,
|
isAnswerSubmitted: false,
|
||||||
isCorrect: false,
|
isCorrect: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
verifyNever(() => mockTestsStateManager.startGameSession(any()));
|
expect(testsStateManager.startCalls, equals(0));
|
||||||
verify(() => mockTestsStateManager.resumeGameSession()).called(1);
|
expect(testsStateManager.resumeCalls, equals(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display active game state', (tester) async {
|
testWidgets('should display active game state', (tester) async {
|
||||||
|
|
@ -152,37 +212,27 @@ void main() {
|
||||||
MultipleChoiceQuestion(
|
MultipleChoiceQuestion(
|
||||||
id: 'q1',
|
id: 'q1',
|
||||||
question: 'What is 2+2?',
|
question: 'What is 2+2?',
|
||||||
options: ['3', '4', '5'],
|
options: const ['3', '4', '5'],
|
||||||
correctAnswer: '4',
|
correctAnswer: '4',
|
||||||
word: 'four',
|
word: 'four',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionActive(
|
TestsState.gameSessionActive(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: questions,
|
questions: questions,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
currentResult: null,
|
currentResult: null,
|
||||||
questionResults: {},
|
questionResults: const {},
|
||||||
isAnswerSubmitted: false,
|
isAnswerSubmitted: false,
|
||||||
isCorrect: false,
|
isCorrect: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
|
|
||||||
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('What is 2+2?'), findsOneWidget);
|
expect(find.text('What is 2+2?'), findsOneWidget);
|
||||||
expect(find.text('3'), findsOneWidget);
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
|
@ -196,26 +246,24 @@ void main() {
|
||||||
MultipleChoiceQuestion(
|
MultipleChoiceQuestion(
|
||||||
id: 'q1',
|
id: 'q1',
|
||||||
question: 'Capital of France?',
|
question: 'Capital of France?',
|
||||||
options: ['Paris', 'London'],
|
options: const ['Paris', 'London'],
|
||||||
correctAnswer: 'Paris',
|
correctAnswer: 'Paris',
|
||||||
word: 'paris',
|
word: 'paris',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionActive(
|
TestsState.gameSessionActive(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: questions,
|
questions: questions,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
currentResult: null,
|
currentResult: null,
|
||||||
questionResults: {},
|
questionResults: const {},
|
||||||
isAnswerSubmitted: false,
|
isAnswerSubmitted: false,
|
||||||
isCorrect: false,
|
isCorrect: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
|
|
||||||
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
|
|
||||||
|
|
||||||
final theme = ThemeData(
|
final theme = ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
|
@ -227,20 +275,16 @@ void main() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(
|
||||||
MultiProvider(
|
tester,
|
||||||
providers: [
|
child: const GamePage(testId: 'test1'),
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
theme: theme,
|
||||||
],
|
|
||||||
child: MaterialApp(
|
|
||||||
theme: theme,
|
|
||||||
home: const GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
|
||||||
final material = tester.widget<Material>(find.byKey(GamePage.questionCardKey));
|
final material = tester.widget<Material>(
|
||||||
|
find.byKey(GamePage.questionCardKey),
|
||||||
|
);
|
||||||
|
|
||||||
expect(material.color, theme.colorScheme.surface);
|
expect(material.color, theme.colorScheme.surface);
|
||||||
expect(material.surfaceTintColor, theme.colorScheme.surfaceTint);
|
expect(material.surfaceTintColor, theme.colorScheme.surfaceTint);
|
||||||
|
|
@ -252,168 +296,121 @@ void main() {
|
||||||
MultipleChoiceQuestion(
|
MultipleChoiceQuestion(
|
||||||
id: 'q1',
|
id: 'q1',
|
||||||
question: 'Capital of France?',
|
question: 'Capital of France?',
|
||||||
options: ['Paris', 'London'],
|
options: const ['Paris', 'London'],
|
||||||
correctAnswer: 'Paris',
|
correctAnswer: 'Paris',
|
||||||
word: 'paris',
|
word: 'paris',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionActive(
|
TestsState.gameSessionActive(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: questions,
|
questions: questions,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
currentResult: null,
|
currentResult: null,
|
||||||
questionResults: {},
|
questionResults: const {},
|
||||||
isAnswerSubmitted: false,
|
isAnswerSubmitted: false,
|
||||||
isCorrect: false,
|
isCorrect: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
|
|
||||||
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('starts session when not active', (tester) async {
|
testWidgets('starts session when not active', (tester) async {
|
||||||
when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading());
|
await testsStateManager.setStateForTest(const TestsState.loading());
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
verify(() => mockTestsStateManager.startGameSession('test1')).called(1);
|
expect(testsStateManager.startCalls, equals(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display completed game state', (tester) async {
|
testWidgets('should display completed game state', (tester) async {
|
||||||
final result = GameSessionResult(
|
final result = GameSessionResult(
|
||||||
testId: 'test1',
|
testId: 'test1',
|
||||||
questionResults: [],
|
questionResults: const [],
|
||||||
totalTime: const Duration(seconds: 30),
|
totalTime: const Duration(seconds: 30),
|
||||||
correctAnswers: 1,
|
correctAnswers: 1,
|
||||||
totalQuestions: 1,
|
totalQuestions: 1,
|
||||||
completedAt: DateTime.now(),
|
completedAt: DateTime(2025, 1, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionCompleted(
|
TestsState.gameSessionCompleted(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
result: result,
|
result: result,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Game Completed!'), findsOneWidget);
|
expect(find.text('Game Completed!'), findsOneWidget);
|
||||||
expect(find.text('1/1 correct answers'), findsOneWidget);
|
expect(find.text('1/1 correct answers'), findsOneWidget);
|
||||||
expect(find.text('Play Again'), findsOneWidget);
|
expect(find.text('Play Again'), findsOneWidget);
|
||||||
expect(find.text('Back to Tests'), findsOneWidget);
|
expect(find.text('Back'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should show exit confirmation dialog', (tester) async {
|
testWidgets('should show exit confirmation dialog', (tester) async {
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
final questions = [
|
||||||
|
GameQuestion.multipleChoice(
|
||||||
|
MultipleChoiceQuestion(
|
||||||
|
id: 'q1',
|
||||||
|
question: 'Capital of France?',
|
||||||
|
options: const ['Paris', 'London'],
|
||||||
|
correctAnswer: 'Paris',
|
||||||
|
word: 'paris',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await testsStateManager.setStateForTest(
|
||||||
TestsState.gameSessionActive(
|
TestsState.gameSessionActive(
|
||||||
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
|
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
|
||||||
questions: [],
|
questions: questions,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
currentResult: null,
|
currentResult: null,
|
||||||
questionResults: {},
|
questionResults: const {},
|
||||||
isAnswerSubmitted: false,
|
isAnswerSubmitted: false,
|
||||||
isCorrect: false,
|
isCorrect: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tap the close button
|
|
||||||
await tester.tap(find.byIcon(Icons.close));
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
await tester.pumpAndSettle();
|
await pumpUntil(tester, find.text('Exit Game'));
|
||||||
|
|
||||||
expect(find.text('Exit Game'), findsOneWidget);
|
expect(find.text('Exit Game'), findsOneWidget);
|
||||||
expect(find.text('Are you sure you want to exit?'), findsOneWidget);
|
expect(
|
||||||
|
find.text('Are you sure you want to exit? Your progress will be lost.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display loading state', (tester) async {
|
testWidgets('should display loading state', (tester) async {
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(const TestsState.loading());
|
||||||
const TestsState.loading(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Loading game...'), findsOneWidget);
|
expect(find.text('Loading game...'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display error state', (tester) async {
|
testWidgets('should display error state', (tester) async {
|
||||||
when(() => mockTestsStateManager.state).thenReturn(
|
await testsStateManager.setStateForTest(const TestsState.error('Test error'));
|
||||||
const TestsState.error('Test error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
|
||||||
MultiProvider(
|
await tester.pump();
|
||||||
providers: [
|
|
||||||
Provider<AppScopeContainer>.value(value: mockAppScope),
|
|
||||||
],
|
|
||||||
child: const MaterialApp(
|
|
||||||
home: GamePage(testId: 'test1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Game Error'), findsOneWidget);
|
expect(find.text('Game Error'), findsOneWidget);
|
||||||
expect(find.text('Test error'), findsOneWidget);
|
expect(find.text('Test error'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initScreenUtil() async {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
ScreenUtil.ensureScreenSize();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -210,12 +210,24 @@ void main() {
|
||||||
tags: any(named: 'tags'),
|
tags: any(named: 'tags'),
|
||||||
)).thenThrow(Exception('Network error'));
|
)).thenThrow(Exception('Network error'));
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/create',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const Scaffold(body: Text('Home')),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/create',
|
||||||
|
builder: (context, state) => const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ScopeProvider<UserScope>(
|
ScopeProvider<UserScope>(
|
||||||
scope: userScope,
|
holder: userScopeHolder,
|
||||||
child: MaterialApp(
|
child: MaterialApp.router(routerConfig: router),
|
||||||
home: const CreateTaskPage(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -272,12 +284,24 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('navigates back on back button', (tester) async {
|
testWidgets('navigates back on back button', (tester) async {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/create',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const Scaffold(body: Text('Home')),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/create',
|
||||||
|
builder: (context, state) => const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ScopeProvider<UserScope>(
|
ScopeProvider<UserScope>(
|
||||||
scope: userScope,
|
holder: userScopeHolder,
|
||||||
child: MaterialApp(
|
child: MaterialApp.router(routerConfig: router),
|
||||||
home: const CreateTaskPage(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,17 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart';
|
|
||||||
import 'package:mnemo_cards_web_v2/presentation/pages/test/test_page.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
import 'package:yx_scope/yx_scope.dart';
|
|
||||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
|
||||||
|
|
||||||
class MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
|
||||||
|
|
||||||
class MockUserScopeHolder extends Mock implements UserScopeHolder {}
|
|
||||||
|
|
||||||
class MockUserScope extends Mock implements UserScope {}
|
|
||||||
|
|
||||||
class MockTestsModule extends Mock implements TestsModule {}
|
|
||||||
|
|
||||||
class MockTestManager extends Mock implements TestManager {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MockAppScopeContainer mockAppScope;
|
testWidgets('/test/:id redirects to /game/:id', (tester) async {
|
||||||
late MockUserScopeHolder mockUserScopeHolder;
|
final router = _buildRouter();
|
||||||
late MockUserScope mockUserScope;
|
addTearDown(router.dispose);
|
||||||
late MockTestsModule mockTestsModule;
|
|
||||||
late MockTestManager mockTestManager;
|
|
||||||
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
|
|
||||||
|
|
||||||
setUp(() {
|
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||||
mockAppScope = MockAppScopeContainer();
|
await tester.pumpAndSettle();
|
||||||
mockUserScopeHolder = MockUserScopeHolder();
|
|
||||||
mockUserScope = MockUserScope();
|
|
||||||
mockTestsModule = MockTestsModule();
|
|
||||||
mockTestManager = MockTestManager();
|
|
||||||
|
|
||||||
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
|
expect(find.text('Game 42'), findsOneWidget);
|
||||||
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
|
expect(router.routeInformationProvider.value.uri.path, equals('/game/42'));
|
||||||
when(() => mockUserScope.testsModule).thenReturn(mockTestsModule);
|
|
||||||
when(() => mockTestsModule.testManager).thenReturn(mockTestManager);
|
|
||||||
when(() => mockTestManager.loadTest(any<String>())).thenAnswer(
|
|
||||||
(_) async => TestDto(
|
|
||||||
id: '42',
|
|
||||||
name: 'Sample Test',
|
|
||||||
questions: const <AbstractTestQuestion>[],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
|
|
||||||
ScopeState.available(scope: mockAppScope),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('TestPage', () {
|
|
||||||
testWidgets('shows interactive-only notice', (tester) async {
|
|
||||||
final router = _buildRouter();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ScopeProvider<AppScopeContainer>(
|
|
||||||
holder: appScopeHolder,
|
|
||||||
child: MaterialApp.router(routerConfig: router),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Sample Test'), findsOneWidget);
|
|
||||||
expect(find.text('Interactive mode only'), findsOneWidget);
|
|
||||||
expect(find.text('Play Interactive Game'), findsOneWidget);
|
|
||||||
|
|
||||||
router.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('navigates to game page on tap', (tester) async {
|
|
||||||
final router = _buildRouter();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ScopeProvider<AppScopeContainer>(
|
|
||||||
holder: appScopeHolder,
|
|
||||||
child: MaterialApp.router(routerConfig: router),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Play Interactive Game'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Game 42'), findsOneWidget);
|
|
||||||
|
|
||||||
router.dispose();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,11 +20,20 @@ GoRouter _buildRouter() {
|
||||||
initialLocation: '/test/42',
|
initialLocation: '/test/42',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/test/:id',
|
path: '/home',
|
||||||
builder: (context, state) => TestPage(
|
builder: (context, state) => const Scaffold(
|
||||||
testId: state.pathParameters['id'] ?? '',
|
body: Center(child: Text('Home')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/test/:id',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final testId = state.pathParameters['id'];
|
||||||
|
if (testId == null || testId.isEmpty) return '/home';
|
||||||
|
return '/game/$testId';
|
||||||
|
},
|
||||||
|
builder: (context, state) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/game/:id',
|
path: '/game/:id',
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:mnemo_cards_web_v2/presentation/widgets/pack_details_controls.dart';
|
import 'package:mnemo_cards_web_v2/presentation/widgets/pack_details_controls.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/presentation/theme/app_colors.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
|
|
@ -33,7 +34,6 @@ void main() {
|
||||||
onToggleView: _noop,
|
onToggleView: _noop,
|
||||||
onShuffle: _noop,
|
onShuffle: _noop,
|
||||||
onToggleFavorites: _noop,
|
onToggleFavorites: _noop,
|
||||||
isShuffleActive: true,
|
|
||||||
shuffleTurns: 1.0,
|
shuffleTurns: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -47,6 +47,44 @@ void main() {
|
||||||
expect(rotation.turns, 1.0);
|
expect(rotation.turns, 1.0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'shuffle button is never rendered as active',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: PackDetailsControls(
|
||||||
|
isGridView: true,
|
||||||
|
onToggleView: _noop,
|
||||||
|
onShuffle: _noop,
|
||||||
|
onToggleFavorites: _noop,
|
||||||
|
shuffleTurns: 0.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final shuffleGesture = find.ancestor(
|
||||||
|
of: find.text('Перемешать'),
|
||||||
|
matching: find.byType(GestureDetector),
|
||||||
|
);
|
||||||
|
expect(shuffleGesture, findsOneWidget);
|
||||||
|
|
||||||
|
final shuffleContainer = find.descendant(
|
||||||
|
of: shuffleGesture,
|
||||||
|
matching: find.byType(Container),
|
||||||
|
);
|
||||||
|
expect(shuffleContainer, findsOneWidget);
|
||||||
|
|
||||||
|
final container = tester.widget<Container>(shuffleContainer);
|
||||||
|
final decoration = container.decoration as BoxDecoration;
|
||||||
|
expect(decoration.color, Colors.transparent);
|
||||||
|
|
||||||
|
final border = decoration.border as Border;
|
||||||
|
expect(border.top.color, AppColors.borderGray);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _noop() {}
|
void _noop() {}
|
||||||
|
|
|
||||||
|
|
@ -368,12 +368,12 @@ void main() {
|
||||||
// Verify data was submitted
|
// Verify data was submitted
|
||||||
expect(submittedData, isNotNull);
|
expect(submittedData, isNotNull);
|
||||||
expect(submittedData!.title, 'Test Task');
|
expect(submittedData!.title, 'Test Task');
|
||||||
expect(submittedData.description, 'This is a test description');
|
expect(submittedData!.description, 'This is a test description');
|
||||||
expect(submittedData.type, TaskType.appInternal);
|
expect(submittedData!.type, TaskType.appInternal);
|
||||||
expect(submittedData.difficulty, TaskDifficulty.easy);
|
expect(submittedData!.difficulty, TaskDifficulty.easy);
|
||||||
expect(submittedData.rewards.length, 1);
|
expect(submittedData!.rewards.length, 1);
|
||||||
expect(submittedData.rewards.first.type, RewardType.xp);
|
expect(submittedData!.rewards.first.type, RewardType.xp);
|
||||||
expect(submittedData.rewards.first.amount, 100);
|
expect(submittedData!.rewards.first.amount, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('parses tags correctly', (tester) async {
|
testWidgets('parses tags correctly', (tester) async {
|
||||||
|
|
@ -446,8 +446,8 @@ void main() {
|
||||||
// Verify tags were parsed
|
// Verify tags were parsed
|
||||||
expect(submittedData, isNotNull);
|
expect(submittedData, isNotNull);
|
||||||
expect(submittedData!.tags, isNotNull);
|
expect(submittedData!.tags, isNotNull);
|
||||||
expect(submittedData.tags!.length, 3);
|
expect(submittedData!.tags!.length, 3);
|
||||||
expect(submittedData.tags, ['tag1', 'tag2', 'tag3']);
|
expect(submittedData!.tags, ['tag1', 'tag2', 'tag3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('validates tags count and length', (tester) async {
|
testWidgets('validates tags count and length', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ void main() {
|
||||||
group('resolveGridMovementOffset', () {
|
group('resolveGridMovementOffset', () {
|
||||||
test('returns zero when card index unchanged', () {
|
test('returns zero when card index unchanged', () {
|
||||||
final offset = resolveGridMovementOffset(
|
final offset = resolveGridMovementOffset(
|
||||||
previousIndexById: {1: 2},
|
previousIndexById: {'1': 2},
|
||||||
cardId: 1,
|
cardId: '1',
|
||||||
currentIndex: 2,
|
currentIndex: 2,
|
||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
itemWidth: 100,
|
itemWidth: 100,
|
||||||
|
|
@ -21,8 +21,8 @@ void main() {
|
||||||
|
|
||||||
test('computes offset between different grid cells', () {
|
test('computes offset between different grid cells', () {
|
||||||
final offset = resolveGridMovementOffset(
|
final offset = resolveGridMovementOffset(
|
||||||
previousIndexById: {1: 0},
|
previousIndexById: {'1': 0},
|
||||||
cardId: 1,
|
cardId: '1',
|
||||||
currentIndex: 5,
|
currentIndex: 5,
|
||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
itemWidth: 100,
|
itemWidth: 100,
|
||||||
|
|
@ -40,7 +40,7 @@ void main() {
|
||||||
test('returns zero when previous position unknown', () {
|
test('returns zero when previous position unknown', () {
|
||||||
final offset = resolveListMovementOffset(
|
final offset = resolveListMovementOffset(
|
||||||
previousIndexById: const {},
|
previousIndexById: const {},
|
||||||
cardId: 1,
|
cardId: '1',
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
itemExtent: 100,
|
itemExtent: 100,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
|
|
@ -51,8 +51,8 @@ void main() {
|
||||||
|
|
||||||
test('computes vertical delta between list positions', () {
|
test('computes vertical delta between list positions', () {
|
||||||
final offset = resolveListMovementOffset(
|
final offset = resolveListMovementOffset(
|
||||||
previousIndexById: {7: 4},
|
previousIndexById: {'7': 4},
|
||||||
cardId: 7,
|
cardId: '7',
|
||||||
currentIndex: 1,
|
currentIndex: 1,
|
||||||
itemExtent: 100,
|
itemExtent: 100,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue