stuff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
fface20519
commit
9dd7662b3d
31 changed files with 1967 additions and 609 deletions
13
PROGRESS.md
13
PROGRESS.md
|
|
@ -37,11 +37,15 @@
|
|||
- Ensured voices list still loads while the form is disabled during save
|
||||
- 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)
|
||||
|
|
|
|||
9
TODO.md
9
TODO.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -143,6 +231,21 @@ class AdminTestsApiV2 {
|
|||
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);
|
||||
final packs = <Map<String, dynamic>>[];
|
||||
|
|
@ -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,
|
||||
};
|
||||
final questionsWithUrls = <Map<String, dynamic>>[];
|
||||
|
||||
// Парсим options (JSON array кнопок)
|
||||
for (final q in questions) {
|
||||
// Parse options/buttons
|
||||
List<dynamic> buttons;
|
||||
try {
|
||||
final options = json.decode(q.options) as List<dynamic>;
|
||||
questionJson['buttons'] = options;
|
||||
} catch (e) {
|
||||
questionJson['buttons'] = [];
|
||||
final decoded = json.decode(q.options);
|
||||
buttons = decoded is List ? decoded : <dynamic>[];
|
||||
} catch (_) {
|
||||
buttons = <dynamic>[];
|
||||
}
|
||||
|
||||
// Добавляем answer
|
||||
questionJson['answer'] = q.answer;
|
||||
|
||||
// Парсим uiData (image, text, audio, template)
|
||||
// Parse uiData
|
||||
Map<String, dynamic> uiData;
|
||||
try {
|
||||
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
||||
questionJson.addAll(uiData);
|
||||
} catch (e) {
|
||||
// Если uiData пустой или невалидный, игнорируем
|
||||
final decoded = json.decode(q.uiData);
|
||||
uiData = decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
} catch (_) {
|
||||
uiData = <String, dynamic>{};
|
||||
}
|
||||
|
||||
return AbstractTestQuestion.fromJson(questionJson);
|
||||
}).toList();
|
||||
var mutated = false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
// 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';
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/policies/admin_policy.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/policies/pack_policy.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/resource_loader.dart';
|
||||
import 'package:mnemo_cards_backend/api/v2/admin_packs_api_v2.dart';
|
||||
import 'package:mnemo_cards_backend/api/v2/admin_tests_api_v2.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||
import 'package:mnemo_cards_backend/packs/pack_dto_converter.dart';
|
||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
void main() {
|
||||
const oneByOnePngBase64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5n5p0AAAAASUVORK5CYII=';
|
||||
|
||||
late AppDatabase db;
|
||||
late AdminTestsApiV2 adminTestsApi;
|
||||
late AdminPacksApiV2 adminPacksApi;
|
||||
|
||||
late AccessService accessService;
|
||||
late UserModel adminUser;
|
||||
|
||||
Request buildAdminRequest(
|
||||
String method,
|
||||
String url, {
|
||||
Object? body,
|
||||
}) {
|
||||
return Request(
|
||||
method,
|
||||
Uri.parse(url),
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
).change(
|
||||
context: {
|
||||
'user': adminUser,
|
||||
'accessService': accessService,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port =
|
||||
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database =
|
||||
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ??
|
||||
Platform.environment['DB_USER'] ??
|
||||
'mnemo_user';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ??
|
||||
Platform.environment['DB_PASSWORD'] ??
|
||||
'';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Ensure pgcrypto is available for gen_random_uuid().
|
||||
await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||
await Migrator(db).createAll();
|
||||
|
||||
adminTestsApi = AdminTestsApiV2(db);
|
||||
adminPacksApi = AdminPacksApiV2(db);
|
||||
|
||||
// Access service (pack policy is unused for admin-only checks).
|
||||
final discountsManager = DiscountsManager(db);
|
||||
final productsPriceResolver = ProductsPriceResolver(discountsManager, db);
|
||||
final adsManager = AdsManager();
|
||||
final packDtoConverter = PackDtoConverter(productsPriceResolver, adsManager);
|
||||
final packManager = PackManager(db, packDtoConverter);
|
||||
|
||||
final resourceLoader = ResourceLoader(packManager);
|
||||
accessService = AccessService(
|
||||
PackAccessPolicy(resourceLoader),
|
||||
AdminAccessPolicy(),
|
||||
);
|
||||
|
||||
adminUser = UserModel(
|
||||
id: 'admin',
|
||||
admin: true,
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('AdminTestsApiV2 images', () {
|
||||
String? packId;
|
||||
String? testId;
|
||||
final createdCardIds = <String>{};
|
||||
|
||||
Future<void> cleanup() async {
|
||||
// Delete relations first.
|
||||
if (testId != null) {
|
||||
await (db.delete(db.testPackRelations)
|
||||
..where((r) => r.testId.equals(testId!)))
|
||||
.go();
|
||||
|
||||
await (db.delete(db.testQuestions)
|
||||
..where((q) => q.testId.equals(testId!)))
|
||||
.go();
|
||||
|
||||
await (db.delete(db.tests)..where((t) => t.id.equals(testId!))).go();
|
||||
}
|
||||
|
||||
if (packId != null) {
|
||||
await (db.delete(db.cardPackCards)
|
||||
..where((c) => c.packId.equals(packId!)))
|
||||
.go();
|
||||
|
||||
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!))).go();
|
||||
}
|
||||
|
||||
for (final cardId in createdCardIds) {
|
||||
// Remove card DB row
|
||||
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId))).go();
|
||||
|
||||
// Remove possible image files in data/cards
|
||||
final cardsDir = Directory('${PackManagerUtils.assetsDirectory.path}/cards');
|
||||
final candidates = [
|
||||
File('${cardsDir.path}/$cardId.png'),
|
||||
File('${cardsDir.path}/$cardId.webp'),
|
||||
File('${cardsDir.path}/$cardId.jpg'),
|
||||
File('${cardsDir.path}/$cardId.jpeg'),
|
||||
File('${cardsDir.path}/$cardId.gif'),
|
||||
];
|
||||
for (final f in candidates) {
|
||||
if (f.existsSync()) {
|
||||
try {
|
||||
await f.delete();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createdCardIds.clear();
|
||||
packId = null;
|
||||
testId = null;
|
||||
}
|
||||
|
||||
tearDown(() async {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('getTest returns image URLs (no base64) and cards are linked to pack',
|
||||
() async {
|
||||
// Create pack
|
||||
packId = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'pack for admin tests images',
|
||||
subtitle: 'subtitle',
|
||||
size: 0,
|
||||
),
|
||||
);
|
||||
|
||||
// Create test with base64 cover + base64 question/button images
|
||||
final createRequest = buildAdminRequest(
|
||||
'POST',
|
||||
'http://localhost/api/v2/admin/tests',
|
||||
body: {
|
||||
'name': 'test with images',
|
||||
'cover': oneByOnePngBase64,
|
||||
'questions': [
|
||||
{
|
||||
'questionType': 'simple',
|
||||
'word': 'word',
|
||||
'answer': 'btn1',
|
||||
'image': oneByOnePngBase64,
|
||||
'buttons': [
|
||||
{
|
||||
'id': 'btn1',
|
||||
'text': 'ok',
|
||||
'image': oneByOnePngBase64,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final createResp = await adminTestsApi.upsertTest(createRequest);
|
||||
expect(createResp.statusCode, anyOf(equals(201), equals(200)));
|
||||
final createBody = jsonDecode(await createResp.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
testId = (createBody['test'] as Map<String, dynamic>)['id'] as String;
|
||||
expect(testId, isNotEmpty);
|
||||
|
||||
// Link test to pack via admin packs API (this must also link referenced cards)
|
||||
final linkRequest = buildAdminRequest(
|
||||
'POST',
|
||||
'http://localhost/api/v2/admin/packs',
|
||||
body: {
|
||||
'id': packId,
|
||||
'title': 'pack for admin tests images',
|
||||
'addTestIds': [testId],
|
||||
},
|
||||
);
|
||||
|
||||
final linkResp = await adminPacksApi.upsertPack(linkRequest);
|
||||
expect(linkResp.statusCode, equals(200));
|
||||
|
||||
// Fetch test and verify URLs
|
||||
final getReq = buildAdminRequest(
|
||||
'GET',
|
||||
'http://localhost/api/v2/admin/tests/$testId',
|
||||
);
|
||||
final getResp = await adminTestsApi.getTest(getReq, testId!);
|
||||
expect(getResp.statusCode, equals(200));
|
||||
|
||||
final body = jsonDecode(await getResp.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final cover = body['cover'] as String?;
|
||||
expect(cover, isNotNull);
|
||||
expect(cover, isNot(contains(oneByOnePngBase64)));
|
||||
expect(cover, startsWith('/api/v2/packs/$packId/cards/'));
|
||||
expect(cover, endsWith('/image'));
|
||||
|
||||
final questions = body['questions'] as List<dynamic>;
|
||||
expect(questions, hasLength(1));
|
||||
final q0 = questions.first as Map<String, dynamic>;
|
||||
|
||||
final qImage = q0['image'] as String?;
|
||||
expect(qImage, isNotNull);
|
||||
expect(qImage, isNot(contains(oneByOnePngBase64)));
|
||||
expect(qImage, startsWith('/api/v2/packs/$packId/cards/'));
|
||||
expect(qImage, endsWith('/image'));
|
||||
|
||||
final buttons = q0['buttons'] as List<dynamic>;
|
||||
expect(buttons, hasLength(1));
|
||||
final b0 = buttons.first as Map<String, dynamic>;
|
||||
final bImage = b0['image'] as String?;
|
||||
expect(bImage, isNotNull);
|
||||
expect(bImage, isNot(contains(oneByOnePngBase64)));
|
||||
expect(bImage, startsWith('/api/v2/packs/$packId/cards/'));
|
||||
expect(bImage, endsWith('/image'));
|
||||
|
||||
// Extract card ids from URLs and ensure they are linked to the pack.
|
||||
final extractCardId = (String url) {
|
||||
final match = RegExp(r'/cards/([^/]+)/image$').firstMatch(url);
|
||||
return match?.group(1);
|
||||
};
|
||||
|
||||
final coverCardId = extractCardId(cover!);
|
||||
final qCardId = extractCardId(qImage!);
|
||||
final bCardId = extractCardId(bImage!);
|
||||
|
||||
expect(coverCardId, isNotNull);
|
||||
expect(qCardId, isNotNull);
|
||||
expect(bCardId, isNotNull);
|
||||
|
||||
createdCardIds.addAll([coverCardId!, qCardId!, bCardId!]);
|
||||
|
||||
final linked = await (db.select(db.cardPackCards)
|
||||
..where((c) => c.packId.equals(packId!) &
|
||||
c.cardId.isIn([coverCardId, qCardId, bCardId])))
|
||||
.get();
|
||||
expect(linked.map((e) => e.cardId).toSet(),
|
||||
containsAll([coverCardId, qCardId, bCardId]));
|
||||
|
||||
// Also ensure DB stores cardIds, not API URLs/base64.
|
||||
final dbQuestions = await db.testDao.getTestQuestions(testId!);
|
||||
expect(dbQuestions, hasLength(1));
|
||||
final qDb = dbQuestions.single;
|
||||
final options = jsonDecode(qDb.options) as List<dynamic>;
|
||||
final uiData = jsonDecode(qDb.uiData) as Map<String, dynamic>;
|
||||
|
||||
final storedUiImage = uiData['image']?.toString();
|
||||
expect(storedUiImage, isNotNull);
|
||||
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||
.hasMatch(storedUiImage!), isTrue);
|
||||
|
||||
final storedBtnImage =
|
||||
(options.first as Map<String, dynamic>)['image']?.toString();
|
||||
expect(storedBtnImage, isNotNull);
|
||||
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||
.hasMatch(storedBtnImage!), isTrue);
|
||||
|
||||
final storedCover = (await db.testDao.getTestById(testId!))!.cover;
|
||||
expect(storedCover, isNotNull);
|
||||
expect(RegExp(r'^[0-9a-f\-]{36}$', caseSensitive: false)
|
||||
.hasMatch(storedCover!), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late TestManager testManager;
|
||||
|
||||
setUpAll(() async {
|
||||
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
|
||||
final port =
|
||||
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
|
||||
final database =
|
||||
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
|
||||
final username = Platform.environment['TEST_DB_USER'] ??
|
||||
Platform.environment['DB_USER'] ??
|
||||
'mnemo_user';
|
||||
final password = Platform.environment['TEST_DB_PASSWORD'] ??
|
||||
Platform.environment['DB_PASSWORD'] ??
|
||||
'';
|
||||
|
||||
db = AppDatabase.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Ensure pgcrypto is available for gen_random_uuid().
|
||||
await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||
|
||||
// Create tables if needed (idempotent in drift for Postgres).
|
||||
await Migrator(db).createAll();
|
||||
|
||||
testManager = TestManager(db);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('TestManager.updateGeneratedTests', () {
|
||||
String? packId;
|
||||
String? cardId;
|
||||
|
||||
tearDown(() async {
|
||||
// Cleanup is scoped to the entities created by this test.
|
||||
// Delete generated tests to avoid leaving orphans if pack is removed.
|
||||
final generatedTests = await (db.select(db.tests)
|
||||
..where((t) => t.version.equals('generated')))
|
||||
.get();
|
||||
for (final t in generatedTests) {
|
||||
await (db.delete(db.tests)..where((x) => x.id.equals(t.id))).go();
|
||||
}
|
||||
|
||||
if (packId != null) {
|
||||
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!)))
|
||||
.go();
|
||||
}
|
||||
if (cardId != null) {
|
||||
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId!)))
|
||||
.go();
|
||||
}
|
||||
|
||||
packId = null;
|
||||
cardId = null;
|
||||
});
|
||||
|
||||
test('creates generated test and links it to pack', () async {
|
||||
packId = await db.packDao.createPack(
|
||||
CardPacksCompanion.insert(
|
||||
title: 'pack for generated test link',
|
||||
subtitle: 'subtitle',
|
||||
size: 1,
|
||||
),
|
||||
);
|
||||
|
||||
cardId = await db.packDao.createCard(
|
||||
GameCardsCompanion.insert(
|
||||
original: 'hello',
|
||||
translation: 'привет',
|
||||
image: 'image_1',
|
||||
),
|
||||
);
|
||||
|
||||
await db.packDao.addCardToPack(
|
||||
packId: packId!,
|
||||
cardId: cardId!,
|
||||
order: 0,
|
||||
);
|
||||
|
||||
final packModel = CardPackModel(
|
||||
id: packId!,
|
||||
title: 'ignored',
|
||||
subtitle: 'ignored',
|
||||
size: 1,
|
||||
);
|
||||
|
||||
await testManager.updateGeneratedTests(packModel);
|
||||
|
||||
final packTests = await db.testDao.getTestsByPackId(packId!);
|
||||
final generated =
|
||||
packTests.where((t) => t.version == 'generated').toList();
|
||||
|
||||
expect(generated, hasLength(1));
|
||||
|
||||
final linkedPackId =
|
||||
await db.testDao.getPackIdForTest(generated.single.id);
|
||||
expect(linkedPackId, equals(packId));
|
||||
|
||||
final relations = await (db.select(db.testPackRelations)
|
||||
..where((r) => r.packId.equals(packId!)))
|
||||
.get();
|
||||
expect(relations, hasLength(1));
|
||||
expect(relations.single.testId, equals(generated.single.id));
|
||||
});
|
||||
});
|
||||
}
|
||||
67
mnemo_cards_backend/test/packs/voice_storage_test.dart
Normal file
67
mnemo_cards_backend/test/packs/voice_storage_test.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('VoiceStorage', () {
|
||||
test('sanitizeVoiceFileName strips voice/ prefix and rejects traversal', () {
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('voice/a.mp3'), 'a.mp3');
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('/voice/a.mp3'), 'a.mp3');
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('a.mp3'), 'a.mp3');
|
||||
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('../a.mp3'), isNull);
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('voice/../a.mp3'), isNull);
|
||||
expect(VoiceStorage.sanitizeVoiceFileName('voice/a/b.mp3'), isNull);
|
||||
});
|
||||
|
||||
test('tryParseBase64Audio parses data: url and detects mp3', () {
|
||||
final bytes = Uint8List.fromList([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
|
||||
final b64 = base64Encode(bytes);
|
||||
|
||||
final parsed = VoiceStorage.tryParseBase64Audio(
|
||||
'data:audio/mpeg;base64,$b64',
|
||||
);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.contentType, 'audio/mpeg');
|
||||
expect(parsed.ext, 'mp3');
|
||||
expect(parsed.bytes, isNotEmpty);
|
||||
});
|
||||
|
||||
test('persistFromBase64 writes file into voice/ and resolves it', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp('voice_assets_');
|
||||
addTearDown(() async {
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
const voiceId = 'f00b6be5-867a-43b4-979f-075e1ccf0f22';
|
||||
final bytes = Uint8List.fromList([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
|
||||
final b64 = base64Encode(bytes);
|
||||
|
||||
final stored = await VoiceStorage.persistFromBase64(
|
||||
voiceId: voiceId,
|
||||
voiceValue: 'data:audio/mpeg;base64,$b64',
|
||||
assetsDirectory: tempDir,
|
||||
);
|
||||
|
||||
expect(stored, isNotNull);
|
||||
expect(stored!.fileName, '$voiceId.mp3');
|
||||
|
||||
final resolved = await VoiceStorage.tryResolveLocalFile(
|
||||
voiceValue: stored.fileName,
|
||||
assetsDirectory: tempDir,
|
||||
);
|
||||
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.fileName, stored.fileName);
|
||||
expect(resolved.contentType, 'audio/mpeg');
|
||||
expect(resolved.bytes, isNotEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||
import 'package:mnemo_cards_backend/tests/generators/question_generators/matrix_question_generator.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('MatrixQuestionGenerator', () {
|
||||
test('generates matrix question with fixed size', () async {
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: List.generate(
|
||||
12,
|
||||
(i) => TestDataItem(
|
||||
id: 'card_$i',
|
||||
original: 'orig_$i',
|
||||
translation: 'tr_$i',
|
||||
image: 'card_$i',
|
||||
audio: 'orig_$i',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final generator = MatrixQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
fixedMatrixSize: 3,
|
||||
);
|
||||
|
||||
final answerCard = data.items.first;
|
||||
final q = await generator.generate(answerCard);
|
||||
|
||||
expect(q, isA<MatrixTestQuestionBody>());
|
||||
final mq = q as MatrixTestQuestionBody;
|
||||
|
||||
expect(mq.questionType, TestQuestionType.matrix);
|
||||
expect(mq.matrixSize, 3);
|
||||
expect(mq.cards.length, 9);
|
||||
|
||||
final ids = mq.cards.map((c) => c.id).toSet();
|
||||
expect(ids.length, 9, reason: 'Matrix must contain unique cards');
|
||||
|
||||
expect(mq.answer, answerCard.id);
|
||||
expect(mq.word, answerCard.original);
|
||||
expect(ids.contains(answerCard.id), isTrue);
|
||||
});
|
||||
|
||||
test('clamps matrix size down when pool is too small', () async {
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: List.generate(
|
||||
5,
|
||||
(i) => TestDataItem(
|
||||
id: 'card_$i',
|
||||
original: 'orig_$i',
|
||||
translation: 'tr_$i',
|
||||
image: 'card_$i',
|
||||
audio: 'orig_$i',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final generator = MatrixQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
fixedMatrixSize: 4, // impossible for pool=5
|
||||
);
|
||||
|
||||
final q = await generator.generate(data.items.first);
|
||||
final mq = q as MatrixTestQuestionBody;
|
||||
|
||||
// sqrt(5) == 2 -> 2x2
|
||||
expect(mq.matrixSize, 2);
|
||||
expect(mq.cards.length, 4);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -5,7 +5,7 @@ import 'package:google_sign_in/google_sign_in.dart';
|
|||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_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
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export 'telegram_web_app_compat_stub.dart'
|
||||
if (dart.library.html) 'package:telegram_web_app/telegram_web_app.dart';
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/// Minimal stub for `package:telegram_web_app` used in non-web platforms.
|
||||
///
|
||||
/// This project is web-only at runtime, but some tests/tools still compile on
|
||||
/// the Dart VM. The real `telegram_web_app` package depends on `dart:js_interop`,
|
||||
/// which is unavailable on the VM.
|
||||
///
|
||||
/// Keep this API surface to what we use in `AuthService`.
|
||||
class TelegramWebApp {
|
||||
TelegramWebApp._();
|
||||
|
||||
static final TelegramWebApp instance = TelegramWebApp._();
|
||||
|
||||
bool get isSupported => false;
|
||||
|
||||
dynamic get initData => null;
|
||||
|
||||
dynamic get initDataUnsafe => null;
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:mnemo_cards_chat/mnemo_cards_chat.dart';
|
||||
|
|
|
|||
|
|
@ -123,8 +123,6 @@ void main() {
|
|||
paymentSystem: PaymentSystem.yookassa,
|
||||
externalToken: 'token',
|
||||
meta: '{}',
|
||||
packs: const [],
|
||||
subscription: false,
|
||||
products: const [
|
||||
MnemoCardsProductDto(
|
||||
id: 'pack-001',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/game_session_manager.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/game_sound_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.dart';
|
||||
import 'package:mnemo_cards_web_v2/presentation/pages/game/game_page.dart';
|
||||
import 'package:yx_scope/yx_scope.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
|
||||
class _MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
||||
|
||||
class _MockUserScopeHolder extends Mock implements UserScopeHolder {}
|
||||
|
||||
class _MockUserScope extends Mock implements UserScope {}
|
||||
|
||||
class _MockTestsModule extends Mock implements TestsModule {}
|
||||
|
||||
class _MockTestManager extends Mock implements TestManager {}
|
||||
|
||||
void main() {
|
||||
late _MockAppScopeContainer mockAppScope;
|
||||
late _MockUserScopeHolder mockUserScopeHolder;
|
||||
late _MockUserScope mockUserScope;
|
||||
late _MockTestsModule mockTestsModule;
|
||||
late _MockTestManager mockTestManager;
|
||||
|
||||
late GameSessionManager gameSessionManager;
|
||||
late GameSoundService gameSoundService;
|
||||
late TestsStateManager testsStateManager;
|
||||
|
||||
late ScopeStateHolder<AppScopeContainer?> appScopeHolder;
|
||||
|
||||
setUp(() {
|
||||
mockAppScope = _MockAppScopeContainer();
|
||||
mockUserScopeHolder = _MockUserScopeHolder();
|
||||
mockUserScope = _MockUserScope();
|
||||
mockTestsModule = _MockTestsModule();
|
||||
mockTestManager = _MockTestManager();
|
||||
|
||||
gameSessionManager = GameSessionManager();
|
||||
gameSoundService = GameSoundService();
|
||||
testsStateManager = TestsStateManager(
|
||||
testManager: mockTestManager,
|
||||
gameSessionManager: gameSessionManager,
|
||||
gameSoundService: gameSoundService,
|
||||
);
|
||||
|
||||
when(() => mockTestManager.loadTest(any())).thenAnswer(
|
||||
(_) async => TestDto(
|
||||
id: '42',
|
||||
name: 'Sample Test',
|
||||
questions: [
|
||||
SimpleTestQuestionBody(
|
||||
word: 'four',
|
||||
answer: '4',
|
||||
text: '2+2?',
|
||||
buttons: [
|
||||
TestButtonDto.text('3', '3'),
|
||||
TestButtonDto.text('4', '4'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
|
||||
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
|
||||
when(() => mockUserScope.testsModule).thenReturn(mockTestsModule);
|
||||
|
||||
when(() => mockTestsModule.testsStateManager).thenReturn(testsStateManager);
|
||||
when(() => mockTestsModule.gameSoundService).thenReturn(gameSoundService);
|
||||
when(() => mockTestsModule.gameSessionManager).thenReturn(gameSessionManager);
|
||||
when(() => mockTestsModule.testManager).thenReturn(mockTestManager);
|
||||
|
||||
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
|
||||
ScopeState.available(scope: mockAppScope),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Exit confirmation navigates to /home when cannot pop',
|
||||
(tester) async {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/game/42',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => Scaffold(
|
||||
body: const Text('Home'),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/game/:testId',
|
||||
builder: (context, state) => GamePage(
|
||||
testId: state.pathParameters['testId']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
holder: appScopeHolder,
|
||||
child: ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (context, child) => MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpUntil(tester, find.byType(GamePage));
|
||||
|
||||
// Tap close -> dialog
|
||||
await tester.tap(find.byIcon(Icons.close).first);
|
||||
await _pumpUntil(tester, find.text('Exit Game'));
|
||||
|
||||
expect(find.text('Exit Game'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Exit'));
|
||||
await _pumpUntil(tester, find.text('Home'));
|
||||
|
||||
expect(router.routeInformationProvider.value.uri.path, equals('/home'));
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
expect(testsStateManager.state, const TestsState.loading());
|
||||
});
|
||||
|
||||
testWidgets('Completed screen Back navigates to /home when cannot pop',
|
||||
(tester) async {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/game/42',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => Scaffold(
|
||||
body: const Text('Home'),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/game/:testId',
|
||||
builder: (context, state) => GamePage(
|
||||
testId: state.pathParameters['testId']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
holder: appScopeHolder,
|
||||
child: ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (context, child) => MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpUntil(tester, find.byType(GamePage));
|
||||
|
||||
// Drive session to completed.
|
||||
await tester.runAsync(() async {
|
||||
await testsStateManager.completeGameSession();
|
||||
});
|
||||
await _pumpUntil(tester, find.text('Game Completed!'));
|
||||
|
||||
expect(find.text('Game Completed!'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await _pumpUntil(tester, find.text('Home'));
|
||||
|
||||
expect(router.routeInformationProvider.value.uri.path, equals('/home'));
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
expect(testsStateManager.state, const TestsState.loading());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(WidgetTester tester, Finder finder) async {
|
||||
for (var i = 0; i < 150; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (finder.evaluate().isNotEmpty) return;
|
||||
}
|
||||
throw TestFailure('Timed out waiting for: $finder');
|
||||
}
|
||||
|
|
@ -2,34 +2,36 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue