Presentation fixes: - Mirror the in-progress pen/highlighter stroke to the audience window live (new 'inkLive' channel) so highlights appear as they are drawn, not only after the pen lifts. - Cover the macOS menu bar on the beamer: raise the audience window above .mainMenu level so the Apple/Wi-Fi strip no longer shows during a presentation. Styling no longer lives in the file: - generateDeck no longer embeds the ThemeProfile; a saved .md holds only content. The profile is inlined only for the transient audience-window payload (inlineStyleProfile: true), never to disk. - On open, the app applies the active style profile (FileService.openDeck / activeProfileFor, DeckNotifier.loadDeck); applyMarkdown preserves the current profile. Quality pass / tests green: - Complete the consent-screen translations (English plus 7 missing strings per other language). - Pass the consent gate in widget/ui-scale tests by seeding the consent key, so the app shell renders. - Update markdown round-trip tests for the new default and add coverage for live stroke streaming and styling-free saves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
475 lines
16 KiB
Dart
475 lines
16 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ocideck/models/deck.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('loadDeck applies the active profile and resolves its relative logo', () {
|
|
final temp = Directory.systemTemp.createTempSync(
|
|
'ocideck_recovered_logo_test_',
|
|
);
|
|
addTearDown(() => temp.deleteSync(recursive: true));
|
|
final logo = File('${temp.path}/logos/client.png')
|
|
..createSync(recursive: true)
|
|
..writeAsBytesSync([1, 2, 3]);
|
|
|
|
final md = MarkdownService();
|
|
// Styling comes from the active style profile, not from the deck/markdown.
|
|
final file = FileService(
|
|
md,
|
|
ImageService(),
|
|
() => const ThemeProfile(logoPath: 'logos/client.png'),
|
|
homeDirectory: () => temp.path,
|
|
);
|
|
final notifier = DeckNotifier(md, file);
|
|
notifier.loadDeck(
|
|
Deck(
|
|
title: 'Hersteld',
|
|
// The deck's own profile is ignored on load.
|
|
themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
|
|
slides: [Slide.create(SlideType.title)],
|
|
),
|
|
);
|
|
|
|
expect(notifier.state.filePath, isNull);
|
|
expect(notifier.state.deck!.projectPath, isNull);
|
|
expect(notifier.state.deck!.themeProfile.logoPath, logo.path);
|
|
});
|
|
|
|
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('clearAllChecklists unchecks every checklist item across the deck', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final s1 = Slide.create(SlideType.bullets).copyWith(
|
|
listStyle: ListStyle.checklist,
|
|
bullets: ['[x] Klaar', '\t[X] Subklaar', '[ ] Open'],
|
|
);
|
|
final s2 = Slide.create(SlideType.bullets).copyWith(
|
|
listStyle: ListStyle.checklist,
|
|
bullets: ['[x] Eerste kolom'],
|
|
bullets2: ['[x] Tweede kolom', '[ ] Nog open'],
|
|
);
|
|
n.loadDeck(n.state.deck!.copyWith(slides: [s1, s2]));
|
|
|
|
expect(n.checkedChecklistCount, 4);
|
|
final revisionBefore = n.state.revision;
|
|
|
|
n.clearAllChecklists();
|
|
|
|
expect(n.checkedChecklistCount, 0);
|
|
final out = n.state.deck!.slides;
|
|
expect(out[0].bullets, ['[ ] Klaar', '\t[ ] Subklaar', '[ ] Open']);
|
|
expect(out[1].bullets, ['[ ] Eerste kolom']);
|
|
expect(out[1].bullets2, ['[ ] Tweede kolom', '[ ] Nog open']);
|
|
// Revision bumps so the open slide editor remounts and reflects the change.
|
|
expect(n.state.revision, greaterThan(revisionBefore));
|
|
});
|
|
|
|
test('clearAllChecklists is a single undoable step that restores the checks', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final slide = Slide.create(SlideType.bullets).copyWith(
|
|
listStyle: ListStyle.checklist,
|
|
bullets: ['[x] Klaar', '[ ] Open'],
|
|
bullets2: ['[x] Tweede'],
|
|
);
|
|
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
|
expect(n.checkedChecklistCount, 2);
|
|
|
|
n.clearAllChecklists();
|
|
expect(n.checkedChecklistCount, 0);
|
|
expect(n.state.canUndo, isTrue);
|
|
final revisionAfterClear = n.state.revision;
|
|
|
|
n.undo();
|
|
|
|
// One undo restores every checked item in both columns...
|
|
expect(n.checkedChecklistCount, 2);
|
|
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
|
|
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
|
|
// ...and bumps the revision again so the open editor reflects the restore.
|
|
expect(n.state.revision, greaterThan(revisionAfterClear));
|
|
});
|
|
|
|
test('clearAllChecklists is a no-op when nothing is checked', () {
|
|
final n = _notifier()..newDeck('D');
|
|
final slide = Slide.create(SlideType.bullets).copyWith(
|
|
listStyle: ListStyle.checklist,
|
|
bullets: ['[ ] Open'],
|
|
);
|
|
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
|
expect(n.state.canUndo, isFalse);
|
|
|
|
n.clearAllChecklists();
|
|
|
|
// No checked items, so no history entry is recorded.
|
|
expect(n.state.canUndo, isFalse);
|
|
expect(n.checkedChecklistCount, 0);
|
|
});
|
|
|
|
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('updateInfo can update the presentation title', () {
|
|
final n = _notifier()..newDeck('D');
|
|
n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
|
|
expect(n.state.deck!.title, 'Nieuwe presentatietitel');
|
|
expect(n.state.deck!.author, 'Auteur');
|
|
});
|
|
|
|
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');
|
|
});
|
|
}
|