Add render diagnostics guardrails

This commit is contained in:
slhx agent
2026-06-21 02:54:10 +02:00
parent e540e64d93
commit fddfd1a5e4
+185
View File
@@ -31,6 +31,26 @@ pub const Viewport = struct {
}
};
pub const RenderDiagnostics = struct {
viewport_width: usize,
viewport_height: usize,
frame_bytes: usize,
frame_lines: usize,
max_line_cells: usize,
clipped_sources: usize,
};
pub const RenderResult = struct {
frame: []u8,
diagnostics: RenderDiagnostics,
warning: []u8,
pub fn deinit(self: RenderResult, allocator: std.mem.Allocator) void {
allocator.free(self.frame);
allocator.free(self.warning);
}
};
pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult {
var client = try Client.init(allocator, viewport);
defer client.deinit();
@@ -127,6 +147,15 @@ pub const Client = struct {
return self.renderEditor(allocator, snap);
}
pub fn renderWithDiagnostics(self: *const Client, allocator: std.mem.Allocator) !RenderResult {
const frame = try self.render(allocator);
errdefer allocator.free(frame);
var diagnostics = diagnoseFrame(self.viewport, frame);
diagnostics.clipped_sources = try self.countClippedSources(allocator);
const warning = try renderWarningAlloc(allocator, diagnostics);
return .{ .frame = frame, .diagnostics = diagnostics, .warning = warning };
}
fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
@@ -334,6 +363,51 @@ pub const Client = struct {
self.saved_bytes = copy;
self.message = "saved";
}
fn countClippedSources(self: *const Client, allocator: std.mem.Allocator) !usize {
const snap = try self.session.snapshot();
var clipped: usize = 0;
if (snap.active_panel_title == null) {
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
while (line_iter.next()) |line| {
if (session_mod.cellWidth(line) > self.viewport.width) clipped += 1;
}
if (self.message) |message| {
if (session_mod.cellWidth(message) > self.viewport.width) clipped += 1;
}
return clipped;
}
const path = try self.session.panelPathAlloc(allocator);
defer allocator.free(path);
const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path});
defer allocator.free(header);
if (session_mod.cellWidth(header) > self.viewport.width) clipped += 1;
const remaining_rows = self.viewport.height - 1;
const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null;
if (rows) |list_rows| {
defer {
for (list_rows) |row| allocator.free(row);
allocator.free(list_rows);
}
for (list_rows) |row| {
if (session_mod.cellWidth(row) > self.viewport.width) clipped += 1;
}
} else if (snap.active_panel_title) |title| {
const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title});
defer allocator.free(detail);
if (session_mod.cellWidth(detail) > self.viewport.width) clipped += 1;
}
const status = if (self.message) |message|
try std.fmt.allocPrint(allocator, "{s}", .{message})
else
try std.fmt.allocPrint(allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth });
defer allocator.free(status);
if (session_mod.cellWidth(status) > self.viewport.width) clipped += 1;
return clipped;
}
};
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
@@ -369,6 +443,52 @@ fn columnAt(bytes: []const u8, cursor_byte: usize) usize {
return session_mod.cellWidth(prefix[line_start..]);
}
fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics {
var diagnostics = RenderDiagnostics{
.viewport_width = viewport.width,
.viewport_height = viewport.height,
.frame_bytes = frame.len,
.frame_lines = 0,
.max_line_cells = 0,
.clipped_sources = 0,
};
var lines = std.mem.splitScalar(u8, frame, '\n');
while (lines.next()) |line| {
diagnostics.frame_lines += 1;
diagnostics.max_line_cells = @max(diagnostics.max_line_cells, session_mod.cellWidth(line));
}
return diagnostics;
}
fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnostics) ![]u8 {
if (diagnostics.frame_lines != diagnostics.viewport_height) {
return std.fmt.allocPrint(
allocator,
"render warning: expected {d} lines, rendered {d}",
.{ diagnostics.viewport_height, diagnostics.frame_lines },
);
}
if (diagnostics.max_line_cells > diagnostics.viewport_width) {
return std.fmt.allocPrint(
allocator,
"render warning: line width {d} exceeds viewport {d}",
.{ diagnostics.max_line_cells, diagnostics.viewport_width },
);
}
if (diagnostics.clipped_sources > 0) {
return std.fmt.allocPrint(
allocator,
"render warning: clipped {d} source line(s) to fit {d} columns",
.{ diagnostics.clipped_sources, diagnostics.viewport_width },
);
}
return std.fmt.allocPrint(
allocator,
"render ok: {d} lines, {d} bytes, max {d}/{d} cells",
.{ diagnostics.frame_lines, diagnostics.frame_bytes, diagnostics.max_line_cells, diagnostics.viewport_width },
);
}
fn assertLinesFit(frame: []const u8, width: usize) !void {
var lines = std.mem.splitScalar(u8, frame, '\n');
while (lines.next()) |line| {
@@ -737,3 +857,68 @@ test "adversarial: long feedback messages are clipped to viewport width" {
try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null);
}
test "regular: render diagnostics report ok for representative narrow editor frame" {
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
defer client.deinit();
try client.handleTraceLine("open fn main() {}");
try client.handleTraceLine("key space");
try client.handleTraceLine("key p");
try client.handleTraceLine("key c");
try client.handleTraceLine("type x");
const result = try client.renderWithDiagnostics(std.testing.allocator);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines);
try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4);
try std.testing.expect(result.diagnostics.max_line_cells <= 32);
try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null);
}
test "regular: render diagnostics report ok for representative list panel frame" {
var client = try Client.init(std.testing.allocator, .{ .width = 36, .height = 5 });
defer client.deinit();
try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md");
try client.handleTraceLine("list_filter src");
try client.handleTraceLine("list_down");
const result = try client.renderWithDiagnostics(std.testing.allocator);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines);
try std.testing.expect(result.diagnostics.frame_bytes <= 36 * 5 * 4);
try std.testing.expect(result.diagnostics.max_line_cells <= 36);
try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null);
}
test "adversarial: render diagnostics name clipped editor sources" {
var client = try Client.init(std.testing.allocator, .{ .width = 12, .height = 4 });
defer client.deinit();
try client.handleTraceLine("open this-line-is-far-too-long-for-phone");
const result = try client.renderWithDiagnostics(std.testing.allocator);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines);
try std.testing.expect(result.diagnostics.max_line_cells <= 12);
try std.testing.expect(result.diagnostics.clipped_sources >= 1);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "12 columns") != null);
}
test "adversarial: render diagnostics name clipped panel sources" {
var client = try Client.init(std.testing.allocator, .{ .width = 14, .height = 4 });
defer client.deinit();
try client.handleTraceLine("list_open files src/very-long-mobile-render-file-name.zig|b.zig");
const result = try client.renderWithDiagnostics(std.testing.allocator);
defer result.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines);
try std.testing.expect(result.diagnostics.max_line_cells <= 14);
try std.testing.expect(result.diagnostics.clipped_sources >= 1);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
}