@thesimonharms/basa 0.1.0 → 0.1.2
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.
- package/bin/basa.js +62 -1
- package/dist/commands/list.command.js +59 -0
- package/dist/commands/new.command.js +24 -0
- package/dist/commands/study.command.js +78 -0
- package/dist/flashcards/deck.js +207 -0
- package/dist/flashcards/halfblock.js +48 -0
- package/dist/flashcards/image.js +89 -0
- package/dist/flashcards/render.js +110 -0
- package/dist/flashcards/sound.js +100 -0
- package/dist/flashcards/srs.js +77 -0
- package/dist/flashcards/types.js +26 -0
- package/dist/providers/AppProvider.js +13 -0
- package/dist/tui/CardView.js +228 -0
- package/dist/tui/Footer.js +57 -0
- package/dist/tui/Header.js +48 -0
- package/dist/tui/StudyApp.js +120 -0
- package/dist/tui/effects.js +55 -0
- package/package.json +5 -5
- package/config/app.ts +0 -8
- package/src/commands/list.command.ts +0 -65
- package/src/commands/new.command.ts +0 -25
- package/src/commands/study.command.ts +0 -84
- package/src/flashcards/deck.ts +0 -218
- package/src/flashcards/halfblock.ts +0 -55
- package/src/flashcards/image.ts +0 -97
- package/src/flashcards/render.ts +0 -123
- package/src/flashcards/sound.ts +0 -115
- package/src/flashcards/srs.ts +0 -88
- package/src/flashcards/types.ts +0 -85
- package/src/providers/AppProvider.ts +0 -14
- package/src/tui/CardView.ts +0 -281
- package/src/tui/Footer.ts +0 -65
- package/src/tui/Header.ts +0 -58
- package/src/tui/StudyApp.ts +0 -147
- package/src/tui/effects.ts +0 -81
- package/test/CardView.test.ts +0 -157
- package/test/deck.test.ts +0 -144
- package/test/effects.test.ts +0 -51
- package/test/halfblock.test.ts +0 -71
- package/test/sound.test.ts +0 -27
- package/test/srs.test.ts +0 -85
- package/test/study.command.test.ts +0 -26
package/test/effects.test.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
});
|
package/test/halfblock.test.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
});
|
package/test/sound.test.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
});
|
package/test/srs.test.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
-
});
|