testomatio-editor-blocks 0.4.79 → 0.4.82

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/src/App.tsx CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
22
22
  import { customSchema, type CustomEditor } from "./editor/customSchema";
23
23
  import { tagBadgeExtension } from "./editor/tagBadge";
24
+ import { autoLinkExtension } from "./editor/autoLink";
25
+ import { exampleTableHighlightExtension } from "./editor/exampleTableHighlight";
24
26
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
25
27
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
26
28
  import {
@@ -307,9 +309,24 @@ const DEMO_USERS: MentionItem[] = [
307
309
 
308
310
  // Simulated "tests" API dataset — insertion uses the numeric id -> "@T{id}".
309
311
  const DEMO_TESTS: MentionItem[] = [
310
- { id: "10231", label: "Login redirects to dashboard", detail: "#10231" },
311
- { id: "10232", label: "Login rejects wrong password", detail: "#10232" },
312
- { id: "10245", label: "Logout clears the session", detail: "#10245" },
312
+ {
313
+ id: "10231",
314
+ label: "Login redirects to dashboard",
315
+ detail:
316
+ "Given a registered user with valid credentials, when they submit the login form, the app authenticates against the API, persists the session token, and redirects to the dashboard within 2 seconds. Verifies the welcome banner shows the user's name and that the navigation reflects an authenticated state.",
317
+ },
318
+ {
319
+ id: "10232",
320
+ label: "Login rejects wrong password",
321
+ detail:
322
+ "Given a registered user, when they submit the login form with an incorrect password, the app keeps them on the login page, shows an inline 'Invalid email or password' error, does not create a session, and increments the failed-attempt counter used for rate limiting after five consecutive failures.",
323
+ },
324
+ {
325
+ id: "10245",
326
+ label: "Logout clears the session",
327
+ detail:
328
+ "Given an authenticated user on any page, when they trigger logout, the app revokes the session token server-side, clears local storage and cookies, redirects to the login screen, and ensures that pressing the browser back button does not restore access to protected routes.",
329
+ },
313
330
  { id: "10310", label: "Checkout applies discount code", detail: "#10310" },
314
331
  { id: "10311", label: "Checkout blocks empty cart", detail: "#10311" },
315
332
  { id: "10420", label: "Profile avatar upload", detail: "#10420" },
@@ -318,8 +335,18 @@ const DEMO_TESTS: MentionItem[] = [
318
335
  ];
319
336
 
320
337
  const DEMO_SUITES: MentionItem[] = [
321
- { id: "1001", label: "Authentication", detail: "#1001" },
322
- { id: "1002", label: "Checkout flow", detail: "#1002" },
338
+ {
339
+ id: "1001",
340
+ label: "Authentication",
341
+ detail:
342
+ "End-to-end coverage of the authentication surface: login with valid and invalid credentials, session persistence and expiry, logout and token revocation, password reset via email, rate limiting after repeated failures, and redirect behaviour for protected routes when unauthenticated.",
343
+ },
344
+ {
345
+ id: "1002",
346
+ label: "Checkout flow",
347
+ detail:
348
+ "Covers the full purchase journey from cart to confirmation: applying and rejecting discount codes, blocking checkout on an empty cart, tax and shipping calculation, payment authorization and failure handling, and the order confirmation screen with a valid receipt number.",
349
+ },
323
350
  { id: "1003", label: "User profile", detail: "#1003" },
324
351
  { id: "1004", label: "Notifications", detail: "#1004" },
325
352
  { id: "1005", label: "Admin panel", detail: "#1005" },
@@ -474,7 +501,7 @@ function CustomSlashMenu() {
474
501
  function App() {
475
502
  const editor = useCreateBlockNote({
476
503
  schema: customSchema,
477
- extensions: [tagBadgeExtension()],
504
+ extensions: [tagBadgeExtension(), autoLinkExtension(), exampleTableHighlightExtension()],
478
505
  pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
479
506
  uploadFile: async (file: File) => {
480
507
  const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectLinks } from "./autoLink";
3
+
4
+ describe("detectLinks", () => {
5
+ it("detects a single https URL", () => {
6
+ expect(detectLinks("https://example.com")).toEqual([
7
+ { start: 0, end: 19, href: "https://example.com" },
8
+ ]);
9
+ });
10
+
11
+ it("detects an http URL", () => {
12
+ expect(detectLinks("http://example.com")).toEqual([
13
+ { start: 0, end: 18, href: "http://example.com" },
14
+ ]);
15
+ });
16
+
17
+ it("detects a URL embedded in prose", () => {
18
+ // "See https://example.com now" — the URL starts at index 4.
19
+ expect(detectLinks("See https://example.com now")).toEqual([
20
+ { start: 4, end: 23, href: "https://example.com" },
21
+ ]);
22
+ });
23
+
24
+ it("detects multiple URLs in one string", () => {
25
+ const matches = detectLinks("a https://a.com b http://b.com");
26
+ expect(matches).toEqual([
27
+ { start: 2, end: 15, href: "https://a.com" },
28
+ { start: 18, end: 30, href: "http://b.com" },
29
+ ]);
30
+ });
31
+
32
+ it("keeps a full URL with path, query and fragment", () => {
33
+ const url = "https://example.com/path/to?q=1&x=2#frag";
34
+ expect(detectLinks(url)).toEqual([{ start: 0, end: url.length, href: url }]);
35
+ });
36
+
37
+ it("trims a trailing sentence period", () => {
38
+ expect(detectLinks("Read https://example.com.")).toEqual([
39
+ { start: 5, end: 24, href: "https://example.com" },
40
+ ]);
41
+ });
42
+
43
+ it("trims trailing punctuation like comma and question mark", () => {
44
+ expect(detectLinks("go to https://a.com, ok?")).toEqual([
45
+ { start: 6, end: 19, href: "https://a.com" },
46
+ ]);
47
+ expect(detectLinks("is it https://a.com?")).toEqual([
48
+ { start: 6, end: 19, href: "https://a.com" },
49
+ ]);
50
+ });
51
+
52
+ it("excludes an unbalanced closing paren wrapping the URL", () => {
53
+ expect(detectLinks("(see https://example.com)")).toEqual([
54
+ { start: 5, end: 24, href: "https://example.com" },
55
+ ]);
56
+ });
57
+
58
+ it("keeps balanced parentheses that belong to the URL", () => {
59
+ const url = "https://en.wikipedia.org/wiki/Foo_(bar)";
60
+ expect(detectLinks(url)).toEqual([{ start: 0, end: url.length, href: url }]);
61
+ });
62
+
63
+ it("keeps a balanced paren but trims the trailing sentence period", () => {
64
+ const text = "https://en.wikipedia.org/wiki/Foo_(bar).";
65
+ expect(detectLinks(text)).toEqual([
66
+ {
67
+ start: 0,
68
+ end: text.length - 1,
69
+ href: "https://en.wikipedia.org/wiki/Foo_(bar)",
70
+ },
71
+ ]);
72
+ });
73
+
74
+ it("stops the URL at whitespace", () => {
75
+ expect(detectLinks("https://a.com and more")).toEqual([
76
+ { start: 0, end: 13, href: "https://a.com" },
77
+ ]);
78
+ });
79
+
80
+ it("does not match bare domains without a scheme", () => {
81
+ expect(detectLinks("visit example.com or www.example.com")).toEqual([]);
82
+ });
83
+
84
+ it("does not match ftp or other schemes", () => {
85
+ expect(detectLinks("ftp://files.example.com")).toEqual([]);
86
+ });
87
+
88
+ it("does not match a scheme with no host", () => {
89
+ expect(detectLinks("https://")).toEqual([]);
90
+ });
91
+
92
+ it("returns an empty array for text without URLs", () => {
93
+ expect(detectLinks("A plain sentence with no links")).toEqual([]);
94
+ });
95
+
96
+ it("is case-insensitive on the scheme", () => {
97
+ expect(detectLinks("HTTPS://Example.com")).toEqual([
98
+ { start: 0, end: 19, href: "HTTPS://Example.com" },
99
+ ]);
100
+ });
101
+
102
+ it("does not keep stale regex state across calls", () => {
103
+ // Guards against a shared lastIndex leaking between invocations.
104
+ expect(detectLinks("https://one.com")).toEqual([
105
+ { start: 0, end: 15, href: "https://one.com" },
106
+ ]);
107
+ expect(detectLinks("https://two.com")).toEqual([
108
+ { start: 0, end: 15, href: "https://two.com" },
109
+ ]);
110
+ });
111
+ });
@@ -0,0 +1,248 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ import type { Mark, Node as PMNode } from "prosemirror-model";
3
+ import { Plugin, PluginKey } from "prosemirror-state";
4
+ import { Decoration, DecorationSet } from "prosemirror-view";
5
+ import type { EditorView } from "prosemirror-view";
6
+
7
+ /**
8
+ * Auto-linking paints bare `http(s)://…` URLs as clickable links *without*
9
+ * modifying the document. Like the tag-badge decoration, the underlying text is
10
+ * left untouched, so markdown serialization round-trips unchanged — a bare URL
11
+ * stays a bare URL (which GFM auto-links anyway) instead of being rewritten into
12
+ * `[url](url)`.
13
+ *
14
+ * A URL is greedily matched as `http://`/`https://` followed by any run of
15
+ * non-whitespace, non-angle-bracket, non-quote characters; trailing sentence
16
+ * punctuation and unbalanced closing brackets are then trimmed so that natural
17
+ * prose like `see https://example.com.` or `(https://example.com)` links the URL
18
+ * without swallowing the surrounding punctuation.
19
+ */
20
+ const URL_DETECT_REGEXP = /https?:\/\/[^\s<>"'`]+/gi;
21
+
22
+ /** Trailing characters that are almost always prose punctuation, not URL. */
23
+ const TRAILING_PUNCTUATION = /[.,;:!?'"”’»)\]}]$/;
24
+
25
+ const CLOSING_TO_OPENING: Record<string, string> = {
26
+ ")": "(",
27
+ "]": "[",
28
+ "}": "{",
29
+ };
30
+
31
+ export interface LinkMatch {
32
+ /** Offset of the first character of the URL within the scanned string. */
33
+ start: number;
34
+ /** Offset just past the last character of the URL. */
35
+ end: number;
36
+ /** The matched URL, used verbatim as the `href`. */
37
+ href: string;
38
+ }
39
+
40
+ function countChar(text: string, char: string): number {
41
+ let count = 0;
42
+ for (const c of text) {
43
+ if (c === char) count++;
44
+ }
45
+ return count;
46
+ }
47
+
48
+ /**
49
+ * Strip trailing punctuation that the greedy match may have swallowed. A closing
50
+ * bracket is only stripped when it is *unbalanced* (no matching opener inside the
51
+ * URL), so real URLs such as a Wikipedia `..._(disambiguation)` link keep their
52
+ * parentheses.
53
+ */
54
+ function trimTrailingPunctuation(url: string): string {
55
+ let result = url;
56
+ // Loop because stripping a bracket can expose more punctuation and vice versa,
57
+ // e.g. `https://x.com/a).` → `https://x.com/a)` → `https://x.com/a`.
58
+ for (;;) {
59
+ const last = result[result.length - 1];
60
+ if (last === undefined) break;
61
+
62
+ const opening = CLOSING_TO_OPENING[last];
63
+ if (opening) {
64
+ if (countChar(result, last) > countChar(result, opening)) {
65
+ result = result.slice(0, -1);
66
+ continue;
67
+ }
68
+ break;
69
+ }
70
+
71
+ if (TRAILING_PUNCTUATION.test(last)) {
72
+ result = result.slice(0, -1);
73
+ continue;
74
+ }
75
+
76
+ break;
77
+ }
78
+ return result;
79
+ }
80
+
81
+ /**
82
+ * Find every `http(s)://` URL inside a plain string and return their offsets.
83
+ * Pure and DOM-free so it can be unit-tested directly.
84
+ */
85
+ export function detectLinks(text: string): LinkMatch[] {
86
+ const matches: LinkMatch[] = [];
87
+ // Fresh regex per call so the shared `lastIndex` never leaks between calls.
88
+ const regexp = new RegExp(URL_DETECT_REGEXP.source, "gi");
89
+ let match: RegExpExecArray | null;
90
+ while ((match = regexp.exec(text)) !== null) {
91
+ const href = trimTrailingPunctuation(match[0]);
92
+ // The trim only ever removes characters from the end, so `start` is stable.
93
+ if (href.length > 0) {
94
+ matches.push({ start: match.index, end: match.index + href.length, href });
95
+ }
96
+ // Defensive guard against a zero-length match looping forever (a URL always
97
+ // starts with `http`, so this should never trigger).
98
+ if (regexp.lastIndex === match.index) {
99
+ regexp.lastIndex++;
100
+ }
101
+ }
102
+ return matches;
103
+ }
104
+
105
+ const autoLinkPluginKey = new PluginKey<DecorationSet>("testomatioAutoLink");
106
+
107
+ /**
108
+ * Text carrying a real `link` mark (already an anchor) or a `code` mark
109
+ * (inline code, where a URL is a literal, not a link) is skipped so we never
110
+ * double-decorate or linkify code.
111
+ */
112
+ function hasBlockingMark(marks: readonly Mark[]): boolean {
113
+ return marks.some(
114
+ (mark) => mark.type.name === "link" || mark.type.name === "code",
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Build inline decorations for every URL in the document. Code blocks are
120
+ * skipped wholesale; inside every other block, each text node is scanned and any
121
+ * URL that isn't already a link or inline code is painted.
122
+ */
123
+ function buildLinkDecorations(doc: PMNode): DecorationSet {
124
+ const decorations: Decoration[] = [];
125
+
126
+ doc.descendants((node, pos) => {
127
+ // Never linkify inside code blocks — a URL there is source text, not a link.
128
+ if (node.type.spec.code) {
129
+ return false;
130
+ }
131
+
132
+ if (!node.isText || !node.text || hasBlockingMark(node.marks)) {
133
+ return undefined;
134
+ }
135
+
136
+ for (const { start, end, href } of detectLinks(node.text)) {
137
+ decorations.push(
138
+ Decoration.inline(
139
+ pos + start,
140
+ pos + end,
141
+ {
142
+ class: "bn-auto-link",
143
+ "data-auto-href": href,
144
+ title: href,
145
+ },
146
+ // Stored on the decoration spec so the click handler can resolve the
147
+ // href from document coordinates without trusting the DOM.
148
+ { autoHref: href },
149
+ ),
150
+ );
151
+ }
152
+
153
+ return undefined;
154
+ });
155
+
156
+ return DecorationSet.create(doc, decorations);
157
+ }
158
+
159
+ /** True when the pointer event carries the platform "open link" modifier. */
160
+ function hasOpenModifier(event: MouseEvent): boolean {
161
+ // Cmd on macOS, Ctrl elsewhere — the same convention as VS Code / IntelliJ.
162
+ return event.metaKey || event.ctrlKey;
163
+ }
164
+
165
+ function findAutoHrefAt(view: EditorView, pos: number): string | null {
166
+ const decorations = autoLinkPluginKey.getState(view.state);
167
+ if (!decorations) return null;
168
+ const found = decorations.find(pos, pos);
169
+ for (const deco of found) {
170
+ const href = (deco.spec as { autoHref?: string }).autoHref;
171
+ if (typeof href === "string") return href;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function openHref(href: string): void {
177
+ if (typeof window === "undefined") return;
178
+ // `noopener,noreferrer` so the opened tab can't reach back via `window.opener`.
179
+ window.open(href, "_blank", "noopener,noreferrer");
180
+ }
181
+
182
+ interface AutoLinkPluginOptions {
183
+ /**
184
+ * When `true`, a plain click opens the link. When `false` (default), the
185
+ * platform modifier (Cmd/Ctrl) is required so plain clicks still place the
186
+ * text cursor for editing — the safer default inside an editor.
187
+ */
188
+ openOnPlainClick: boolean;
189
+ }
190
+
191
+ function autoLinkPlugin(options: AutoLinkPluginOptions): Plugin<DecorationSet> {
192
+ return new Plugin<DecorationSet>({
193
+ key: autoLinkPluginKey,
194
+ state: {
195
+ init: (_config, state) => buildLinkDecorations(state.doc),
196
+ apply: (tr, value) =>
197
+ tr.docChanged ? buildLinkDecorations(tr.doc) : value,
198
+ },
199
+ props: {
200
+ decorations(state) {
201
+ return autoLinkPluginKey.getState(state);
202
+ },
203
+ handleClick(view, pos, event) {
204
+ if (!options.openOnPlainClick && !hasOpenModifier(event)) {
205
+ return false;
206
+ }
207
+ const href = findAutoHrefAt(view, pos);
208
+ if (!href) return false;
209
+ event.preventDefault();
210
+ openHref(href);
211
+ return true;
212
+ },
213
+ },
214
+ });
215
+ }
216
+
217
+ /**
218
+ * BlockNote extension that renders bare `http(s)://` URLs as clickable links.
219
+ *
220
+ * Editor extensions are supplied at editor-creation time and cannot be carried
221
+ * by the schema, so consumers add this to their `useCreateBlockNote` call:
222
+ *
223
+ * ```ts
224
+ * useCreateBlockNote({
225
+ * schema: customSchema,
226
+ * extensions: [autoLinkExtension()],
227
+ * });
228
+ * ```
229
+ *
230
+ * By default a link opens on Cmd/Ctrl+click (plain clicks still edit the text).
231
+ * Pass `{ openOnPlainClick: true }` to open on a plain click instead.
232
+ */
233
+ export class AutoLinkExtension extends BlockNoteExtension {
234
+ static key() {
235
+ return "autoLink";
236
+ }
237
+
238
+ constructor(options: Partial<AutoLinkPluginOptions> = {}) {
239
+ super();
240
+ this.addProsemirrorPlugin(
241
+ autoLinkPlugin({ openOnPlainClick: options.openOnPlainClick ?? false }),
242
+ );
243
+ }
244
+ }
245
+
246
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
247
+ export const autoLinkExtension = (options?: Partial<AutoLinkPluginOptions>) =>
248
+ new AutoLinkExtension(options);
@@ -0,0 +1,24 @@
1
+ import { createReactBlockSpec } from "@blocknote/react";
2
+
3
+ /**
4
+ * A read-only marker rendered from the `<!-- example -->` HTML comment that
5
+ * precedes a data/examples table in a testomat.io test file. It shows a compact
6
+ * "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
7
+ * marker reads as a UI label instead of raw comment text. It has no fields and
8
+ * round-trips back to `<!-- example -->` on serialize.
9
+ */
10
+ export const exampleMarkerBlock = createReactBlockSpec(
11
+ { type: "exampleMarker", content: "none", propSchema: {} },
12
+ {
13
+ render: () => (
14
+ <div
15
+ className="bn-example-marker"
16
+ contentEditable={false}
17
+ suppressContentEditableWarning
18
+ draggable={false}
19
+ >
20
+ <span className="bn-example-marker__label">examples</span>
21
+ </div>
22
+ ),
23
+ },
24
+ );
@@ -3182,6 +3182,34 @@ describe("test/suite metadata comments", () => {
3182
3182
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3183
3183
  });
3184
3184
 
3185
+ it("parses an <!-- example --> marker into an exampleMarker block", () => {
3186
+ const blocks = markdownToBlocks("<!-- example -->");
3187
+ expect(blocks).toEqual([
3188
+ {
3189
+ type: "exampleMarker",
3190
+ props: {},
3191
+ children: [],
3192
+ },
3193
+ ]);
3194
+ });
3195
+
3196
+ it("parses the plural <!-- examples --> spelling too", () => {
3197
+ const blocks = markdownToBlocks("<!-- examples -->");
3198
+ expect(blocks).toEqual([
3199
+ {
3200
+ type: "exampleMarker",
3201
+ props: {},
3202
+ children: [],
3203
+ },
3204
+ ]);
3205
+ });
3206
+
3207
+ it("round-trips an <!-- example --> marker", () => {
3208
+ const markdown = "<!-- example -->";
3209
+ const blocks = markdownToBlocks(markdown);
3210
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3211
+ });
3212
+
3185
3213
  it("parses a multi-line suite block with ordered fields", () => {
3186
3214
  const markdown = [
3187
3215
  "<!-- suite",
@@ -425,6 +425,8 @@ function serializeBlock(
425
425
  }
426
426
  return lines;
427
427
  }
428
+ case "exampleMarker":
429
+ return ["<!-- example -->"];
428
430
  case "testMeta": {
429
431
  const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
430
432
  const inline = Boolean((block.props as any).metaInline);
@@ -1435,6 +1437,23 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
1435
1437
 
1436
1438
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1437
1439
 
1440
+ // Marks the data/examples table that follows in a testomat.io test file. Accepts
1441
+ // both the singular `example` and plural `examples` spellings.
1442
+ const EXAMPLE_MARKER_REGEX = /^<!--\s*examples?\s*-->\s*$/i;
1443
+
1444
+ function parseExampleMarker(
1445
+ lines: string[],
1446
+ index: number,
1447
+ ): { block: CustomPartialBlock; nextIndex: number } | null {
1448
+ if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
1449
+ return null;
1450
+ }
1451
+ return {
1452
+ block: { type: "exampleMarker", props: {}, children: [] } as CustomPartialBlock,
1453
+ nextIndex: index + 1,
1454
+ };
1455
+ }
1456
+
1438
1457
  // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1439
1458
  // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1440
1459
  // round-trip back to a list on serialize; everything else stays a flat line.
@@ -1691,6 +1710,15 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp
1691
1710
  continue;
1692
1711
  }
1693
1712
 
1713
+ // Caught before parseParagraph so the `<!-- example -->` marker renders as
1714
+ // an "examples" panel instead of raw comment text.
1715
+ const exampleMarker = parseExampleMarker(lines, index);
1716
+ if (exampleMarker) {
1717
+ blocks.push(exampleMarker.block);
1718
+ index = exampleMarker.nextIndex;
1719
+ continue;
1720
+ }
1721
+
1694
1722
  const snippetWrapper = stepsHeadingLevel !== null
1695
1723
  ? parseSnippetWrapper(lines, index)
1696
1724
  : null;
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
3
3
  import { stepBlock } from "./blocks/step";
4
4
  import { snippetBlock } from "./blocks/snippet";
5
5
  import { testMetaBlock } from "./blocks/testMeta";
6
+ import { exampleMarkerBlock } from "./blocks/exampleMarker";
6
7
  import { fileBlock } from "./blocks/fileBlock";
7
8
  import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
8
9
 
@@ -13,6 +14,7 @@ export const customSchema = BlockNoteSchema.create({
13
14
  testStep: stepBlock,
14
15
  snippet: snippetBlock,
15
16
  testMeta: testMetaBlock,
17
+ exampleMarker: exampleMarkerBlock,
16
18
  },
17
19
  });
18
20
 
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isSuiteDocument, markExampleTables } from "./exampleTableHighlight";
3
+
4
+ describe("isSuiteDocument", () => {
5
+ it("is true only when the first block is a suite testMeta", () => {
6
+ expect(isSuiteDocument("testMeta", "suite")).toBe(true);
7
+ });
8
+
9
+ it("is false for a document opening with a test (not a suite)", () => {
10
+ expect(isSuiteDocument("testMeta", "test")).toBe(false);
11
+ });
12
+
13
+ it("is false when the first block is not a testMeta", () => {
14
+ expect(isSuiteDocument("heading", undefined)).toBe(false);
15
+ expect(isSuiteDocument("paragraph", "suite")).toBe(false);
16
+ });
17
+
18
+ it("is false for an empty document", () => {
19
+ expect(isSuiteDocument(undefined, undefined)).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("markExampleTables", () => {
24
+ it("flags a table that follows an example marker", () => {
25
+ expect(markExampleTables(["exampleMarker", "paragraph", "table"])).toEqual([
26
+ false,
27
+ false,
28
+ true,
29
+ ]);
30
+ });
31
+
32
+ it("stops the region at the next test/suite comment", () => {
33
+ // First table is in the example region; the testMeta closes it, so the
34
+ // second table (belonging to the next test) is not flagged.
35
+ expect(
36
+ markExampleTables(["exampleMarker", "table", "testMeta", "table"]),
37
+ ).toEqual([false, true, false, false]);
38
+ });
39
+
40
+ it("does not flag tables without a preceding example marker", () => {
41
+ expect(markExampleTables(["table"])).toEqual([false]);
42
+ expect(markExampleTables(["testMeta", "table"])).toEqual([false, false]);
43
+ });
44
+
45
+ it("keeps the region open across headings and other blocks", () => {
46
+ expect(
47
+ markExampleTables(["exampleMarker", "heading", "paragraph", "table"]),
48
+ ).toEqual([false, false, false, true]);
49
+ });
50
+
51
+ it("flags every table in the region until the next test", () => {
52
+ expect(
53
+ markExampleTables(["exampleMarker", "table", "table", "testMeta", "table"]),
54
+ ).toEqual([false, true, true, false, false]);
55
+ });
56
+
57
+ it("reopens the region when a new example marker appears", () => {
58
+ expect(
59
+ markExampleTables(["exampleMarker", "table", "testMeta", "exampleMarker", "table"]),
60
+ ).toEqual([false, true, false, false, true]);
61
+ });
62
+
63
+ it("returns an empty array for no blocks", () => {
64
+ expect(markExampleTables([])).toEqual([]);
65
+ });
66
+ });