saterminal 0.2.0 → 0.3.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,223 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultFocus } from "../src/focus.ts";
3
+ import {
4
+ formatFocus,
5
+ formatHistory,
6
+ formatStats,
7
+ formatWeak,
8
+ parseArgs,
9
+ } from "../src/cli.ts";
10
+ import { buildSummaryRows, recordAttempt } from "../src/state.ts";
11
+ import type { AttemptEvent, Focus, QuestionMeta } from "../src/types.ts";
12
+
13
+ describe("cli", () => {
14
+ test("uses subcommands for report selection and flags for format", () => {
15
+ expect(parseArgs([])).toEqual({ kind: "tui" });
16
+ expect(parseArgs(["history"])).toEqual({ kind: "command", command: "history", format: "text" });
17
+ expect(parseArgs(["history", "-p"])).toEqual({ kind: "command", command: "history", format: "pretty" });
18
+ expect(parseArgs(["--json", "stats"])).toEqual({ kind: "command", command: "stats", format: "json" });
19
+ expect(parseArgs(["weak", "--pretty", "--no-color"])).toEqual({ kind: "command", command: "weak", format: "pretty", color: false });
20
+ expect(parseArgs(["review"])).toEqual({ kind: "review" });
21
+ expect(parseArgs(["--history"])).toEqual({ kind: "error", message: "Unknown option: --history" });
22
+ });
23
+
24
+ test("rejects conflicting output format flags", () => {
25
+ expect(parseArgs(["focus", "--json", "--pretty"])).toEqual({
26
+ kind: "error",
27
+ message: "Choose either `--pretty` or `--json`, not both.",
28
+ });
29
+ });
30
+
31
+ test("parses and applies history filters", () => {
32
+ expect(parseArgs(["history", "--wrong", "--corrected", "--limit", "5", "--since=7d"])).toEqual({
33
+ kind: "command",
34
+ command: "history",
35
+ format: "text",
36
+ filters: { wrong: true, corrected: true, limit: 5, since: "7d" },
37
+ });
38
+ expect(parseArgs(["stats", "--wrong"])).toEqual({
39
+ kind: "error",
40
+ message: "History filters only work with `sat history`.",
41
+ });
42
+
43
+ const attempts = new Map();
44
+ recordAttempt(attempts, "old", false, 10, new Date("2026-01-01T00:00:00.000Z"));
45
+ recordAttempt(attempts, "wrong", false, 20, new Date("2026-01-08T00:00:00.000Z"));
46
+ recordAttempt(attempts, "fixed", false, 30, new Date("2026-01-08T00:00:00.000Z"));
47
+ recordAttempt(attempts, "fixed", true, 40, new Date("2026-01-09T00:00:00.000Z"));
48
+
49
+ expect(JSON.parse(formatHistory([...attempts.values()], "json", { filters: { wrong: true }, now: new Date("2026-01-10T00:00:00.000Z") }))).toEqual([
50
+ {
51
+ question_id: "wrong",
52
+ outcome: "incorrect",
53
+ updated_at: "2026-01-08T00:00:00.000Z",
54
+ elapsed_seconds: 20,
55
+ },
56
+ {
57
+ question_id: "old",
58
+ outcome: "incorrect",
59
+ updated_at: "2026-01-01T00:00:00.000Z",
60
+ elapsed_seconds: 10,
61
+ },
62
+ ]);
63
+ expect(JSON.parse(formatHistory([...attempts.values()], "json", {
64
+ filters: { corrected: true, since: "7d", limit: 1 },
65
+ now: new Date("2026-01-10T00:00:00.000Z"),
66
+ }))).toEqual([
67
+ {
68
+ question_id: "fixed",
69
+ outcome: "corrected",
70
+ updated_at: "2026-01-09T00:00:00.000Z",
71
+ elapsed_seconds: 40,
72
+ },
73
+ ]);
74
+ });
75
+
76
+ test("formats history as sorted JSON and pretty table output", () => {
77
+ const attempts = new Map();
78
+ recordAttempt(attempts, "older", false, 65, new Date("2026-01-01T00:00:00.000Z"));
79
+ recordAttempt(attempts, "newer", true, 5, new Date("2026-01-02T00:00:00.000Z"));
80
+
81
+ expect(JSON.parse(formatHistory([...attempts.values()], "json"))).toEqual([
82
+ {
83
+ question_id: "newer",
84
+ outcome: "correct",
85
+ updated_at: "2026-01-02T00:00:00.000Z",
86
+ elapsed_seconds: 5,
87
+ },
88
+ {
89
+ question_id: "older",
90
+ outcome: "incorrect",
91
+ updated_at: "2026-01-01T00:00:00.000Z",
92
+ elapsed_seconds: 65,
93
+ },
94
+ ]);
95
+
96
+ const pretty = formatHistory([...attempts.values()], "pretty");
97
+ expect(pretty).toContain("\x1b[");
98
+ expect(stripAnsi(pretty)).toContain("history\n2 attempts 1 mastered 1 needs review");
99
+ expect(stripAnsi(pretty)).toContain("newer");
100
+ expect(stripAnsi(pretty)).toContain("correct");
101
+ expect(stripAnsi(pretty)).toContain("1:05");
102
+ });
103
+
104
+ test("formats stats with numeric JSON and human percentages", () => {
105
+ const attempts = new Map();
106
+ recordAttempt(attempts, "a", true, 20, new Date("2026-01-01T00:00:00.000Z"));
107
+ recordAttempt(attempts, "b", false, 40, new Date("2026-01-01T00:00:00.000Z"));
108
+ const rows = buildSummaryRows(attempts, new Date("2026-01-02T00:00:00.000Z"));
109
+
110
+ expect(JSON.parse(formatStats(rows, "json"))).toEqual({
111
+ answered: 2,
112
+ correct: 1,
113
+ incorrect: 1,
114
+ corrected: 0,
115
+ accuracy: 0.5,
116
+ avg_seconds: 30,
117
+ });
118
+ const pretty = formatStats(rows, "pretty");
119
+ expect(pretty).toContain("\x1b[");
120
+ expect(stripAnsi(pretty)).toContain("stats\n2 answered 50% accuracy 0:30 avg");
121
+ expect(stripAnsi(pretty)).toContain("correct 1");
122
+ expect(stripAnsi(pretty)).toContain("incorrect 1");
123
+ expect(pretty).toContain("\x1b[42m \x1b[0m\x1b[100m \x1b[0m");
124
+ expect(stripAnsi(pretty)).not.toContain("#");
125
+ expect(stripAnsi(pretty)).not.toContain("░");
126
+ expect(formatStats(rows, "text")).toContain("avg seconds 30.0s");
127
+
128
+ const noColor = formatStats(rows, "pretty", { color: false });
129
+ expect(noColor).not.toContain("\x1b[");
130
+ expect(noColor).toContain("[████████████ ]");
131
+ });
132
+
133
+ test("formats stats with streak and activity when events are available", () => {
134
+ const attempts = new Map();
135
+ recordAttempt(attempts, "a", true, 20, new Date("2026-01-09T12:00:00"));
136
+ const rows = buildSummaryRows(attempts, new Date("2026-01-10T12:00:00"));
137
+ const events: AttemptEvent[] = [
138
+ attemptEvent("a", true, "2026-01-08T12:00:00"),
139
+ attemptEvent("b", false, "2026-01-09T12:00:00"),
140
+ ];
141
+
142
+ expect(JSON.parse(formatStats(rows, "json", { events, now: new Date("2026-01-10T12:00:00") })).activity).toMatchObject({
143
+ streak: 2,
144
+ activeDays: 2,
145
+ todayCount: 0,
146
+ totalEvents: 2,
147
+ });
148
+
149
+ const rawPretty = formatStats(rows, "pretty", { events, now: new Date("2026-01-10T12:00:00") });
150
+ const pretty = stripAnsi(rawPretty);
151
+ expect(pretty).toContain("2 day streak");
152
+ expect(pretty).toContain("activity\nlast 12 weeks");
153
+ expect(pretty).toContain("Mon");
154
+ expect(pretty).toContain("Wed");
155
+ expect(pretty).toContain("Fri");
156
+ expect(pretty).toContain("Oct");
157
+ expect(pretty).toContain("Jan");
158
+ expect(rawPretty).toContain("\x1b[38;5;238m■\x1b[0m");
159
+ expect(rawPretty).toContain("\x1b[38;5;22m■\x1b[0m");
160
+ expect(rawPretty).not.toContain("·");
161
+ });
162
+
163
+ test("formats focus as raw selections or labeled pretty output", () => {
164
+ const focus: Focus = { difficulties: ["H"], domains: ["CAS"], skills: ["WIC"] };
165
+
166
+ expect(formatFocus(focus, "json")).toBe(JSON.stringify(focus));
167
+ expect(formatFocus(focus, "text")).toBe("difficulties: H\ndomains: CAS\nskills: WIC");
168
+ expect(formatFocus(focus, "pretty")).toContain("\x1b[");
169
+ expect(stripAnsi(formatFocus(focus, "pretty"))).toContain("H Hard");
170
+ expect(stripAnsi(formatFocus(defaultFocus, "pretty"))).toContain("10 skills");
171
+ });
172
+
173
+ test("formats weak spots from metadata-backed attempts", () => {
174
+ const attempts = new Map();
175
+ recordAttempt(attempts, "wic1", false, 50, new Date("2026-01-01T00:00:00.000Z"), sampleMeta);
176
+ recordAttempt(
177
+ attempts,
178
+ "ctc1",
179
+ true,
180
+ 20,
181
+ new Date("2026-01-02T00:00:00.000Z"),
182
+ { ...sampleMeta, questionId: "ctc1", skill_cd: "CTC", skill_desc: "Cross-Text Connections" },
183
+ );
184
+
185
+ expect(JSON.parse(formatWeak([...attempts.values()], "json"))[0]).toMatchObject({
186
+ skill: "WIC",
187
+ missed: 1,
188
+ total: 1,
189
+ accuracy: 0,
190
+ });
191
+ expect(formatWeak([...attempts.values()], "text")).toContain("WIC 0%");
192
+ expect(stripAnsi(formatWeak([...attempts.values()], "pretty"))).toContain("weak spots\nWIC has 1 misses");
193
+ });
194
+ });
195
+
196
+ function stripAnsi(value: string): string {
197
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
198
+ }
199
+
200
+ function attemptEvent(questionId: string, correct: boolean, answeredAt: string): AttemptEvent {
201
+ return {
202
+ question_id: questionId,
203
+ correct,
204
+ answered_at: answeredAt,
205
+ elapsed_seconds: 20,
206
+ difficulty: "M",
207
+ domain: "CAS",
208
+ domain_desc: "Craft and Structure",
209
+ skill: "WIC",
210
+ skill_desc: "Words in Context",
211
+ };
212
+ }
213
+
214
+ const sampleMeta: QuestionMeta = {
215
+ questionId: "wic1",
216
+ uId: "wic1",
217
+ external_id: "external-1",
218
+ difficulty: "M",
219
+ primary_class_cd: "CAS",
220
+ primary_class_cd_desc: "Craft and Structure",
221
+ skill_cd: "WIC",
222
+ skill_desc: "Words in Context",
223
+ };
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Frame, FrameRenderer, type FrameOutput, type TextAttr } from "../src/tui/frame.ts";
3
+
4
+ class RecordingOutput implements FrameOutput {
5
+ ops: string[] = [];
6
+
7
+ clear(): void {
8
+ this.ops.push("clear");
9
+ }
10
+
11
+ moveTo(x: number, y: number): void {
12
+ this.ops.push(`move:${x},${y}`);
13
+ }
14
+
15
+ eraseLineAfter(): void {
16
+ this.ops.push("erase");
17
+ }
18
+
19
+ reset(): void {
20
+ this.ops.push("reset");
21
+ }
22
+
23
+ write(value: string, attr: TextAttr = {}): void {
24
+ const color = typeof attr.color === "string" ? attr.color : "default";
25
+ const bold = attr.bold ? ":bold" : "";
26
+ this.ops.push(`write:${color}${bold}:${value}`);
27
+ }
28
+ }
29
+
30
+ describe("frame renderer", () => {
31
+ test("clips and truncates writes inside frame bounds", () => {
32
+ const frame = new Frame(6, 2);
33
+
34
+ frame.writeText(2, 0, "abcdef", { color: "cyan" }, 4);
35
+ frame.writeText(-1, 1, "xyz", { color: "red" });
36
+ frame.writeText(0, 5, "ignored");
37
+
38
+ expect(frame.rowRuns(0)).toEqual([{ x: 2, text: "abc…", attr: { color: "cyan" } }]);
39
+ expect(frame.rowRuns(1)).toEqual([{ x: 0, text: "yz", attr: { color: "red" } }]);
40
+ });
41
+
42
+ test("emits no operations when the next frame is unchanged", () => {
43
+ const output = new RecordingOutput();
44
+ const renderer = new FrameRenderer(output);
45
+ const frame = new Frame(10, 3);
46
+ frame.writeText(0, 0, "sat", { bold: true });
47
+
48
+ renderer.draw(frame);
49
+ output.ops = [];
50
+ renderer.draw(frame);
51
+
52
+ expect(output.ops).toEqual([]);
53
+ });
54
+
55
+ test("only repaints rows whose rendered content changed", () => {
56
+ const output = new RecordingOutput();
57
+ const renderer = new FrameRenderer(output);
58
+ const first = new Frame(20, 3);
59
+ first.writeText(0, 0, "sat", { bold: true });
60
+ first.writeText(0, 1, "----------", { color: "gray" });
61
+ renderer.draw(first);
62
+
63
+ output.ops = [];
64
+ const second = new Frame(20, 3);
65
+ second.writeText(0, 0, "sat", { bold: true });
66
+ second.writeText(0, 1, "----------", { color: "gray" });
67
+ second.writeText(7, 0, "time 0:02", { color: "gray" });
68
+ renderer.draw(second);
69
+
70
+ expect(output.ops).toEqual([
71
+ "move:1,1",
72
+ "reset",
73
+ "erase",
74
+ "move:1,1",
75
+ "write:default:bold:sat",
76
+ "move:8,1",
77
+ "write:gray:time 0:02",
78
+ "reset",
79
+ ]);
80
+ });
81
+
82
+ test("clears a row when content is removed", () => {
83
+ const output = new RecordingOutput();
84
+ const renderer = new FrameRenderer(output);
85
+ const first = new Frame(20, 2);
86
+ first.writeText(0, 0, "temporary");
87
+ renderer.draw(first);
88
+
89
+ output.ops = [];
90
+ renderer.draw(new Frame(20, 2));
91
+
92
+ expect(output.ops).toEqual(["move:1,1", "reset", "erase", "reset"]);
93
+ });
94
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { progressBarText } from "../src/progress.ts";
3
+
4
+ describe("progress", () => {
5
+ test("renders a block progress bar with empty cells", () => {
6
+ expect(progressBarText(0.5, 8)).toBe("████ ");
7
+ });
8
+
9
+ test("uses partial block cells for fractional progress", () => {
10
+ expect(progressBarText(0.3125, 8)).toBe("██▌ ");
11
+ });
12
+
13
+ test("clamps invalid ratios", () => {
14
+ expect(progressBarText(-1, 4)).toBe(" ");
15
+ expect(progressBarText(2, 4)).toBe("████");
16
+ expect(progressBarText(Number.NaN, 4)).toBe(" ");
17
+ });
18
+ });
@@ -6,6 +6,8 @@ import { defaultFocus, normalizeFocus } from "../src/focus.ts";
6
6
  import {
7
7
  buildSummaryRows,
8
8
  displayStateDir,
9
+ appendAttemptEvent,
10
+ loadAttemptEvents,
9
11
  loadAttempts,
10
12
  loadFocus,
11
13
  nextOutcome,
@@ -15,6 +17,7 @@ import {
15
17
  saveFocus,
16
18
  stateDirExists,
17
19
  } from "../src/state.ts";
20
+ import type { QuestionMeta } from "../src/types.ts";
18
21
 
19
22
  describe("state", () => {
20
23
  test("resolves state dir under the user home directory", () => {
@@ -43,12 +46,16 @@ describe("state", () => {
43
46
 
44
47
  try {
45
48
  const attempts = await loadAttempts(path);
46
- recordAttempt(attempts, "abc12345", false, 42, new Date("2026-01-01T00:00:00.000Z"));
49
+ recordAttempt(attempts, "abc12345", false, 42, new Date("2026-01-01T00:00:00.000Z"), sampleMeta);
47
50
  await saveAttempts(attempts, path);
48
51
 
49
52
  const raw = await readFile(path, "utf8");
50
53
  expect(raw).toBe(
51
- "question_id,outcome,updated_at,elapsed_seconds\nabc12345,incorrect,2026-01-01T00:00:00.000Z,42\n",
54
+ [
55
+ "question_id,outcome,updated_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc",
56
+ "abc12345,incorrect,2026-01-01T00:00:00.000Z,42,H,CAS,Craft and Structure,WIC,Words in Context",
57
+ "",
58
+ ].join("\n"),
52
59
  );
53
60
  expect(await loadAttempts(path)).toEqual(attempts);
54
61
  } finally {
@@ -71,7 +78,11 @@ describe("state", () => {
71
78
  try {
72
79
  await writeFile(
73
80
  path,
74
- "question_id,outcome,updated_at,elapsed_seconds\n\"abc,123\",correct,\"2026-01-01T00:00:00.000Z\",12\n",
81
+ [
82
+ "question_id,outcome,updated_at,elapsed_seconds,difficulty,domain,domain_desc,skill,skill_desc",
83
+ "\"abc,123\",correct,\"2026-01-01T00:00:00.000Z\",12,M,INI,\"Information, Ideas\",CID,Central Ideas",
84
+ "",
85
+ ].join("\n"),
75
86
  "utf8",
76
87
  );
77
88
 
@@ -81,6 +92,11 @@ describe("state", () => {
81
92
  outcome: "correct",
82
93
  updated_at: "2026-01-01T00:00:00.000Z",
83
94
  elapsed_seconds: 12,
95
+ difficulty: "M",
96
+ domain: "INI",
97
+ domain_desc: "Information, Ideas",
98
+ skill: "CID",
99
+ skill_desc: "Central Ideas",
84
100
  }],
85
101
  ]));
86
102
  } finally {
@@ -110,6 +126,49 @@ describe("state", () => {
110
126
  });
111
127
  });
112
128
 
129
+ test("appends and loads attempt events", async () => {
130
+ const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
131
+ const path = join(dir, "events.csv");
132
+
133
+ try {
134
+ await appendAttemptEvent(sampleMeta, true, 31, new Date("2026-01-01T12:00:00.000Z"), path);
135
+ await appendAttemptEvent(
136
+ { ...sampleMeta, questionId: "def67890", skill_cd: "CTC", skill_desc: "Cross-Text Connections" },
137
+ false,
138
+ 44,
139
+ new Date("2026-01-02T12:00:00.000Z"),
140
+ path,
141
+ );
142
+
143
+ expect(await loadAttemptEvents(path)).toEqual([
144
+ {
145
+ question_id: "abc12345",
146
+ correct: true,
147
+ answered_at: "2026-01-01T12:00:00.000Z",
148
+ elapsed_seconds: 31,
149
+ difficulty: "H",
150
+ domain: "CAS",
151
+ domain_desc: "Craft and Structure",
152
+ skill: "WIC",
153
+ skill_desc: "Words in Context",
154
+ },
155
+ {
156
+ question_id: "def67890",
157
+ correct: false,
158
+ answered_at: "2026-01-02T12:00:00.000Z",
159
+ elapsed_seconds: 44,
160
+ difficulty: "H",
161
+ domain: "CAS",
162
+ domain_desc: "Craft and Structure",
163
+ skill: "CTC",
164
+ skill_desc: "Cross-Text Connections",
165
+ },
166
+ ]);
167
+ } finally {
168
+ await rm(dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+
113
172
  test("normalizes invalid focus selections to valid defaults", () => {
114
173
  expect(normalizeFocus({ difficulties: [], domains: ["NOPE"], skills: ["CID"] })).toEqual({
115
174
  difficulties: defaultFocus.difficulties,
@@ -165,3 +224,14 @@ describe("state", () => {
165
224
  }
166
225
  });
167
226
  });
227
+
228
+ const sampleMeta: QuestionMeta = {
229
+ questionId: "abc12345",
230
+ uId: "abc12345",
231
+ external_id: "external-1",
232
+ difficulty: "H",
233
+ primary_class_cd: "CAS",
234
+ primary_class_cd_desc: "Craft and Structure",
235
+ skill_cd: "WIC",
236
+ skill_desc: "Words in Context",
237
+ };
@@ -1,110 +0,0 @@
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
- }