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

This commit is contained in:
Dmitry 2025-12-19 01:22:53 +03:00
parent fface20519
commit 9dd7662b3d
31 changed files with 1967 additions and 609 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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 ? (

View file

@ -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')
}) })

View file

@ -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"

View file

@ -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">

View file

@ -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

View file

@ -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'];

View file

@ -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,

View file

@ -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);
});
});
}

View file

@ -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));
});
});
}

View 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);
});
});
}

View file

@ -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);
});
});
}

View file

@ -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
/// ///

View file

@ -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'),
), ),

View file

@ -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();

View file

@ -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() {

View file

@ -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,
),
); );
}, },
), ),

View file

@ -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,
), ),

View file

@ -0,0 +1,3 @@
export 'telegram_web_app_compat_stub.dart'
if (dart.library.html) 'package:telegram_web_app/telegram_web_app.dart';

View file

@ -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;
}

View file

@ -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';

View file

@ -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',

View file

@ -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'],

View file

@ -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');
}

View file

@ -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();
}

View file

@ -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(),
),
), ),
); );

View file

@ -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(

View file

@ -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() {}

View file

@ -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 {

View file

@ -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,