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
44 changes: 44 additions & 0 deletions __tests__/display-width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { visibleWidth } from "@earendil-works/pi-tui";
import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width";

describe("truncateToDisplayWidth", () => {
it("短文本原样返回", () => {
expect(truncateToDisplayWidth("hello", 20)).toBe("hello");
});

it("超宽 ASCII 截断并加省略号", () => {
const r = truncateToDisplayWidth("A".repeat(50), 20);
expect(visibleWidth(r)).toBeLessThanOrEqual(20);
expect(r.endsWith("…")).toBe(true);
});

it("中文按显示宽度截断(每字 2 列)", () => {
const r = truncateToDisplayWidth("产品".repeat(30), 20);
// 20 列 = 9 个汉字 + 省略号(1 列)→ 前缀 9 字
expect(visibleWidth(r)).toBe(19);
expect(r.endsWith("…")).toBe(true);
});

it("截断不注入 ANSI(纯文本契约)", () => {
const r = truncateToDisplayWidth("产品".repeat(30), 20);
expect(r).not.toContain("\x1b");
});
});

describe("padToDisplayWidth", () => {
it("ASCII 补齐到目标宽度", () => {
expect(visibleWidth(padToDisplayWidth("ab", 10))).toBe(10);
});

it("中文按显示宽度补齐(不按码元)", () => {
const padded = padToDisplayWidth("产品", 10);
// “产品” 显示 4 列 → 补 6 个空格
expect(visibleWidth(padded)).toBe(10);
expect(padded.length).toBe(8);
});

it("超宽时不补齐原样返回", () => {
expect(padToDisplayWidth("A".repeat(20), 10)).toBe("A".repeat(20));
});
});
35 changes: 34 additions & 1 deletion __tests__/related-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 通过 matchesKey 解码——与 filter-input 测试同一契约。
*/
import { describe, it, expect } from "vitest";
import { matchesKey, Key } from "@earendil-works/pi-tui";
import { matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import {
createBrowserNav,
buildBrowserContent,
Expand Down Expand Up @@ -159,6 +159,39 @@ describe("buildBrowserContent", () => {
});
});

// ====== tabs 行超宽截断(回归:TUI 宽度断言崩溃)======

describe("tabs 行宽度", () => {
it("长表名标签 join 后超出内容宽度(回归前提成立)", () => {
const labels = Array.from(
{ length: 6 },
(_, i) => `order_items_2024_${i}_snapshot(${i + 1} 行)`,
);
const styled = labels.map((l, i) => (i === 0 ? `\x1b[1m▶ ${l}\x1b[22m` : ` ${l}`)).join(" ");
// 终端 163 列、overlay 92%、Box 内边距 2 → 内容宽 ≈ 148
expect(visibleWidth(styled)).toBeGreaterThan(148);
});

it("truncateToWidth 把超宽标签行截到内容宽度内并保留省略号", () => {
const labels = Array.from(
{ length: 6 },
(_, i) => `order_items_2024_${i}_snapshot(${i + 1} 行)`,
);
const styled = labels.map((l, i) => (i === 0 ? `\x1b[1m▶ ${l}\x1b[22m` : ` ${l}`)).join(" ");
const truncated = truncateToWidth(styled, 148);
expect(visibleWidth(truncated)).toBeLessThanOrEqual(148);
// 默认省略号为 ASCII 省略号
expect(truncated).toContain("...");
});

it("truncateToWidth 截断 ANSI 样式文本时保留样式(冒烟契约)", () => {
const styled = "\x1b[32m" + "A".repeat(50) + "\x1b[0m";
const truncated = truncateToWidth(styled, 20);
expect(truncated).toContain("\x1b[32m");
expect(visibleWidth(truncated)).toBeLessThanOrEqual(20);
});
});

// ====== formatScrollInfo / formatBrowserFooter ======

describe("formatScrollInfo", () => {
Expand Down
118 changes: 118 additions & 0 deletions __tests__/renderers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeAll } from "vitest";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { renderQueryResult, type QueryResultEntryData } from "../commands/renderers";

/** keyHint 依赖全局 theme——测试环境需先初始化(pi 运行时已初始化)。 */
beforeAll(() => {
initTheme("dark");
});

/** 透传颜色标记的 fake theme——断言只看文本与宽度。 */
const fakeTheme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };

function entry(over: Partial<QueryResultEntryData>): QueryResultEntryData {
return {
database: "test_db",
sql: "SELECT 1",
rowCount: 1,
elapsed: "3ms",
columns: ["id"],
rows: [{ id: "1" }],
related: [],
...over,
};
}

/** 断言所有渲染行可见宽度不超过 width。 */
function expectAllLinesWithin(lines: string[], width: number): void {
for (const line of lines) {
expect(visibleWidth(line), `超宽行:${line.slice(0, 60)}…`).toBeLessThanOrEqual(width);
}
}

// ====== renderQueryResult(/db query 的 TUI 条目渲染)======

describe("renderQueryResult 宽度防线", () => {
const WIDTH = 120;

it("长 SQL 行被截断到渲染宽度(回归:TUI 宽度断言崩溃)", () => {
const sql = "SELECT * FROM t WHERE a = 1 AND b = " + "x".repeat(200);
const lines = renderQueryResult(entry({ sql }), WIDTH, false, fakeTheme);
expectAllLinesWithin(lines, WIDTH);
expect(lines.some((l) => l.includes("SQL: SELECT"))).toBe(true);
});

it("折叠态中文单元格表格行不超过渲染宽度", () => {
const lines = renderQueryResult(
entry({
columns: ["id", "description"],
rows: [{ id: "1", description: "产品".repeat(60) }],
}),
WIDTH,
false,
fakeTheme,
);
expectAllLinesWithin(lines, WIDTH);
});

it("展开态长值(ASCII)行不超过渲染宽度", () => {
const lines = renderQueryResult(
entry({
columns: ["id", "payload"],
rows: [{ id: "1", payload: "A".repeat(300) }],
}),
WIDTH,
true,
fakeTheme,
);
expectAllLinesWithin(lines, WIDTH);
});

it("展开态长值(中文)行不超过渲染宽度", () => {
const lines = renderQueryResult(
entry({
columns: ["id", "payload"],
rows: [{ id: "1", payload: "中".repeat(200) }],
}),
WIDTH,
true,
fakeTheme,
);
expectAllLinesWithin(lines, WIDTH);
});

it("折叠态关联表摘要(长表名)行不超过渲染宽度", () => {
const related = Array.from({ length: 8 }, (_, i) => ({
schema: "db",
table: `order_items_2024_${i}_snapshot`,
joinPath: "",
rowCount: 1,
elapsed: "1ms",
columns: ["id"],
rows: [{ id: "1" }],
}));
const lines = renderQueryResult(entry({ related }), WIDTH, false, fakeTheme);
expectAllLinesWithin(lines, WIDTH);
});

it("展开态关联表节(长表名标题)行不超过渲染宽度", () => {
const related = Array.from({ length: 8 }, (_, i) => ({
schema: "db",
table: `order_items_2024_${i}_snapshot`,
joinPath: "",
rowCount: 1,
elapsed: "1ms",
columns: ["id"],
rows: [{ id: "1" }],
}));
const lines = renderQueryResult(entry({ related }), WIDTH, true, fakeTheme);
expectAllLinesWithin(lines, WIDTH);
});

it("短内容保持原样不被截断", () => {
const lines = renderQueryResult(entry({}), 120, false, fakeTheme);
expect(lines.some((l) => l.includes("SELECT 1"))).toBe(true);
expect(lines.some((l) => l.includes("1 行"))).toBe(true);
});
});
78 changes: 78 additions & 0 deletions __tests__/result-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* formatting/result-table.ts 的测试——纯函数,无依赖。
*/
import { describe, it, expect } from "vitest";
import { visibleWidth } from "@earendil-works/pi-tui";
import {
analyzeColumns,
layoutColumns,
Expand Down Expand Up @@ -330,4 +331,81 @@ describe("formatTableDisplay", () => {
// 12 列单行放不下横向 → 转置。格式化器不应崩溃。
expect(result).not.toBe("(空结果)");
});

it("窄宽度下所有行不超过给定宽度(回归:超宽行触发 TUI 断言崩溃)", () => {
// 长列名 + 10 行的宽表:横向放不下 → 转置。修复前 cellWidth 下限 4,
// 窄宽度(40/55)下总行宽超出 width。
const cols = [
"very_long_column_name_a",
"very_long_column_name_b",
"very_long_column_name_c",
"very_long_column_name_d",
];
const rows = Array.from({ length: 10 }, (_, r) =>
Object.fromEntries(cols.map((c, i) => [c, `value_${r}_${i}`])),
);
for (const width of [40, 55, 70, 90]) {
const result = formatTableDisplay({ columns: cols, rows }, width);
for (const line of result.split("\n")) {
expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width);
}
}
});

it("窄宽度下纵向模式的行宽不超过给定宽度", () => {
// 20 列长列名 + 15 行:横向放不下(minW 钳制后仍超预算)、行数 > 10 不走转置
// → 纵向。修复前 valueCap 下限 20,窄宽度(40/55)下键值行超出 width。
const cols = Array.from({ length: 20 }, (_, i) => `very_long_column_name_${i}`);
const rows = Array.from({ length: 15 }, (_, r) =>
Object.fromEntries(cols.map((c, i) => [c, `value_${r}_${i}`])),
);
for (const width of [40, 55, 70, 90]) {
const result = formatTableDisplay({ columns: cols, rows }, width);
expect(result).toContain("─── Row");
for (const line of result.split("\n")) {
expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width);
}
}
});

it("中文长值横向表格的行宽不超过给定宽度(回归:宽字符按码元截断)", () => {
// 修复前 pad()/布局预算按 UTF-16 码元计算:中文显示宽度 2 倍,
// 截断到码元宽度后显示仍超宽 → TUI 宽度断言崩溃。
const cols = ["id", "description", "note"];
const rows = [{ id: 1, description: "产品".repeat(60), note: "备注" }];
for (const width of [40, 80, 120]) {
const result = formatTableDisplay({ columns: cols, rows }, width);
for (const line of result.split("\n")) {
expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width);
}
}
});

it("中文单元格截断后带省略号且不超预算", () => {
const result = formatTableDisplay(
{ columns: ["id", "description"], rows: [{ id: 1, description: "产品".repeat(30) }] },
60,
);
const valueLine = result.split("\n").find((l) => l.includes("…"));
expect(valueLine).toBeDefined();
expect(visibleWidth(valueLine!)).toBeLessThanOrEqual(60);
});

it("中文列名表格的行宽不超过给定宽度", () => {
// 列名本身是中文(占 2 列):colNameWidth/布局按显示宽度计算,
// 修复前 padEnd 按码元补齐导致行超宽。40 宽走转置、60/80 宽
// 走横向(按比例压缩后 minW>=2 且总宽 ≤ width)——两种布局都不得超宽。
const cols = Array.from({ length: 10 }, (_, i) => `字段名称${i}`);
const rows = Array.from({ length: 3 }, (_, r) =>
Object.fromEntries(cols.map((c, i) => [c, `值${r}${i}`])),
);
for (const width of [40, 60, 80]) {
const result = formatTableDisplay({ columns: cols, rows }, width);
for (const line of result.split("\n")) {
expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width);
// 表格行是纯文本契约——截断不得注入 ANSI
expect(line, `width=${width} 不应含 ANSI`).not.toContain("\x1b");
}
}
});
});
Loading
Loading