feat(frontend): RawSegmentsPanel collapsible debug aid
This commit is contained in:
@@ -0,0 +1,203 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Pure prop-driven component; match the act() convention used by the
|
||||||
|
// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton /
|
||||||
|
// ClaimDrawerError / ValidationPanel / ServiceLinesTable / DiagnosesList
|
||||||
|
// / PartiesGrid).
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
||||||
|
|
||||||
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RawSegmentsPanel", () => {
|
||||||
|
it("test_section_label_includes_count", () => {
|
||||||
|
const segments = [
|
||||||
|
["ISA", "00", " ", "00", " ", "ZZ", "SUBMITTERID ", "ZZ", "RECEIVERID ", "260615", "1200", "^", "00501", "000000001", "0", "P", ":"],
|
||||||
|
["GS", "HC", "SUBMITTERID", "RECEIVERID", "20260615", "1200", "1", "X", "005010X222A1"],
|
||||||
|
["ST", "837", "0001", "005010X222A1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={segments} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = container.querySelector('[data-testid="section-label"]');
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
|
||||||
|
// `uppercase` is applied via Tailwind CSS, so textContent gives the
|
||||||
|
// source-case string "Raw Segments"; assert case-insensitively and
|
||||||
|
// confirm the visual transform via the className sniff below.
|
||||||
|
const text = (label?.textContent ?? "").toLowerCase();
|
||||||
|
expect(text).toContain("raw segments");
|
||||||
|
expect(text).toContain("(3)");
|
||||||
|
|
||||||
|
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||||
|
const cls = label?.className ?? "";
|
||||||
|
expect(cls).toContain("uppercase");
|
||||||
|
expect(cls).toContain("tracking-[0.18em]");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_collapsed_by_default", () => {
|
||||||
|
const segments = [
|
||||||
|
["ISA", "00", " "],
|
||||||
|
["GS", "HC", "X"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={segments} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
// The HTML `open` attribute is absent (not just `false`) when
|
||||||
|
// collapsed — assert the attribute is literally missing.
|
||||||
|
expect(details?.hasAttribute("open")).toBe(false);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_clicking_summary_expands_the_panel", () => {
|
||||||
|
const segments = [
|
||||||
|
["ISA", "00"],
|
||||||
|
["GS", "HC"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={segments} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
const summary = container.querySelector("summary");
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
// Pre-click: collapsed.
|
||||||
|
expect(details?.hasAttribute("open")).toBe(false);
|
||||||
|
|
||||||
|
// Click the summary to expand.
|
||||||
|
act(() => {
|
||||||
|
summary?.dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(details?.open).toBe(true);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_expanded_panel_renders_segments_with_star_separator", () => {
|
||||||
|
const segments = [
|
||||||
|
["ISA", "00", " ", "00", " ", "ZZ", "SUBMITTERID "],
|
||||||
|
["GS", "HC", "SUBMITTERID", "RECEIVERID", "20260615", "1200", "1", "X", "005010X222A1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={segments} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
const summary = container.querySelector("summary");
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
act(() => {
|
||||||
|
summary?.dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(details?.open).toBe(true);
|
||||||
|
|
||||||
|
const pre = container.querySelector('[data-testid="raw-segments-pre"]');
|
||||||
|
expect(pre).not.toBeNull();
|
||||||
|
const text = pre?.textContent ?? "";
|
||||||
|
|
||||||
|
// First segment: elements joined by "*" on a single line.
|
||||||
|
expect(text).toContain("ISA*00*");
|
||||||
|
expect(text).toContain("SUBMITTERID ");
|
||||||
|
// Second segment also rendered.
|
||||||
|
expect(text).toContain("GS*HC*SUBMITTERID*RECEIVERID*20260615*1200*1*X*005010X222A1");
|
||||||
|
|
||||||
|
// Lines are separated — newlines in the rendered text.
|
||||||
|
const lines = text.split("\n");
|
||||||
|
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_empty_array_renders_count_and_empty_state", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={[]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section label still present with count "(0)".
|
||||||
|
const label = container.querySelector('[data-testid="section-label"]');
|
||||||
|
expect(label?.textContent ?? "").toContain("(0)");
|
||||||
|
|
||||||
|
// Empty-state body is present.
|
||||||
|
const empty = container.querySelector('[data-testid="raw-segments-empty"]');
|
||||||
|
expect(empty).not.toBeNull();
|
||||||
|
expect(empty?.textContent ?? "").toContain("No segments");
|
||||||
|
|
||||||
|
// The <pre> with segments is NOT rendered in the empty state.
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="raw-segments-pre"]')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// The <details>/<summary> are still present so the user can see
|
||||||
|
// the section header even when there's nothing inside.
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
const summary = container.querySelector("summary");
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_uses_monospace_font_for_pre_block", () => {
|
||||||
|
const segments = [["ST", "837", "0001"]];
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<RawSegmentsPanel rawSegments={segments} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = container.querySelector("summary");
|
||||||
|
act(() => {
|
||||||
|
summary?.dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pre = container.querySelector('[data-testid="raw-segments-pre"]');
|
||||||
|
expect(pre).not.toBeNull();
|
||||||
|
// The mono styling is either via class or inline style; sniff both.
|
||||||
|
const cls = pre?.className ?? "";
|
||||||
|
const style = pre?.getAttribute("style") ?? "";
|
||||||
|
const isMono =
|
||||||
|
cls.includes("font-mono") ||
|
||||||
|
style.includes("--m-font-mono") ||
|
||||||
|
style.includes("font-mono");
|
||||||
|
expect(isMono).toBe(true);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
|
type RawSegmentsPanelProps = {
|
||||||
|
rawSegments: ClaimDetail["rawSegments"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single segment by joining its elements with `*` (the X12
|
||||||
|
* element separator). Trailing/leading whitespace inside individual
|
||||||
|
* elements is preserved — these strings come straight off the parser
|
||||||
|
* and any trimming would change the visible byte-for-byte content.
|
||||||
|
*/
|
||||||
|
function formatSegment(elements: string[]): string {
|
||||||
|
return elements.join("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-segments debug section of the claim detail drawer (SP4).
|
||||||
|
*
|
||||||
|
* Renders the exact X12 segment strings the parser saw, collapsed by
|
||||||
|
* default behind a native <details>/<summary> so the user can opt in
|
||||||
|
* to a wall of monospace when they're debugging. Element separator is
|
||||||
|
* `*` and segment terminator is implicit at the end of each line —
|
||||||
|
* matches what the parser produces. This is a read-only diagnostic;
|
||||||
|
* the plan keeps it intentionally simple (no copy button for v1).
|
||||||
|
*/
|
||||||
|
export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="raw-segments-panel"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
data-testid="section-label"
|
||||||
|
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||||
|
>
|
||||||
|
Raw Segments ({rawSegments.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<details className="rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]">
|
||||||
|
<summary
|
||||||
|
className="cursor-pointer select-none px-4 py-2 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
|
||||||
|
data-testid="raw-segments-summary"
|
||||||
|
>
|
||||||
|
{rawSegments.length === 0
|
||||||
|
? "Show raw segments"
|
||||||
|
: "Show raw segments"}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
{rawSegments.length === 0 ? (
|
||||||
|
<p
|
||||||
|
data-testid="raw-segments-empty"
|
||||||
|
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||||
|
>
|
||||||
|
No segments
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
data-testid="raw-segments-pre"
|
||||||
|
className="overflow-x-auto whitespace-pre-wrap px-4 pb-3 pt-1 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
|
||||||
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
|
>
|
||||||
|
{rawSegments.map((segment, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 ? "\n" : null}
|
||||||
|
<span data-testid="raw-segment-line">{formatSegment(segment)}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user