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
- Added unit tests for the upsert+voice flow (`mnemo_cards_admin`)
- **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`)
- **Admin Tests: Pack Linking**: Added ability to link tests to packs in admin
- Test editor now supports selecting packs and syncing links on Save
- 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
- 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)
- 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
- Pack details: "Перемешать" button no longer has an active (pressed) state (widget test updated)
- Added app version display on authentication page
- **UI Components Refactoring**: Extracted authentication components
- Created `SignInWithGoogleButton` component for Google authentication
@ -98,6 +103,7 @@
- Added SafeArea wrapping and themed question cards on `game_page.dart`
- 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`
- 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`
- Added configurable `GameTestSettings` (sounds/haptics/delay)
- 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)
- 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)
- **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
- Added `telegram` field to `UserModel` and `UserDto` alongside `email`
- Added `telegram` column to `users` table in database (migration v2→v3)

View file

@ -53,6 +53,7 @@
- Common libraries: Target 90% coverage
- ✅ 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: Pack details "Перемешать" button has no active (pressed) state (widget test updated)
- ✅ **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/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
- ✅ 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
- ✅ 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
- ✅ **Test Page Code Quality Fix**: Fixed critical code duplication in `test_page.dart`
- Removed 700+ lines of duplicate code
@ -110,6 +112,7 @@
- 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 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)
### Features
@ -123,10 +126,14 @@
- Added voice uploader to card editor (upload on Save)
- Added unit tests for upsert+voice flow
- [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
- [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
- [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)
- 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)

View file

@ -19,12 +19,14 @@ import { Search, Check } from 'lucide-react'
interface TestPacksManagerProps {
currentPackIds: string[]
onPacksChange: (addIds: string[], removeIds: string[]) => void
onSelectedPackColorChange?: (color: string) => void
disabled?: boolean
}
export function TestPacksManager({
currentPackIds,
onPacksChange,
onSelectedPackColorChange,
disabled = false,
}: TestPacksManagerProps) {
// Reset internal state when switching tests / pack list.
@ -35,6 +37,7 @@ export function TestPacksManager({
key={stateKey}
currentPackIds={currentPackIds}
onPacksChange={onPacksChange}
onSelectedPackColorChange={onSelectedPackColorChange}
disabled={disabled}
/>
)
@ -43,6 +46,7 @@ export function TestPacksManager({
function TestPacksManagerInner({
currentPackIds,
onPacksChange,
onSelectedPackColorChange,
disabled = false,
}: TestPacksManagerProps) {
const [search, setSearch] = useState('')
@ -72,6 +76,11 @@ function TestPacksManagerInner({
enabled: !disabled,
})
const allPacks: CardPackPreviewDto[] = packsData?.items || []
const packsById = useMemo(() => {
return new Map(allPacks.map((pack) => [String(pack.id), pack]))
}, [allPacks])
useEffect(() => {
const toAdd = selectedList.filter(
(id) => !currentPackIds.includes(id) && !removedList.includes(id),
@ -93,28 +102,35 @@ function TestPacksManagerInner({
selectedPacks.size,
])
const handleTogglePack = (packId: string) => {
if (disabled || !packId) return
const handleTogglePack = (pack: CardPackPreviewDto) => {
if (disabled || !pack?.id) return
const packIdStr = String(packId)
const packIdStr = String(pack.id)
const isCurrentlySelected =
selectedPacks.has(packIdStr) && !removedPacks.has(packIdStr)
const isInCurrentTest = currentPackIds.includes(packIdStr)
if (isCurrentlySelected) {
const newSelected = new Set(selectedPacks)
newSelected.delete(packIdStr)
setSelectedPacks(newSelected)
const nextSelected = new Set(selectedPacks)
nextSelected.delete(packIdStr)
setSelectedPacks(nextSelected)
if (isInCurrentTest) {
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
}
const newSelected = new Set(selectedPacks)
newSelected.add(packIdStr)
setSelectedPacks(newSelected)
const nextSelected = new Set(selectedPacks)
nextSelected.add(packIdStr)
setSelectedPacks(nextSelected)
if (removedPacks.has(packIdStr)) {
setRemovedPacks((prev) => {
@ -123,6 +139,10 @@ function TestPacksManagerInner({
return next
})
}
if (onSelectedPackColorChange) {
onSelectedPackColorChange(pack.color ?? '')
}
}
const isPackSelected = (packId: string | number) => {
@ -134,7 +154,6 @@ function TestPacksManagerInner({
return currentPackIds.includes(String(packId))
}
const allPacks: CardPackPreviewDto[] = packsData?.items || []
const filteredPacks = search
? allPacks.filter(
(pack) =>
@ -197,7 +216,7 @@ function TestPacksManagerInner({
<TableRow
key={pack.id}
className={selected ? 'bg-muted/50' : ''}
onClick={() => handleTogglePack(packIdStr)}
onClick={() => handleTogglePack(pack)}
>
<TableCell>
{selected ? (

View file

@ -19,19 +19,19 @@ describe('ColorPaletteInput', () => {
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()
render(
<ColorPaletteInput
id="color"
value=""
onChange={onChange}
palette={['#123456', '#ABCDEF']}
/>,
<>
<label htmlFor="color">Color</label>
<ColorPaletteInput id="color" value="" onChange={onChange} />
</>,
)
fireEvent.click(screen.getByRole('button', { name: 'Set color #ABCDEF' }))
fireEvent.change(screen.getByLabelText('Color'), {
target: { value: '#ABCDEF' },
})
expect(onChange).toHaveBeenCalledWith('#ABCDEF')
})

View file

@ -2,22 +2,6 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
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 trimmed = value.trim()
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(trimmed)
@ -28,7 +12,6 @@ export type ColorPaletteInputProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
palette?: readonly string[]
placeholder?: string
}
@ -37,7 +20,6 @@ export const ColorPaletteInput = ({
value,
onChange,
disabled = false,
palette = DEFAULT_COLOR_PALETTE,
placeholder = '#FF0000',
}: ColorPaletteInputProps) => {
const trimmed = value.trim()
@ -84,27 +66,6 @@ export const ColorPaletteInput = ({
</div>
<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
type="button"
variant="outline"

View file

@ -10,6 +10,7 @@ import { questionFromJson, questionToJson } from '@/types/questions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
import {
Table,
TableBody,
@ -481,10 +482,13 @@ export default function TestsPage() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
<ColorPaletteInput
id="color"
value={formData.color}
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
onChange={(value) =>
setFormData((prev) => ({ ...prev, color: value }))
}
disabled={isSaving}
placeholder="#FF0000"
/>
</div>
@ -533,6 +537,11 @@ export default function TestsPage() {
<TestPacksManager
currentPackIds={currentPackIds}
onPacksChange={handlePacksChange}
onSelectedPackColorChange={(color) => {
const trimmed = color.trim()
if (!trimmed) return
setFormData((prev) => ({ ...prev, color: trimmed }))
}}
disabled={isSaving}
/>
<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/helpers.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:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -21,6 +22,202 @@ class AdminPacksApiV2 {
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(
Object? data, {
int statusCode = 200,
@ -372,6 +569,10 @@ class AdminPacksApiV2 {
);
}
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) {
// 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:mnemo_cards_backend/database/database.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_router/shelf_router.dart';
@ -21,14 +20,23 @@ class AdminTestsApiV2 {
AdminTestsApiV2(this._db);
// Helper function to check if string is base64 encoded
bool _isBase64(String value) {
if (value.isEmpty) return 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;
final base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$');
return base64Regex.hasMatch(value) && value.length > 100;
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;
}
// 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(
Object? data, {
int statusCode = 200,
@ -142,6 +230,21 @@ class AdminTestsApiV2 {
final testDtos = <Map<String, dynamic>>[];
for (final test in allTests) {
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
final packIds = await _db.testDao.getPackIdsForTest(test.id);
@ -160,7 +263,7 @@ class AdminTestsApiV2 {
'id': test.id,
'name': test.name,
'color': test.color,
'cover': test.cover,
'cover': _imageValueToApiUrl(normalizedCover, packId: packIdForCover),
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
@ -257,115 +360,142 @@ class AdminTestsApiV2 {
// Get questions
final questions = await _db.testDao.getTestQuestions(testId);
final questionsList = questions.map((q) {
// Новая структура: собираем вопрос из отдельных полей
Map<String, dynamic> questionJson = {
'questionType': q.questionType,
'id': q.id,
'word': q.word,
};
// Парсим options (JSON array кнопок)
try {
final options = json.decode(q.options) as List<dynamic>;
questionJson['buttons'] = options;
} catch (e) {
questionJson['buttons'] = [];
}
// Добавляем answer
questionJson['answer'] = q.answer;
// Парсим uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
questionJson.addAll(uiData);
} catch (e) {
// Если uiData пустой или невалидный, игнорируем
}
return AbstractTestQuestion.fromJson(questionJson);
}).toList();
final questionsWithUrls = <Map<String, dynamic>>[];
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == 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;
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>[];
}
// If packId is null, we can't convert to URL, return as is
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;
// 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>{};
}
// 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';
}
var mutated = false;
// Normalize question image in uiData
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;
}
}
// 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
// This handles cases where the value is stored as card ID
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Convert image IDs to URLs in questions
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,
// Normalize button images
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()),
),
);
}
// Convert images in buttons
if (questionJson['buttons'] != null) {
final buttons = questionJson['buttons'] as List<dynamic>;
for (final button in buttons) {
if (button is Map<String, dynamic> && button['image'] != null) {
button['image'] = _convertImageToUrl(
button['image'] as String?,
packId,
// Build response question map (images as URLs)
final questionJson = <String, dynamic>{
'questionType': q.questionType,
'id': q.id,
'word': q.word,
'answer': q.answer,
'buttons': 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(),
};
return questionJson;
}).toList();
final uiDataForResponse = Map<String, dynamic>.from(uiData);
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({
'id': test.id,
'name': test.name,
'color': test.color,
'cover': test.cover,
'cover': _imageValueToApiUrl(normalizedCover, packId: packId),
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
@ -447,10 +577,16 @@ class AdminTestsApiV2 {
// Update test
final testToUpdate = await _db.testDao.getTestById(testId);
if (testToUpdate != null) {
final packIdForTest = await _db.testDao.getPackIdForTest(testId);
final normalizedCover =
await _normalizeImageValueForDb(cover, packId: packIdForTest);
final updatedTest = testToUpdate.copyWith(
name: name,
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(),
time: time != null ? drift.Value(time) : 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);
}
// 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);
// Add new questions
@ -481,21 +617,20 @@ class AdminTestsApiV2 {
final answer = questionJson['answer'] as String? ?? '';
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) {
final convertedButtons = <Map<String, dynamic>>[];
for (final button in buttons) {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image'] as String;
// Check if it's base64
if (_isBase64(imageValue)) {
// Convert base64 to card
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
buttonMap['image'] = cardId;
}
final raw = buttonMap['image']?.toString();
final normalized =
await _normalizeImageValueForDb(raw, packId: packId);
if (normalized == null) {
buttonMap.remove('image');
} else {
buttonMap['image'] = normalized;
}
}
convertedButtons.add(buttonMap);
@ -509,17 +644,11 @@ class AdminTestsApiV2 {
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) {
final imageValue = questionJson['image'] as String;
// Convert base64 image to card if needed
if (_isBase64(imageValue)) {
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
uiData['image'] = cardId;
} else {
uiData['image'] = imageValue;
}
} else {
uiData['image'] = imageValue;
final raw = questionJson['image']?.toString();
final normalized =
await _normalizeImageValueForDb(raw, packId: packId);
if (normalized != null) {
uiData['image'] = normalized;
}
}
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
@ -564,11 +693,17 @@ class AdminTestsApiV2 {
});
} else {
// Create new test
final normalizedCover = await _normalizeImageValueForDb(
cover,
packId: null,
);
final newTest = await _db.testDao.createTest(
TestsCompanion.insert(
name: name,
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(),
time: time != null ? drift.Value(time) : const drift.Value.absent(),
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
@ -577,14 +712,10 @@ class AdminTestsApiV2 {
// Add questions if provided
if (questions != null) {
// Get packId if test is linked to a pack
String? packId;
try {
packId = await _db.testDao.getPackIdForTest(newTest);
} catch (e) {
// Test might not be linked to a pack yet
packId = null;
}
// Test can be linked to packs only after save (admin UI does it).
// We'll persist images as cards even without packId and later link
// those cards to packs when the test is linked.
final packId = await _db.testDao.getPackIdForTest(newTest);
int orderIndex = 0;
for (final q in questions) {
@ -596,21 +727,20 @@ class AdminTestsApiV2 {
final answer = questionJson['answer'] as String? ?? '';
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) {
final convertedButtons = <Map<String, dynamic>>[];
for (final button in buttons) {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image'] as String;
// Check if it's base64
if (_isBase64(imageValue)) {
// Convert base64 to card
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
buttonMap['image'] = cardId;
}
final raw = buttonMap['image']?.toString();
final normalized =
await _normalizeImageValueForDb(raw, packId: packId);
if (normalized == null) {
buttonMap.remove('image');
} else {
buttonMap['image'] = normalized;
}
}
convertedButtons.add(buttonMap);
@ -624,17 +754,11 @@ class AdminTestsApiV2 {
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) {
final imageValue = questionJson['image'] as String;
// Convert base64 image to card if needed
if (_isBase64(imageValue)) {
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
uiData['image'] = cardId;
} else {
uiData['image'] = imageValue;
}
} else {
uiData['image'] = imageValue;
final raw = questionJson['image']?.toString();
final normalized =
await _normalizeImageValueForDb(raw, packId: packId);
if (normalized != null) {
uiData['image'] = normalized;
}
}
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];

View file

@ -2,7 +2,9 @@ import 'dart:convert';
import 'dart:math';
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/packs/card_image_storage.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_common/mnemo_cards_common.dart';
@ -16,6 +18,130 @@ class TestManager {
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(
String userId, String testId) async {
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
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 statistics = await _testStatisticsDto(user.id!, testId);
@ -107,14 +213,94 @@ class TestManager {
uiData = {};
}
// Convert question image to URL
var mutated = false;
if (uiData['image'] != null) {
uiData['image'] = _convertImageToUrl(
uiData['image'] as String?,
packId,
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;
}
}
// 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
// actual matrix cards from pack pool on-the-fly if buttons are missing.
@ -185,9 +371,9 @@ class TestManager {
.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
buttonMap['image'] = _imageValueToApiUrl(
buttonMap['image']?.toString(),
packId: packId,
);
return buttonMap;
}
@ -202,7 +388,10 @@ class TestManager {
id: testId.toString(),
name: test.name,
color: test.color,
cover: test.cover,
cover: _imageValueToApiUrl(
await _normalizeImageValueForDb(test.cover, packId: packId),
packId: packId,
),
version: test.version ?? '1.0',
time: test.time,
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_web_v2/domain/models/telegram_auth_code_status.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
///

View file

@ -23,10 +23,12 @@ import '../../../presentation/widgets/loading_view.dart';
class GamePage extends StatefulWidget {
const GamePage({
required this.testId,
this.returnToLocation,
super.key,
});
final String testId;
final String? returnToLocation;
static const questionCardKey = Key('game_question_card');
@override
@ -406,40 +408,45 @@ class _GamePageState extends State<GamePage> {
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TweenAnimationBuilder<int>(
tween: Tween<int>(begin: 0, end: accuracy),
duration: const Duration(milliseconds: 1200),
builder: (context, animatedAccuracy, child) {
return Text(
'$animatedAccuracy%',
style: TextStyle(
fontSize: 32.sp,
fontWeight: FontWeight.bold,
color: scoreColor,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TweenAnimationBuilder<int>(
tween: Tween<int>(begin: 0, end: accuracy),
duration: const Duration(milliseconds: 1200),
builder: (context, animatedAccuracy, child) {
return Text(
'$animatedAccuracy%',
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),
ElevatedButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.home),
label: const Text('Back to Tests'),
onPressed: _leaveGame,
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
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() {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
@ -587,18 +637,21 @@ class _GamePageState extends State<GamePage> {
void _showExitConfirmation(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Exit Game'),
content: const Text('Are you sure you want to exit? Your progress will be lost.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.pop();
onPressed: () async {
Navigator.of(dialogContext).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'),
),

View file

@ -198,7 +198,10 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
}
log('Launching test: ${test.name}', name: 'PackDetailsPage');
context.push('/test/${test.id}');
context.push(
'/game/${test.id}',
extra: '/pack/${widget.packId}',
);
}
@override
@ -326,7 +329,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
});
try {
await Future.delayed(const Duration(milliseconds: 100));
await Future<void>.delayed(const Duration(milliseconds: 100));
if (mounted) {
await context.push('/purchase/${widget.packId}');
}
@ -401,7 +404,6 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
});
},
isFavoritesMode: _isFavoritesMode,
isShuffleActive: _isShuffled,
shuffleTurns: _shuffleAnimationTurns,
),
const SizedBox(height: 16),
@ -451,6 +453,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
}
/// Модуль "проверка знаний" для мобильных экранов
// ignore: unused_element
Widget _buildMobileTestsSection(Color packColor) {
final tests = _getTests();

View file

@ -229,7 +229,7 @@ class _TestPageState extends State<TestPage> {
}
void _playGame(BuildContext context) {
context.go('/game/${widget.testId}');
context.push('/game/${widget.testId}');
}
void _handleBack() {

View file

@ -12,7 +12,6 @@ import '../pages/purchase/purchase_page.dart';
import '../pages/statistics/statistics_page.dart';
import '../pages/tasks/create_task_page.dart';
import '../pages/tasks/tasks_page.dart';
import '../pages/test/test_page.dart';
import '../widgets/main_shell.dart';
/// Создает конфигурацию роутера приложения
@ -132,12 +131,14 @@ GoRouter createAppRouter({
GoRoute(
path: '/test/:testId',
name: 'test',
pageBuilder: (context, state) {
final testId = state.pathParameters['testId']!;
return MaterialPage(
child: TestPage(testId: testId),
);
redirect: (context, state) {
final testId = state.pathParameters['testId'];
if (testId == null || testId.isEmpty) return '/home';
return '/game/$testId';
},
pageBuilder: (context, state) => const NoTransitionPage(
child: SizedBox.shrink(),
),
),
// Game Page
@ -147,7 +148,10 @@ GoRouter createAppRouter({
pageBuilder: (context, state) {
final testId = state.pathParameters['testId']!;
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.onToggleFavorites,
this.isFavoritesMode = false,
this.isShuffleActive = false,
this.shuffleTurns = 0.0,
super.key,
});
@ -25,7 +24,6 @@ class PackDetailsControls extends StatelessWidget {
final VoidCallback onShuffle;
final VoidCallback onToggleFavorites;
final bool isFavoritesMode;
final bool isShuffleActive;
final double shuffleTurns;
@override
@ -49,7 +47,6 @@ class PackDetailsControls extends StatelessWidget {
icon: Icons.shuffle,
label: 'Перемешать',
onTap: onShuffle,
isActive: isShuffleActive,
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:mocktail/mocktail.dart';
import 'package:mnemo_cards_chat/mnemo_cards_chat.dart';

View file

@ -123,8 +123,6 @@ void main() {
paymentSystem: PaymentSystem.yookassa,
externalToken: 'token',
meta: '{}',
packs: const [],
subscription: false,
products: const [
MnemoCardsProductDto(
id: 'pack-001',

View file

@ -282,7 +282,7 @@ void main() {
group('Legacy getStatistics method', () {
test('still works for backward compatibility', () {
final user = UserDto(
id: 1,
id: '1',
name: 'Test User',
email: 'test@example.com',
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_test/flutter_test.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_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/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_sound_service.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/presentation/pages/game/game_page.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:provider/provider.dart';
import 'package:yx_scope/yx_scope.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 {
FakeTestsModule({
class _FakeTestsModule extends Fake implements TestsModule {
_FakeTestsModule({
required this.testsStateManager,
required this.gameSoundService,
required this.gameSessionManager,
required this.testManager,
});
@override
@ -39,70 +41,137 @@ class FakeTestsModule extends Fake implements TestsModule {
final GameSoundService gameSoundService;
@override
GameSessionManager get gameSessionManager => throw UnimplementedError();
final GameSessionManager gameSessionManager;
@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() {
late MockAppScopeContainer mockAppScope;
late MockUserScopeHolder mockUserScopeHolder;
late MockUserScope mockUserScope;
late MockTestsStateManager mockTestsStateManager;
late MockGameSoundService mockGameSoundService;
late FakeTestsModule fakeTestsModule;
late _MockAppScopeContainer mockAppScope;
late _MockUserScopeHolder mockUserScopeHolder;
late _MockUserScope mockUserScope;
late _MockGameSoundService mockGameSoundService;
late _MockTestManager mockTestManager;
setUp(() {
mockAppScope = MockAppScopeContainer();
mockUserScopeHolder = MockUserScopeHolder();
mockUserScope = MockUserScope();
mockTestsStateManager = MockTestsStateManager();
mockGameSoundService = MockGameSoundService();
fakeTestsModule = FakeTestsModule(
testsStateManager: mockTestsStateManager,
late GameSessionManager gameSessionManager;
late _SpyTestsStateManager testsStateManager;
late _FakeTestsModule fakeTestsModule;
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
ScreenUtil.ensureScreenSize();
mockAppScope = _MockAppScopeContainer();
mockUserScopeHolder = _MockUserScopeHolder();
mockUserScope = _MockUserScope();
mockGameSoundService = _MockGameSoundService();
mockTestManager = _MockTestManager();
gameSessionManager = GameSessionManager();
testsStateManager = _SpyTestsStateManager(
testManager: mockTestManager,
gameSessionManager: gameSessionManager,
gameSoundService: mockGameSoundService,
);
// Setup the mock chain
fakeTestsModule = _FakeTestsModule(
testsStateManager: testsStateManager,
gameSoundService: mockGameSoundService,
gameSessionManager: gameSessionManager,
testManager: mockTestManager,
);
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
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.playGameStart()).thenAnswer((_) async {});
// Initialize screen util
_initScreenUtil();
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
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', () {
testWidgets('should display preparing state', (tester) async {
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionPreparing(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
questions: [],
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: const [],
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Ready to Start?'), findsOneWidget);
expect(find.text('Start Game'), findsOneWidget);
expect(testsStateManager.startCalls, equals(0));
});
testWidgets('does not restart when session already active', (tester) async {
@ -111,39 +180,30 @@ void main() {
MultipleChoiceQuestion(
id: 'q1',
question: '2+2?',
options: ['3', '4'],
options: const ['3', '4'],
correctAnswer: '4',
word: 'four',
),
),
];
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: {},
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await tester.pumpAndSettle();
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
verifyNever(() => mockTestsStateManager.startGameSession(any()));
verify(() => mockTestsStateManager.resumeGameSession()).called(1);
expect(testsStateManager.startCalls, equals(0));
expect(testsStateManager.resumeCalls, equals(1));
});
testWidgets('should display active game state', (tester) async {
@ -152,37 +212,27 @@ void main() {
MultipleChoiceQuestion(
id: 'q1',
question: 'What is 2+2?',
options: ['3', '4', '5'],
options: const ['3', '4', '5'],
correctAnswer: '4',
word: 'four',
),
),
];
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: {},
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('What is 2+2?'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
@ -196,26 +246,24 @@ void main() {
MultipleChoiceQuestion(
id: 'q1',
question: 'Capital of France?',
options: ['Paris', 'London'],
options: const ['Paris', 'London'],
correctAnswer: 'Paris',
word: 'paris',
),
),
];
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: {},
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
final theme = ThemeData(
colorScheme: ColorScheme.fromSeed(
@ -227,20 +275,16 @@ void main() {
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: MaterialApp(
theme: theme,
home: const GamePage(testId: 'test1'),
),
),
await pumpGamePage(
tester,
child: const GamePage(testId: 'test1'),
theme: theme,
);
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.surfaceTintColor, theme.colorScheme.surfaceTint);
@ -252,168 +296,121 @@ void main() {
MultipleChoiceQuestion(
id: 'q1',
question: 'Capital of France?',
options: ['Paris', 'London'],
options: const ['Paris', 'London'],
correctAnswer: 'Paris',
word: 'paris',
),
),
];
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: {},
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
when(() => mockTestsStateManager.canGoNext).thenReturn(true);
when(() => mockTestsStateManager.canGoPrevious).thenReturn(false);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await tester.pumpAndSettle();
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.byIcon(Icons.close), findsOneWidget);
});
testWidgets('starts session when not active', (tester) async {
when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading());
await testsStateManager.setStateForTest(const TestsState.loading());
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await tester.pumpAndSettle();
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
verify(() => mockTestsStateManager.startGameSession('test1')).called(1);
expect(testsStateManager.startCalls, equals(1));
});
testWidgets('should display completed game state', (tester) async {
final result = GameSessionResult(
testId: 'test1',
questionResults: [],
questionResults: const [],
totalTime: const Duration(seconds: 30),
correctAnswers: 1,
totalQuestions: 1,
completedAt: DateTime.now(),
completedAt: DateTime(2025, 1, 1),
);
when(() => mockTestsStateManager.state).thenReturn(
await testsStateManager.setStateForTest(
TestsState.gameSessionCompleted(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
result: result,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Game Completed!'), findsOneWidget);
expect(find.text('1/1 correct answers'), 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 {
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(
test: TestDto(id: 'test1', name: 'Test Game', questions: []),
questions: [],
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0,
currentResult: null,
questionResults: {},
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
// Tap the close button
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('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 {
when(() => mockTestsStateManager.state).thenReturn(
const TestsState.loading(),
);
await testsStateManager.setStateForTest(const TestsState.loading());
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Loading game...'), findsOneWidget);
});
testWidgets('should display error state', (tester) async {
when(() => mockTestsStateManager.state).thenReturn(
const TestsState.error('Test error'),
);
await testsStateManager.setStateForTest(const TestsState.error('Test error'));
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AppScopeContainer>.value(value: mockAppScope),
],
child: const MaterialApp(
home: GamePage(testId: 'test1'),
),
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Game 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'),
)).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(
ScopeProvider<UserScope>(
scope: userScope,
child: MaterialApp(
home: const CreateTaskPage(),
),
holder: userScopeHolder,
child: MaterialApp.router(routerConfig: router),
),
);
@ -272,12 +284,24 @@ void main() {
});
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(
ScopeProvider<UserScope>(
scope: userScope,
child: MaterialApp(
home: const CreateTaskPage(),
),
holder: userScopeHolder,
child: MaterialApp.router(routerConfig: router),
),
);

View file

@ -1,96 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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() {
late MockAppScopeContainer mockAppScope;
late MockUserScopeHolder mockUserScopeHolder;
late MockUserScope mockUserScope;
late MockTestsModule mockTestsModule;
late MockTestManager mockTestManager;
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
testWidgets('/test/:id redirects to /game/:id', (tester) async {
final router = _buildRouter();
addTearDown(router.dispose);
setUp(() {
mockAppScope = MockAppScopeContainer();
mockUserScopeHolder = MockUserScopeHolder();
mockUserScope = MockUserScope();
mockTestsModule = MockTestsModule();
mockTestManager = MockTestManager();
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
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();
});
expect(find.text('Game 42'), findsOneWidget);
expect(router.routeInformationProvider.value.uri.path, equals('/game/42'));
});
}
@ -99,11 +20,20 @@ GoRouter _buildRouter() {
initialLocation: '/test/42',
routes: [
GoRoute(
path: '/test/:id',
builder: (context, state) => TestPage(
testId: state.pathParameters['id'] ?? '',
path: '/home',
builder: (context, state) => const Scaffold(
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(
path: '/game/:id',
builder: (context, state) => Scaffold(

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.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/theme/app_colors.dart';
void main() {
testWidgets(
@ -33,7 +34,6 @@ void main() {
onToggleView: _noop,
onShuffle: _noop,
onToggleFavorites: _noop,
isShuffleActive: true,
shuffleTurns: 1.0,
),
),
@ -47,6 +47,44 @@ void main() {
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() {}

View file

@ -368,12 +368,12 @@ void main() {
// Verify data was submitted
expect(submittedData, isNotNull);
expect(submittedData!.title, 'Test Task');
expect(submittedData.description, 'This is a test description');
expect(submittedData.type, TaskType.appInternal);
expect(submittedData.difficulty, TaskDifficulty.easy);
expect(submittedData.rewards.length, 1);
expect(submittedData.rewards.first.type, RewardType.xp);
expect(submittedData.rewards.first.amount, 100);
expect(submittedData!.description, 'This is a test description');
expect(submittedData!.type, TaskType.appInternal);
expect(submittedData!.difficulty, TaskDifficulty.easy);
expect(submittedData!.rewards.length, 1);
expect(submittedData!.rewards.first.type, RewardType.xp);
expect(submittedData!.rewards.first.amount, 100);
});
testWidgets('parses tags correctly', (tester) async {
@ -446,8 +446,8 @@ void main() {
// Verify tags were parsed
expect(submittedData, isNotNull);
expect(submittedData!.tags, isNotNull);
expect(submittedData.tags!.length, 3);
expect(submittedData.tags, ['tag1', 'tag2', 'tag3']);
expect(submittedData!.tags!.length, 3);
expect(submittedData!.tags, ['tag1', 'tag2', 'tag3']);
});
testWidgets('validates tags count and length', (tester) async {

View file

@ -7,8 +7,8 @@ void main() {
group('resolveGridMovementOffset', () {
test('returns zero when card index unchanged', () {
final offset = resolveGridMovementOffset(
previousIndexById: {1: 2},
cardId: 1,
previousIndexById: {'1': 2},
cardId: '1',
currentIndex: 2,
crossAxisCount: 3,
itemWidth: 100,
@ -21,8 +21,8 @@ void main() {
test('computes offset between different grid cells', () {
final offset = resolveGridMovementOffset(
previousIndexById: {1: 0},
cardId: 1,
previousIndexById: {'1': 0},
cardId: '1',
currentIndex: 5,
crossAxisCount: 3,
itemWidth: 100,
@ -40,7 +40,7 @@ void main() {
test('returns zero when previous position unknown', () {
final offset = resolveListMovementOffset(
previousIndexById: const {},
cardId: 1,
cardId: '1',
currentIndex: 0,
itemExtent: 100,
spacing: 8,
@ -51,8 +51,8 @@ void main() {
test('computes vertical delta between list positions', () {
final offset = resolveListMovementOffset(
previousIndexById: {7: 4},
cardId: 7,
previousIndexById: {'7': 4},
cardId: '7',
currentIndex: 1,
itemExtent: 100,
spacing: 8,