Skip to content
Merged
Changes from 2 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
112 changes: 112 additions & 0 deletions test/property/table-formatter.property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import * as fc from "fast-check";
import {
buildTable,
buildTableRow,
type TableColumn,
type TableOptions,
} from "../../lib/table-formatter.js";
import { displayWidth } from "../../lib/ui/display-width.js";

// Mix single-column ASCII with double-column CJK and emoji so padding and
// truncation are exercised in display columns, not UTF-16 code units (ui-02).
const arbCellText = fc
.array(fc.constantFrom("a", "B", "7", " ", "-", "é", "漢", "字", "🚀", "⚡"), {
minLength: 0,
maxLength: 12,
})
.map((chars) => chars.join(""));

const arbColumn: fc.Arbitrary<TableColumn> = fc.record({
header: arbCellText,
width: fc.integer({ min: 0, max: 10 }),
align: fc.constantFrom<"left" | "right">("left", "right"),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
});

const arbOptions: fc.Arbitrary<TableOptions> = fc
.array(arbColumn, { minLength: 1, maxLength: 5 })
.map((columns) => ({ columns }));

function expectedLineWidth(options: TableOptions): number {
const widths = options.columns.map((column) => Math.max(0, column.width));
return widths.reduce((sum, width) => sum + width, 0) + (options.columns.length - 1);
}

describe("table formatter property invariants", () => {
it("every line of any table has the exact same display width as the layout", () => {
fc.assert(
fc.property(
arbOptions,
fc.array(fc.array(arbCellText, { minLength: 0, maxLength: 6 }), {
minLength: 0,
maxLength: 8,
}),
(options, rows) => {
const lines = buildTable(rows, options);
expect(lines).toHaveLength(rows.length + 2);
const layoutWidth = expectedLineWidth(options);
for (const line of lines) {
// Header, separator, and every data row stay in lockstep no
// matter what content (incl. CJK/emoji, missing cells, or
// extra cells beyond the column count) lands in the rows.
expect(displayWidth(line)).toBe(layoutWidth);
}
},
),
);
});

it("content that fits is preserved verbatim with padding on the declared side", () => {
fc.assert(
fc.property(
arbCellText,
fc.integer({ min: 1, max: 14 }),
fc.constantFrom<"left" | "right">("left", "right"),
(value, width, align) => {
fc.pre(displayWidth(value) <= width);
const row = buildTableRow([value], {
columns: [{ header: "h", width, align }],
});
const pad = " ".repeat(width - displayWidth(value));
expect(row).toBe(align === "right" ? pad + value : value + pad);
},
),
);
});

it("content that overflows is truncated to a prefix plus ellipsis, never overflowing", () => {
fc.assert(
fc.property(
arbCellText,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
fc.integer({ min: 1, max: 6 }),
fc.constantFrom<"left" | "right">("left", "right"),
(value, width, align) => {
fc.pre(displayWidth(value) > width);
const row = buildTableRow([value], {
columns: [{ header: "h", width, align }],
});
expect(displayWidth(row)).toBe(width);
// Truncation happens before alignment padding, so stripping the
// padding side must reveal a prefix of the original value
// followed by the ellipsis — for either alignment.
const visible =
align === "left" ? row.replace(/ +$/, "") : row.replace(/^ +/, "");
expect(visible.endsWith("…")).toBe(true);
expect(value.startsWith(visible.slice(0, -1))).toBe(true);
expect(displayWidth(visible)).toBeLessThanOrEqual(width);
},
),
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("zero-width columns render empty and never leak an ellipsis into the layout", () => {
fc.assert(
fc.property(arbCellText, (value) => {
const row = buildTableRow([value], {
columns: [{ header: "h", width: 0 }],
});
expect(row).toBe("");
}),
);
});
});