paperlint 14.2.0 → 14.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.
- package/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +27 -2
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +5 -3
- package/skills/build-label-page/SKILL.md.spec.ts +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +44 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +452 -10
- package/skills/build-label-page/scripts/label-page-core.js +105 -9
- package/skills/build-label-page/scripts/label-page-core.test.ts +25 -1
- package/skills/build-label-page/scripts/label-page-core.ts +178 -10
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/page-template.html +292 -53
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
chromium,
|
|
10
10
|
type Locator,
|
|
11
11
|
type Page,
|
|
12
|
+
selectors,
|
|
12
13
|
} from "playwright-core";
|
|
13
14
|
import { readFileSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
@@ -363,9 +364,9 @@ async function onWitnessPage(
|
|
|
363
364
|
}
|
|
364
365
|
}
|
|
365
366
|
|
|
366
|
-
/** The code, the linter's message, the question, the rule docs'
|
|
367
|
+
/** The code, the linter's message, the question, the rule docs' heading: one read of the laid-out card,
|
|
367
368
|
* so a re-render between reads cannot mix two layouts. */
|
|
368
|
-
const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", "
|
|
369
|
+
const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", ".rulebox > h3"];
|
|
369
370
|
const Layout = z.array(z.object({ top: z.number(), bottom: z.number() }));
|
|
370
371
|
const layout = async (card: Locator) =>
|
|
371
372
|
Layout.parse(
|
|
@@ -380,7 +381,7 @@ const layout = async (card: Locator) =>
|
|
|
380
381
|
);
|
|
381
382
|
|
|
382
383
|
test(
|
|
383
|
-
"🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code,
|
|
384
|
+
"🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code, shows the rule docs and the settings in place, opens a long text on «more», and nothing pushes the page sideways",
|
|
384
385
|
async () => {
|
|
385
386
|
await onWitnessPage("browser-witness", async (page) => {
|
|
386
387
|
// Measure after the store's practice answers arrived: before them the practice cards sit above.
|
|
@@ -391,19 +392,24 @@ test(
|
|
|
391
392
|
await card.locator("pre.numbered .hit").textContent(),
|
|
392
393
|
'▶18print(f"seeded {len(rows)} rows")◀ this line',
|
|
393
394
|
);
|
|
394
|
-
// Guards: the rule docs, the files like it and the
|
|
395
|
+
// Guards: nothing a card needs is folded (#392); the rule docs, the files like it and the
|
|
396
|
+
// settings are shown in place, each under its heading.
|
|
395
397
|
assert.deepEqual(
|
|
396
398
|
[
|
|
397
399
|
await card.locator("details").count(),
|
|
398
|
-
await card.locator("
|
|
400
|
+
await card.locator(".rulebox > h3").allTextContents(),
|
|
401
|
+
],
|
|
402
|
+
[
|
|
403
|
+
0,
|
|
404
|
+
[
|
|
405
|
+
"What the linter rule does (official docs)",
|
|
406
|
+
"3 more file(s) with the same situation",
|
|
407
|
+
"Settings behind this card",
|
|
408
|
+
],
|
|
399
409
|
],
|
|
400
|
-
[3, 0],
|
|
401
410
|
);
|
|
402
411
|
// The question sits right under the code it is about: only the linter's message between them,
|
|
403
412
|
// and the rule's docs start below the answers.
|
|
404
|
-
const docsSummary = card.locator("summary", {
|
|
405
|
-
hasText: "What the linter rule does (official docs)",
|
|
406
|
-
});
|
|
407
413
|
const box = await layout(card);
|
|
408
414
|
const [code, said, ask, docs] = box;
|
|
409
415
|
assert.ok(code && said && ask && docs, JSON.stringify(box));
|
|
@@ -413,7 +419,6 @@ test(
|
|
|
413
419
|
);
|
|
414
420
|
assert.ok(ask.top - said.bottom < 40, JSON.stringify(box));
|
|
415
421
|
assert.ok(docs.top >= ask.bottom, JSON.stringify(box));
|
|
416
|
-
await docsSummary.click();
|
|
417
422
|
const why = card.locator(".clamped", { hasText: "Why:" });
|
|
418
423
|
const folded = (await why.boundingBox())?.height ?? 0;
|
|
419
424
|
await card.getByRole("button", { name: "more" }).first().click();
|
|
@@ -915,3 +920,440 @@ test(
|
|
|
915
920
|
},
|
|
916
921
|
BROWSER_TEST_MS,
|
|
917
922
|
);
|
|
923
|
+
|
|
924
|
+
// ── the approved card (#392): a code-review note on the marked line, tap-to-see notes on terms ──
|
|
925
|
+
// Every element these tests touch carries `data-test`, found only by getByTestId; its values are the
|
|
926
|
+
// JSON in `data-test-meta`. A text that is content (the annotation, the quote) is read against the
|
|
927
|
+
// fixture's own value, never retyped here.
|
|
928
|
+
selectors.setTestIdAttribute("data-test");
|
|
929
|
+
const V7_FIXTURE = join(import.meta.dirname, "fixtures", "v7-cards-items.json");
|
|
930
|
+
const V7_CARD = z
|
|
931
|
+
.object({
|
|
932
|
+
source_id: z.string(),
|
|
933
|
+
terms: z.array(
|
|
934
|
+
z.object({
|
|
935
|
+
term: z.string(),
|
|
936
|
+
note: z.array(
|
|
937
|
+
z
|
|
938
|
+
.object({
|
|
939
|
+
quote: z.string().optional(),
|
|
940
|
+
url: z.string(),
|
|
941
|
+
url_label: z.string(),
|
|
942
|
+
})
|
|
943
|
+
.loose(),
|
|
944
|
+
),
|
|
945
|
+
}),
|
|
946
|
+
),
|
|
947
|
+
excerpt: z.object({
|
|
948
|
+
gutter_header: z.string().optional(),
|
|
949
|
+
annotation: z.string(),
|
|
950
|
+
lines: z.array(
|
|
951
|
+
z.object({
|
|
952
|
+
n: z.number(),
|
|
953
|
+
mark: z.boolean(),
|
|
954
|
+
context: z.boolean().optional(),
|
|
955
|
+
gutter: z.string().optional(),
|
|
956
|
+
}),
|
|
957
|
+
),
|
|
958
|
+
}),
|
|
959
|
+
answer_notes: z.object({ forbid: z.record(z.string(), z.string()) }),
|
|
960
|
+
})
|
|
961
|
+
.loose();
|
|
962
|
+
const V7_CONFIG = z
|
|
963
|
+
.object({ practice: z.array(z.unknown()), items: z.array(z.unknown()) })
|
|
964
|
+
.loose()
|
|
965
|
+
.parse(JSON.parse(readFileSync(V7_FIXTURE, "utf8")));
|
|
966
|
+
const V7_CARDS = z.array(V7_CARD).parse(V7_CONFIG.items);
|
|
967
|
+
/** The fixture's card of that source id. */
|
|
968
|
+
const fixtureCard = (sourceId: string) => {
|
|
969
|
+
const found = V7_CARDS.find((i) => i.source_id === sourceId);
|
|
970
|
+
assert.ok(found !== undefined, sourceId);
|
|
971
|
+
return found;
|
|
972
|
+
};
|
|
973
|
+
/** A fixture text as the page shows it: its `backticks` are code spans, not characters. */
|
|
974
|
+
const unticked = (text: string): string => text.split("`").join("");
|
|
975
|
+
/** The values an element carries in `data-test-meta`. */
|
|
976
|
+
const metaOf = async (l: Locator): Promise<unknown> =>
|
|
977
|
+
JSON.parse((await l.getAttribute("data-test-meta")) ?? "null");
|
|
978
|
+
/** The elements of one kind, each with its values. */
|
|
979
|
+
const allByKind = async (scope: Page | Locator, kind: string) =>
|
|
980
|
+
Promise.all(
|
|
981
|
+
(await scope.getByTestId(kind).all()).map(async (loc) => ({
|
|
982
|
+
loc,
|
|
983
|
+
meta: await metaOf(loc),
|
|
984
|
+
})),
|
|
985
|
+
);
|
|
986
|
+
const Id = z.object({ id: z.string() });
|
|
987
|
+
const Term = z.object({ term: z.string() });
|
|
988
|
+
const Answer = z.object({ question: z.string(), value: z.string() });
|
|
989
|
+
const CodeLine = z.object({
|
|
990
|
+
line: z.number(),
|
|
991
|
+
marked: z.boolean(),
|
|
992
|
+
gutter: z.string(),
|
|
993
|
+
context: z.boolean(),
|
|
994
|
+
});
|
|
995
|
+
/** The three-card fixture, practice done, without its time on screen, at 400 px wide. */
|
|
996
|
+
async function onV7Page(
|
|
997
|
+
name: string,
|
|
998
|
+
steps: (
|
|
999
|
+
page: Page,
|
|
1000
|
+
cardOf: (sourceId: string) => Promise<Locator>,
|
|
1001
|
+
) => Promise<void>,
|
|
1002
|
+
withTime = false,
|
|
1003
|
+
): Promise<void> {
|
|
1004
|
+
const { page: html, key } = build(
|
|
1005
|
+
setup(name, withTime ? V7_CONFIG : R.omit(V7_CONFIG, ["min_seconds"])),
|
|
1006
|
+
);
|
|
1007
|
+
const done = ["forbid", "allow", "elsewhere"];
|
|
1008
|
+
const page = await (
|
|
1009
|
+
await sharedBrowser()
|
|
1010
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1011
|
+
try {
|
|
1012
|
+
await page.addInitScript(
|
|
1013
|
+
STORE(
|
|
1014
|
+
Object.fromEntries(
|
|
1015
|
+
done.map((v, n) => [
|
|
1016
|
+
`r1-P${String(n + 1)}`,
|
|
1017
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1018
|
+
]),
|
|
1019
|
+
),
|
|
1020
|
+
20,
|
|
1021
|
+
),
|
|
1022
|
+
);
|
|
1023
|
+
await page.goto("about:blank");
|
|
1024
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
1025
|
+
await page.getByTestId("item").first().waitFor();
|
|
1026
|
+
const cardOf = async (sourceId: string) => {
|
|
1027
|
+
const id = Object.entries(key.ids).find(
|
|
1028
|
+
([, v]) => v.source_id === sourceId,
|
|
1029
|
+
)?.[0];
|
|
1030
|
+
const found = (await allByKind(page, "item")).find(
|
|
1031
|
+
(c) => Id.parse(c.meta).id === id,
|
|
1032
|
+
);
|
|
1033
|
+
assert.ok(found !== undefined, `${sourceId} is drawn`);
|
|
1034
|
+
return found.loc;
|
|
1035
|
+
};
|
|
1036
|
+
await steps(page, cardOf);
|
|
1037
|
+
} finally {
|
|
1038
|
+
await page.context().close();
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** The term of that text on a card. */
|
|
1042
|
+
const termOf = async (card: Locator, term: string) => {
|
|
1043
|
+
const found = (await allByKind(card, "term")).find(
|
|
1044
|
+
(t) => Term.parse(t.meta).term === term,
|
|
1045
|
+
);
|
|
1046
|
+
assert.ok(found !== undefined, `${term} is a button`);
|
|
1047
|
+
return found.loc;
|
|
1048
|
+
};
|
|
1049
|
+
const answerOf = async (card: Locator, value: string) => {
|
|
1050
|
+
const found = (await allByKind(card, "answer")).find(
|
|
1051
|
+
(a) => Answer.parse(a.meta).value === value,
|
|
1052
|
+
);
|
|
1053
|
+
assert.ok(found !== undefined, `${value} is an answer`);
|
|
1054
|
+
return found.loc;
|
|
1055
|
+
};
|
|
1056
|
+
/** The open note: whether it shows, which term, its box, and the terms marked open. */
|
|
1057
|
+
const Tip = z.object({ term: z.string(), open: z.boolean() });
|
|
1058
|
+
const tipNow = async (page: Page) => {
|
|
1059
|
+
const tip = page.getByTestId("tip");
|
|
1060
|
+
const drawn = (await tip.count()) > 0;
|
|
1061
|
+
const state = drawn
|
|
1062
|
+
? Tip.parse(await metaOf(tip))
|
|
1063
|
+
: { term: "", open: false };
|
|
1064
|
+
const box = drawn && state.open ? await tip.boundingBox() : null;
|
|
1065
|
+
const expanded = await Promise.all(
|
|
1066
|
+
(await allByKind(page, "term")).map(async (t) => ({
|
|
1067
|
+
term: Term.parse(t.meta).term,
|
|
1068
|
+
open: (await t.loc.getAttribute("aria-expanded")) === "true",
|
|
1069
|
+
})),
|
|
1070
|
+
);
|
|
1071
|
+
return {
|
|
1072
|
+
...state,
|
|
1073
|
+
left: box?.x ?? -1,
|
|
1074
|
+
right: box ? box.x + box.width : -1,
|
|
1075
|
+
width: box?.width ?? -1,
|
|
1076
|
+
expanded: expanded.filter((t) => t.open).map((t) => t.term),
|
|
1077
|
+
};
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
/** Wait until the note reports itself closed (it closes on its own schedule). */
|
|
1081
|
+
const noteClosed = (page: Page) =>
|
|
1082
|
+
page.waitForFunction(
|
|
1083
|
+
`JSON.parse(document.querySelector('[data-test="tip"]').getAttribute("data-test-meta")).open === false`,
|
|
1084
|
+
undefined,
|
|
1085
|
+
{ timeout: 5_000 },
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
test(
|
|
1089
|
+
"🔴 a dotted term opens one floating note on a tap, with its quote and its source link, anchored and inside a 400 px viewport",
|
|
1090
|
+
async () => {
|
|
1091
|
+
await onV7Page("browser-v7-tip", async (page, cardOf) => {
|
|
1092
|
+
const docstring = fixtureCard("card-docstring");
|
|
1093
|
+
const google = docstring.terms.find((t) => t.term === "Google-style");
|
|
1094
|
+
assert.ok(google !== undefined);
|
|
1095
|
+
await (await termOf(await cardOf("card-docstring"), google.term)).click();
|
|
1096
|
+
const tip = await tipNow(page);
|
|
1097
|
+
assert.deepEqual(
|
|
1098
|
+
[tip.term, tip.open, tip.expanded],
|
|
1099
|
+
[google.term, true, [google.term]],
|
|
1100
|
+
);
|
|
1101
|
+
assert.deepEqual(
|
|
1102
|
+
[
|
|
1103
|
+
await page.getByTestId("tip-quote").allTextContents(),
|
|
1104
|
+
(await allByKind(page, "tip-link")).map((l) => l.meta),
|
|
1105
|
+
await page.getByTestId("tip-link").allTextContents(),
|
|
1106
|
+
],
|
|
1107
|
+
[
|
|
1108
|
+
google.note.flatMap((e) => e.quote ?? []),
|
|
1109
|
+
google.note.map((e) => ({ url: e.url })),
|
|
1110
|
+
google.note.map((e) => e.url_label),
|
|
1111
|
+
],
|
|
1112
|
+
);
|
|
1113
|
+
// Inside the viewport and at most 300 px wide, for a term near either edge.
|
|
1114
|
+
const edges = [];
|
|
1115
|
+
for (const [source, term] of [
|
|
1116
|
+
["card-docstring", ".github/copilot-instructions.md"],
|
|
1117
|
+
["card-depth", "guard clauses"],
|
|
1118
|
+
["card-call", "document.getElementById"],
|
|
1119
|
+
] as const) {
|
|
1120
|
+
// Each term is measured on its own: the note of the one before may cover this one.
|
|
1121
|
+
await page.keyboard.press("Escape");
|
|
1122
|
+
await noteClosed(page);
|
|
1123
|
+
await (await termOf(await cardOf(source), term)).click();
|
|
1124
|
+
const t = await tipNow(page);
|
|
1125
|
+
edges.push([t.term, t.left >= 0 && t.right <= 400 && t.width <= 300]);
|
|
1126
|
+
}
|
|
1127
|
+
assert.deepEqual(edges, [
|
|
1128
|
+
[".github/copilot-instructions.md", true],
|
|
1129
|
+
["guard clauses", true],
|
|
1130
|
+
["document.getElementById", true],
|
|
1131
|
+
]);
|
|
1132
|
+
});
|
|
1133
|
+
},
|
|
1134
|
+
BROWSER_TEST_MS,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
test(
|
|
1138
|
+
"🔴 a tap outside the open note closes it and answers nothing; Esc closes it; a second term closes the first",
|
|
1139
|
+
async () => {
|
|
1140
|
+
await onV7Page("browser-v7-close", async (page, cardOf) => {
|
|
1141
|
+
const depth = await cardOf("card-depth");
|
|
1142
|
+
// The note covers the first answers; the tap lands on an answer it leaves visible.
|
|
1143
|
+
await (await termOf(depth, "guard clauses")).click();
|
|
1144
|
+
await (await answerOf(depth, "unsettled")).click();
|
|
1145
|
+
await page.waitForTimeout(100);
|
|
1146
|
+
assert.deepEqual(
|
|
1147
|
+
[(await tipNow(page)).open, (await writes(page)).length],
|
|
1148
|
+
[false, 0],
|
|
1149
|
+
);
|
|
1150
|
+
await (await termOf(await cardOf("card-call"), "AGENTS.md")).click();
|
|
1151
|
+
await page.keyboard.press("Escape");
|
|
1152
|
+
assert.equal((await tipNow(page)).open, false);
|
|
1153
|
+
const docstring = await cardOf("card-docstring");
|
|
1154
|
+
const d413 = fixtureCard("card-docstring").terms.find(
|
|
1155
|
+
(t) => t.term === "D413",
|
|
1156
|
+
);
|
|
1157
|
+
assert.ok(d413 !== undefined);
|
|
1158
|
+
await (await termOf(docstring, "Ruff")).click();
|
|
1159
|
+
await (await termOf(docstring, d413.term)).click();
|
|
1160
|
+
const t = await tipNow(page);
|
|
1161
|
+
assert.deepEqual(
|
|
1162
|
+
[
|
|
1163
|
+
t.open,
|
|
1164
|
+
t.expanded,
|
|
1165
|
+
await page.getByTestId("tip-quote").allTextContents(),
|
|
1166
|
+
],
|
|
1167
|
+
[true, [d413.term], d413.note.flatMap((e) => e.quote ?? [])],
|
|
1168
|
+
);
|
|
1169
|
+
// With the note closed, the same answer saves.
|
|
1170
|
+
await page.keyboard.press("Escape");
|
|
1171
|
+
await (await answerOf(depth, "unsettled")).click();
|
|
1172
|
+
await page.waitForTimeout(100);
|
|
1173
|
+
assert.equal((await writes(page)).length, 1);
|
|
1174
|
+
});
|
|
1175
|
+
},
|
|
1176
|
+
BROWSER_TEST_MS,
|
|
1177
|
+
);
|
|
1178
|
+
|
|
1179
|
+
test(
|
|
1180
|
+
"🔴 a note opened by hover stays open while the pointer crosses the gap to it, keeps its link reachable, and closes after the pointer leaves for good",
|
|
1181
|
+
async () => {
|
|
1182
|
+
await onV7Page("browser-v7-hover", async (page, cardOf) => {
|
|
1183
|
+
const docstring = await cardOf("card-docstring");
|
|
1184
|
+
const google = fixtureCard("card-docstring").terms.find(
|
|
1185
|
+
(t) => t.term === "Google-style",
|
|
1186
|
+
);
|
|
1187
|
+
assert.ok(google !== undefined);
|
|
1188
|
+
const term = await termOf(docstring, google.term);
|
|
1189
|
+
// A slow runner must not outrun the grace delay while the pointer crosses the gap.
|
|
1190
|
+
await page.evaluate("window.__tipGraceMs = 5000");
|
|
1191
|
+
// At its top left corner: a term that wraps has a box whose middle may lie over another term.
|
|
1192
|
+
await term.hover({ position: { x: 4, y: 4 } });
|
|
1193
|
+
assert.deepEqual(
|
|
1194
|
+
[(await tipNow(page)).term, (await tipNow(page)).open],
|
|
1195
|
+
[google.term, true],
|
|
1196
|
+
);
|
|
1197
|
+
// Straight down from the term, in small steps, through the gap into the note. Only the gap
|
|
1198
|
+
// lies between them on this path, so no other term opens on the way.
|
|
1199
|
+
const tip = page.getByTestId("tip");
|
|
1200
|
+
const from = await term.boundingBox();
|
|
1201
|
+
const to = await tip.boundingBox();
|
|
1202
|
+
assert.ok(from !== null && to !== null);
|
|
1203
|
+
const top = from.y + from.height;
|
|
1204
|
+
// The other terms do not react while the pointer crosses: only the gap is under test, and
|
|
1205
|
+
// which term lies in it depends on how the line wraps. The arrow bridges the gap at the
|
|
1206
|
+
// middle of the term, so the crossing is at its right end.
|
|
1207
|
+
await page.addStyleTag({
|
|
1208
|
+
content: 'button.term[aria-expanded="false"] { pointer-events: none; }',
|
|
1209
|
+
});
|
|
1210
|
+
const x = from.x + from.width - 8;
|
|
1211
|
+
assert.ok(
|
|
1212
|
+
to.y > top && x > to.x && x < to.x + to.width,
|
|
1213
|
+
"the note lies below the term and covers that column",
|
|
1214
|
+
);
|
|
1215
|
+
await page.mouse.move(x, from.y + from.height - 1);
|
|
1216
|
+
for (const k of R.range(0, Math.ceil((to.y + 10 - top) / 3))) {
|
|
1217
|
+
await page.mouse.move(x, top + 3 * k);
|
|
1218
|
+
}
|
|
1219
|
+
await page.mouse.move(x, to.y + 10);
|
|
1220
|
+
const inside = await tipNow(page);
|
|
1221
|
+
assert.deepEqual(
|
|
1222
|
+
[inside.term, inside.open, inside.expanded],
|
|
1223
|
+
[google.term, true, [google.term]],
|
|
1224
|
+
);
|
|
1225
|
+
// Guards: the link answers a click at its own position, nothing covers it.
|
|
1226
|
+
await page.getByTestId("tip-link").first().click({ trial: true });
|
|
1227
|
+
// Far from the term and the note, it closes by itself once the grace delay has run out.
|
|
1228
|
+
await page.evaluate("window.__tipGraceMs = 50");
|
|
1229
|
+
await page.mouse.move(2, 2, { steps: 20 });
|
|
1230
|
+
await noteClosed(page);
|
|
1231
|
+
assert.equal((await tipNow(page)).open, false);
|
|
1232
|
+
});
|
|
1233
|
+
},
|
|
1234
|
+
BROWSER_TEST_MS,
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
/** What the depth card shows around its marked line, read from the page. */
|
|
1238
|
+
async function annotatedFacts(depth: Locator) {
|
|
1239
|
+
const lines = await allByKind(depth, "code-line");
|
|
1240
|
+
const header = depth.getByTestId("code-header");
|
|
1241
|
+
const anno = depth.getByTestId("annotation");
|
|
1242
|
+
const subs = await allByKind(depth, "answer-sub");
|
|
1243
|
+
return {
|
|
1244
|
+
annotation: [await metaOf(anno), await anno.textContent()],
|
|
1245
|
+
header: [
|
|
1246
|
+
await metaOf(header),
|
|
1247
|
+
await header.getByTestId("gutter").textContent(),
|
|
1248
|
+
],
|
|
1249
|
+
lines: lines.map((l) => l.meta),
|
|
1250
|
+
gutters: await Promise.all(
|
|
1251
|
+
lines.map((l) => l.loc.getByTestId("gutter").textContent()),
|
|
1252
|
+
),
|
|
1253
|
+
gaps: (await allByKind(depth, "gap")).map((g) => g.meta),
|
|
1254
|
+
subs: await Promise.all(
|
|
1255
|
+
subs.map(async (s) => [s.meta, await s.loc.textContent()]),
|
|
1256
|
+
),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/** The same facts, as the fixture states them. */
|
|
1260
|
+
function annotatedExpected(fixture: z.infer<typeof V7_CARD>) {
|
|
1261
|
+
const rows = fixture.excerpt.lines;
|
|
1262
|
+
return {
|
|
1263
|
+
annotation: [
|
|
1264
|
+
{ line: rows.find((l) => l.mark)?.n },
|
|
1265
|
+
unticked(fixture.excerpt.annotation),
|
|
1266
|
+
],
|
|
1267
|
+
header: [
|
|
1268
|
+
{ gutter: fixture.excerpt.gutter_header },
|
|
1269
|
+
fixture.excerpt.gutter_header,
|
|
1270
|
+
],
|
|
1271
|
+
lines: rows.map((l) => ({
|
|
1272
|
+
line: l.n,
|
|
1273
|
+
marked: l.mark,
|
|
1274
|
+
gutter: l.gutter ?? "",
|
|
1275
|
+
context: l.context ?? false,
|
|
1276
|
+
})),
|
|
1277
|
+
gutters: rows.map((l) => l.gutter ?? ""),
|
|
1278
|
+
gaps: R.zip(rows, rows.slice(1))
|
|
1279
|
+
.filter(([a, b]) => b.n > a.n + 1)
|
|
1280
|
+
.map(([a, b]) => ({ from: a.n + 1, to: b.n - 1 })),
|
|
1281
|
+
subs: Object.entries(fixture.answer_notes.forbid).map(([v, text]) => [
|
|
1282
|
+
{ question: "forbid", value: v },
|
|
1283
|
+
unticked(text),
|
|
1284
|
+
]),
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
test(
|
|
1289
|
+
"🔴 the annotation sits right under the marked line inside the code; the gutter column has its header and its values; each answer carries its sub-line",
|
|
1290
|
+
async () => {
|
|
1291
|
+
await onV7Page("browser-v7-card", async (page, cardOf) => {
|
|
1292
|
+
const depth = await cardOf("card-depth");
|
|
1293
|
+
const lines = await allByKind(depth, "code-line");
|
|
1294
|
+
const marked = lines.find((l) => CodeLine.parse(l.meta).marked);
|
|
1295
|
+
assert.ok(marked !== undefined, "the marked line is drawn");
|
|
1296
|
+
const [hit, note] = [
|
|
1297
|
+
await marked.loc.boundingBox(),
|
|
1298
|
+
await depth.getByTestId("annotation").boundingBox(),
|
|
1299
|
+
];
|
|
1300
|
+
assert.ok(hit && note, "both drawn");
|
|
1301
|
+
assert.ok(
|
|
1302
|
+
note.y >= hit.y + hit.height && note.y - (hit.y + hit.height) < 16,
|
|
1303
|
+
JSON.stringify([hit, note]),
|
|
1304
|
+
);
|
|
1305
|
+
assert.deepEqual(
|
|
1306
|
+
await annotatedFacts(depth),
|
|
1307
|
+
annotatedExpected(fixtureCard("card-depth")),
|
|
1308
|
+
);
|
|
1309
|
+
assert.equal(
|
|
1310
|
+
await page.evaluate(() => document.documentElement.scrollWidth),
|
|
1311
|
+
400,
|
|
1312
|
+
);
|
|
1313
|
+
});
|
|
1314
|
+
},
|
|
1315
|
+
BROWSER_TEST_MS,
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
test(
|
|
1319
|
+
"🔴 a card still on its time shows its wait bar with the seconds left, in the page's own words, and the answers wait",
|
|
1320
|
+
async () => {
|
|
1321
|
+
await onV7Page(
|
|
1322
|
+
"browser-v7-wait",
|
|
1323
|
+
async (page, cardOf) => {
|
|
1324
|
+
const { labels, min_seconds: total } = z
|
|
1325
|
+
.object({
|
|
1326
|
+
labels: z.object({ wait: z.string() }),
|
|
1327
|
+
min_seconds: z.number(),
|
|
1328
|
+
})
|
|
1329
|
+
.loose()
|
|
1330
|
+
.parse(V7_CONFIG);
|
|
1331
|
+
const bar = (await cardOf("card-depth")).getByTestId("wait");
|
|
1332
|
+
// One read of both, so a tick between two reads cannot mix them.
|
|
1333
|
+
const [text, raw] = z
|
|
1334
|
+
.tuple([z.string(), z.string()])
|
|
1335
|
+
.parse(
|
|
1336
|
+
await bar.evaluate((n) => [
|
|
1337
|
+
n.textContent,
|
|
1338
|
+
n.getAttribute("data-test-meta"),
|
|
1339
|
+
]),
|
|
1340
|
+
);
|
|
1341
|
+
const meta = z
|
|
1342
|
+
.object({ id: z.string(), seconds: z.number() })
|
|
1343
|
+
.parse(JSON.parse(raw));
|
|
1344
|
+
assert.deepEqual(
|
|
1345
|
+
[
|
|
1346
|
+
meta.seconds > 0 && meta.seconds <= total,
|
|
1347
|
+
text,
|
|
1348
|
+
await (
|
|
1349
|
+
await answerOf(await cardOf("card-depth"), "forbid")
|
|
1350
|
+
).isDisabled(),
|
|
1351
|
+
],
|
|
1352
|
+
[true, labels.wait.replace("{s}", String(meta.seconds)), true],
|
|
1353
|
+
);
|
|
1354
|
+
},
|
|
1355
|
+
true,
|
|
1356
|
+
);
|
|
1357
|
+
},
|
|
1358
|
+
BROWSER_TEST_MS,
|
|
1359
|
+
);
|