feat(import): utilities, media tooling, PyMuPDF helper, synthetic fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
10cf93c27e
commit
34f27d05c9
@@ -3,5 +3,6 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only"
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""PDF helper for the STCourses importer. Run as: python -I pdf_tool.py render <pdf> <outdir> [--width 1280] [--quality 80]
|
||||
|
||||
Writes outdir/p-001.jpg ... and outdir/text.json ([{"page": 1, "text": "..."}]).
|
||||
Prints one "page N/M" line per page on stdout and a final JSON line {"pages": M, "title": "..."}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import pymupdf as fitz
|
||||
except ImportError: # older PyMuPDF
|
||||
import fitz
|
||||
|
||||
|
||||
def render(pdf, outdir, width, quality):
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
doc = fitz.open(pdf)
|
||||
n = len(doc)
|
||||
texts = []
|
||||
for i, page in enumerate(doc, start=1):
|
||||
zoom = width / max(page.rect.width, 1)
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
|
||||
pix.save(os.path.join(outdir, "p-%03d.jpg" % i), jpg_quality=quality)
|
||||
texts.append({"page": i, "text": page.get_text().strip()})
|
||||
print("page %d/%d" % (i, n), flush=True)
|
||||
with open(os.path.join(outdir, "text.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(texts, f, ensure_ascii=False)
|
||||
print(json.dumps({"pages": n, "title": (doc.metadata or {}).get("title") or ""}), flush=True)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 4 or argv[1] != "render":
|
||||
print("usage: pdf_tool.py render <pdf> <outdir> [--width N] [--quality N]", file=sys.stderr)
|
||||
return 2
|
||||
width, quality = 1280, 80
|
||||
rest = argv[4:]
|
||||
while rest:
|
||||
flag, val = rest[0], rest[1] if len(rest) > 1 else ""
|
||||
if flag == "--width":
|
||||
width = int(val)
|
||||
elif flag == "--quality":
|
||||
quality = int(val)
|
||||
rest = rest[2:]
|
||||
render(argv[2], argv[3], width, quality)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,40 @@
|
||||
import { link, mkdir, copyFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { run } from "./util/exec";
|
||||
|
||||
export interface Tools { ffmpeg: string; ffprobe: string }
|
||||
|
||||
export async function probeDuration(t: Tools, file: string): Promise<number | undefined> {
|
||||
try {
|
||||
const out = await run([t.ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]);
|
||||
const s = Number(out.trim());
|
||||
return Number.isFinite(s) && s > 0 ? Math.round(s) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** One JPEG frame at `fraction` of the clip, 640px wide. */
|
||||
export async function extractFrame(t: Tools, file: string, out: string, fraction = 0.2): Promise<void> {
|
||||
await mkdir(dirname(out), { recursive: true });
|
||||
const dur = (await probeDuration(t, file)) ?? 0;
|
||||
const at = Math.max(0, dur * fraction);
|
||||
await run([t.ffmpeg, "-v", "error", "-y", "-ss", String(at), "-i", file, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "4", out]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a large file into the course. "copy" uses `cp --reflink=auto` (a CoW clone where the filesystem supports it,
|
||||
* a plain copy elsewhere); "link" makes a hard link and falls back to copying across filesystems.
|
||||
* Either way the original is only read, and the course folder stays self-contained (the media route refuses symlinks that leave it).
|
||||
*/
|
||||
export async function putFile(src: string, dest: string, mode: "copy" | "link" = "copy"): Promise<void> {
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
if (mode === "link") {
|
||||
try { await link(src, dest); return; } catch { /* cross-device or unsupported: copy */ }
|
||||
}
|
||||
try {
|
||||
await run(["cp", "--reflink=auto", "--", src, dest]);
|
||||
} catch {
|
||||
await copyFile(src, dest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from "node:path";
|
||||
import { run } from "./util/exec";
|
||||
|
||||
export interface DeckRender { pages: number; title: string }
|
||||
|
||||
const TOOL = join(import.meta.dir, "..", "py", "pdf_tool.py");
|
||||
|
||||
/**
|
||||
* Renders page images (p-001.jpg ...) and text.json into `outDir` with PyMuPDF in a subprocess,
|
||||
* so the server process never holds a PDF engine. `python` is STC_PYTHON (default python3).
|
||||
*/
|
||||
export async function renderPdf(python: string, pdf: string, outDir: string, opts: { onPage?: (page: number, total: number) => void; signal?: AbortSignal } = {}): Promise<DeckRender> {
|
||||
let last = "";
|
||||
await run([python, "-I", TOOL, "render", pdf, outDir], {
|
||||
signal: opts.signal,
|
||||
onLine(l) {
|
||||
const m = /^page (\d+)\/(\d+)$/.exec(l);
|
||||
if (m) opts.onPage?.(Number(m[1]), Number(m[2]));
|
||||
else if (l.trim()) last = l;
|
||||
},
|
||||
});
|
||||
return JSON.parse(last) as DeckRender;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Runs a command (no shell). Resolves with stdout; rejects with the stderr tail on a non-zero exit. */
|
||||
export async function run(cmd: string[], opts: { signal?: AbortSignal; env?: Record<string, string | undefined>; onLine?: (line: string) => void } = {}): Promise<string> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
stdout: "pipe", stderr: "pipe", stdin: "ignore",
|
||||
env: { ...process.env, LC_ALL: "C.UTF-8", ...opts.env } as Record<string, string>,
|
||||
signal: opts.signal,
|
||||
});
|
||||
const out = (async () => {
|
||||
let text = "";
|
||||
let pending = "";
|
||||
const dec = new TextDecoder();
|
||||
for await (const chunk of proc.stdout) {
|
||||
const s = dec.decode(chunk, { stream: true });
|
||||
text += s;
|
||||
if (opts.onLine) {
|
||||
pending += s;
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop()!;
|
||||
for (const l of lines) opts.onLine(l);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
})();
|
||||
const [stdout, stderr, code] = await Promise.all([out, new Response(proc.stderr).text(), proc.exited]);
|
||||
if (code !== 0) {
|
||||
const tail = stderr.trim().split("\n").slice(-6).join("\n");
|
||||
throw new Error(`${cmd[0]} exited with ${code}${tail ? `: ${tail}` : ""}`);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { ManifestSchema } from "@stc/course-format";
|
||||
|
||||
export type RawManifest = Record<string, any>;
|
||||
|
||||
export async function readRawManifest(courseDir: string): Promise<RawManifest> {
|
||||
return Bun.YAML.parse(await readFile(join(courseDir, "stcourse.yaml"), "utf8")) as RawManifest;
|
||||
}
|
||||
|
||||
/** Validates against the course-format schema, then writes stcourse.yaml via temp file + rename. */
|
||||
export async function writeManifest(courseDir: string, manifest: RawManifest): Promise<void> {
|
||||
const r = ManifestSchema.safeParse(manifest);
|
||||
if (!r.success) {
|
||||
throw new Error(`generated manifest is invalid: ${r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
||||
}
|
||||
const target = join(courseDir, "stcourse.yaml");
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, Bun.YAML.stringify(manifest, null, 2) + "\n");
|
||||
await rename(tmp, target);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export function slugify(s: string): string {
|
||||
const slug = s
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
return slug || "course";
|
||||
}
|
||||
|
||||
export function uniqueId(base: string, taken: (id: string) => boolean): string {
|
||||
if (!taken(base)) return base;
|
||||
for (let i = 2; ; i++) if (!taken(`${base}-${i}`)) return `${base}-${i}`;
|
||||
}
|
||||
|
||||
const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
||||
export function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
|
||||
if (e[0] === "#") {
|
||||
const code = e[1]!.toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
||||
}
|
||||
return NAMED[e.toLowerCase()] ?? m;
|
||||
});
|
||||
}
|
||||
|
||||
export function stripTags(s: string): string {
|
||||
return decodeEntities(s.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
||||
export function naturalSort(list: string[]): string[] {
|
||||
return [...list].sort((a, b) => collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Lowercase alphanumeric words, for loose name matching. */
|
||||
export function words(s: string): string[] {
|
||||
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
||||
}
|
||||
/** Share of the words of `a` that also occur in `b`. */
|
||||
export function containment(a: string, b: string): number {
|
||||
const wa = words(a);
|
||||
if (wa.length === 0) return 0;
|
||||
const wb = new Set(words(b));
|
||||
return wa.filter((w) => wb.has(w)).length / wa.length;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Tiny synthetic inputs for the importer tests. Nothing here is real course material.
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { run } from "../src/util/exec";
|
||||
|
||||
// PyMuPDF is needed for PDF tests: set STC_PYTHON, or this machine's venv is used when present.
|
||||
const VENV_PY = "/home/user/.venvs/pdf/bin/python";
|
||||
export const PYTHON = process.env.STC_PYTHON ?? (existsSync(VENV_PY) ? VENV_PY : "python3");
|
||||
|
||||
export async function tmp(prefix = "stc-imp-"): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export async function makeVideo(path: string, seconds = 2): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", `testsrc=duration=${seconds}:size=160x90:rate=10`, "-f", "lavfi", "-i", `sine=frequency=440:duration=${seconds}`,
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", path]);
|
||||
}
|
||||
|
||||
/** Minimal valid PDF with one line of text per page. */
|
||||
export function pdfBytes(pages: string[]): Uint8Array {
|
||||
const objs: string[] = [];
|
||||
const kids = pages.map((_, i) => `${4 + i * 2} 0 R`).join(" ");
|
||||
objs.push("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
objs.push(`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`);
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
||||
pages.forEach((text, i) => {
|
||||
const content = `BT /F1 24 Tf 40 150 Td (${text.replace(/[()\\]/g, "")}) Tj ET`;
|
||||
objs.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 200] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + i * 2} 0 R >>`);
|
||||
objs.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
|
||||
});
|
||||
let out = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objs.forEach((o, i) => { offsets.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
|
||||
const xref = out.length;
|
||||
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n` + offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
||||
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return new TextEncoder().encode(out);
|
||||
}
|
||||
|
||||
export async function makePdf(path: string, pages = ["First slide alpha", "Second slide beta"]): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, pdfBytes(pages));
|
||||
}
|
||||
|
||||
export async function zipDir(srcDir: string, zipPath: string): Promise<void> {
|
||||
await run([PYTHON, "-I", "-c",
|
||||
"import sys,os,zipfile\nsrc,out=sys.argv[1:3]\nz=zipfile.ZipFile(out,'w',zipfile.ZIP_STORED)\nfor r,d,f in os.walk(src):\n for n in f:\n p=os.path.join(r,n); z.write(p, os.path.relpath(p,src))\nz.close()",
|
||||
srcDir, zipPath]);
|
||||
}
|
||||
|
||||
const li = (t: string) => `<li>${t}</li>`;
|
||||
const clipHtml = (s: number, m: number, title: string) => `
|
||||
<li>
|
||||
<img src="images/image.jpg" class="image-holder"/>
|
||||
<h1>${s}.${m} ${title}</h1>
|
||||
<span>In this video, ${title}, you'll learn it.</span>
|
||||
<div id="video${s}_${m}" class="btn btn-small btn-primary watch" data-video="videos/video${s}_${m}.mp4" data-poster="images/videoImage.jpg">Watch Video</div>
|
||||
<a href="videos/video${s}_${m}.mp4" class="btn download">Download</a>
|
||||
</li>`;
|
||||
|
||||
export const PACKT_SECTIONS = [
|
||||
{ title: "Intro to Docker", clips: ["Course Introduction", "Docker & Friends"] },
|
||||
{ title: "Images (Sample)", clips: ["Topic Introduction - Images"] },
|
||||
];
|
||||
|
||||
/** Mimics the real package layout: summary lists without "Video N.M:" prefixes, then chapter blocks. */
|
||||
export function packtIndexHtml(opts: { dropChapter?: number } = {}): string {
|
||||
const summary = PACKT_SECTIONS.map((s, i) => `
|
||||
<li>
|
||||
<img src="images/image.jpg" class="image-holder"/>
|
||||
<h1>Section ${i + 1}: ${s.title}</h1>
|
||||
<p>Video ${i + 1}.1: ${s.clips[0]}</p>
|
||||
<ul>${s.clips.map(li).join("\n")}</ul>
|
||||
<div class="btn btn-primary browse" id="#content-${i + 1}">Browse Videos</div>
|
||||
</li>`).join("");
|
||||
const chapters = PACKT_SECTIONS.map((s, i) => i + 1 === opts.dropChapter ? "" : `
|
||||
<div class="chapter" id="content-${i + 1}" style="display: none;">
|
||||
<ul class="move">${s.clips.map((c, j) => clipHtml(i + 1, j + 1, c)).join("")}
|
||||
</ul>
|
||||
</div>`).join("");
|
||||
return `<!doctype html><html><head><title>Fake</title></head><body>
|
||||
<div class="intro"><div class="inner"><h1>Fake Docker Course</h1></div></div>
|
||||
<div id="content-main"><ul class="move">${summary}</ul>${chapters}</div></body></html>`;
|
||||
}
|
||||
|
||||
export async function makePacktTree(root: string): Promise<void> {
|
||||
await mkdir(join(root, "package", "videos"), { recursive: true });
|
||||
await writeFile(join(root, "package", "index.html"), packtIndexHtml());
|
||||
await writeFile(join(root, "Video Package_User Guide.pdf"), "not used");
|
||||
await makeVideo(join(root, "package/videos/video1_1.mp4"), 2);
|
||||
for (const n of ["video1_2", "video2_1"]) await Bun.write(join(root, "package/videos", `${n}.mp4`), Bun.file(join(root, "package/videos/video1_1.mp4")));
|
||||
}
|
||||
|
||||
export async function makePacktZip(dir: string): Promise<string> {
|
||||
const tree = join(dir, "packt-tree");
|
||||
await makePacktTree(tree);
|
||||
const zip = join(dir, "video_zip.zip");
|
||||
await zipDir(tree, zip);
|
||||
return zip;
|
||||
}
|
||||
|
||||
/** Companion repo with folders that have spaces; S01 has a deck and a cheat sheet, S02 has code. */
|
||||
export async function makeRepoTree(root: string): Promise<string> {
|
||||
const top = join(root, "Course-Repo-main");
|
||||
await makePdf(join(top, "S01 - Intro to Docker", "Slides_Intro.pdf"), ["Repo deck one", "Repo deck two", "Repo deck three"]);
|
||||
await makePdf(join(top, "S01 - Intro to Docker", "Docker_CheatSheet.pdf"), ["cheats"]);
|
||||
await mkdir(join(top, "S02 - Images (Sample)", "app"), { recursive: true });
|
||||
await writeFile(join(top, "S02 - Images (Sample)", "app", "main.py"), "print('hi')\n");
|
||||
await writeFile(join(top, "S02 - Images (Sample)", "Ünïcode notes.txt"), "notes\n");
|
||||
await mkdir(join(top, "Bonus Extras"), { recursive: true });
|
||||
await writeFile(join(top, "Bonus Extras", "x.txt"), "x");
|
||||
await writeFile(join(top, "README.md"), "# repo\n");
|
||||
return top;
|
||||
}
|
||||
|
||||
export async function makeRepoZip(dir: string): Promise<string> {
|
||||
const tree = join(dir, "repo-tree");
|
||||
await makeRepoTree(tree);
|
||||
const zip = join(dir, "repo.zip");
|
||||
await zipDir(tree, zip);
|
||||
return zip;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { extractFrame, probeDuration, putFile } from "../src/media";
|
||||
import { renderPdf } from "../src/pdf";
|
||||
import { makePdf, makeVideo, PYTHON, tmp } from "./fixtures";
|
||||
|
||||
const tools = { ffmpeg: "ffmpeg", ffprobe: "ffprobe" };
|
||||
|
||||
test("duration and frame extraction on a tiny clip", async () => {
|
||||
const d = await tmp();
|
||||
await makeVideo(join(d, "a.mp4"), 2);
|
||||
expect(await probeDuration(tools, join(d, "a.mp4"))).toBe(2);
|
||||
await extractFrame(tools, join(d, "a.mp4"), join(d, "out", "f.jpg"));
|
||||
expect((await stat(join(d, "out", "f.jpg"))).size).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("putFile copies and links without touching the original", async () => {
|
||||
const d = await tmp();
|
||||
await makeVideo(join(d, "sp ace", "ö.mp4"), 1);
|
||||
const before = await readFile(join(d, "sp ace", "ö.mp4"));
|
||||
await putFile(join(d, "sp ace", "ö.mp4"), join(d, "c", "x.mp4"), "copy");
|
||||
await putFile(join(d, "sp ace", "ö.mp4"), join(d, "l", "x.mp4"), "link");
|
||||
expect(Buffer.compare(await readFile(join(d, "c", "x.mp4")), before)).toBe(0);
|
||||
expect((await stat(join(d, "l", "x.mp4"))).ino).toBe((await stat(join(d, "sp ace", "ö.mp4"))).ino);
|
||||
});
|
||||
|
||||
test("renderPdf writes page images and text", async () => {
|
||||
const d = await tmp();
|
||||
await makePdf(join(d, "deck.pdf"), ["Hello alpha", "Second beta"]);
|
||||
const seen: number[] = [];
|
||||
const r = await renderPdf(PYTHON, join(d, "deck.pdf"), join(d, "out"), { onPage: (p) => seen.push(p) });
|
||||
expect(r.pages).toBe(2);
|
||||
expect(seen).toEqual([1, 2]);
|
||||
expect((await stat(join(d, "out", "p-002.jpg"))).size).toBeGreaterThan(100);
|
||||
const text = JSON.parse(await readFile(join(d, "out", "text.json"), "utf8"));
|
||||
expect(text[0].text).toContain("Hello alpha");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { decodeEntities, naturalSort, slugify, uniqueId } from "../src/util/text";
|
||||
import { run } from "../src/util/exec";
|
||||
import { readRawManifest, writeManifest } from "../src/util/manifest";
|
||||
|
||||
test("slugify folds accents and falls back", () => {
|
||||
expect(slugify("Mastering Docker Essentials - Hands-on DevOps!")).toBe("mastering-docker-essentials-hands-on-devops");
|
||||
expect(slugify("Café Ünïcode")).toBe("cafe-unicode");
|
||||
expect(slugify("日本語")).toBe("course");
|
||||
});
|
||||
|
||||
test("uniqueId appends a counter", () => {
|
||||
expect(uniqueId("a", (x) => x === "a" || x === "a-2")).toBe("a-3");
|
||||
expect(uniqueId("a", () => false)).toBe("a");
|
||||
});
|
||||
|
||||
test("decodeEntities handles named and numeric entities", () => {
|
||||
expect(decodeEntities("you'll & <b> "x" A")).toBe(`you'll & <b> "x" A`);
|
||||
});
|
||||
|
||||
test("naturalSort orders numbers numerically", () => {
|
||||
expect(naturalSort(["10 b.mp4", "2 a.mp4", "1 z.mp4"])).toEqual(["1 z.mp4", "2 a.mp4", "10 b.mp4"]);
|
||||
});
|
||||
|
||||
test("run reports stderr on failure", async () => {
|
||||
expect(await run(["echo", "hi"])).toContain("hi");
|
||||
await expect(run(["sh", "-c", "echo oops >&2; exit 3"])).rejects.toThrow(/oops/);
|
||||
});
|
||||
|
||||
test("writeManifest validates and round-trips", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "stc-m-"));
|
||||
await mkdir(join(dir, "c"));
|
||||
const m = { schema: 1, id: "x", title: "T: with colon", visibility: "restricted", modules: [{ id: "m1", title: "S08 - Foo", lessons: [] }] };
|
||||
await writeManifest(join(dir, "c"), m);
|
||||
expect((await readRawManifest(join(dir, "c")) as any).modules[0].title).toBe("S08 - Foo");
|
||||
await expect(writeManifest(join(dir, "c"), { ...m, id: "BAD ID" })).rejects.toThrow(/id/);
|
||||
await writeFile(join(dir, "c", "marker"), "");
|
||||
});
|
||||
Reference in New Issue
Block a user