Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/(tools)/json-formatter/components/OutputPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo, useState } from "react";
import { parse, LosslessNumber } from "lossless-json";
import { ChevronDown, ChevronRight, List, TreePine, FoldVertical, UnfoldVertical } from "lucide-react";
import { tokenize } from "@/lib/json-utils";
import { cn } from "@/lib/cn";
Expand Down Expand Up @@ -146,10 +147,10 @@ function CodeView({ output, theme }: { output: string; theme: "dark" | "light" }
}

/* Tree view */
function TreeView({ output, forceOpen }: { output: string; forceOpen: boolean | null }) {
export function TreeView({ output, forceOpen }: { output: string; forceOpen: boolean | null }) {
const data = useMemo(() => {
try {
return { ok: true, value: JSON.parse(output) } as const;
return { ok: true, value: parse(output) } as const;
} catch {
return { ok: false } as const;
}
Expand Down Expand Up @@ -179,7 +180,7 @@ function TreeNode({
}) {
const defaultOpen = forceOpen === null ? depth < 2 : forceOpen;
const [open, setOpen] = useState(defaultOpen);
const isObj = value !== null && typeof value === "object" && !Array.isArray(value);
const isObj = value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof LosslessNumber);
const isArr = Array.isArray(value);
const isContainer = isObj || isArr;
const entries = isObj
Expand Down Expand Up @@ -269,6 +270,7 @@ function TreeNode({
function ValueToken({ value }: { value: unknown }) {
if (value === null) return <span className="tok-null">null</span>;
if (typeof value === "string") return <span className="tok-string">&quot;{value}&quot;</span>;
if (value instanceof LosslessNumber) return <span className="tok-number">{value.toString()}</span>;
if (typeof value === "number") return <span className="tok-number">{String(value)}</span>;
if (typeof value === "boolean") return <span className="tok-bool">{String(value)}</span>;
return <span>{String(value)}</span>;
Expand Down
77 changes: 77 additions & 0 deletions lib/__tests__/json-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it, expect } from "vitest";
import { TreeView } from "../../app/(tools)/json-formatter/components/OutputPanel";

function renderTree(output: string, forceOpen: boolean | null = true) {
return renderToStaticMarkup(createElement(TreeView, { output, forceOpen }));
}

describe("JSON Tree view", () => {
it("renders large integers and high-precision decimals as exact numeric leaves", () => {
const html = renderTree('{"id":12345678901234567890,"pi":3.141592653589793238}');

expect(html).toContain('<span class="tok-number">12345678901234567890</span>');
expect(html).toContain('<span class="tok-number">3.141592653589793238</span>');
// Only the enclosing object should have an expand/collapse control.
expect(html.match(/<button /g)).toHaveLength(1);
expect(html).not.toContain("isLosslessNumber");
});

it.each([
"9007199254740993",
"-12345678901234567890",
"3.141592653589793238",
"1e400",
"1e-400",
"-0",
"1.2300",
"42",
])("keeps the numeric root %s as a leaf even when collapsed", (number) => {
const html = renderTree(number, false);

expect(html).toContain(`<span class="tok-number">${number}</span>`);
expect(html).not.toContain("<button");
});

it("preserves exact numeric leaves in nested arrays and objects", () => {
const html = renderTree('{"items":[12345678901234567890,{"value":3.141592653589793238}]}');

expect(html).toContain('<span class="tok-number">12345678901234567890</span>');
expect(html).toContain('<span class="tok-number">3.141592653589793238</span>');
// Root object, items array and nested object — never the numbers themselves.
expect(html.match(/<button /g)).toHaveLength(3);
});

it("keeps strings, booleans and null distinct from numeric leaves", () => {
const html = renderTree('["12345678901234567890",true,false,null,42]');

expect(html).toContain('<span class="tok-string">&quot;12345678901234567890&quot;</span>');
expect(html).toContain('<span class="tok-bool">true</span>');
expect(html).toContain('<span class="tok-bool">false</span>');
expect(html).toContain('<span class="tok-null">null</span>');
expect(html).toContain('<span class="tok-number">42</span>');
});

it("still collapses real containers", () => {
const html = renderTree('{"id":12345678901234567890,"items":[]}', false);

expect(html).toContain('aria-label="Expand"');
expect(html).toContain("2 keys");
expect(html).not.toContain('class="tok-number"');
});

it("does not mistake user objects with LosslessNumber-like fields for numbers", () => {
const html = renderTree('{"isLosslessNumber":true,"value":"test","toString":"text"}');

expect(html.match(/<button /g)).toHaveLength(1);
expect(html).toContain('<span class="tok-key">&quot;isLosslessNumber&quot;</span>');
expect(html).toContain('<span class="tok-string">&quot;test&quot;</span>');
expect(html).toContain('<span class="tok-string">&quot;text&quot;</span>');
expect(html).not.toContain('class="tok-number"');
});

it("shows the existing error for invalid JSON", () => {
expect(renderTree('{"id":')).toContain("Could not parse tree view");
});
});
4 changes: 4 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";

export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL(".", import.meta.url)) },
},
test: {
environment: "node",
include: ["lib/**/*.test.ts"],
Expand Down