diff --git a/src/editor/customMarkdownConverter.test.ts b/src/editor/customMarkdownConverter.test.ts index f5a4e0e..c3fc7d7 100644 --- a/src/editor/customMarkdownConverter.test.ts +++ b/src/editor/customMarkdownConverter.test.ts @@ -849,6 +849,86 @@ describe("markdownToBlocks", () => { ]); }); + it("parses sub-bulleted *Expected*: lines as the step's expected result", () => { + const markdown = [ + "### Steps", + "", + "* Navigate to the “Transfer Funds” page.", + " * *Expected*: The Transfer Funds page loads, showing fields for source account, destination account, and amount.", + "", + "* Select the user’s own source account from the dropdown list.", + " * *Expected*: The selected account is displayed and its current balance is shown.", + ].join("\n"); + + const blocks = markdownToBlocks(markdown); + const stepBlocks = blocks.filter((b) => b.type === "testStep"); + expect(stepBlocks).toEqual([ + { + type: "testStep", + props: { + stepTitle: "Navigate to the “Transfer Funds” page.", + stepData: "", + expectedResult: + "The Transfer Funds page loads, showing fields for source account, destination account, and amount.\n", + listStyle: "bullet", + }, + children: [], + }, + { + type: "testStep", + props: { + stepTitle: "Select the user’s own source account from the dropdown list.", + stepData: "", + expectedResult: + "The selected account is displayed and its current balance is shown.", + listStyle: "bullet", + }, + children: [], + }, + ]); + }); + + it("parses sub-bulleted **Expected**: (bold) lines as the step's expected result", () => { + const markdown = [ + "### Steps", + "", + "* Open the Login page.", + " * **Expected**: The Login page loads successfully.", + ].join("\n"); + + const blocks = markdownToBlocks(markdown); + const stepBlocks = blocks.filter((b) => b.type === "testStep"); + expect(stepBlocks).toEqual([ + { + type: "testStep", + props: { + stepTitle: "Open the Login page.", + stepData: "", + expectedResult: "The Login page loads successfully.", + listStyle: "bullet", + }, + children: [], + }, + ]); + }); + + it("round-trips sub-bulleted *Expected*: lines through the canonical serialized form", () => { + const markdown = [ + "### Steps", + "", + "* Navigate to the “Transfer Funds” page.", + " * *Expected*: The Transfer Funds page loads.", + ].join("\n"); + + const firstPass = markdownToBlocks(markdown); + const serialized = blocksToMarkdown(firstPass as CustomEditorBlock[]); + const secondPass = markdownToBlocks(serialized); + + const firstSteps = firstPass.filter((b) => b.type === "testStep"); + const secondSteps = secondPass.filter((b) => b.type === "testStep"); + expect(secondSteps).toEqual(firstSteps); + }); + it("parses a step with empty title but with step data", () => { const markdown = ["### Steps", "", "* ", " Navigate to the page"].join("\n"); @@ -1332,6 +1412,25 @@ describe("markdownToBlocks", () => { expect(nestedChildren.some((child) => child.type === "bulletListItem")).toBe(true); }); + it("parses a uniformly indented list as a flat top-level list", () => { + const markdown = [ + "# Requirements", + "", + " * User has an active account on the platform.", + " * User has sufficient funds in the source account.", + " * QR code contains valid transfer details and is scannable.", + " * The device has camera access and QR scanning capability.", + " * The user is authenticated and authorized to perform transfers.", + ].join("\n"); + + const blocks = markdownToBlocks(markdown); + const bulletItems = blocks.filter((b) => b.type === "bulletListItem"); + expect(bulletItems).toHaveLength(5); + for (const item of bulletItems) { + expect(item.children ?? []).toEqual([]); + } + }); + it("does not freeze on indented list items without a parent", () => { const markdown = [ "### Requirements", @@ -2503,6 +2602,59 @@ describe("markdownToBlocks", () => { // Most importantly: should not have a standalone "!" at the end expect(roundTripMarkdown).not.toMatch(/\n!\s*$/); }); + + it("parses a single-line fenced code block", () => { + const markdown = "```{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two```"; + const blocks = markdownToBlocks(markdown); + expect(blocks).toEqual([ + { + type: "codeBlock", + props: { language: "" }, + content: [ + { + type: "text", + text: "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two", + styles: {}, + }, + ], + children: [], + }, + ]); + }); + + it("parses an empty single-line fence without swallowing following lines", () => { + const markdown = ["``````", "next line"].join("\n"); + const blocks = markdownToBlocks(markdown); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toEqual({ + type: "codeBlock", + props: { language: "" }, + content: undefined, + children: [], + }); + expect(blocks[1].type).toBe("paragraph"); + }); + + it("normalizes a single-line fenced code block to multi-line on round-trip", () => { + const markdown = "```hello world```"; + const blocks = markdownToBlocks(markdown); + expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe( + ["```", "hello world", "```"].join("\n"), + ); + }); + + it("still treats an opening fence with a language identifier as multi-line", () => { + const markdown = ["```js", "const x = 1;", "```"].join("\n"); + const blocks = markdownToBlocks(markdown); + expect(blocks).toEqual([ + { + type: "codeBlock", + props: { language: "js" }, + content: [{ type: "text", text: "const x = 1;", styles: {} }], + children: [], + }, + ]); + }); }); describe("file block serialization", () => { diff --git a/src/editor/customMarkdownConverter.ts b/src/editor/customMarkdownConverter.ts index 80e3a1d..7870787 100644 --- a/src/editor/customMarkdownConverter.ts +++ b/src/editor/customMarkdownConverter.ts @@ -845,6 +845,11 @@ function parseList( ): ListParseResult { const items: CustomPartialBlock[] = []; let index = startIndex; + // The minimum leading-space count for items at this list level. Initialized to + // the parent's expected indent, but updated to the first item's actual indent + // so a uniformly indented list stays flat instead of nesting under itself. + let baseIndent = indentLevel * 2; + let firstItemSeen = false; while (index < lines.length) { const rawLine = lines[index]; @@ -864,7 +869,7 @@ function parseList( } const nextLine = lines[lookahead]; const nextIndent = countIndent(nextLine); - if (nextIndent < indentLevel * 2) { + if (nextIndent < baseIndent) { break; } const nextType = detectListType(nextLine.trim()); @@ -877,14 +882,17 @@ function parseList( let indent = countIndent(rawLine); - if (indent < indentLevel * 2) { + if (indent < baseIndent) { break; } - // Check if this line should be parsed as nested content - // Only go deeper if indent is at least 2 more than the next level's expected indent - const nextLevelExpectedIndent = (indentLevel + 1) * 2; - if (indent >= nextLevelExpectedIndent && items.length > 0) { + if (!firstItemSeen) { + baseIndent = indent; + firstItemSeen = true; + } + + // Only go deeper if indent is at least 2 more than this list's base indent + if (indent >= baseIndent + 2 && items.length > 0) { const lastItem = items.at(-1); if (!lastItem) { break; @@ -1063,11 +1071,18 @@ function parseTestStep( break; } - // Check for expected result labels with different formatting - const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX); - const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) || - rawTrimmed.match(/^\*expected\*:\s*(.*)$/i) || - rawTrimmed.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i); + // Check for expected result labels with different formatting. + // Strip an optional leading bullet marker so patterns like + // "* *Expected*: ..." (a sub-bullet with an italic Expected label) are + // recognized via the same matchers as the bare label forms. + const bulletPrefixMatch = rawTrimmed.match(/^[*-]\s+/); + const lineForExpectedCheck = bulletPrefixMatch + ? rawTrimmed.slice(bulletPrefixMatch[0].length) + : rawTrimmed; + const expectedMatch = lineForExpectedCheck.match(EXPECTED_LABEL_REGEX); + const expectedStarMatch = lineForExpectedCheck.match(/^\*expected\s*\*:\s*(.*)$/i) || + lineForExpectedCheck.match(/^\*expected\*:\s*(.*)$/i) || + lineForExpectedCheck.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i); if (expectedMatch || expectedStarMatch) { inExpectedResult = true; @@ -1076,7 +1091,7 @@ function parseTestStep( if (expectedStarMatch) { content = (expectedStarMatch[1] || '').trim(); } else { - content = rawTrimmed.slice(expectedMatch![0].length).trim(); + content = lineForExpectedCheck.slice(expectedMatch![0].length).trim(); } // Add the content (if any) from this line @@ -1258,7 +1273,24 @@ function parseCodeBlock(lines: string[], index: number): { block: CustomPartialB return null; } - const language = trimmed.slice(3).trim(); + const afterOpening = trimmed.slice(3); + const closeMatch = afterOpening.match(/```\s*$/); + if (closeMatch) { + const content = afterOpening.slice(0, afterOpening.length - closeMatch[0].length); + return { + block: { + type: "codeBlock", + props: { language: "" }, + content: content.length + ? [{ type: "text", text: content, styles: {} }] + : undefined, + children: [], + }, + nextIndex: index + 1, + }; + } + + const language = afterOpening.trim(); const body: string[] = []; let next = index + 1; while (next < lines.length && !lines[next].startsWith("```") ) {