@littlebigbrain/create 0.2.0 → 0.4.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.
- package/README.md +7 -2
- package/lib/create.mjs +28 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +185 -5
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/rows/README.md +150 -0
- package/templates/rows/csv.ts +260 -0
- package/templates/rows/samples/expected.json +150 -0
- package/templates/rows/samples/properties.json +11 -0
- package/templates/rows/samples/q3-customers.csv +10 -0
- package/templates/rows/samples/q3-orders.tsv +5 -0
- package/templates/rows/samples/reports.json +5 -0
- package/templates/rows/samples.ts +56 -0
- package/templates/rows/setup.ts +18 -0
- package/templates/rows/try.ts +32 -0
- package/templates/rows/worker.ts +24 -0
- package/templates/rows/workflow.test.ts +541 -0
- package/templates/rows/workflow.ts +657 -0
- package/templates/sections/README.md +118 -0
- package/templates/sections/markdown.ts +121 -0
- package/templates/sections/samples/expected.json +82 -0
- package/templates/sections/samples/guides.json +4 -0
- package/templates/sections/samples/support-handbook.md +34 -0
- package/templates/sections/samples.ts +41 -0
- package/templates/sections/setup.ts +18 -0
- package/templates/sections/try.ts +32 -0
- package/templates/sections/worker.ts +24 -0
- package/templates/sections/workflow.test.ts +265 -0
- package/templates/sections/workflow.ts +433 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// sections on its samples, without a server: the files and the derivations
|
|
2
|
+
// are the mock's (MockContent), the graph is in memory, and the SPARQL reads
|
|
3
|
+
// are answered from it. The expected sections come from samples/expected.json.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
MockActivities,
|
|
10
|
+
MockContent,
|
|
11
|
+
contentMessage,
|
|
12
|
+
phases,
|
|
13
|
+
readJson,
|
|
14
|
+
runMessage,
|
|
15
|
+
samplesDir,
|
|
16
|
+
type MemoryGraph,
|
|
17
|
+
type MockFile,
|
|
18
|
+
} from "../_shared/testing.js";
|
|
19
|
+
import { atxHeading, lineFragment, markdownSections, splitLines } from "./markdown.js";
|
|
20
|
+
import {
|
|
21
|
+
PART_OF,
|
|
22
|
+
createWorkflow,
|
|
23
|
+
defaults,
|
|
24
|
+
derivationKey,
|
|
25
|
+
labelKey,
|
|
26
|
+
type SectionsState,
|
|
27
|
+
type Settings,
|
|
28
|
+
} from "./workflow.js";
|
|
29
|
+
|
|
30
|
+
const SAMPLES = samplesDir(import.meta.url, "sections");
|
|
31
|
+
const HANDBOOK = readFileSync(join(SAMPLES, "support-handbook.md"));
|
|
32
|
+
const GUIDES = readJson<{ name: string }[]>(join(SAMPLES, "guides.json"));
|
|
33
|
+
interface ExpectedSection {
|
|
34
|
+
position: number;
|
|
35
|
+
level: number;
|
|
36
|
+
title: string;
|
|
37
|
+
parent: number | null;
|
|
38
|
+
lines: string;
|
|
39
|
+
heading_path?: string;
|
|
40
|
+
text: string | null;
|
|
41
|
+
}
|
|
42
|
+
const EXPECTED = readJson<Record<string, { sections: ExpectedSection[]; represents: string }>>(
|
|
43
|
+
join(SAMPLES, "expected.json"),
|
|
44
|
+
)["support-handbook.md"]!;
|
|
45
|
+
const WITH_GUIDES: Settings = { ...defaults, represents: { class: "Guide" } };
|
|
46
|
+
|
|
47
|
+
/** The SPARQL reads of the workflow, answered from the graph in memory. */
|
|
48
|
+
function sparql(query: string, graph: MemoryGraph): Record<string, string>[] {
|
|
49
|
+
if (query.startsWith("# sections: document")) {
|
|
50
|
+
const iri = /VALUES \?doc \{ <([^>]+)> \}/.exec(query)![1]!;
|
|
51
|
+
const doc = graph.entities.get(iri);
|
|
52
|
+
if (!doc) return [];
|
|
53
|
+
const p = doc.properties;
|
|
54
|
+
return [{ name: String(p.name), sha: String(p.file_sha256), type: String(p.content_type) }];
|
|
55
|
+
}
|
|
56
|
+
if (query.startsWith("# sections: represents")) {
|
|
57
|
+
const cls = decodeURIComponent(
|
|
58
|
+
/<https:\/\/littlebigbrain\.com\/class\/([^>]+)>/.exec(query)![1]!,
|
|
59
|
+
);
|
|
60
|
+
const wanted = JSON.parse(/= ("(?:[^"\\]|\\.)*")\)/.exec(query)![1]!) as string;
|
|
61
|
+
return [...graph.entities.values()]
|
|
62
|
+
.filter((e) => e.type.toLowerCase() === cls && labelKey(e.name) === wanted)
|
|
63
|
+
.map((e) => ({ s: e.iri, label: e.name }))
|
|
64
|
+
.slice(0, 2);
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`unexpected query: ${query.slice(0, 60)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A run of the workflow on a graph with the sample guides. */
|
|
70
|
+
function setup(settings: Settings = WITH_GUIDES, guides = GUIDES.map((g) => g.name)) {
|
|
71
|
+
const lbb = new MockActivities({ sparql });
|
|
72
|
+
for (const name of guides) lbb.graph.entity({ type: "Guide", name });
|
|
73
|
+
const content = new MockContent(lbb.graph);
|
|
74
|
+
const definition = createWorkflow(lbb, content, settings);
|
|
75
|
+
const run = (file: MockFile, state: unknown = {}) =>
|
|
76
|
+
runMessage<SectionsState>(definition, contentMessage("sections", file), state);
|
|
77
|
+
return { lbb, content, run };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The written sections by position. */
|
|
81
|
+
function written(graph: MemoryGraph) {
|
|
82
|
+
return graph
|
|
83
|
+
.ofType("Section")
|
|
84
|
+
.sort((a, b) => Number(a.properties.position) - Number(b.properties.position));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("the handbook gives the sections of the answer key, each PART_OF its parent", async () => {
|
|
88
|
+
const { lbb, content, run } = setup();
|
|
89
|
+
const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
|
|
90
|
+
const { state, turns } = await run(file);
|
|
91
|
+
const sections = written(lbb.graph);
|
|
92
|
+
|
|
93
|
+
assert.equal(turns.length, 1);
|
|
94
|
+
assert.deepEqual(state, { contents: 1, skipped: 0, sections: 8, represents: 1 });
|
|
95
|
+
assert.equal(sections.length, EXPECTED.sections.length);
|
|
96
|
+
const lines = splitLines(new TextDecoder().decode(file.bytes));
|
|
97
|
+
for (const [i, want] of EXPECTED.sections.entries()) {
|
|
98
|
+
const got = sections[i]!;
|
|
99
|
+
assert.equal(got.name, want.title, `section ${want.position}`);
|
|
100
|
+
assert.deepEqual(got.properties, {
|
|
101
|
+
title: want.title,
|
|
102
|
+
level: want.level,
|
|
103
|
+
...(want.text !== null ? { text: want.text } : {}),
|
|
104
|
+
...(want.heading_path ? { heading_path: want.heading_path } : {}),
|
|
105
|
+
position: want.position,
|
|
106
|
+
});
|
|
107
|
+
const edge = lbb.graph.edges.find((e) => e.source === got.iri && e.relation === PART_OF)!;
|
|
108
|
+
const parent = want.parent === null ? file.document : sections[want.parent]!.iri;
|
|
109
|
+
assert.equal(edge.target, parent, `the parent of ${want.title}`);
|
|
110
|
+
const evidence = edge.evidence as { text: string; source_id: string };
|
|
111
|
+
assert.equal(evidence.source_id, `${file.document}#${want.lines}`);
|
|
112
|
+
// `line=a,b` is lines a + 1 to b: the first is the heading line, the quote.
|
|
113
|
+
const [from] = want.lines.slice("line=".length).split(",").map(Number);
|
|
114
|
+
assert.equal(evidence.text, lines[from!]!.trim());
|
|
115
|
+
}
|
|
116
|
+
const represents = lbb.graph.edgesOf("REPRESENTS");
|
|
117
|
+
assert.equal(represents.length, 1);
|
|
118
|
+
assert.equal(represents[0]!.source, file.document);
|
|
119
|
+
assert.equal(lbb.graph.entities.get(represents[0]!.target)!.name, EXPECTED.represents);
|
|
120
|
+
const derivation = content.recorded.get(
|
|
121
|
+
`sections@v1/${derivationKey(file.sha256, WITH_GUIDES).settings_digest}/${file.sha256}`,
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(derivation?.outputs, {
|
|
124
|
+
sections: 8,
|
|
125
|
+
levels: { 0: 1, 1: 2, 2: 3, 3: 2 },
|
|
126
|
+
represents: represents[0]!.target,
|
|
127
|
+
commits: [1, 2],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("text/plain is read with a .md name and skipped without one", async () => {
|
|
132
|
+
const { lbb, content, run } = setup(defaults);
|
|
133
|
+
const md = content.upload("support-handbook.md", HANDBOOK, "text/plain");
|
|
134
|
+
const txt = content.upload("notes.txt", "# Notes\n\nNot Markdown by its name.\n", "text/plain");
|
|
135
|
+
const one = await run(md);
|
|
136
|
+
const two = await run(txt, one.state);
|
|
137
|
+
|
|
138
|
+
assert.deepEqual(two.state, { contents: 1, skipped: 1, sections: 8, represents: 0 });
|
|
139
|
+
assert.equal(lbb.graph.edgesOf("REPRESENTS").length, 0, "the defaults name no class");
|
|
140
|
+
assert.equal(content.recorded.size, 1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a file without a heading is one section of level 0 named after the file", async () => {
|
|
144
|
+
const { lbb, content, run } = setup(defaults);
|
|
145
|
+
const file = content.upload(
|
|
146
|
+
"meeting-notes.md",
|
|
147
|
+
"\nWe agreed on the plan.\nNext review in May.\n\n",
|
|
148
|
+
"text/markdown",
|
|
149
|
+
);
|
|
150
|
+
await run(file);
|
|
151
|
+
const [only, ...others] = written(lbb.graph);
|
|
152
|
+
|
|
153
|
+
assert.equal(others.length, 0);
|
|
154
|
+
assert.equal(only!.name, "meeting-notes");
|
|
155
|
+
assert.deepEqual(only!.properties, {
|
|
156
|
+
title: "meeting-notes",
|
|
157
|
+
level: 0,
|
|
158
|
+
text: "We agreed on the plan.\nNext review in May.",
|
|
159
|
+
position: 0,
|
|
160
|
+
});
|
|
161
|
+
const edge = lbb.graph.edgesOf(PART_OF)[0]!;
|
|
162
|
+
assert.equal(edge.target, file.document);
|
|
163
|
+
assert.equal((edge.evidence as { source_id: string }).source_id, `${file.document}#line=1,3`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an empty file writes no section and records a derivation", async () => {
|
|
167
|
+
const { lbb, content, run } = setup(defaults);
|
|
168
|
+
const file = content.upload("empty.md", "", "text/markdown");
|
|
169
|
+
const { state } = await run(file);
|
|
170
|
+
|
|
171
|
+
assert.deepEqual(state, { contents: 1, skipped: 0, sections: 0, represents: 0 });
|
|
172
|
+
assert.equal(lbb.graph.ofType("Section").length, 0);
|
|
173
|
+
assert.equal(([...content.recorded.values()][0]!.outputs as { sections: number }).sections, 0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a file of more sections than a turn writes continues in a new turn", async () => {
|
|
177
|
+
const { lbb, content, run } = setup({ ...WITH_GUIDES, sectionsPerTurn: 3 });
|
|
178
|
+
const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
|
|
179
|
+
const { state, turns } = await run(file);
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
turns.map((t) => t.keys),
|
|
183
|
+
[["check", "plan", "represents", "write-0"], ["write-0"], ["write-0", "record"]],
|
|
184
|
+
);
|
|
185
|
+
assert.equal(state.sections, 8);
|
|
186
|
+
assert.equal(written(lbb.graph).length, 8);
|
|
187
|
+
// A section of a later turn still names its parent of an earlier turn.
|
|
188
|
+
const chat = written(lbb.graph).find((s) => s.name === "Chat")!;
|
|
189
|
+
const edge = lbb.graph.edges.find((e) => e.source === chat.iri)!;
|
|
190
|
+
assert.equal(lbb.graph.entities.get(edge.target)!.name, "Response times");
|
|
191
|
+
const outputs = [...content.recorded.values()][0]!.outputs as { commits: number[] };
|
|
192
|
+
assert.equal(outputs.commits.length, 4);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a content with a derivation for these settings is skipped, under any name", async () => {
|
|
196
|
+
const { lbb, content, run } = setup();
|
|
197
|
+
const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
|
|
198
|
+
const first = await run(file);
|
|
199
|
+
const commits = lbb.graph.commits;
|
|
200
|
+
const renamed = content.upload("handbook-copy.md", HANDBOOK, "text/markdown");
|
|
201
|
+
const again = await run(renamed, first.state);
|
|
202
|
+
|
|
203
|
+
assert.equal(renamed.sha256, file.sha256);
|
|
204
|
+
assert.deepEqual(again.state, { ...first.state, skipped: 1 });
|
|
205
|
+
assert.equal(lbb.graph.commits, commits);
|
|
206
|
+
assert.equal(content.reads, 1);
|
|
207
|
+
assert.deepEqual(again.turns[0]!.keys, ["check"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("REPRESENTS needs exactly one instance whose label is the file's name", async () => {
|
|
211
|
+
const counts: number[] = [];
|
|
212
|
+
for (const guides of [
|
|
213
|
+
["Billing guide"],
|
|
214
|
+
["Support handbook"],
|
|
215
|
+
["Support handbook", "support_handbook"],
|
|
216
|
+
]) {
|
|
217
|
+
const { lbb, content, run } = setup(WITH_GUIDES, guides);
|
|
218
|
+
const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
|
|
219
|
+
await run(file);
|
|
220
|
+
counts.push(lbb.graph.edgesOf("REPRESENTS").length);
|
|
221
|
+
assert.equal(lbb.reviews.size, 0, "nothing is proposed");
|
|
222
|
+
}
|
|
223
|
+
assert.deepEqual(counts, [0, 1, 0]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("each step reports its phase in the progress convention", async () => {
|
|
227
|
+
const { content, run } = setup();
|
|
228
|
+
const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
|
|
229
|
+
const { turns } = await run(file);
|
|
230
|
+
assert.deepEqual(phases(turns), [
|
|
231
|
+
"checking the file",
|
|
232
|
+
"reading sections",
|
|
233
|
+
"writing facts",
|
|
234
|
+
"recording the derivation",
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("ATX headings: levels 1 to 6, closing hashes, indents, fences and line ends", () => {
|
|
239
|
+
assert.deepEqual(atxHeading("# One"), { level: 1, title: "One" });
|
|
240
|
+
assert.deepEqual(atxHeading("###### Six ######"), { level: 6, title: "Six" });
|
|
241
|
+
assert.deepEqual(atxHeading(" ## Indented 3"), { level: 2, title: "Indented 3" });
|
|
242
|
+
assert.deepEqual(atxHeading("#"), { level: 1, title: "" });
|
|
243
|
+
assert.deepEqual(atxHeading("## C# and F#"), { level: 2, title: "C# and F#" });
|
|
244
|
+
assert.equal(atxHeading("####### Seven"), null);
|
|
245
|
+
assert.equal(atxHeading(" # Code"), null);
|
|
246
|
+
assert.equal(atxHeading("#hashtag"), null);
|
|
247
|
+
|
|
248
|
+
const text =
|
|
249
|
+
"# A\r\n~~~\r\n# not\r\n~~~\r\n## B\r\n\r\n### C\r\ntext of C\r\n```\r\n# open fence to the end";
|
|
250
|
+
const sections = markdownSections(text);
|
|
251
|
+
assert.deepEqual(
|
|
252
|
+
sections.map((s) => [s.position, s.level, s.title, s.parent, lineFragment(s)]),
|
|
253
|
+
[
|
|
254
|
+
[1, 1, "A", null, "line=0,4"],
|
|
255
|
+
[2, 2, "B", 1, "line=4,5"],
|
|
256
|
+
[3, 3, "C", 2, "line=6,10"],
|
|
257
|
+
],
|
|
258
|
+
);
|
|
259
|
+
assert.deepEqual(sections[2]!.headingPath, ["A", "B", "C"]);
|
|
260
|
+
assert.equal(sections[2]!.text, "text of C\n```\n# open fence to the end");
|
|
261
|
+
// RFC 5147: `line=4,5` selects line 5 alone, the heading `## B`.
|
|
262
|
+
assert.equal(splitLines(text)[4], "## B");
|
|
263
|
+
assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
|
|
264
|
+
assert.deepEqual(splitLines(""), []);
|
|
265
|
+
});
|