saterminal 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,110 @@
1
+ import { wrapText } from "../text.ts";
2
+ import type { TextSegment } from "../text.ts";
3
+ import { term, gutter, terminalSize } from "./kit.ts";
4
+
5
+ export { term, gutter, terminalSize, contentBounds } from "./kit.ts";
6
+
7
+ export function line(y: number, value: string): void {
8
+ term.moveTo(1, y)(value.slice(0, term.width - 1));
9
+ }
10
+
11
+ export function lineAt(x: number, y: number, width: number, value: string): void {
12
+ term.moveTo(x, y)(value.slice(0, width));
13
+ }
14
+
15
+ export function lineAtColor(
16
+ x: number,
17
+ y: number,
18
+ width: number,
19
+ value: string,
20
+ color: "cyan" | "green" | "red" | "yellow",
21
+ bold = false,
22
+ ): void {
23
+ const output = value.slice(0, width);
24
+ if (bold) {
25
+ term.moveTo(x, y).bold[color](output);
26
+ } else {
27
+ term.moveTo(x, y)[color](output);
28
+ }
29
+ }
30
+
31
+ export function paneLayout(): {
32
+ leftX: number;
33
+ leftWidth: number;
34
+ rightX: number;
35
+ rightWidth: number;
36
+ } {
37
+ const { width } = terminalSize();
38
+ const usable = Math.max(40, width - gutter);
39
+ const leftWidth = Math.max(20, Math.floor(usable / 2));
40
+ const rightX = leftWidth + gutter + 1;
41
+ const rightWidth = Math.max(20, width - rightX);
42
+
43
+ return {
44
+ leftX: 1,
45
+ leftWidth,
46
+ rightX,
47
+ rightWidth,
48
+ };
49
+ }
50
+
51
+ export function printWrapped(value: string | undefined, y: number, maxY: number, bold = false): number {
52
+ return printWrappedAt(value, 1, y, terminalSize().width - 4, maxY, bold);
53
+ }
54
+
55
+ export function printWrappedAt(
56
+ value: string | undefined,
57
+ x: number,
58
+ y: number,
59
+ width: number,
60
+ maxY: number,
61
+ bold = false,
62
+ ): number {
63
+ for (const row of wrapText(value ?? "", width)) {
64
+ if (y > maxY) {
65
+ lineAt(x, y, width, "...");
66
+ return y + 1;
67
+ }
68
+ if (bold) {
69
+ term.moveTo(x, y++).bold(row.slice(0, width));
70
+ } else {
71
+ lineAt(x, y++, width, row);
72
+ }
73
+ }
74
+
75
+ return y;
76
+ }
77
+
78
+ export function renderStyledLine(
79
+ x: number,
80
+ y: number,
81
+ width: number,
82
+ segments: TextSegment[],
83
+ forceBold = false,
84
+ ): void {
85
+ let col = 0;
86
+
87
+ for (const segment of segments) {
88
+ if (col >= width) {
89
+ break;
90
+ }
91
+
92
+ const text = segment.text.slice(0, width - col);
93
+ if (!text) {
94
+ continue;
95
+ }
96
+
97
+ const bold = forceBold || segment.style.bold;
98
+ const underline = segment.style.underline;
99
+ let output = term.moveTo(x + col, y);
100
+ if (bold && underline) {
101
+ output = output.bold.underline;
102
+ } else if (bold) {
103
+ output = output.bold;
104
+ } else if (underline) {
105
+ output = output.underline;
106
+ }
107
+ output(text);
108
+ col += text.length;
109
+ }
110
+ }
@@ -0,0 +1,43 @@
1
+ import type { AppState } from "./types.ts";
2
+
3
+ export function toggleTimer(state: AppState): void {
4
+ if (state.timerPaused) {
5
+ resumeTimer(state);
6
+ } else {
7
+ pauseTimer(state);
8
+ }
9
+ }
10
+
11
+ export function pauseTimer(state: AppState): void {
12
+ if (state.timerPaused || state.timerStartedAt === undefined) {
13
+ return;
14
+ }
15
+
16
+ state.elapsedMs += Date.now() - state.timerStartedAt;
17
+ state.timerStartedAt = undefined;
18
+ state.timerPaused = true;
19
+ }
20
+
21
+ export function resumeTimer(state: AppState): void {
22
+ if (!state.timerPaused || state.view !== "practice") {
23
+ return;
24
+ }
25
+
26
+ state.timerStartedAt = Date.now();
27
+ state.timerPaused = false;
28
+ }
29
+
30
+ export function elapsedQuestionSeconds(state: AppState): number {
31
+ const activeMs = state.timerPaused || state.timerStartedAt === undefined ? 0 : Date.now() - state.timerStartedAt;
32
+ return Math.max(0, Math.round((state.elapsedMs + activeMs) / 1000));
33
+ }
34
+
35
+ export function formatElapsed(seconds: number): string {
36
+ const minutes = Math.floor(seconds / 60);
37
+ const remainingSeconds = seconds % 60;
38
+ return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
39
+ }
40
+
41
+ export function timerStatus(state: AppState): string {
42
+ return state.view === "practice" && state.timerPaused ? " paused" : "";
43
+ }
@@ -0,0 +1,48 @@
1
+ import type { Attempt, Focus, PracticeQuestion } from "../types.ts";
2
+ import type { TextSegment } from "../text.ts";
3
+ import type { PaneId } from "./viewport.ts";
4
+
5
+ export type View = "focus" | "loading" | "practice" | "review" | "history" | "summary" | "detail" | "error";
6
+
7
+ export type AppState = {
8
+ attempts: Map<string, Attempt>;
9
+ skippedIds: Set<string>;
10
+ nextQuestion?: Promise<PracticeQuestion | undefined>;
11
+ focus: Focus;
12
+ focusIndex: number;
13
+ focusColumn: number;
14
+ focusRow: number;
15
+ view: View;
16
+ question?: PracticeQuestion;
17
+ selected: number;
18
+ questionScroll: number;
19
+ answerScroll: number;
20
+ activePane: PaneId;
21
+ elapsedMs: number;
22
+ timerStartedAt?: number;
23
+ timerPaused: boolean;
24
+ timerHidden: boolean;
25
+ lastAnswer?: string;
26
+ lastCorrect?: boolean;
27
+ detailQuestion?: PracticeQuestion;
28
+ historyIndex: number;
29
+ error?: string;
30
+ };
31
+
32
+ export type KeyData = {
33
+ code?: string | Buffer | number;
34
+ codepoint?: number;
35
+ isCharacter?: boolean;
36
+ };
37
+
38
+ export type DisplayRow = {
39
+ segments: TextSegment[];
40
+ bold?: boolean;
41
+ };
42
+
43
+ export type PaneLayout = {
44
+ leftX: number;
45
+ leftWidth: number;
46
+ rightX: number;
47
+ rightWidth: number;
48
+ };
@@ -0,0 +1,60 @@
1
+ export type PaneId = "question" | "answers";
2
+
3
+ export type PaneViewport = {
4
+ scroll: number;
5
+ height: number;
6
+ contentRows: number;
7
+ };
8
+
9
+ export function alternatePane(pane: PaneId): PaneId {
10
+ return pane === "question" ? "answers" : "question";
11
+ }
12
+
13
+ export function maxScroll(viewport: PaneViewport): number {
14
+ return Math.max(0, viewport.contentRows - viewport.height);
15
+ }
16
+
17
+ export function clampScroll(viewport: PaneViewport): number {
18
+ return Math.max(0, Math.min(viewport.scroll, maxScroll(viewport)));
19
+ }
20
+
21
+ export function scrollBy(viewport: PaneViewport, delta: number): number {
22
+ return clampScroll({ ...viewport, scroll: viewport.scroll + delta });
23
+ }
24
+
25
+ export function scrollPage(viewport: PaneViewport, direction: 1 | -1): number {
26
+ return scrollBy(viewport, direction * Math.max(1, viewport.height - 1));
27
+ }
28
+
29
+ export function scrollToEdge(viewport: PaneViewport, edge: "top" | "bottom"): number {
30
+ return edge === "top" ? 0 : maxScroll(viewport);
31
+ }
32
+
33
+ export function ensureRowVisible(viewport: PaneViewport, row: number): number {
34
+ const scroll = clampScroll(viewport);
35
+ if (row < scroll) {
36
+ return clampScroll({ ...viewport, scroll: row });
37
+ }
38
+
39
+ const lastVisible = scroll + viewport.height - 1;
40
+ if (row > lastVisible) {
41
+ return clampScroll({ ...viewport, scroll: row - viewport.height + 1 });
42
+ }
43
+
44
+ return scroll;
45
+ }
46
+
47
+ export function ensureRangeVisible(viewport: PaneViewport, start: number, end: number): number {
48
+ const scroll = clampScroll(viewport);
49
+ const lastVisible = scroll + viewport.height - 1;
50
+
51
+ if (start < scroll) {
52
+ return clampScroll({ ...viewport, scroll: start });
53
+ }
54
+
55
+ if (end > lastVisible) {
56
+ return clampScroll({ ...viewport, scroll: end - viewport.height + 1 });
57
+ }
58
+
59
+ return scroll;
60
+ }
package/src/tui.ts ADDED
@@ -0,0 +1 @@
1
+ export { runTui } from "./tui/app.ts";
@@ -0,0 +1,118 @@
1
+ declare module "terminal-kit" {
2
+ type KeyData = {
3
+ code?: string | Buffer | number;
4
+ codepoint?: number;
5
+ isCharacter?: boolean;
6
+ };
7
+
8
+ export type Terminal = {
9
+ width: number;
10
+ height: number;
11
+ clear(): Terminal;
12
+ moveTo(x: number, y: number): Terminal;
13
+ bold: Terminal & Record<string, Terminal>;
14
+ gray: Terminal;
15
+ cyan: Terminal;
16
+ green: Terminal;
17
+ red: Terminal;
18
+ yellow: Terminal;
19
+ underline: Terminal;
20
+ inverse: Terminal;
21
+ defaultColor: Terminal;
22
+ fullscreen(enabled: boolean): void;
23
+ hideCursor(enabled?: boolean): void;
24
+ grabInput(enabled?: boolean): void;
25
+ processExit(code: number): void;
26
+ on(event: "key", listener: (name: string, matches?: string[], data?: KeyData) => void): void;
27
+ on(event: "resize", listener: (width: number, height: number) => void): void;
28
+ off(event: "resize", listener: (width: number, height: number) => void): void;
29
+ (value: string): void;
30
+ };
31
+
32
+ export type DocumentOptions = {
33
+ inlineTerm?: Terminal;
34
+ eventSource?: Terminal;
35
+ outputDst?: Terminal;
36
+ outputX?: number;
37
+ outputY?: number;
38
+ outputWidth?: number;
39
+ outputHeight?: number;
40
+ backgroundAttr?: Record<string, unknown>;
41
+ noInput?: boolean;
42
+ };
43
+
44
+ export type MenuItem = {
45
+ content: string;
46
+ value: string;
47
+ disabled?: boolean;
48
+ };
49
+
50
+ export type ColumnMenuMultiOptions = {
51
+ parent?: unknown;
52
+ id?: string;
53
+ x?: number;
54
+ y?: number;
55
+ width?: number;
56
+ items?: MenuItem[];
57
+ value?: Record<string, boolean>;
58
+ multiLineItems?: boolean;
59
+ master?: MenuItem;
60
+ };
61
+
62
+ export type WindowOptions = {
63
+ parent?: unknown;
64
+ title?: string;
65
+ x?: number;
66
+ y?: number;
67
+ width?: number;
68
+ height?: number;
69
+ };
70
+
71
+ export type TextOptions = {
72
+ parent?: unknown;
73
+ content?: string | string[];
74
+ x?: number;
75
+ y?: number;
76
+ width?: number;
77
+ height?: number;
78
+ attr?: Record<string, unknown>;
79
+ contentHasMarkup?: boolean | "ansi" | "legacyAnsi";
80
+ noDraw?: boolean;
81
+ };
82
+
83
+ export class Document {
84
+ constructor(options: DocumentOptions);
85
+ clear(): void;
86
+ draw(): void;
87
+ destroy(): void;
88
+ focusNext(): void;
89
+ giveFocusTo(element: unknown, type?: string): void;
90
+ on(event: "key", listener: (key: string) => void): void;
91
+ }
92
+
93
+ export class ColumnMenuMulti {
94
+ constructor(options: ColumnMenuMultiOptions);
95
+ value: Record<string, boolean>;
96
+ setValue(value: Record<string, boolean>, noDraw?: boolean): void;
97
+ on(event: "itemToggle", listener: (key: string, enabled: boolean) => void): void;
98
+ on(event: "submit", listener: (...args: unknown[]) => void): void;
99
+ }
100
+
101
+ export class Window {
102
+ constructor(options: WindowOptions);
103
+ }
104
+
105
+ export class Text {
106
+ constructor(options: TextOptions);
107
+ }
108
+
109
+ const terminalKit: {
110
+ terminal: Terminal;
111
+ Document: typeof Document;
112
+ ColumnMenuMulti: typeof ColumnMenuMulti;
113
+ Window: typeof Window;
114
+ Text: typeof Text;
115
+ };
116
+
117
+ export default terminalKit;
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type Outcome = "correct" | "incorrect" | "corrected";
2
+
3
+ export type Attempt = {
4
+ question_id: string;
5
+ outcome: Outcome;
6
+ updated_at: string;
7
+ elapsed_seconds: number;
8
+ };
9
+
10
+ export type SummaryRow = {
11
+ metric: string;
12
+ value: string;
13
+ updated_at: string;
14
+ };
15
+
16
+ export type Difficulty = "E" | "M" | "H";
17
+ export type Domain = "INI" | "CAS" | "EOI" | "SEC";
18
+ export type Skill = "CID" | "INF" | "COE" | "WIC" | "TSP" | "CTC" | "SYN" | "TRA" | "BOU" | "FSS";
19
+
20
+ export type Focus = {
21
+ difficulties: Difficulty[];
22
+ domains: Domain[];
23
+ skills: Skill[];
24
+ };
25
+
26
+ export type QuestionMeta = {
27
+ questionId: string;
28
+ uId: string;
29
+ external_id: string;
30
+ difficulty: string;
31
+ primary_class_cd: string;
32
+ primary_class_cd_desc?: string;
33
+ program?: string;
34
+ skill_cd: string;
35
+ skill_desc?: string;
36
+ score_band_range_cd?: number;
37
+ };
38
+
39
+ export type QuestionDetail = {
40
+ externalid: string;
41
+ type: string;
42
+ stimulus?: string;
43
+ stem: string;
44
+ answerOptions: Record<string, string>;
45
+ correct_answer: string[];
46
+ rationale?: string;
47
+ };
48
+
49
+ export type PracticeQuestion = {
50
+ meta: QuestionMeta;
51
+ detail: QuestionDetail;
52
+ };
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fetchQuestionBank } from "../src/api.ts";
3
+
4
+ describe("api", () => {
5
+ test("passes excluded short question ids to the question bank endpoint", async () => {
6
+ const originalFetch = globalThis.fetch;
7
+ let requested = "";
8
+ globalThis.fetch = ((input: RequestInfo | URL) => {
9
+ requested = String(input);
10
+ return Promise.resolve(
11
+ new Response(JSON.stringify({ success: true, data: [] }), {
12
+ status: 200,
13
+ headers: { "content-type": "application/json" },
14
+ }),
15
+ );
16
+ }) as typeof fetch;
17
+
18
+ try {
19
+ await fetchQuestionBank(["a", "b"]);
20
+ expect(requested).toContain("assessment=SAT");
21
+ expect(requested).toContain("excludeIds=a%2Cb");
22
+ expect(requested).toContain("difficulties=M%2CH");
23
+ } finally {
24
+ globalThis.fetch = originalFetch;
25
+ }
26
+ });
27
+
28
+ test("passes focus filters to the question bank endpoint", async () => {
29
+ const originalFetch = globalThis.fetch;
30
+ let requested = "";
31
+ globalThis.fetch = ((input: RequestInfo | URL) => {
32
+ requested = String(input);
33
+ return Promise.resolve(
34
+ new Response(JSON.stringify({ success: true, data: [] }), {
35
+ status: 200,
36
+ headers: { "content-type": "application/json" },
37
+ }),
38
+ );
39
+ }) as typeof fetch;
40
+
41
+ try {
42
+ await fetchQuestionBank([], {
43
+ difficulties: ["H"],
44
+ domains: ["SEC"],
45
+ skills: ["BOU", "FSS"],
46
+ });
47
+ expect(requested).toContain("difficulties=H");
48
+ expect(requested).toContain("domains=SEC");
49
+ expect(requested).toContain("skills=BOU%2CFSS");
50
+ } finally {
51
+ globalThis.fetch = originalFetch;
52
+ }
53
+ });
54
+
55
+ test("derives domain filters from selected skill filters", async () => {
56
+ const originalFetch = globalThis.fetch;
57
+ let requested = "";
58
+ globalThis.fetch = ((input: RequestInfo | URL) => {
59
+ requested = String(input);
60
+ return Promise.resolve(
61
+ new Response(JSON.stringify({ success: true, data: [] }), {
62
+ status: 200,
63
+ headers: { "content-type": "application/json" },
64
+ }),
65
+ );
66
+ }) as typeof fetch;
67
+
68
+ try {
69
+ await fetchQuestionBank([], {
70
+ difficulties: ["H"],
71
+ domains: ["INI"],
72
+ skills: ["WIC"],
73
+ });
74
+ expect(requested).toContain("domains=CAS");
75
+ expect(requested).toContain("skills=WIC");
76
+ } finally {
77
+ globalThis.fetch = originalFetch;
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { focusRows, toggleFocusRow } from "../src/focus.ts";
3
+ import { focusGrid, moveFocusGridPosition, toggleFocusGridRow } from "../src/tui/focus-grid.ts";
4
+ import type { Focus } from "../src/types.ts";
5
+
6
+ describe("focus", () => {
7
+ test("renders skills as nested domain children", () => {
8
+ const rows = focusRows({ difficulties: ["H"], domains: ["CAS"], skills: ["WIC"] });
9
+ const cas = rows.find((row) => row.kind === "option" && row.value === "CAS");
10
+ const wic = rows.find((row) => row.kind === "option" && row.value === "WIC");
11
+
12
+ expect(cas).toMatchObject({ kind: "option", checked: true, partial: true, depth: 0 });
13
+ expect(wic).toMatchObject({ kind: "option", checked: true, depth: 1 });
14
+ });
15
+
16
+ test("domain toggles operate on their child skills", () => {
17
+ const focus: Focus = { difficulties: ["H"], domains: ["CAS"], skills: ["WIC"] };
18
+ const cas = focusRows(focus).find((row) => row.kind === "option" && row.value === "CAS");
19
+
20
+ expect(toggleFocusRow(focus, cas)).toEqual({
21
+ difficulties: ["H"],
22
+ domains: ["CAS"],
23
+ skills: ["WIC", "TSP", "CTC"],
24
+ });
25
+ });
26
+
27
+ test("grid navigation moves predictably across columns and rows", () => {
28
+ const focus: Focus = { difficulties: ["H"], domains: ["CAS"], skills: ["WIC"] };
29
+ const columns = focusGrid(focus);
30
+
31
+ expect(moveFocusGridPosition(columns, { column: 0, row: 1 }, "down")).toEqual({ column: 0, row: 2 });
32
+ expect(moveFocusGridPosition(columns, { column: 0, row: 2 }, "down")).toEqual({ column: 0, row: 2 });
33
+ expect(moveFocusGridPosition(columns, { column: 0, row: 2 }, "next")).toEqual({ column: 1, row: 2 });
34
+ expect(moveFocusGridPosition(columns, { column: 0, row: 2 }, "previous")).toEqual({ column: 4, row: 2 });
35
+ });
36
+
37
+ test("grid navigation clamps row when moving into shorter columns", () => {
38
+ const focus: Focus = { difficulties: ["H"], domains: ["INI"], skills: ["CID", "INF", "COE"] };
39
+ const columns = focusGrid(focus);
40
+
41
+ expect(moveFocusGridPosition(columns, { column: 1, row: 3 }, "next")).toEqual({ column: 2, row: 3 });
42
+ expect(moveFocusGridPosition(columns, { column: 2, row: 3 }, "next")).toEqual({ column: 3, row: 2 });
43
+ });
44
+
45
+ test("grid toggles use focus constraints", () => {
46
+ const focus: Focus = { difficulties: ["H"], domains: ["CAS"], skills: ["WIC"] };
47
+
48
+ expect(toggleFocusGridRow(focus, { column: 0, row: 2 })).toBe(focus);
49
+ expect(toggleFocusGridRow(focus, { column: 2, row: 0 })).toEqual({
50
+ difficulties: ["H"],
51
+ domains: ["CAS"],
52
+ skills: ["WIC", "TSP", "CTC"],
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defaultFocus, normalizeFocus } from "../src/focus.ts";
6
+ import {
7
+ buildSummaryRows,
8
+ loadAttempts,
9
+ loadFocus,
10
+ nextOutcome,
11
+ recordAttempt,
12
+ saveAttempts,
13
+ saveFocus,
14
+ } from "../src/state.ts";
15
+
16
+ describe("state", () => {
17
+ test("creates and reads a compact attempts csv", async () => {
18
+ const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
19
+ const path = join(dir, "attempts.csv");
20
+
21
+ try {
22
+ const attempts = await loadAttempts(path);
23
+ recordAttempt(attempts, "abc12345", false, 42, new Date("2026-01-01T00:00:00.000Z"));
24
+ await saveAttempts(attempts, path);
25
+
26
+ const raw = await readFile(path, "utf8");
27
+ expect(raw).toBe(
28
+ "question_id,outcome,updated_at,elapsed_seconds\nabc12345,incorrect,2026-01-01T00:00:00.000Z,42\n",
29
+ );
30
+ expect(await loadAttempts(path)).toEqual(attempts);
31
+ } finally {
32
+ await rm(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test("uses corrected for a later right answer after a miss", () => {
37
+ const attempts = new Map();
38
+ recordAttempt(attempts, "abc12345", false, 12, new Date("2026-01-01T00:00:00.000Z"));
39
+ recordAttempt(attempts, "abc12345", true, 10, new Date("2026-01-02T00:00:00.000Z"));
40
+
41
+ expect(attempts.get("abc12345")?.outcome).toBe("corrected");
42
+ });
43
+
44
+ test("loads escaped csv fields", async () => {
45
+ const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
46
+ const path = join(dir, "attempts.csv");
47
+
48
+ try {
49
+ await writeFile(
50
+ path,
51
+ "question_id,outcome,updated_at,elapsed_seconds\n\"abc,123\",correct,\"2026-01-01T00:00:00.000Z\",12\n",
52
+ "utf8",
53
+ );
54
+
55
+ expect(await loadAttempts(path)).toEqual(new Map([
56
+ ["abc,123", {
57
+ question_id: "abc,123",
58
+ outcome: "correct",
59
+ updated_at: "2026-01-01T00:00:00.000Z",
60
+ elapsed_seconds: 12,
61
+ }],
62
+ ]));
63
+ } finally {
64
+ await rm(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("does not downgrade mastered outcomes", () => {
69
+ expect(nextOutcome("correct", false)).toBe("correct");
70
+ expect(nextOutcome("corrected", false)).toBe("corrected");
71
+ });
72
+
73
+ test("builds summary rows from attempts", () => {
74
+ const attempts = new Map();
75
+ recordAttempt(attempts, "a", true, 20, new Date("2026-01-01T00:00:00.000Z"));
76
+ recordAttempt(attempts, "b", false, 10, new Date("2026-01-01T00:00:00.000Z"));
77
+ recordAttempt(attempts, "b", true, 40, new Date("2026-01-02T00:00:00.000Z"));
78
+
79
+ const rows = buildSummaryRows(attempts, new Date("2026-01-03T00:00:00.000Z"));
80
+ expect(Object.fromEntries(rows.map((row) => [row.metric, row.value]))).toEqual({
81
+ answered: "2",
82
+ correct: "1",
83
+ incorrect: "0",
84
+ corrected: "1",
85
+ accuracy: "1.00",
86
+ avg_seconds: "30.0",
87
+ });
88
+ });
89
+
90
+ test("normalizes invalid focus selections to valid defaults", () => {
91
+ expect(normalizeFocus({ difficulties: [], domains: ["NOPE"], skills: ["CID"] })).toEqual({
92
+ difficulties: defaultFocus.difficulties,
93
+ domains: ["INI"],
94
+ skills: ["CID"],
95
+ });
96
+ });
97
+
98
+ test("derives focus domains from selected skills", () => {
99
+ expect(normalizeFocus({ difficulties: ["H"], domains: ["INI"], skills: ["WIC"] })).toEqual({
100
+ difficulties: ["H"],
101
+ domains: ["CAS"],
102
+ skills: ["WIC"],
103
+ });
104
+ });
105
+
106
+ test("saves and loads focus json", async () => {
107
+ const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
108
+ const path = join(dir, "focus.json");
109
+
110
+ try {
111
+ await saveFocus({ difficulties: ["H"], domains: ["SEC"], skills: ["BOU", "FSS"] }, path);
112
+ expect(await loadFocus(path)).toEqual({ difficulties: ["H"], domains: ["SEC"], skills: ["BOU", "FSS"] });
113
+
114
+ await writeFile(path, "{\"difficulties\":[],\"domains\":[],\"skills\":[]}", "utf8");
115
+ expect(await loadFocus(path)).toEqual(defaultFocus);
116
+ } finally {
117
+ await rm(dir, { recursive: true, force: true });
118
+ }
119
+ });
120
+ });