ed1df8986f
From a multi-agent bug-hunt + adversarial verification pass (0 critical/ high; 15 mediums). Fixed 13; 2 deferred as bigger refactors. Correctness: - srs: "Hard" no longer overshoots "Good" for reviewed cards (reps>=2). It compounded ease AND x1.2; now grows x1.2 off the previous interval only. + regression test. - enrich/dates: stop reading the modal verb "may" as the month May (phantom calendar events). "may" needs an ordinal/year/date-preposition now. - enrich/bib: disambiguate colliding BibTeX keys (smith2020, smith2020a, …) — duplicates corrupted reference-manager imports. - learn/glossary: junk-term guard used && (dead); now || so all-stopword terms like "there" are actually skipped. - transcription/engineImpl.web: don't collapse Whisper's null end-timestamp to a zero-length [t,t] segment; estimate from the next chunk or window duration (fixes citation/seam anchors). - transcription/pipeline: re-check the abort signal AFTER each chunk so Cancel works on single-chunk audio (it previously still saved). - db/repo.native: use withExclusiveTransactionAsync for reassign / deleteCourse / upsertVectors / createFlashcards (withTransactionAsync is not isolated on a shared connection → interleaved/half-applied writes). - db/repo.native: don't cache a rejected open/migrate promise — a transient first-open failure no longer bricks storage for the whole session. - stores/transcriptsStore: sequence-guard refresh() so overlapping focus/typing/filter refreshes can't resolve out of order and show stale results. - audio/wav: decode WAVE_FORMAT_EXTENSIBLE (0xFFFE) via its SubFormat GUID + add 24-bit PCM — common ffmpeg/Windows WAVs no longer hard-fail native import. + tests. Performance / footprint: - audio/decode.native: decode straight to 16kHz (decodeAudioData sampleRate hint) so the JS side never holds a full-rate buffer or runs the resample loop — big memory/OOM win on long lectures. - models/catalog display: Settings model sizes are now backend-aware (the no-GPU WASM path pulls ~2x fp32 weights; was advertising ~half). Feature gap: - download: native exports were silent no-ops. New download.native.ts writes to cache + opens the share sheet (expo-sharing); transcript/ICS/Anki-CSV/ BibTeX/RIS exports now work on device. Deferred (bigger): lexical-search recall ceiling (needs a full-corpus rank path on both repos); triple in-memory copy of the encoded file during transcribe (media is keyed by a not-yet-existing transcript id). Validated: tsc clean, 282 tests pass, web export clean (native deps not bundled), arm64 APK compiles with expo-sharing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.0 KiB
TypeScript
96 lines
3.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { initialSrs, review } from './srs';
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
describe('initialSrs', () => {
|
|
it('creates a neutral, immediately-due card', () => {
|
|
const s = initialSrs(NOW);
|
|
expect(s).toEqual({
|
|
ease: 2.5,
|
|
intervalDays: 0,
|
|
reps: 0,
|
|
lapses: 0,
|
|
due: NOW,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('review', () => {
|
|
it('does not mutate the input state', () => {
|
|
const s = initialSrs(NOW);
|
|
const frozen = { ...s };
|
|
review(s, 2, NOW);
|
|
expect(s).toEqual(frozen);
|
|
});
|
|
|
|
it('produces monotonically increasing intervals on repeated "good"', () => {
|
|
let s = initialSrs(NOW);
|
|
const intervals: number[] = [];
|
|
let t = NOW;
|
|
for (let i = 0; i < 5; i++) {
|
|
s = review(s, 2, t);
|
|
intervals.push(s.intervalDays);
|
|
t += s.intervalDays * MS_PER_DAY;
|
|
}
|
|
// 1 day, 6 days, then *ease each time -> strictly increasing.
|
|
for (let i = 1; i < intervals.length; i++) {
|
|
expect(intervals[i]!).toBeGreaterThan(intervals[i - 1]!);
|
|
}
|
|
expect(intervals[0]).toBe(1);
|
|
expect(intervals[1]).toBe(6);
|
|
});
|
|
|
|
it('increments reps on passing grades and resets on "again"', () => {
|
|
let s = initialSrs(NOW);
|
|
s = review(s, 2, NOW); // good
|
|
s = review(s, 2, NOW); // good
|
|
expect(s.reps).toBe(2);
|
|
s = review(s, 0, NOW); // again
|
|
expect(s.reps).toBe(0);
|
|
expect(s.lapses).toBe(1);
|
|
});
|
|
|
|
it('floors ease at 1.3 even after many "again" reviews', () => {
|
|
let s = initialSrs(NOW);
|
|
for (let i = 0; i < 20; i++) s = review(s, 0, NOW);
|
|
expect(s.ease).toBe(1.3);
|
|
});
|
|
|
|
it('lowers ease on hard, raises it on easy', () => {
|
|
const good = review(initialSrs(NOW), 2, NOW);
|
|
expect(good.ease).toBe(2.5);
|
|
const hard = review(initialSrs(NOW), 1, NOW);
|
|
expect(hard.ease).toBeCloseTo(2.35, 5);
|
|
const easy = review(initialSrs(NOW), 3, NOW);
|
|
expect(easy.ease).toBeCloseTo(2.65, 5);
|
|
});
|
|
|
|
it('computes due = now + intervalDays * 86400000 and sets lastReviewed', () => {
|
|
const s = review(initialSrs(NOW), 2, NOW);
|
|
expect(s.intervalDays).toBe(1);
|
|
expect(s.due).toBe(NOW + 1 * MS_PER_DAY);
|
|
expect(s.lastReviewed).toBe(NOW);
|
|
});
|
|
|
|
it('gives "easy" a longer interval than "good" at the same step', () => {
|
|
const good = review(initialSrs(NOW), 2, NOW);
|
|
const easy = review(initialSrs(NOW), 3, NOW);
|
|
expect(easy.intervalDays).toBeGreaterThan(good.intervalDays);
|
|
});
|
|
|
|
it('keeps "hard" shorter than "good" for a reviewed card (reps >= 2)', () => {
|
|
// Bring a card to reps=2 with two Goods (interval 6, ease 2.5).
|
|
let s = initialSrs(NOW);
|
|
s = review(s, 2, NOW);
|
|
s = review(s, 2, NOW);
|
|
expect(s.reps).toBe(2);
|
|
const hard = review(s, 1, NOW);
|
|
const good = review(s, 2, NOW);
|
|
expect(hard.intervalDays).toBeLessThan(good.intervalDays);
|
|
// And Hard still grows the interval (it's a pass, not a lapse).
|
|
expect(hard.intervalDays).toBeGreaterThan(s.intervalDays);
|
|
});
|
|
});
|