saterminal 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "saterminal",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "bin": {
6
- "sat": "./src/main.ts"
6
+ "sat": "src/main.ts"
7
7
  },
8
+ "files": [
9
+ "src"
10
+ ],
8
11
  "scripts": {
9
12
  "dev": "bun run src/main.ts",
10
13
  "start": "bun run src/main.ts",
package/src/api.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { defaultFocus, domainsForSkills } from "./focus.ts";
2
2
  import type { Focus, PracticeQuestion, QuestionDetail, QuestionMeta } from "./types.ts";
3
3
 
4
- const baseUrl = "https://practicesat.vercel.app/api";
4
+ const baseUrl = "https://mysatprep.fun/api";
5
5
 
6
6
  type ApiEnvelope<T> = {
7
7
  success: boolean;
8
8
  data: T;
9
+ error?: string;
9
10
  message?: string;
10
11
  };
11
12
 
@@ -18,7 +19,7 @@ export async function fetchQuestionBank(excludeIds: Iterable<string> = [], focus
18
19
 
19
20
  const response = await fetchJson<ApiEnvelope<QuestionMeta[]>>(`${baseUrl}/get-questions?${params}`);
20
21
  if (!response.success || !Array.isArray(response.data)) {
21
- throw new Error(response.message || "Question bank fetch failed.");
22
+ throw new Error(response.error || response.message || "Question bank fetch failed.");
22
23
  }
23
24
 
24
25
  return response.data.filter((item) => item.questionId && item.external_id);
@@ -27,7 +28,7 @@ export async function fetchQuestionBank(excludeIds: Iterable<string> = [], focus
27
28
  export async function fetchQuestion(id: string): Promise<QuestionDetail> {
28
29
  const response = await fetchJson<ApiEnvelope<QuestionDetail>>(`${baseUrl}/question/${id}`);
29
30
  if (!response.success || !response.data) {
30
- throw new Error(response.message || `Question ${id} fetch failed.`);
31
+ throw new Error(response.error || response.message || `Question ${id} fetch failed.`);
31
32
  }
32
33
 
33
34
  return response.data;
@@ -69,10 +70,44 @@ async function fetchJson<T>(url: string): Promise<T> {
69
70
  accept: "application/json",
70
71
  },
71
72
  });
73
+ const body = await response.text();
74
+ const contentType = response.headers.get("content-type") ?? "unknown content type";
72
75
 
73
76
  if (!response.ok) {
74
- throw new Error(`${response.status} ${response.statusText}`);
77
+ throw new Error(`${response.status} ${response.statusText}: ${responseError(body)}`);
75
78
  }
76
79
 
77
- return (await response.json()) as T;
80
+ if (!contentType.toLowerCase().includes("application/json")) {
81
+ throw new Error(`Expected JSON from ${url}, got ${contentType}: ${snippet(body)}`);
82
+ }
83
+
84
+ try {
85
+ return JSON.parse(body) as T;
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ throw new Error(`Invalid JSON from ${url}: ${message}. Response: ${snippet(body)}`);
89
+ }
90
+ }
91
+
92
+ function responseError(body: string): string {
93
+ try {
94
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown; details?: unknown };
95
+ const message = parsed.error || parsed.message || parsed.details;
96
+ if (typeof message === "string" && message.trim()) {
97
+ return message;
98
+ }
99
+ } catch {
100
+ // Fall back to the raw body below.
101
+ }
102
+
103
+ return snippet(body);
104
+ }
105
+
106
+ function snippet(value: string): string {
107
+ const normalized = value.replace(/\s+/g, " ").trim();
108
+ if (!normalized) {
109
+ return "empty response";
110
+ }
111
+
112
+ return normalized.length > 160 ? `${normalized.slice(0, 157)}...` : normalized;
78
113
  }
package/src/cli.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { difficultyLabels, domainLabels, focusSummary, skillLabels } from "./focus.ts";
2
3
  import { progressBarText } from "./progress.ts";
3
4
  import { buildSummaryRows, loadAttemptEvents, loadAttempts, loadFocus } from "./state.ts";
@@ -16,6 +17,7 @@ export type HistoryFilters = {
16
17
  export type ParsedCli =
17
18
  | { kind: "tui" }
18
19
  | { kind: "review" }
20
+ | { kind: "version" }
19
21
  | { kind: "help" }
20
22
  | { kind: "error"; message: string }
21
23
  | {
@@ -107,6 +109,10 @@ export function parseArgs(args: string[]): ParsedCli {
107
109
  return { kind: "help" };
108
110
  }
109
111
 
112
+ if (arg === "-V" || arg === "--version") {
113
+ return { kind: "version" };
114
+ }
115
+
110
116
  if (arg === "-p" || arg === "--pretty") {
111
117
  pretty = true;
112
118
  continue;
@@ -222,6 +228,11 @@ export async function runCliCommand(
222
228
  return 0;
223
229
  }
224
230
 
231
+ if (parsed.kind === "version") {
232
+ stdout.write(`sat ${await packageVersion()}\n`);
233
+ return 0;
234
+ }
235
+
225
236
  if (parsed.kind === "error") {
226
237
  stderr.write(`${parsed.message}\n\n${helpText()}\n`);
227
238
  return 1;
@@ -392,12 +403,19 @@ export function helpText(): string {
392
403
  " -p, --pretty Use colorized human-readable output",
393
404
  " --json Output JSON",
394
405
  " --no-color Disable ANSI color in pretty output",
406
+ " -V, --version Show version",
395
407
  " -h, --help Show this help",
396
408
  "",
397
409
  "Run `sat` with no command to open the interactive TUI.",
398
410
  ].join("\n");
399
411
  }
400
412
 
413
+ async function packageVersion(): Promise<string> {
414
+ const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf8");
415
+ const parsed = JSON.parse(packageJson) as { version?: unknown };
416
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : "unknown";
417
+ }
418
+
401
419
  export function filterHistory(attempts: Attempt[], filters: HistoryFilters = {}, now = new Date()): Attempt[] {
402
420
  let rows = [...attempts].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
403
421
 
@@ -30,11 +30,11 @@ export function questionRows(detail: QuestionDetail, width: number): DisplayRow[
30
30
  }
31
31
 
32
32
  export function practiceQuestionUrl(question: PracticeQuestion): string {
33
- return `https://practicesat.vercel.app/question?questionId=${encodeURIComponent(question.meta.questionId)}`;
33
+ return `https://mysatprep.fun/question/${encodeURIComponent(question.meta.questionId)}`;
34
34
  }
35
35
 
36
36
  export function practiceQuestionApiUrl(question: PracticeQuestion): string {
37
- return `https://practicesat.vercel.app/api/question/${encodeURIComponent(question.meta.external_id)}`;
37
+ return `https://mysatprep.fun/api/question/${encodeURIComponent(question.meta.external_id)}`;
38
38
  }
39
39
 
40
40
  export function openExternalQuestion(question: PracticeQuestion): void {
package/.envrc DELETED
@@ -1 +0,0 @@
1
- use flake
package/AGENTS.md DELETED
@@ -1,37 +0,0 @@
1
- # Environment
2
-
3
- I use Nix & `direnv`. Glance `flake.nix` initially at once.
4
-
5
- # Git
6
-
7
- - Commit regularly.
8
- - Prefer subsystem-first commit subjects over Conventional Commits.
9
- - Use `subsystem: concise change`.
10
- - Do not default to `feat(...)`, `fix(...)`, `chore(...)`, etc.
11
- - Pick the subsystem people would search for later: `cli`, `state`, `tui`, `api`, `docs`, `nix`, `test`, or a narrower module when useful.
12
- - Keep messages compact.
13
- - A good small commit can be only a subject line.
14
- - Add a body when the reason, tradeoff, or implementation detail matters.
15
- - Body bullets should explain why/how, not repeat the subject.
16
- - Prefer backticks for paths, commands, package names, settings, and other literal identifiers when they improve scanability.
17
-
18
- ```text
19
- cli: render activity as square heatmap
20
-
21
- - use one colored square per day instead of intensity glyphs
22
- - keep empty days visible as gray cells
23
- - orient the grid by week columns and weekday rows
24
- ```
25
-
26
- ```text
27
- state: record attempt events
28
-
29
- - keep `attempts.csv` as the latest per-question snapshot
30
- - append every answer to `events.csv` for streaks and activity
31
- ```
32
-
33
- ```text
34
- docs: tighten commit guidance
35
- ```
36
-
37
- - Do not force a three-bullet format. Use fewer bullets, more bullets, or none based on the commit.
package/bun.lock DELETED
@@ -1,100 +0,0 @@
1
- {
2
- "lockfileVersion": 1,
3
- "configVersion": 1,
4
- "workspaces": {
5
- "": {
6
- "name": "saterminal",
7
- "dependencies": {
8
- "he": "^1.2.0",
9
- "node-html-parser": "^8.0.4",
10
- "terminal-kit": "^3.1.2",
11
- "word-wrap": "^1.2.5",
12
- },
13
- "devDependencies": {
14
- "@types/bun": "^1.2.22",
15
- "@types/he": "^1.2.3",
16
- "@types/word-wrap": "^1.2.1",
17
- "typescript": "^5.9.3",
18
- },
19
- },
20
- },
21
- "packages": {
22
- "@cronvel/get-pixels": ["@cronvel/get-pixels@3.4.1", "", { "dependencies": { "jpeg-js": "^0.4.4", "ndarray": "^1.0.19", "ndarray-pack": "^1.1.1", "node-bitmap": "0.0.1", "omggif": "^1.0.10", "pngjs": "^6.0.0" } }, "sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g=="],
23
-
24
- "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
25
-
26
- "@types/he": ["@types/he@1.2.3", "", {}, "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="],
27
-
28
- "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
29
-
30
- "@types/word-wrap": ["@types/word-wrap@1.2.1", "", { "dependencies": { "word-wrap": "*" } }, "sha512-McFkme6x07Y1LCGT5YJW1VN5+NTGcNe4IrJwKuQLObdxEIh57SIU+lIo2cF1/Z0XDJrjtXRoynn4HrvS6pO/8A=="],
31
-
32
- "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
33
-
34
- "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
35
-
36
- "chroma-js": ["chroma-js@2.6.0", "", {}, "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A=="],
37
-
38
- "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
39
-
40
- "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
41
-
42
- "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="],
43
-
44
- "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
45
-
46
- "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
47
-
48
- "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
49
-
50
- "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
51
-
52
- "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
53
-
54
- "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
55
-
56
- "iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
57
-
58
- "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
59
-
60
- "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
61
-
62
- "lazyness": ["lazyness@1.2.0", "", {}, "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g=="],
63
-
64
- "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
65
-
66
- "ndarray-pack": ["ndarray-pack@1.2.1", "", { "dependencies": { "cwise-compiler": "^1.1.2", "ndarray": "^1.0.13" } }, "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g=="],
67
-
68
- "nextgen-events": ["nextgen-events@1.5.3", "", {}, "sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA=="],
69
-
70
- "node-bitmap": ["node-bitmap@0.0.1", "", {}, "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA=="],
71
-
72
- "node-html-parser": ["node-html-parser@8.0.4", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-w2YxujN/TqrSIWGVNW/fVgKKfAyQeHMXCvnKZI0owLnfP0tfjdLecUoy9zhOMZGoEFk6eP5qSDyvUarjPl3bwQ=="],
73
-
74
- "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
75
-
76
- "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
77
-
78
- "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
79
-
80
- "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
81
-
82
- "seventh": ["seventh@0.9.4", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-O85mosi4sOfxG+slvqy0j7zLuFD4ylUgEMt7Pvt9Q/wnwNwG/6MNnHKzV9JkAoPoPM26t/DLFn17p7o7u5kIBA=="],
83
-
84
- "string-kit": ["string-kit@0.19.5", "", {}, "sha512-MqNWtQQw6xMFbOSa26HfyS5uyDu1tR4OEPz44VDP6JWa/S0E0N11uw8CXrPt5hme/tBXRp5AmoIvFl5B9lt7PA=="],
85
-
86
- "terminal-kit": ["terminal-kit@3.1.3", "", { "dependencies": { "@cronvel/get-pixels": "^3.4.1", "chroma-js": "^2.4.2", "lazyness": "^1.2.0", "ndarray": "^1.0.19", "nextgen-events": "^1.5.3", "seventh": "^0.9.4", "string-kit": "^0.19.5", "tree-kit": "^0.8.10" } }, "sha512-URPwQqXe/T5dZoD4qBHUO7eS+Vtf0PjliCftJU2EPaF5uVw/QG1zqgLy5kqwTrn1ix9e9HtMgMKAnzgaAnr3yA=="],
87
-
88
- "tree-kit": ["tree-kit@0.8.10", "", {}, "sha512-mwuV3lHL+utI9z7vxah/27wrMJprx925xhkw1N4KuGa1dqIi0DLHWfXJpHEyR+ZI0Ij80zN58ztiGp3KlH0wtw=="],
89
-
90
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
91
-
92
- "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
93
-
94
- "uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="],
95
-
96
- "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
97
-
98
- "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
99
- }
100
- }
package/flake.lock DELETED
@@ -1,59 +0,0 @@
1
- {
2
- "nodes": {
3
- "flake-utils": {
4
- "inputs": {
5
- "systems": "systems"
6
- },
7
- "locked": {
8
- "lastModified": 1731533236,
9
- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
- "owner": "numtide",
11
- "repo": "flake-utils",
12
- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
- "type": "github"
14
- },
15
- "original": {
16
- "owner": "numtide",
17
- "repo": "flake-utils",
18
- "type": "github"
19
- }
20
- },
21
- "nixpkgs": {
22
- "locked": {
23
- "lastModified": 1781577229,
24
- "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
25
- "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
26
- "revCount": 1017464,
27
- "type": "tarball",
28
- "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nixpkgs-weekly/0.1.1017464%2Brev-567a49d1913ce81ac6e9582e3553dd90a955875f/019f1749-32d6-7283-9e9a-861bd8478e6a/source.tar.gz"
29
- },
30
- "original": {
31
- "type": "tarball",
32
- "url": "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.tar.gz"
33
- }
34
- },
35
- "root": {
36
- "inputs": {
37
- "flake-utils": "flake-utils",
38
- "nixpkgs": "nixpkgs"
39
- }
40
- },
41
- "systems": {
42
- "locked": {
43
- "lastModified": 1681028828,
44
- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
45
- "owner": "nix-systems",
46
- "repo": "default",
47
- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
48
- "type": "github"
49
- },
50
- "original": {
51
- "owner": "nix-systems",
52
- "repo": "default",
53
- "type": "github"
54
- }
55
- }
56
- },
57
- "root": "root",
58
- "version": 7
59
- }
package/flake.nix DELETED
@@ -1,21 +0,0 @@
1
- {
2
- inputs = {
3
- nixpkgs.url = "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.tar.gz";
4
- flake-utils.url = "github:numtide/flake-utils";
5
- };
6
-
7
- outputs = { nixpkgs, flake-utils, ... }:
8
- flake-utils.lib.eachDefaultSystem (
9
- system:
10
- let
11
- pkgs = import nixpkgs { inherit system; };
12
- in
13
- {
14
- devShells.default = pkgs.mkShell {
15
- packages = with pkgs; [
16
- bun
17
- ];
18
- };
19
- }
20
- );
21
- }
package/test/api.test.ts DELETED
@@ -1,80 +0,0 @@
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
- });
package/test/cli.test.ts DELETED
@@ -1,223 +0,0 @@
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
- };
@@ -1,55 +0,0 @@
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
- });
@@ -1,94 +0,0 @@
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
- });
@@ -1,18 +0,0 @@
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
- });
@@ -1,237 +0,0 @@
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
- displayStateDir,
9
- appendAttemptEvent,
10
- loadAttemptEvents,
11
- loadAttempts,
12
- loadFocus,
13
- nextOutcome,
14
- recordAttempt,
15
- resolveStateDir,
16
- saveAttempts,
17
- saveFocus,
18
- stateDirExists,
19
- } from "../src/state.ts";
20
- import type { QuestionMeta } from "../src/types.ts";
21
-
22
- describe("state", () => {
23
- test("resolves state dir under the user home directory", () => {
24
- expect(resolveStateDir("/home/user")).toBe("/home/user/.saterminal/userlocal");
25
- });
26
-
27
- test("displays state dir with a tilde prefix", () => {
28
- expect(displayStateDir("/home/user/.saterminal/userlocal", "/home/user")).toBe("~/.saterminal/userlocal");
29
- expect(displayStateDir("/home/user", "/home/user")).toBe("~");
30
- });
31
-
32
- test("detects whether the state directory exists", async () => {
33
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
34
-
35
- try {
36
- expect(await stateDirExists(dir)).toBe(true);
37
- expect(await stateDirExists(join(dir, "missing"))).toBe(false);
38
- } finally {
39
- await rm(dir, { recursive: true, force: true });
40
- }
41
- });
42
-
43
- test("creates and reads a compact attempts csv", async () => {
44
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
45
- const path = join(dir, "attempts.csv");
46
-
47
- try {
48
- const attempts = await loadAttempts(path);
49
- recordAttempt(attempts, "abc12345", false, 42, new Date("2026-01-01T00:00:00.000Z"), sampleMeta);
50
- await saveAttempts(attempts, path);
51
-
52
- const raw = await readFile(path, "utf8");
53
- expect(raw).toBe(
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"),
59
- );
60
- expect(await loadAttempts(path)).toEqual(attempts);
61
- } finally {
62
- await rm(dir, { recursive: true, force: true });
63
- }
64
- });
65
-
66
- test("uses corrected for a later right answer after a miss", () => {
67
- const attempts = new Map();
68
- recordAttempt(attempts, "abc12345", false, 12, new Date("2026-01-01T00:00:00.000Z"));
69
- recordAttempt(attempts, "abc12345", true, 10, new Date("2026-01-02T00:00:00.000Z"));
70
-
71
- expect(attempts.get("abc12345")?.outcome).toBe("corrected");
72
- });
73
-
74
- test("loads escaped csv fields", async () => {
75
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
76
- const path = join(dir, "attempts.csv");
77
-
78
- try {
79
- await writeFile(
80
- path,
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"),
86
- "utf8",
87
- );
88
-
89
- expect(await loadAttempts(path)).toEqual(new Map([
90
- ["abc,123", {
91
- question_id: "abc,123",
92
- outcome: "correct",
93
- updated_at: "2026-01-01T00:00:00.000Z",
94
- elapsed_seconds: 12,
95
- difficulty: "M",
96
- domain: "INI",
97
- domain_desc: "Information, Ideas",
98
- skill: "CID",
99
- skill_desc: "Central Ideas",
100
- }],
101
- ]));
102
- } finally {
103
- await rm(dir, { recursive: true, force: true });
104
- }
105
- });
106
-
107
- test("does not downgrade mastered outcomes", () => {
108
- expect(nextOutcome("correct", false)).toBe("correct");
109
- expect(nextOutcome("corrected", false)).toBe("corrected");
110
- });
111
-
112
- test("builds summary rows from attempts", () => {
113
- const attempts = new Map();
114
- recordAttempt(attempts, "a", true, 20, new Date("2026-01-01T00:00:00.000Z"));
115
- recordAttempt(attempts, "b", false, 10, new Date("2026-01-01T00:00:00.000Z"));
116
- recordAttempt(attempts, "b", true, 40, new Date("2026-01-02T00:00:00.000Z"));
117
-
118
- const rows = buildSummaryRows(attempts, new Date("2026-01-03T00:00:00.000Z"));
119
- expect(Object.fromEntries(rows.map((row) => [row.metric, row.value]))).toEqual({
120
- answered: "2",
121
- correct: "1",
122
- incorrect: "0",
123
- corrected: "1",
124
- accuracy: "1.00",
125
- avg_seconds: "30.0",
126
- });
127
- });
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
-
172
- test("normalizes invalid focus selections to valid defaults", () => {
173
- expect(normalizeFocus({ difficulties: [], domains: ["NOPE"], skills: ["CID"] })).toEqual({
174
- difficulties: defaultFocus.difficulties,
175
- domains: ["INI"],
176
- skills: ["CID"],
177
- });
178
- });
179
-
180
- test("derives focus domains from selected skills", () => {
181
- expect(normalizeFocus({ difficulties: ["H"], domains: ["INI"], skills: ["WIC"] })).toEqual({
182
- difficulties: ["H"],
183
- domains: ["CAS"],
184
- skills: ["WIC"],
185
- });
186
- });
187
-
188
- test("creates default focus when file is missing", async () => {
189
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
190
- const path = join(dir, "focus.json");
191
-
192
- try {
193
- expect(await loadFocus(path)).toEqual(defaultFocus);
194
- expect(await readFile(path, "utf8")).toContain("\"difficulties\"");
195
- } finally {
196
- await rm(dir, { recursive: true, force: true });
197
- }
198
- });
199
-
200
- test("saves and loads focus json", async () => {
201
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
202
- const path = join(dir, "focus.json");
203
-
204
- try {
205
- await saveFocus({ difficulties: ["H"], domains: ["SEC"], skills: ["BOU", "FSS"] }, path);
206
- expect(await loadFocus(path)).toEqual({ difficulties: ["H"], domains: ["SEC"], skills: ["BOU", "FSS"] });
207
-
208
- await writeFile(path, "{\"difficulties\":[],\"domains\":[],\"skills\":[]}", "utf8");
209
- expect(await loadFocus(path)).toEqual(defaultFocus);
210
- } finally {
211
- await rm(dir, { recursive: true, force: true });
212
- }
213
- });
214
-
215
- test("throws when focus json is invalid", async () => {
216
- const dir = await mkdtemp(join(tmpdir(), "saterminal-"));
217
- const path = join(dir, "focus.json");
218
-
219
- try {
220
- await writeFile(path, "{not json", "utf8");
221
- await expect(loadFocus(path)).rejects.toThrow(/Invalid focus file/);
222
- } finally {
223
- await rm(dir, { recursive: true, force: true });
224
- }
225
- });
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
- };
package/test/text.test.ts DELETED
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { hasHtmlTable, htmlToText, parseHtmlSegments, wrapSegments, wrapText } from "../src/text.ts";
3
-
4
- describe("text", () => {
5
- test("converts the API html subset into terminal text", () => {
6
- expect(htmlToText("<p><strong>Text 1</strong></p><p>Alice&rsquo;s claim&mdash;briefly.</p>")).toBe(
7
- "Text 1\nAlice's claim-briefly.",
8
- );
9
- });
10
-
11
- test("uses media labels instead of leaking svg internals", () => {
12
- expect(
13
- htmlToText(
14
- '<figure><svg aria-label="Line graph titled Exports"><text>90807060</text></svg></figure><p>Read the graph.</p>',
15
- ),
16
- ).toBe("[Graph: Line graph titled Exports]\nRead the graph.");
17
- });
18
-
19
- test("keeps image alt text visible", () => {
20
- expect(htmlToText('<p><img alt="Triangle ABC" src="/triangle.png"></p>')).toBe("[Image: Triangle ABC]");
21
- });
22
-
23
- test("detects html tables", () => {
24
- expect(hasHtmlTable("<p>Text</p>", "<table><tr><td>1</td></tr></table>")).toBe(true);
25
- expect(hasHtmlTable("<p>Text</p>")).toBe(false);
26
- });
27
-
28
- test("normalizes SAT blank markers", () => {
29
- expect(htmlToText("<p>The answer is ______blank because of the data.</p>")).toBe(
30
- "The answer is _______ because of the data.",
31
- );
32
- });
33
-
34
- test("wraps text without dropping words", () => {
35
- expect(wrapText("one two three four", 8)).toEqual(["one two", "three", "four"]);
36
- });
37
-
38
- test("preserves underline segments from u tags", () => {
39
- const segments = parseHtmlSegments("<p>Before <u>underlined claim</u> after.</p>");
40
- expect(segments.some((segment) => segment.style.underline && segment.text.includes("underlined claim"))).toBe(true);
41
- expect(htmlToText("<p>Before <u>underlined claim</u> after.</p>")).toBe("Before underlined claim after.");
42
- });
43
-
44
- test("normalizes API blank spans in parsed segments", () => {
45
- const html =
46
- '<p>The answer is <span aria-hidden="true">______</span><span class="sr-only">blank</span> because of the data.</p>';
47
- const text = parseHtmlSegments(html)
48
- .map((segment) => segment.text)
49
- .join("")
50
- .trim();
51
- expect(text).toBe("The answer is _______ because of the data.");
52
- });
53
-
54
- test("wraps styled segments without losing underline spans", () => {
55
- const segments = parseHtmlSegments("<u>This insightful depiction of a preteen girl</u>");
56
- const lines = wrapSegments(segments, 20);
57
- expect(lines.flat().some((segment) => segment.style.underline)).toBe(true);
58
- });
59
-
60
- test("collapses spacer paragraphs and decodes accented entities in dual-text stimuli", () => {
61
- const html = `<p><strong><span role="heading">Text 1</span></strong></p>
62
- <p>the object that struck the Yucat&aacute;n Peninsula</p>
63
- <p>&nbsp;</p>
64
- <p><strong><span role="heading">Text 2</span></strong></p>
65
- <p>Artemieva argues that an asteroid is plausible.</p>`;
66
- const lines = wrapSegments(parseHtmlSegments(html), 40).map((line) => line.map((segment) => segment.text).join(""));
67
- const blankLines = lines.filter((line) => !line.trim()).length;
68
- expect(blankLines).toBeLessThanOrEqual(2);
69
- expect(lines.join("\n")).toContain("Yucatán");
70
- expect(lines.join("\n")).not.toContain("&aacute;");
71
- });
72
- });
@@ -1,39 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- clampScroll,
4
- ensureRangeVisible,
5
- ensureRowVisible,
6
- maxScroll,
7
- scrollBy,
8
- scrollPage,
9
- scrollToEdge,
10
- } from "../src/tui/viewport.ts";
11
-
12
- describe("viewport", () => {
13
- test("clamps scroll to the available content", () => {
14
- expect(maxScroll({ scroll: 0, height: 5, contentRows: 12 })).toBe(7);
15
- expect(clampScroll({ scroll: -3, height: 5, contentRows: 12 })).toBe(0);
16
- expect(clampScroll({ scroll: 20, height: 5, contentRows: 12 })).toBe(7);
17
- expect(clampScroll({ scroll: 20, height: 5, contentRows: 3 })).toBe(0);
18
- });
19
-
20
- test("scrolls by lines and pages", () => {
21
- const viewport = { scroll: 4, height: 5, contentRows: 20 };
22
-
23
- expect(scrollBy(viewport, 2)).toBe(6);
24
- expect(scrollBy(viewport, -10)).toBe(0);
25
- expect(scrollPage(viewport, 1)).toBe(8);
26
- expect(scrollPage(viewport, -1)).toBe(0);
27
- expect(scrollToEdge(viewport, "bottom")).toBe(15);
28
- });
29
-
30
- test("keeps a selected row or wrapped range visible", () => {
31
- const viewport = { scroll: 4, height: 5, contentRows: 20 };
32
-
33
- expect(ensureRowVisible(viewport, 3)).toBe(3);
34
- expect(ensureRowVisible(viewport, 8)).toBe(4);
35
- expect(ensureRowVisible(viewport, 12)).toBe(8);
36
- expect(ensureRangeVisible(viewport, 11, 13)).toBe(9);
37
- expect(ensureRangeVisible(viewport, 5, 7)).toBe(4);
38
- });
39
- });
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "Bundler",
6
- "strict": true,
7
- "skipLibCheck": true,
8
- "types": ["bun"],
9
- "allowImportingTsExtensions": true,
10
- "noEmit": true
11
- },
12
- "include": ["src/**/*.ts", "src/**/*.d.ts", "test/**/*.ts"]
13
- }