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