Skip to content

Commit 7b07ad7

Browse files
committed
new learn mode stuff and match
1 parent 219aeb1 commit 7b07ad7

22 files changed

Lines changed: 852 additions & 89 deletions

__mocks__/obsidian.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export class TFile {
5656
basename: string;
5757
extension: string;
5858

59-
constructor(path: string) {
60-
this.path = path;
61-
this.basename = path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '';
62-
this.extension = path.split('.').pop() || '';
59+
constructor(path?: string) {
60+
this.path = path || '';
61+
this.basename = (this.path && this.path.split('/').pop()?.replace(/\.[^/.]+$/, '')) || '';
62+
this.extension = (this.path && this.path.split('.').pop()) || '';
6363
}
6464
}
6565

@@ -157,6 +157,24 @@ export class Modal {
157157
}
158158
}
159159

160+
// Minimal SuggestModal mock implementation used in tests
161+
export class SuggestModal<T> extends Modal {
162+
placeholder?: string;
163+
constructor(app: App) {
164+
super(app);
165+
}
166+
setPlaceholder(text: string): this {
167+
this.placeholder = text;
168+
return this;
169+
}
170+
open(): void {
171+
// no-op for tests
172+
}
173+
close(): void {
174+
// no-op for tests
175+
}
176+
}
177+
160178
export class Notice {
161179
message: string;
162180

__tests__/browser-viewmodel.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@ describe('BrowserViewModel', () => {
383383
});
384384

385385
it('should calculate cards due today', () => {
386-
const now = new Date();
386+
// Use a fixed midday reference to avoid day-boundary flakiness in CI/local timezones.
387+
const now = new Date('2026-05-10T12:00:00.000Z');
387388
const cards: FlashlyCard[] = [
388389
createMockCard({
389390
fsrsCard: {

__tests__/deck-metadata.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { StorageService } from '../src/services/storage-service';
2+
import { createMockPlugin } from './setup';
3+
4+
describe('Deck metadata (star/archive)', () => {
5+
let mockPlugin: ReturnType<typeof createMockPlugin>;
6+
let service: StorageService;
7+
8+
beforeEach(async () => {
9+
mockPlugin = createMockPlugin();
10+
mockPlugin.loadData.mockResolvedValue(null);
11+
service = new StorageService(mockPlugin);
12+
await service.load();
13+
});
14+
15+
it('toggles starred state and persists it', async () => {
16+
expect(service.isDeckStarred('Alpha')).toBe(false);
17+
service.toggleDeckStarred('Alpha');
18+
expect(service.isDeckStarred('Alpha')).toBe(true);
19+
20+
await service.save();
21+
const saved = mockPlugin.saveData.mock.calls[0][0];
22+
expect(saved.decks).toBeDefined();
23+
expect(saved.decks.Alpha).toBeDefined();
24+
expect(saved.decks.Alpha.starred).toBe(true);
25+
26+
// Reload into new instance
27+
const reloadedPlugin = createMockPlugin();
28+
reloadedPlugin.loadData.mockResolvedValue(saved);
29+
const reloaded = new StorageService(reloadedPlugin);
30+
await reloaded.load();
31+
expect(reloaded.isDeckStarred('Alpha')).toBe(true);
32+
});
33+
34+
it('toggles archived state and persists it', async () => {
35+
expect(service.isDeckArchived('Beta')).toBe(false);
36+
service.toggleDeckArchived('Beta');
37+
expect(service.isDeckArchived('Beta')).toBe(true);
38+
39+
await service.save();
40+
const saved = mockPlugin.saveData.mock.calls[0][0];
41+
expect(saved.decks).toBeDefined();
42+
expect(saved.decks.Beta).toBeDefined();
43+
expect(saved.decks.Beta.archived).toBe(true);
44+
45+
// Reload into new instance
46+
const reloadedPlugin = createMockPlugin();
47+
reloadedPlugin.loadData.mockResolvedValue(saved);
48+
const reloaded = new StorageService(reloadedPlugin);
49+
await reloaded.load();
50+
expect(reloaded.isDeckArchived('Beta')).toBe(true);
51+
});
52+
});

__tests__/quiz-match.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createEmptyCard } from 'ts-fsrs';
2+
import { FlashlyCard, createFlashlyCard } from '../src/models/card';
3+
import { QuizConfig, QuizQuestion, checkAnswer } from '../src/models/quiz';
4+
import { TraditionalQuizGenerator } from '../src/quiz/traditional-quiz-generator';
5+
6+
describe('Match quiz support', () => {
7+
it('generates a match question when enabled', () => {
8+
const generator = new TraditionalQuizGenerator();
9+
const cards: FlashlyCard[] = [
10+
createFlashlyCard('Term 1', 'Definition 1', 'test1.md', 1, createEmptyCard(new Date())),
11+
createFlashlyCard('Term 2', 'Definition 2', 'test2.md', 2, createEmptyCard(new Date())),
12+
createFlashlyCard('Term 3', 'Definition 3', 'test3.md', 3, createEmptyCard(new Date())),
13+
createFlashlyCard('Term 4', 'Definition 4', 'test4.md', 4, createEmptyCard(new Date()))
14+
];
15+
16+
const config: QuizConfig = {
17+
questionCount: 1,
18+
includeMultipleChoice: false,
19+
includeFillBlank: false,
20+
includeTrueFalse: false,
21+
includeMatch: true,
22+
useAI: false
23+
};
24+
25+
const questions = generator.generateQuestions(cards, config);
26+
27+
expect(questions).toHaveLength(1);
28+
expect(questions[0].type).toBe('match');
29+
expect(Array.isArray(questions[0].correctAnswer)).toBe(true);
30+
if (Array.isArray(questions[0].correctAnswer)) {
31+
expect(questions[0].correctAnswer).toHaveLength(4);
32+
expect(questions[0].correctAnswer[0]).toHaveProperty('left');
33+
expect(questions[0].correctAnswer[0]).toHaveProperty('right');
34+
}
35+
});
36+
37+
it('scores match answers independent of order', () => {
38+
const question: QuizQuestion = {
39+
id: 'q-match',
40+
type: 'match',
41+
prompt: 'Match pairs',
42+
correctAnswer: [
43+
{ left: 'A', right: '1' },
44+
{ left: 'B', right: '2' }
45+
]
46+
};
47+
48+
expect(checkAnswer(question, [
49+
{ left: 'B', right: '2' },
50+
{ left: 'A', right: '1' }
51+
])).toBe(true);
52+
53+
expect(checkAnswer(question, [
54+
{ left: 'A', right: '1' },
55+
{ left: 'B', right: '3' }
56+
])).toBe(false);
57+
});
58+
});

main.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default class FlashlyPlugin extends Plugin {
3232
exportService: ExportService;
3333
exportCommand: ExportCommand;
3434
replayTutorialCommand: ReplayTutorialCommand;
35-
statusBarItem: HTMLElement;
35+
statusBarItem: HTMLElement | null = null;
3636
logger: Logger;
3737

3838
async onload() {
@@ -150,14 +150,19 @@ export default class FlashlyPlugin extends Plugin {
150150
void this.activateBrowserView();
151151
});
152152

153-
// Add status bar item
154-
this.statusBarItem = this.addStatusBarItem();
155-
this.updateStatusBar();
156-
157-
// Update status bar every 60 seconds
158-
this.registerInterval(
159-
window.setInterval(() => this.updateStatusBar(), 60000)
160-
);
153+
// Add status bar item when the platform exposes one.
154+
try {
155+
this.statusBarItem = this.addStatusBarItem();
156+
this.updateStatusBar();
157+
158+
// Update status bar every 60 seconds only if it exists.
159+
this.registerInterval(
160+
window.setInterval(() => this.updateStatusBar(), 60000)
161+
);
162+
} catch (error) {
163+
console.warn('Flashly: status bar unavailable on this platform', error);
164+
this.statusBarItem = null;
165+
}
161166

162167
// Add settings tab
163168
this.addSettingTab(new FlashlySettingTab(this.app, this));

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/generate-quiz-command.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ class GenerateQuizModal extends Modal {
8787
});
8888
});
8989

90+
new Setting(contentEl)
91+
.setName('Match pairs')
92+
.setDesc('Include matching questions where you pair terms with definitions')
93+
.addToggle(toggle => {
94+
toggle.setValue(this.config.includeMatch ?? false);
95+
toggle.onChange(value => {
96+
this.config.includeMatch = value;
97+
});
98+
});
99+
90100
// Learn Mode
91101
new Setting(contentEl)
92102
.setName('Learn mode')
@@ -103,8 +113,8 @@ class GenerateQuizModal extends Modal {
103113
.setName('Filter by decks')
104114
.setDesc('Select which decks to include (leave all unchecked for all decks)');
105115

106-
// Get all available decks
107-
const allCards = this.plugin.storage.getAllCards();
116+
// Get all available decks (exclude archived)
117+
const allCards = this.plugin.storage.getActiveCards();
108118
const deckSet = new Set<string>();
109119
allCards.forEach(card => {
110120
if (card.deck) {
@@ -337,7 +347,7 @@ class GenerateQuizModal extends Modal {
337347
}
338348

339349
// Get available cards
340-
let availableCards = this.plugin.storage.getAllCards();
350+
let availableCards = this.plugin.storage.getActiveCards();
341351

342352
// Apply deck filter if specified
343353
if (this.config.deckFilter && this.config.deckFilter.length > 0) {
@@ -526,7 +536,7 @@ class GenerateQuizModal extends Modal {
526536

527537
try {
528538
// Validate at least one question type
529-
if (!this.config.includeMultipleChoice && !this.config.includeFillBlank && !this.config.includeTrueFalse) {
539+
if (!this.config.includeMultipleChoice && !this.config.includeFillBlank && !this.config.includeTrueFalse && !this.config.includeMatch) {
530540
new Notice('Please select at least one question type');
531541
return;
532542
}
@@ -536,7 +546,7 @@ class GenerateQuizModal extends Modal {
536546
const title = titleInput?.value || 'Untitled Quiz';
537547

538548
// Get cards
539-
let cards = this.plugin.storage.getAllCards();
549+
let cards = this.plugin.storage.getActiveCards();
540550

541551
// Apply card selection filter if AI is enabled and cards are selected
542552
if (this.config.useAI && this.config.selectedCardIds && this.config.selectedCardIds.length > 0) {

src/commands/refresh-decks-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class RefreshDecksCommand {
3535
}
3636

3737
const deckSet = selection ? new Set(selection.map(deck => deck.toLowerCase())) : null;
38-
const cards = this.storage.getAllCards();
38+
const cards = this.storage.getActiveCards();
3939
const now = new Date();
4040
let updatedCount = 0;
4141

src/commands/scan-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class ScanCommand {
2929
* Get command ID for registration
3030
*/
3131
getId(): string {
32-
return 'scan-vault';
32+
return 'flashly-scan-vault';
3333
}
3434

3535
/**

src/models/quiz.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@
33
* Supports both traditional and AI-generated quizzes
44
*/
55

6-
export type QuizQuestionType = 'multiple-choice' | 'fill-blank' | 'true-false' | 'audio-prompt';
6+
export type QuizQuestionType = 'multiple-choice' | 'fill-blank' | 'true-false' | 'audio-prompt' | 'match';
7+
8+
export interface QuizMatchPair {
9+
left: string;
10+
right: string;
11+
sourceCardId?: string;
12+
}
13+
14+
export type QuizAnswer = string | number | QuizMatchPair[];
715

816
export type QuizGenerationMethod = 'traditional' | 'ai-generated';
917

@@ -14,8 +22,8 @@ export interface QuizQuestion {
1422
type: QuizQuestionType; // Question type
1523
prompt: string; // Question text
1624
options?: string[]; // Options for multiple choice (undefined for others)
17-
correctAnswer: string | number; // Correct answer (string or index)
18-
userAnswer?: string | number; // User's answer
25+
correctAnswer: QuizAnswer; // Correct answer (string, index, or pairs)
26+
userAnswer?: QuizAnswer; // User's answer
1927
correct?: boolean; // Whether user answered correctly
2028
sourceCardId?: string; // Original card ID (for traditional)
2129
explanation?: string; // Explanation for the answer (AI can provide this)
@@ -55,6 +63,7 @@ export interface QuizConfig {
5563
includeMultipleChoice: boolean; // Include MC questions
5664
includeFillBlank: boolean; // Include fill-in-blank questions
5765
includeTrueFalse: boolean; // Include true/false questions
66+
includeMatch?: boolean; // Include matching questions
5867
deckFilter?: string[]; // Optional deck filter
5968
useAI: boolean; // Use AI generation
6069
aiProvider?: AIProvider; // AI provider if using AI
@@ -169,7 +178,23 @@ export function calculateQuizScore(quiz: Quiz): { score: number; correctCount: n
169178
/**
170179
* Check if question answer is correct
171180
*/
172-
export function checkAnswer(question: QuizQuestion, userAnswer: string | number): boolean {
181+
182+
function normalizeMatchPair(pair: QuizMatchPair): string {
183+
return `${pair.left.trim().toLowerCase()}=>${pair.right.trim().toLowerCase()}`;
184+
}
185+
186+
export function checkAnswer(question: QuizQuestion, userAnswer: QuizAnswer): boolean {
187+
if (question.type === 'match' && Array.isArray(question.correctAnswer) && Array.isArray(userAnswer)) {
188+
const correctPairs = [...question.correctAnswer].map(normalizeMatchPair).sort();
189+
const userPairs = [...userAnswer].map(normalizeMatchPair).sort();
190+
191+
if (correctPairs.length !== userPairs.length) {
192+
return false;
193+
}
194+
195+
return correctPairs.every((pair, index) => pair === userPairs[index]);
196+
}
197+
173198
if (question.type === 'multiple-choice' && typeof question.correctAnswer === 'number') {
174199
return userAnswer === question.correctAnswer;
175200
}
@@ -189,6 +214,7 @@ export const DEFAULT_QUIZ_CONFIG: QuizConfig = {
189214
includeMultipleChoice: true,
190215
includeFillBlank: true,
191216
includeTrueFalse: true,
217+
includeMatch: false,
192218
useAI: false,
193219
learnMode: false
194220
};

0 commit comments

Comments
 (0)