@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/src/tui/Footer.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { BaseComponent } from '@mudah-cli/tui';
|
|
2
|
-
import { detectCapabilities } from '@mudah-cli/terminal';
|
|
3
|
-
import { paint } from '@mudah-cli/ui';
|
|
4
|
-
|
|
5
|
-
const KEYS: ReadonlyArray<{ key: string; label: string; tone: 'good' | 'meh' | 'bad' | 'neutral' }> = [
|
|
6
|
-
{ key: '1', label: 'Again', tone: 'bad' },
|
|
7
|
-
{ key: '2', label: 'Hard', tone: 'meh' },
|
|
8
|
-
{ key: '3', label: 'Good', tone: 'good' },
|
|
9
|
-
{ key: '4', label: 'Easy', tone: 'good' },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const TONES: Record<'good' | 'meh' | 'bad' | 'neutral', string> = {
|
|
13
|
-
good: '#4ade80',
|
|
14
|
-
meh: '#fbbf24',
|
|
15
|
-
bad: '#f87171',
|
|
16
|
-
neutral: '#94a3b8',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/** The key legend. Centered, three rows: title, four key chips, hints. */
|
|
20
|
-
export class Footer extends BaseComponent {
|
|
21
|
-
readonly focusable = false;
|
|
22
|
-
|
|
23
|
-
private width: number;
|
|
24
|
-
|
|
25
|
-
constructor(width: number) {
|
|
26
|
-
super();
|
|
27
|
-
this.width = width;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
resize(width: number): void {
|
|
31
|
-
this.width = width;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
render(): string[] {
|
|
35
|
-
const lvl = detectCapabilities().colorLevel;
|
|
36
|
-
const chips = KEYS.map((k) => {
|
|
37
|
-
const color = TONES[k.tone];
|
|
38
|
-
return `${paint('#0f172a', ` ${k.key} `, lvl)}${paint(color, ` ${k.label} `, lvl)}`;
|
|
39
|
-
}).join(' ');
|
|
40
|
-
const row1 = centerLine(chips, this.width);
|
|
41
|
-
const row2 = centerLine(
|
|
42
|
-
paint('#64748b', 'space/enter: reveal n: skip esc: quit', lvl),
|
|
43
|
-
this.width,
|
|
44
|
-
);
|
|
45
|
-
return ['', row1, row2];
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function centerLine(text: string, width: number): string {
|
|
50
|
-
return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function visibleOf(text: string): number {
|
|
54
|
-
let visible = 0;
|
|
55
|
-
let inEscape = false;
|
|
56
|
-
for (const ch of text) {
|
|
57
|
-
if (ch === '\x1b') { inEscape = true; continue; }
|
|
58
|
-
if (inEscape) {
|
|
59
|
-
if (ch === 'm') inEscape = false;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
visible++;
|
|
63
|
-
}
|
|
64
|
-
return visible;
|
|
65
|
-
}
|
package/src/tui/Header.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { BaseComponent } from '@mudah-cli/tui';
|
|
2
|
-
import { detectCapabilities } from '@mudah-cli/terminal';
|
|
3
|
-
import { paint } from '@mudah-cli/ui';
|
|
4
|
-
|
|
5
|
-
export interface HeaderOptions {
|
|
6
|
-
deckName: string;
|
|
7
|
-
reviewed: number;
|
|
8
|
-
total: number;
|
|
9
|
-
width: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Deck name + progress bar. One row, centered. */
|
|
13
|
-
export class Header extends BaseComponent {
|
|
14
|
-
readonly focusable = false;
|
|
15
|
-
|
|
16
|
-
private readonly options: HeaderOptions;
|
|
17
|
-
|
|
18
|
-
constructor(options: HeaderOptions) {
|
|
19
|
-
super();
|
|
20
|
-
this.options = options;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
update(options: HeaderOptions): void {
|
|
24
|
-
Object.assign(this.options, options);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
render(): string[] {
|
|
28
|
-
const lvl = detectCapabilities().colorLevel;
|
|
29
|
-
const barWidth = Math.max(10, Math.min(40, this.options.width - 30));
|
|
30
|
-
const ratio = this.options.total > 0 ? this.options.reviewed / this.options.total : 0;
|
|
31
|
-
const filled = Math.round(ratio * barWidth);
|
|
32
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
33
|
-
const left = paint('#e2e8f0', this.options.deckName, lvl);
|
|
34
|
-
const right = paint('#94a3b8', ` ${this.options.reviewed}/${this.options.total}`, lvl);
|
|
35
|
-
const progress = paint(ratio >= 1 ? '#4ade80' : '#60a5fa', bar, lvl);
|
|
36
|
-
const line = `${left} ${progress}${right}`;
|
|
37
|
-
return [centerLine(line, this.options.width)];
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function centerLine(text: string, width: number): string {
|
|
42
|
-
// Use a simple visible-length estimate: count chars, ignore ANSI.
|
|
43
|
-
return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function visibleOf(text: string): number {
|
|
47
|
-
let visible = 0;
|
|
48
|
-
let inEscape = false;
|
|
49
|
-
for (const ch of text) {
|
|
50
|
-
if (ch === '\x1b') { inEscape = true; continue; }
|
|
51
|
-
if (inEscape) {
|
|
52
|
-
if (ch === 'm') inEscape = false;
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
visible++;
|
|
56
|
-
}
|
|
57
|
-
return visible;
|
|
58
|
-
}
|
package/src/tui/StudyApp.ts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { Container } from '@mudah-cli/tui';
|
|
2
|
-
import type { Deck, Grade, ReviewCard } from '../flashcards/types.js';
|
|
3
|
-
import { grade, pickNext } from '../flashcards/srs.js';
|
|
4
|
-
import { saveReviewCards } from '../flashcards/deck.js';
|
|
5
|
-
import { BasaFx } from '../flashcards/sound.js';
|
|
6
|
-
import { CardView } from './CardView.js';
|
|
7
|
-
import { Header } from './Header.js';
|
|
8
|
-
import { Footer } from './Footer.js';
|
|
9
|
-
|
|
10
|
-
export interface StudyAppOptions {
|
|
11
|
-
deck: Deck;
|
|
12
|
-
deckPath: string;
|
|
13
|
-
cards: ReviewCard[];
|
|
14
|
-
width: number;
|
|
15
|
-
height: number;
|
|
16
|
-
fx: BasaFx;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface SessionStats {
|
|
20
|
-
reviewed: number;
|
|
21
|
-
again: number;
|
|
22
|
-
hard: number;
|
|
23
|
-
good: number;
|
|
24
|
-
easy: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The study session. Owns SRS state, the focused card, the session stats.
|
|
29
|
-
* Exposes a `Container` for the TUI program to mount, plus `tick()` and
|
|
30
|
-
* `resize()` methods the host calls every frame and on terminal resize.
|
|
31
|
-
*/
|
|
32
|
-
export class StudyApp {
|
|
33
|
-
readonly root: Container;
|
|
34
|
-
private readonly cardView: CardView;
|
|
35
|
-
private readonly header: Header;
|
|
36
|
-
private readonly footer: Footer;
|
|
37
|
-
private cards: ReviewCard[];
|
|
38
|
-
private current: ReviewCard | undefined;
|
|
39
|
-
private stats: SessionStats = { reviewed: 0, again: 0, hard: 0, good: 0, easy: 0 };
|
|
40
|
-
private width: number;
|
|
41
|
-
private height: number;
|
|
42
|
-
|
|
43
|
-
private readonly options: StudyAppOptions;
|
|
44
|
-
|
|
45
|
-
constructor(options: StudyAppOptions) {
|
|
46
|
-
this.options = options;
|
|
47
|
-
this.cards = options.cards;
|
|
48
|
-
this.width = options.width;
|
|
49
|
-
this.height = options.height;
|
|
50
|
-
|
|
51
|
-
const cardHeight = Math.max(8, options.height - 8);
|
|
52
|
-
this.cardView = new CardView({
|
|
53
|
-
deckPath: options.deckPath,
|
|
54
|
-
cellsWidth: options.width,
|
|
55
|
-
cellsHeight: cardHeight,
|
|
56
|
-
onGraded: (g, typed) => void this.handleGrade(g, typed),
|
|
57
|
-
onSkip: () => this.handleSkip(),
|
|
58
|
-
});
|
|
59
|
-
this.header = new Header({
|
|
60
|
-
deckName: options.deck.name,
|
|
61
|
-
reviewed: 0,
|
|
62
|
-
total: this.cards.length,
|
|
63
|
-
width: options.width,
|
|
64
|
-
});
|
|
65
|
-
this.footer = new Footer(options.width);
|
|
66
|
-
|
|
67
|
-
this.root = new Container().add(this.header).add(this.cardView).add(this.footer);
|
|
68
|
-
void this.advance();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Drive animations. Called by the host on every frame. */
|
|
72
|
-
tick(): void {
|
|
73
|
-
this.cardView.tick();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Apply a terminal resize. */
|
|
77
|
-
resize(width: number, height: number): void {
|
|
78
|
-
this.width = width;
|
|
79
|
-
this.height = height;
|
|
80
|
-
this.header.update({
|
|
81
|
-
deckName: this.options.deck.name,
|
|
82
|
-
reviewed: this.stats.reviewed,
|
|
83
|
-
total: this.cards.length,
|
|
84
|
-
width,
|
|
85
|
-
});
|
|
86
|
-
this.footer.resize(width);
|
|
87
|
-
const cardHeight = Math.max(8, height - 8);
|
|
88
|
-
this.cardView.resize(width, cardHeight);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Persist state on shutdown. Safe to call multiple times. */
|
|
92
|
-
async persist(): Promise<void> {
|
|
93
|
-
await saveReviewCards(this.options.deckPath, this.cards).catch(() => {});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private async advance(): Promise<void> {
|
|
97
|
-
this.current = pickNext(this.cards, Date.now());
|
|
98
|
-
if (this.current === undefined) {
|
|
99
|
-
await this.cardView.setCard(undefined, this.stats);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
await this.cardView.setCard(this.current);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private async handleGrade(gradeValue: Grade, _typed: string): Promise<void> {
|
|
106
|
-
if (this.current === undefined) return;
|
|
107
|
-
const idx = this.cards.indexOf(this.current);
|
|
108
|
-
if (idx < 0) return;
|
|
109
|
-
const next = grade(this.current.state, gradeValue);
|
|
110
|
-
this.cards[idx] = { card: this.current.card, state: next };
|
|
111
|
-
this.stats = {
|
|
112
|
-
reviewed: this.stats.reviewed + 1,
|
|
113
|
-
again: this.stats.again + (gradeValue === 0 ? 1 : 0),
|
|
114
|
-
hard: this.stats.hard + (gradeValue === 1 ? 1 : 0),
|
|
115
|
-
good: this.stats.good + (gradeValue === 2 ? 1 : 0),
|
|
116
|
-
easy: this.stats.easy + (gradeValue === 3 ? 1 : 0),
|
|
117
|
-
};
|
|
118
|
-
this.header.update({
|
|
119
|
-
deckName: this.options.deck.name,
|
|
120
|
-
reviewed: this.stats.reviewed,
|
|
121
|
-
total: this.cards.length,
|
|
122
|
-
width: this.width,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
if (gradeValue === 0) {
|
|
126
|
-
await this.options.fx.playIncorrect();
|
|
127
|
-
} else {
|
|
128
|
-
await this.options.fx.playCorrect(gradeValue);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Persist after every grade so a crash doesn't lose progress.
|
|
132
|
-
await saveReviewCards(this.options.deckPath, this.cards).catch(() => {});
|
|
133
|
-
|
|
134
|
-
await this.advance();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private handleSkip(): void {
|
|
138
|
-
if (this.current === undefined) return;
|
|
139
|
-
const idx = this.cards.indexOf(this.current);
|
|
140
|
-
if (idx < 0) return;
|
|
141
|
-
this.cards[idx] = {
|
|
142
|
-
card: this.current.card,
|
|
143
|
-
state: { ...this.current.state, due: Date.now() + 30_000 },
|
|
144
|
-
};
|
|
145
|
-
void this.advance();
|
|
146
|
-
}
|
|
147
|
-
}
|
package/src/tui/effects.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Visual effect helpers. Each is a small mutable object that the card view
|
|
3
|
-
* ticks every frame. They never touch the terminal directly — they just
|
|
4
|
-
* transform strings the component is about to render.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export interface TypeOnState {
|
|
8
|
-
/** Total frames the effect should run for. */
|
|
9
|
-
durationFrames: number;
|
|
10
|
-
/** Frame counter, incremented by the host. */
|
|
11
|
-
frame: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Reveal a string array row-by-row. 0 → 0 rows, 1 → all rows. */
|
|
15
|
-
export function typeOnRows(state: TypeOnState, rows: string[]): string[] {
|
|
16
|
-
const t = Math.min(1, state.frame / Math.max(1, state.durationFrames));
|
|
17
|
-
const visible = Math.max(1, Math.ceil(rows.length * t));
|
|
18
|
-
return rows.slice(0, visible);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ShakeState {
|
|
22
|
-
durationFrames: number;
|
|
23
|
-
frame: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Apply a per-row horizontal jitter that decays. The component pads each
|
|
27
|
-
* row with random leading spaces within a window that shrinks over time. */
|
|
28
|
-
export function shakeRows(state: ShakeState, rows: string[], seed: number): string[] {
|
|
29
|
-
const t = Math.min(1, state.frame / Math.max(1, state.durationFrames));
|
|
30
|
-
const amplitude = Math.round((1 - t) * 3);
|
|
31
|
-
if (amplitude === 0) return rows;
|
|
32
|
-
return rows.map((row, i) => {
|
|
33
|
-
// Deterministic pseudo-random per row+frame so the diff renderer still works.
|
|
34
|
-
const x = pseudo(seed + i * 7 + state.frame * 13);
|
|
35
|
-
const offset = Math.floor(x * (amplitude * 2 + 1)) - amplitude;
|
|
36
|
-
if (offset === 0) return row;
|
|
37
|
-
if (offset > 0) return ' '.repeat(offset) + row;
|
|
38
|
-
return row.slice(-offset);
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function pseudo(n: number): number {
|
|
43
|
-
const x = Math.sin(n * 12.9898) * 43758.5453;
|
|
44
|
-
return x - Math.floor(x);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ConfettiSpec {
|
|
48
|
-
/** Frame counter, incremented by the host. */
|
|
49
|
-
frame: number;
|
|
50
|
-
/** Total frames. */
|
|
51
|
-
durationFrames: number;
|
|
52
|
-
/** Pseudo-random seed. */
|
|
53
|
-
seed: number;
|
|
54
|
-
/** Width in cells. */
|
|
55
|
-
width: number;
|
|
56
|
-
/** Palette: 24-bit color codes as strings. */
|
|
57
|
-
palette: string[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Render a confetti overlay as N rows. Each row is a string. */
|
|
61
|
-
export function confettiRows(spec: ConfettiSpec, height: number): string[] {
|
|
62
|
-
const out: string[] = [];
|
|
63
|
-
for (let row = 0; row < height; row++) {
|
|
64
|
-
let line = '';
|
|
65
|
-
for (let col = 0; col < spec.width; col++) {
|
|
66
|
-
const x = pseudo(spec.seed + row * 91 + col * 17 + spec.frame * 5);
|
|
67
|
-
const visible = x > 0.65;
|
|
68
|
-
if (!visible) {
|
|
69
|
-
line += ' ';
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const colorIndex = Math.floor(pseudo(spec.seed + row + col + spec.frame * 3) * spec.palette.length);
|
|
73
|
-
const color = spec.palette[colorIndex] ?? spec.palette[0]!;
|
|
74
|
-
const glyphs = ['✦', '✧', '•', '·', '◆', '◇'];
|
|
75
|
-
const glyph = glyphs[Math.floor(pseudo(spec.seed + col + row) * glyphs.length)] ?? '·';
|
|
76
|
-
line += `${color}${glyph}\x1b[0m`;
|
|
77
|
-
}
|
|
78
|
-
out.push(line);
|
|
79
|
-
}
|
|
80
|
-
return out;
|
|
81
|
-
}
|
package/test/CardView.test.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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
|
-
});
|
package/test/deck.test.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
});
|