- Bind Ctrl/Cmd+O app-wide to the open-presentation dialog (was unbound). - Add a "copy to another deck" bulk action to the slide list: with multiple slides selected, pick a target open tab; the slides are appended there as fresh copies, leaving the source deck untouched. Multi-select, bulk delete and bulk skip/show already existed. Test: cross-deck copy keeps the source intact and assigns fresh ids. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
365 lines
12 KiB
Dart
365 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ocideck/models/settings.dart';
|
|
import 'package:ocideck/models/slide.dart';
|
|
import 'package:ocideck/services/file_service.dart';
|
|
import 'package:ocideck/services/image_service.dart';
|
|
import 'package:ocideck/services/markdown_service.dart';
|
|
import 'package:ocideck/state/deck_provider.dart';
|
|
|
|
DeckNotifier _notifier() {
|
|
final md = MarkdownService();
|
|
final file = FileService(md, ImageService(), () => const ThemeProfile());
|
|
return DeckNotifier(md, file);
|
|
}
|
|
|
|
void main() {
|
|
test('newDeck creates one title slide and is dirty', () {
|
|
final n = _notifier();
|
|
n.newDeck('Mijn deck');
|
|
expect(n.state.isOpen, isTrue);
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
expect(n.state.deck!.slides.single.type, SlideType.title);
|
|
expect(n.state.deck!.slides.single.title, 'Mijn deck');
|
|
expect(n.state.isDirty, isTrue);
|
|
});
|
|
|
|
test('addSlide inserts right after the given index', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets); // appended -> index 1
|
|
n.addSlide(SlideType.image, afterIndex: 0); // inserted at index 1
|
|
final types = n.state.deck!.slides.map((s) => s.type).toList();
|
|
expect(types, [SlideType.title, SlideType.image, SlideType.bullets]);
|
|
});
|
|
|
|
test('removeSlide removes a slide but never the last one', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
n.removeSlide(0);
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
// Now only one remains; removing again is a no-op.
|
|
n.removeSlide(0);
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
});
|
|
|
|
test('duplicateSlide inserts a copy with a fresh id', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final originalId = n.state.deck!.slides.first.id;
|
|
n.duplicateSlide(0);
|
|
final slides = n.state.deck!.slides;
|
|
expect(slides, hasLength(2));
|
|
expect(slides[1].title, slides[0].title);
|
|
expect(slides[1].id, isNot(originalId));
|
|
});
|
|
|
|
test('insertSlides duplicates with fresh ids and returns insert index', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final source = Slide.create(SlideType.bullets).copyWith(title: 'Bron');
|
|
final at = n.insertSlides([source], afterIndex: 0);
|
|
expect(at, 1);
|
|
expect(n.state.deck!.slides, hasLength(2));
|
|
expect(n.state.deck!.slides[1].title, 'Bron');
|
|
expect(n.state.deck!.slides[1].id, isNot(source.id));
|
|
});
|
|
|
|
test('copying a selection into another deck leaves the source intact', () {
|
|
// Source deck with three slides; "select" indices 0 and 2.
|
|
final source = _notifier()..newDeck('Bron');
|
|
source.addSlide(SlideType.bullets); // 1
|
|
source.addSlide(SlideType.image); // 2
|
|
final picked = [source.state.deck!.slides[0], source.state.deck!.slides[2]];
|
|
final sourceIdsBefore = source.state.deck!.slides.map((s) => s.id).toList();
|
|
|
|
// Target deck receives the copies (appended at the end).
|
|
final target = _notifier()..newDeck('Doel');
|
|
final at = target.insertSlides(picked);
|
|
|
|
expect(at, 1); // appended after the single title slide
|
|
expect(target.state.deck!.slides, hasLength(3));
|
|
expect(target.state.deck!.slides[1].type, SlideType.title);
|
|
expect(target.state.deck!.slides[2].type, SlideType.image);
|
|
// Copies must have fresh ids, not the source ids.
|
|
expect(
|
|
target.state.deck!.slides[1].id,
|
|
isNot(anyOf(picked.map((s) => s.id))),
|
|
);
|
|
// The source deck is untouched.
|
|
expect(
|
|
source.state.deck!.slides.map((s) => s.id).toList(),
|
|
sourceIdsBefore,
|
|
);
|
|
});
|
|
|
|
test('reorderSlides moves a slide to a new position', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets); // 1
|
|
n.addSlide(SlideType.image); // 2
|
|
n.reorderSlides(2, 0);
|
|
expect(n.state.deck!.slides.first.type, SlideType.image);
|
|
});
|
|
|
|
test('updateSlide replaces the slide at an index', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final updated = n.state.deck!.slides.first.copyWith(title: 'Nieuw');
|
|
n.updateSlide(0, updated);
|
|
expect(n.state.deck!.slides.first.title, 'Nieuw');
|
|
});
|
|
|
|
test('updateMeta changes deck title/theme/paginate', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false);
|
|
expect(n.state.deck!.title, 'Andere titel');
|
|
expect(n.state.deck!.theme, 'custom');
|
|
expect(n.state.deck!.paginate, isFalse);
|
|
});
|
|
|
|
test('generateMarkdown and applyMarkdown round-trip the deck', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
|
n.updateSlide(
|
|
1,
|
|
n.state.deck!.slides[1].copyWith(
|
|
title: 'Met beeld',
|
|
bullets: ['Punt'],
|
|
imagePath: 'images/x.png',
|
|
),
|
|
);
|
|
|
|
final markdown = n.generateMarkdown();
|
|
expect(n.applyMarkdown(markdown), isTrue);
|
|
|
|
final slides = n.state.deck!.slides;
|
|
expect(slides, hasLength(2));
|
|
expect(slides[1].type, SlideType.bulletsImage);
|
|
expect(slides[1].imagePath, 'images/x.png');
|
|
});
|
|
|
|
test('slide operations are no-ops when no deck is open', () {
|
|
final n = _notifier();
|
|
n.addSlide(SlideType.bullets);
|
|
n.removeSlide(0);
|
|
n.duplicateSlide(0);
|
|
expect(n.state.isOpen, isFalse);
|
|
});
|
|
|
|
// ── Ongedaan maken / opnieuw uitvoeren ─────────────────────────────────────
|
|
|
|
test('a fresh deck has nothing to undo or redo', () {
|
|
final n = _notifier()..newDeck('D');
|
|
expect(n.canUndo, isFalse);
|
|
expect(n.canRedo, isFalse);
|
|
expect(n.state.canUndo, isFalse);
|
|
expect(n.state.canRedo, isFalse);
|
|
});
|
|
|
|
test('undo reverts the last structural change and redo re-applies it', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
expect(n.state.deck!.slides, hasLength(2));
|
|
expect(n.canUndo, isTrue);
|
|
|
|
n.undo();
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
expect(n.canUndo, isFalse);
|
|
expect(n.canRedo, isTrue);
|
|
|
|
n.redo();
|
|
expect(n.state.deck!.slides, hasLength(2));
|
|
expect(n.canRedo, isFalse);
|
|
});
|
|
|
|
test('undo/redo bumps revision so editors can re-sync', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
final rev0 = n.state.revision;
|
|
n.undo();
|
|
expect(n.state.revision, rev0 + 1);
|
|
n.redo();
|
|
expect(n.state.revision, rev0 + 2);
|
|
});
|
|
|
|
test('rapid edits to the same slide coalesce into one undo step', () {
|
|
final n = _notifier()..newDeck('D');
|
|
// Three quick edits to slide 0 within the coalesce window.
|
|
n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'A'));
|
|
n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'AB'));
|
|
n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'ABC'));
|
|
expect(n.state.deck!.slides.first.title, 'ABC');
|
|
|
|
// A single undo jumps back past the whole burst to the original title.
|
|
n.undo();
|
|
expect(n.state.deck!.slides.first.title, 'D');
|
|
expect(n.canUndo, isFalse);
|
|
});
|
|
|
|
test('a new edit clears the redo stack', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
n.undo();
|
|
expect(n.canRedo, isTrue);
|
|
n.addSlide(SlideType.image); // diverging edit
|
|
expect(n.canRedo, isFalse);
|
|
});
|
|
|
|
test('undo and redo are no-ops when their stacks are empty', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.undo(); // nothing to undo
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
n.redo(); // nothing to redo
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
});
|
|
|
|
test('loading a deck clears the history', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
expect(n.canUndo, isTrue);
|
|
n.loadDeck(n.state.deck!);
|
|
expect(n.canUndo, isFalse);
|
|
expect(n.canRedo, isFalse);
|
|
});
|
|
|
|
// ── Slides overslaan ───────────────────────────────────────────────────────
|
|
|
|
test('toggleSkip flips a single slide and reports the count', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
expect(n.skippedCount, 0);
|
|
|
|
n.toggleSkip(1);
|
|
expect(n.state.deck!.slides[1].skipped, isTrue);
|
|
expect(n.state.deck!.slides[0].skipped, isFalse);
|
|
expect(n.skippedCount, 1);
|
|
|
|
n.toggleSkip(1);
|
|
expect(n.state.deck!.slides[1].skipped, isFalse);
|
|
expect(n.skippedCount, 0);
|
|
});
|
|
|
|
test('toggleSkip is undoable', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.toggleSkip(0);
|
|
expect(n.state.deck!.slides.first.skipped, isTrue);
|
|
n.undo();
|
|
expect(n.state.deck!.slides.first.skipped, isFalse);
|
|
});
|
|
|
|
test('clearAllSkips resets every slide in one step', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
n.addSlide(SlideType.image);
|
|
n.toggleSkip(0);
|
|
n.toggleSkip(2);
|
|
expect(n.skippedCount, 2);
|
|
|
|
n.clearAllSkips();
|
|
expect(n.skippedCount, 0);
|
|
|
|
// One undo brings back the whole skipped set.
|
|
n.undo();
|
|
expect(n.skippedCount, 2);
|
|
});
|
|
|
|
test('clearAllSkips is a no-op when nothing is skipped', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final before = n.canUndo;
|
|
n.clearAllSkips();
|
|
expect(n.canUndo, before); // geen nieuwe ongedaan-maken-stap
|
|
});
|
|
|
|
// ── Bulk-acties (multi-select) ─────────────────────────────────────────────
|
|
|
|
test('removeSlides deletes several at once but keeps at least one', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets); // 1
|
|
n.addSlide(SlideType.image); // 2
|
|
n.addSlide(SlideType.quote); // 3 → 4 slides total
|
|
n.removeSlides({1, 3});
|
|
final types = n.state.deck!.slides.map((s) => s.type).toList();
|
|
expect(types, [SlideType.title, SlideType.image]);
|
|
|
|
// Mag nooit alles verwijderen.
|
|
n.removeSlides({0, 1});
|
|
expect(n.state.deck!.slides, hasLength(2));
|
|
});
|
|
|
|
test('removeSlides is one undoable step', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
n.addSlide(SlideType.image);
|
|
n.removeSlides({1, 2});
|
|
expect(n.state.deck!.slides, hasLength(1));
|
|
n.undo();
|
|
expect(n.state.deck!.slides, hasLength(3));
|
|
});
|
|
|
|
test('setSkippedForSlides toggles skip on a set in one step', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.addSlide(SlideType.bullets);
|
|
n.addSlide(SlideType.image);
|
|
n.setSkippedForSlides({0, 2}, true);
|
|
expect(n.state.deck!.slides[0].skipped, isTrue);
|
|
expect(n.state.deck!.slides[1].skipped, isFalse);
|
|
expect(n.state.deck!.slides[2].skipped, isTrue);
|
|
expect(n.skippedCount, 2);
|
|
|
|
n.setSkippedForSlides({0, 2}, false);
|
|
expect(n.skippedCount, 0);
|
|
});
|
|
|
|
// ── Zoeken & vervangen ─────────────────────────────────────────────────────
|
|
|
|
test('countMatches and replaceAll span all text fields', () {
|
|
final n = _notifier()..newDeck('Acme jaarverslag');
|
|
n.updateSlide(
|
|
0,
|
|
n.state.deck!.slides.first.copyWith(
|
|
title: 'Acme groeit',
|
|
bullets: ['Acme wint', 'overig'],
|
|
notes: 'Acme intern',
|
|
),
|
|
);
|
|
|
|
expect(n.countMatches('Acme'), 3);
|
|
|
|
final replaced = n.replaceAll('Acme', 'Globex');
|
|
expect(replaced, 3);
|
|
expect(n.countMatches('Acme'), 0);
|
|
|
|
final slide = n.state.deck!.slides.first;
|
|
expect(slide.title, 'Globex groeit');
|
|
expect(slide.bullets.first, 'Globex wint');
|
|
expect(slide.notes, 'Globex intern');
|
|
});
|
|
|
|
test('replaceAll is case-insensitive by default and exact when asked', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.updateSlide(
|
|
0,
|
|
n.state.deck!.slides.first.copyWith(title: 'Test test TEST'),
|
|
);
|
|
|
|
// Default: alle drie, ongeacht hoofdletters.
|
|
expect(n.countMatches('test'), 3);
|
|
// Hoofdlettergevoelig: alleen de exacte 'test'.
|
|
expect(n.countMatches('test', caseSensitive: true), 1);
|
|
|
|
n.replaceAll('test', 'x', caseSensitive: true);
|
|
expect(n.state.deck!.slides.first.title, 'Test x TEST');
|
|
});
|
|
|
|
test('replaceAll is a single undoable step and a no-op without matches', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.updateSlide(
|
|
0,
|
|
n.state.deck!.slides.first.copyWith(title: 'Hallo wereld'),
|
|
);
|
|
final undosBefore = n.canUndo;
|
|
|
|
expect(n.replaceAll('xyz', 'q'), 0); // geen match → niets verandert
|
|
expect(n.canUndo, undosBefore);
|
|
|
|
n.replaceAll('wereld', 'aarde');
|
|
expect(n.state.deck!.slides.first.title, 'Hallo aarde');
|
|
n.undo(); // één stap terug herstelt de hele vervanging
|
|
expect(n.state.deck!.slides.first.title, 'Hallo wereld');
|
|
});
|
|
}
|