@thesimonharms/basa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CardView } from '../src/tui/CardView.js';
3
+ import type { ReviewCard } from '../src/flashcards/types.js';
4
+ import { freshState } from '../src/flashcards/srs.js';
5
+ import type { KeyEvent } from '@mudah-cli/terminal';
6
+
7
+ function key(name: string, ch?: string): KeyEvent {
8
+ return { name, ch, kind: 'press' };
9
+ }
10
+
11
+ function makeCard(front: string, back: string): ReviewCard {
12
+ return { card: { front, back }, state: freshState(0) };
13
+ }
14
+
15
+ describe('CardView', () => {
16
+ it('renders the front text and a "press space to reveal" prompt', async () => {
17
+ const view = new CardView({
18
+ deckPath: '/tmp/x.yml',
19
+ cellsWidth: 60,
20
+ cellsHeight: 16,
21
+ onGraded: () => {},
22
+ onSkip: () => {},
23
+ });
24
+ await view.setCard(makeCard('hola', 'hello'));
25
+ const rows = view.render();
26
+ const all = rows.join('\n');
27
+ expect(all).toContain('hola');
28
+ expect(all).toContain('reveal');
29
+ expect(all).not.toContain('hello');
30
+ });
31
+
32
+ it('space reveals the back and renders the answer', async () => {
33
+ let grade = -1;
34
+ const view = new CardView({
35
+ deckPath: '/tmp/x.yml',
36
+ cellsWidth: 60,
37
+ cellsHeight: 16,
38
+ onGraded: (g) => { grade = g; },
39
+ onSkip: () => {},
40
+ });
41
+ await view.setCard(makeCard('hola', 'hello'));
42
+ view.onKey(key('space'));
43
+ // Drive the reveal animation to completion.
44
+ for (let i = 0; i < 20; i++) view.tick();
45
+ const all = view.render().join('\n');
46
+ expect(all).toContain('hello');
47
+ expect(grade).toBe(-1);
48
+ });
49
+
50
+ it('keys 1..4 grade after reveal; 0 → Again, 3 → Easy', async () => {
51
+ const grades: number[] = [];
52
+ const view = new CardView({
53
+ deckPath: '/tmp/x.yml',
54
+ cellsWidth: 60,
55
+ cellsHeight: 16,
56
+ onGraded: (g) => grades.push(g),
57
+ onSkip: () => {},
58
+ });
59
+ await view.setCard(makeCard('a', 'A'));
60
+ view.onKey(key('space'));
61
+ for (let i = 0; i < 20; i++) view.tick();
62
+ view.onKey(key('1'));
63
+ expect(grades).toEqual([0]);
64
+
65
+ await view.setCard(makeCard('b', 'B'));
66
+ view.onKey(key('space'));
67
+ for (let i = 0; i < 20; i++) view.tick();
68
+ view.onKey(key('4'));
69
+ expect(grades).toEqual([0, 3]);
70
+ });
71
+
72
+ it('keys 1..4 grade after reveal even when the live KeyEvent carries ch (real parseKeys output)', async () => {
73
+ // parseKeys emits digits as { name: '1', ch: '1', kind: 'press' } — see
74
+ // @mudah-cli/terminal/keys.js. The keymap must not buffer the digit into
75
+ // the typed-answer field before the grade switch has a chance to run.
76
+ const grades: number[] = [];
77
+ const typed: string[] = [];
78
+ const view = new CardView({
79
+ deckPath: '/tmp/x.yml',
80
+ cellsWidth: 60,
81
+ cellsHeight: 16,
82
+ onGraded: (g, t) => { grades.push(g); typed.push(t); },
83
+ onSkip: () => {},
84
+ });
85
+ await view.setCard(makeCard('a', 'A'));
86
+ view.onKey(key('space'));
87
+ for (let i = 0; i < 20; i++) view.tick();
88
+ view.onKey({ name: '1', ch: '1', kind: 'press' });
89
+ expect(grades).toEqual([0]);
90
+ expect(typed).toEqual(['']);
91
+ });
92
+
93
+ it('keys 1..4 do nothing before reveal', async () => {
94
+ const grades: number[] = [];
95
+ const view = new CardView({
96
+ deckPath: '/tmp/x.yml',
97
+ cellsWidth: 60,
98
+ cellsHeight: 16,
99
+ onGraded: (g) => grades.push(g),
100
+ onSkip: () => {},
101
+ });
102
+ await view.setCard(makeCard('a', 'A'));
103
+ view.onKey(key('1'));
104
+ expect(grades).toEqual([]);
105
+ });
106
+
107
+ it('typing buffers characters and backspace removes them', async () => {
108
+ const view = new CardView({
109
+ deckPath: '/tmp/x.yml',
110
+ cellsWidth: 60,
111
+ cellsHeight: 16,
112
+ onGraded: () => {},
113
+ onSkip: () => {},
114
+ });
115
+ await view.setCard(makeCard('a', 'A'));
116
+ view.onKey(key('h', 'h'));
117
+ view.onKey(key('e', 'e'));
118
+ view.onKey(key('l', 'l'));
119
+ view.onKey(key('l', 'l'));
120
+ view.onKey(key('o', 'o'));
121
+ let all = view.render().join('\n');
122
+ expect(all).toContain('hello');
123
+
124
+ view.onKey(key('backspace'));
125
+ all = view.render().join('\n');
126
+ expect(all).toContain('hell');
127
+ });
128
+
129
+ it('"n" skips without grading', async () => {
130
+ let skipped = false;
131
+ const view = new CardView({
132
+ deckPath: '/tmp/x.yml',
133
+ cellsWidth: 60,
134
+ cellsHeight: 16,
135
+ onGraded: () => {},
136
+ onSkip: () => { skipped = true; },
137
+ });
138
+ await view.setCard(makeCard('a', 'A'));
139
+ view.onKey(key('n'));
140
+ expect(skipped).toBe(true);
141
+ });
142
+
143
+ it('setCard(undefined) shows the done view', async () => {
144
+ const view = new CardView({
145
+ deckPath: '/tmp/x.yml',
146
+ cellsWidth: 60,
147
+ cellsHeight: 16,
148
+ onGraded: () => {},
149
+ onSkip: () => {},
150
+ });
151
+ await view.setCard(undefined, { reviewed: 5, again: 1, hard: 1, good: 2, easy: 1 });
152
+ const all = view.render().join('\n');
153
+ expect(all).toContain('session complete');
154
+ expect(all).toContain('reviewed');
155
+ expect(all).toContain('5');
156
+ });
157
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { loadDeck, saveReviewCards, loadReviewCards, createDeck, DeckLoadError, listDecks, defaultDecksDir } from '../src/flashcards/deck.js';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { freshState, grade } from '../src/flashcards/srs.js';
7
+
8
+ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
9
+ const dir = await mkdtemp(join(tmpdir(), 'basa-test-'));
10
+ try {
11
+ return await fn(dir);
12
+ } finally {
13
+ await rm(dir, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ describe('deck loading', () => {
18
+ it('loads a YAML deck with string sides', async () => {
19
+ await withTempDir(async (dir) => {
20
+ const file = join(dir, 'greetings.yml');
21
+ await writeFile(
22
+ file,
23
+ 'name: greetings\ncards:\n - front: hola\n back: hello\n - front: adios\n back: goodbye\n',
24
+ 'utf8',
25
+ );
26
+ const deck = await loadDeck(file);
27
+ expect(deck.name).toBe('greetings');
28
+ expect(deck.cards).toHaveLength(2);
29
+ expect(deck.cards[0]?.front).toBe('hola');
30
+ expect(deck.cards[0]?.back).toBe('hello');
31
+ });
32
+ });
33
+
34
+ it('loads a JSON deck with field-list sides', async () => {
35
+ await withTempDir(async (dir) => {
36
+ const file = join(dir, 'jp.json');
37
+ await writeFile(
38
+ file,
39
+ JSON.stringify({
40
+ name: 'jp',
41
+ cards: [
42
+ {
43
+ front: [{ text: 'こんにちは' }, { image: './konnichiwa.png' }],
44
+ back: 'hello',
45
+ },
46
+ ],
47
+ }),
48
+ 'utf8',
49
+ );
50
+ const deck = await loadDeck(file);
51
+ const first = deck.cards[0];
52
+ expect(Array.isArray(first?.front)).toBe(true);
53
+ const fields = first?.front as Array<Record<string, string>>;
54
+ expect(fields[0]?.text).toBe('こんにちは');
55
+ expect(fields[1]?.image).toBe('./konnichiwa.png');
56
+ });
57
+ });
58
+
59
+ it('rejects a deck without a name', async () => {
60
+ await withTempDir(async (dir) => {
61
+ const file = join(dir, 'bad.yml');
62
+ await writeFile(file, 'cards: []\n', 'utf8');
63
+ await expect(loadDeck(file)).rejects.toBeInstanceOf(DeckLoadError);
64
+ });
65
+ });
66
+
67
+ it('rejects a card missing back', async () => {
68
+ await withTempDir(async (dir) => {
69
+ const file = join(dir, 'bad.yml');
70
+ await writeFile(file, 'name: x\ncards:\n - front: hi\n', 'utf8');
71
+ await expect(loadDeck(file)).rejects.toThrow(/missing front or back/);
72
+ });
73
+ });
74
+
75
+ it('rejects a field with no text/image/audio', async () => {
76
+ await withTempDir(async (dir) => {
77
+ const file = join(dir, 'bad.json');
78
+ await writeFile(file, JSON.stringify({ name: 'x', cards: [{ front: [{}], back: 'b' }] }), 'utf8');
79
+ await expect(loadDeck(file)).rejects.toThrow(/has no text, image, or audio/);
80
+ });
81
+ });
82
+
83
+ it('persists and re-loads SRS state', async () => {
84
+ await withTempDir(async (dir) => {
85
+ const file = join(dir, 'd.yml');
86
+ await writeFile(file, 'name: d\ncards:\n - front: a\n back: A\n - front: b\n back: B\n', 'utf8');
87
+ const deck = await loadDeck(file);
88
+ const initial = await loadReviewCards(deck, file);
89
+ const reviewed = initial.map((rc, i) => ({
90
+ ...rc,
91
+ state: grade(rc.state, 2 as const, 1_000 + i * 1000),
92
+ }));
93
+ await saveReviewCards(file, reviewed);
94
+
95
+ const reloaded = await loadReviewCards(deck, file);
96
+ expect(reloaded[0]?.state.streak).toBe(1);
97
+ expect(reloaded[0]?.state.reviews).toBe(1);
98
+ expect(reloaded[1]?.state.streak).toBe(1);
99
+ });
100
+ });
101
+
102
+ it('corrupt progress file is ignored, fresh state returned', async () => {
103
+ await withTempDir(async (dir) => {
104
+ const file = join(dir, 'd.yml');
105
+ await writeFile(file, 'name: d\ncards:\n - front: a\n back: A\n', 'utf8');
106
+ const deck = await loadDeck(file);
107
+ await writeFile(`${file}.progress.json`, '{ this is not json');
108
+ const cards = await loadReviewCards(deck, file);
109
+ expect(cards[0]?.state.streak).toBe(0);
110
+ });
111
+ });
112
+
113
+ it('createDeck writes a scaffold file', async () => {
114
+ await withTempDir(async (dir) => {
115
+ const file = join(dir, 'new.yml');
116
+ const path = await createDeck(file, 'New Deck');
117
+ expect(path).toBe(file);
118
+ const deck = await loadDeck(file);
119
+ expect(deck.name).toBe('New Deck');
120
+ expect(deck.cards.length).toBeGreaterThan(0);
121
+ });
122
+ });
123
+
124
+ it('listDecks finds YAML and JSON files', async () => {
125
+ await withTempDir(async (dir) => {
126
+ await writeFile(join(dir, 'a.yml'), 'name: a\ncards: []\n', 'utf8');
127
+ await writeFile(join(dir, 'b.yaml'), 'name: b\ncards: []\n', 'utf8');
128
+ await writeFile(join(dir, 'c.json'), '{"name":"c","cards":[]}', 'utf8');
129
+ await writeFile(join(dir, 'README.md'), 'ignored', 'utf8');
130
+ const files = await listDecks(dir);
131
+ expect(files).toHaveLength(3);
132
+ expect(files.some((f) => f.endsWith('a.yml'))).toBe(true);
133
+ expect(files.some((f) => f.endsWith('b.yaml'))).toBe(true);
134
+ expect(files.some((f) => f.endsWith('c.json'))).toBe(true);
135
+ });
136
+ });
137
+ });
138
+
139
+ describe('defaultDecksDir', () => {
140
+ it('returns a path under the home directory', () => {
141
+ const dir = defaultDecksDir();
142
+ expect(dir).toMatch(/\/basa\/decks$/);
143
+ });
144
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { confettiRows, shakeRows, typeOnRows } from '../src/tui/effects.js';
3
+
4
+ describe('typeOnRows', () => {
5
+ it('returns 1 row at frame 0 (the floor)', () => {
6
+ const rows = typeOnRows({ durationFrames: 4, frame: 0 }, ['a', 'b', 'c']);
7
+ expect(rows.length).toBe(1);
8
+ });
9
+
10
+ it('returns all rows after the duration', () => {
11
+ const rows = typeOnRows({ durationFrames: 4, frame: 4 }, ['a', 'b', 'c']);
12
+ expect(rows).toEqual(['a', 'b', 'c']);
13
+ });
14
+
15
+ it('reveals progressively with each frame', () => {
16
+ const a = typeOnRows({ durationFrames: 4, frame: 1 }, ['a', 'b', 'c', 'd']);
17
+ const b = typeOnRows({ durationFrames: 4, frame: 2 }, ['a', 'b', 'c', 'd']);
18
+ expect(a.length).toBeLessThanOrEqual(b.length);
19
+ });
20
+ });
21
+
22
+ describe('shakeRows', () => {
23
+ it('is a no-op at the end of the animation', () => {
24
+ const rows = shakeRows({ durationFrames: 4, frame: 4 }, ['hi'], 42);
25
+ expect(rows).toEqual(['hi']);
26
+ });
27
+
28
+ it('shifts rows horizontally mid-animation', () => {
29
+ const rows = shakeRows({ durationFrames: 8, frame: 0 }, ['hi'], 42);
30
+ // Amplitude is 3 at frame 0, so a shift of up to 3 chars is possible.
31
+ const shifted = rows[0] !== 'hi';
32
+ expect(typeof shifted).toBe('boolean');
33
+ });
34
+ });
35
+
36
+ describe('confettiRows', () => {
37
+ it('emits the requested number of rows', () => {
38
+ const rows = confettiRows({ frame: 0, durationFrames: 10, seed: 1, width: 20, palette: ['\x1b[0m'] }, 5);
39
+ expect(rows).toHaveLength(5);
40
+ // Visible cell count is the width (ANSI codes don't count).
41
+ for (const row of rows) {
42
+ const visible = row.replace(/\x1b\[[0-9;]*m/g, '').length;
43
+ expect(visible).toBe(20);
44
+ }
45
+ });
46
+
47
+ it('emits a reset suffix so cells don\'t bleed color', () => {
48
+ const rows = confettiRows({ frame: 0, durationFrames: 10, seed: 1, width: 10, palette: ['\x1b[31m'] }, 1);
49
+ expect(rows[0]).toContain('\x1b[0m');
50
+ });
51
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { halfBlockLines, fitToCells } from '../src/flashcards/halfblock.js';
3
+ import { isImagePath } from '../src/flashcards/image.js';
4
+
5
+ describe('halfBlockLines', () => {
6
+ it('emits one row per 2 pixels of height', () => {
7
+ const pixels = new Uint8Array(4 * 4 * 4); // 4x4, all zero (black)
8
+ const lines = halfBlockLines({ width: 4, height: 4, pixels });
9
+ expect(lines).toHaveLength(2);
10
+ for (const line of lines) {
11
+ expect(line).toContain('\x1b[0m');
12
+ expect(line).toContain('▀');
13
+ }
14
+ });
15
+
16
+ it('emits color codes per row when pixels differ', () => {
17
+ const pixels = new Uint8Array(2 * 2 * 4);
18
+ // Row 0 (top), col 0: red.
19
+ pixels[0] = 255; pixels[1] = 0; pixels[2] = 0;
20
+ // Row 0, col 1: green.
21
+ pixels[4] = 0; pixels[5] = 255; pixels[6] = 0;
22
+ // Row 1 (bottom), col 0: blue.
23
+ pixels[8] = 0; pixels[9] = 0; pixels[10] = 255;
24
+ // Row 1, col 1: yellow.
25
+ pixels[12] = 255; pixels[13] = 255; pixels[14] = 0;
26
+ const lines = halfBlockLines({ width: 2, height: 2, pixels });
27
+ expect(lines).toHaveLength(1);
28
+ expect(lines[0]).toContain('38;2;255;0;0');
29
+ expect(lines[0]).toContain('48;2;0;0;255');
30
+ expect(lines[0]).toContain('38;2;0;255;0');
31
+ expect(lines[0]).toContain('48;2;255;255;0');
32
+ });
33
+ });
34
+
35
+ describe('fitToCells', () => {
36
+ it('respects a tall narrow source', () => {
37
+ const { width, height } = fitToCells(100, 1000, 80, 24);
38
+ expect(width).toBeLessThanOrEqual(80);
39
+ expect(height).toBeLessThanOrEqual(48);
40
+ });
41
+
42
+ it('respects a wide short source', () => {
43
+ const { width, height } = fitToCells(1000, 100, 80, 24);
44
+ expect(width).toBeLessThanOrEqual(80);
45
+ expect(height).toBeLessThanOrEqual(48);
46
+ });
47
+
48
+ it('returns sane defaults for a zero-size source', () => {
49
+ const { width, height } = fitToCells(0, 0, 80, 24);
50
+ expect(width).toBe(1);
51
+ expect(height).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('isImagePath', () => {
56
+ it('accepts common image extensions', () => {
57
+ expect(isImagePath('a.png')).toBe(true);
58
+ expect(isImagePath('a.PNG')).toBe(true);
59
+ expect(isImagePath('path/to/a.jpg')).toBe(true);
60
+ expect(isImagePath('a.jpeg')).toBe(true);
61
+ expect(isImagePath('a.gif')).toBe(true);
62
+ expect(isImagePath('a.webp')).toBe(true);
63
+ expect(isImagePath('a.svg')).toBe(true);
64
+ });
65
+
66
+ it('rejects non-image extensions', () => {
67
+ expect(isImagePath('a.txt')).toBe(false);
68
+ expect(isImagePath('README.md')).toBe(false);
69
+ expect(isImagePath('a')).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { BasaFx } from '../src/flashcards/sound.js';
3
+
4
+ describe('BasaFx', () => {
5
+ it('opens without error even with no audio backend available', async () => {
6
+ const fx = await BasaFx.open();
7
+ expect(typeof fx.isLive).toBe('boolean');
8
+ await fx.dispose();
9
+ });
10
+
11
+ it('isLive is false in CI environments', async () => {
12
+ const fx = await BasaFx.open();
13
+ if (process.env.CI !== undefined) {
14
+ expect(fx.isLive).toBe(false);
15
+ }
16
+ await fx.dispose();
17
+ });
18
+
19
+ it('playCorrect and playIncorrect are no-ops on silent instance', async () => {
20
+ const fx = await BasaFx.open();
21
+ // Both must not throw regardless of backend availability.
22
+ await fx.playCorrect(2);
23
+ await fx.playCorrect(3);
24
+ await fx.playIncorrect();
25
+ await fx.dispose();
26
+ });
27
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { freshState, grade, isDue, pickNext } from '../src/flashcards/srs.js';
3
+ import type { ReviewCard } from '../src/flashcards/types.js';
4
+
5
+ describe('SRS algorithm', () => {
6
+ it('freshState starts due immediately', () => {
7
+ const s = freshState(1_000);
8
+ expect(s.due).toBeNull();
9
+ expect(s.streak).toBe(0);
10
+ expect(s.ease).toBe(2.5);
11
+ expect(s.intervalDays).toBe(0);
12
+ expect(s.reviews).toBe(0);
13
+ expect(s.lastGrade).toBe(null);
14
+ });
15
+
16
+ it('Again resets streak, drops ease, and re-queues in 10 minutes', () => {
17
+ const s = freshState(0);
18
+ const next = grade(s, 0, 0);
19
+ expect(next.streak).toBe(0);
20
+ expect(next.ease).toBeLessThan(2.5);
21
+ expect(next.intervalDays).toBe(0);
22
+ expect(next.due).toBe(10 * 60 * 1000);
23
+ expect(next.reviews).toBe(1);
24
+ expect(next.lastGrade).toBe(0);
25
+ });
26
+
27
+ it('Good progresses 1 day → 6 days → scaling', () => {
28
+ const s = freshState(0);
29
+ const day = 24 * 60 * 60 * 1000;
30
+ const s1 = grade(s, 2, 0);
31
+ expect(s1.intervalDays).toBe(1);
32
+ expect(s1.due).toBe(day);
33
+ expect(s1.streak).toBe(1);
34
+
35
+ const s2 = grade(s1, 2, 0);
36
+ expect(s2.intervalDays).toBe(6);
37
+ expect(s2.streak).toBe(2);
38
+
39
+ const s3 = grade(s2, 2, 0);
40
+ expect(s3.intervalDays).toBeGreaterThanOrEqual(6);
41
+ expect(s3.streak).toBe(3);
42
+ });
43
+
44
+ it('Easy bumps ease; Hard drops it; Good holds', () => {
45
+ const s = freshState(0);
46
+ const easy = grade(s, 3, 0);
47
+ expect(easy.ease).toBeGreaterThan(2.5);
48
+ const hard = grade(s, 1, 0);
49
+ expect(hard.ease).toBeLessThan(2.5);
50
+ const good = grade(s, 2, 0);
51
+ expect(good.ease).toBe(2.5);
52
+ });
53
+
54
+ it('ease is clamped to [1.3, 2.8]', () => {
55
+ let s = freshState(0);
56
+ for (let i = 0; i < 20; i++) s = grade(s, 1, 0);
57
+ expect(s.ease).toBeGreaterThanOrEqual(1.3);
58
+ let s2 = freshState(0);
59
+ for (let i = 0; i < 20; i++) s2 = grade(s2, 3, 0);
60
+ expect(s2.ease).toBeLessThanOrEqual(2.8);
61
+ });
62
+
63
+ it('isDue returns true when due is null or in the past', () => {
64
+ expect(isDue({ ...freshState(0), due: null }, 1000)).toBe(true);
65
+ expect(isDue({ ...freshState(0), due: 500 }, 1000)).toBe(true);
66
+ expect(isDue({ ...freshState(0), due: 1500 }, 1000)).toBe(false);
67
+ });
68
+
69
+ it('pickNext returns the oldest-due card, or undefined if none due', () => {
70
+ const cards: ReviewCard[] = [
71
+ { card: { front: 'a', back: 'A' }, state: { ...freshState(0), due: 100 } },
72
+ { card: { front: 'b', back: 'B' }, state: { ...freshState(0), due: 50 } },
73
+ { card: { front: 'c', back: 'C' }, state: { ...freshState(0), due: 200 } },
74
+ ];
75
+ const next = pickNext(cards, 1000);
76
+ expect(next?.card.front).toBe('b');
77
+ });
78
+
79
+ it('pickNext returns undefined when no cards are due', () => {
80
+ const cards: ReviewCard[] = [
81
+ { card: { front: 'a', back: 'A' }, state: { ...freshState(0), due: 5_000_000 } },
82
+ ];
83
+ expect(pickNext(cards, 1000)).toBeUndefined();
84
+ });
85
+ });
@@ -0,0 +1,26 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { TestApp } from '@mudah-cli/mudah/testing';
4
+
5
+ const appDir = fileURLToPath(new URL('..', import.meta.url));
6
+
7
+ describe('study command', () => {
8
+ it('rejects when no deck is given and the default dir is empty', async () => {
9
+ const app = await TestApp.create({ cwd: appDir, env: { BASA_DECKS_DIR: '/tmp/basa-empty-test-dir' } });
10
+ // BasaConfig reads `app.decksDir`; with no env override the test's
11
+ // HOME will be a temp dir. Just ensure the command fails gracefully
12
+ // if there's nothing to study.
13
+ const result = await app.dispatch(['study']);
14
+ expect(result.code).not.toBe(0);
15
+ });
16
+ });
17
+
18
+ describe('list command', () => {
19
+ it('reports no decks when the directory is empty', async () => {
20
+ const app = await TestApp.create({ cwd: appDir });
21
+ const result = await app.dispatch(['list']);
22
+ // The test's HOME-derived default dir may or may not have decks; just
23
+ // verify the command exits cleanly.
24
+ expect([0, 1]).toContain(result.code);
25
+ });
26
+ });