Implement and use generic approx equality tester (#8979)

Seems like there needs to be a general, easy-to-use solution for
approximate equality testing of containers holding floats (see, e.g.,
https://github.com/ghostty-org/ghostty/pull/8563#pullrequestreview-3281357931).
How's this?
This commit is contained in:
Mitchell Hashimoto
2025-10-03 07:10:16 -07:00
committed by GitHub
2 changed files with 153 additions and 27 deletions

View File

@@ -0,0 +1,147 @@
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
const std = @import("std");
/// Generic, recursive equality testing utility using approximate comparison for
/// floats and equality for everything else
///
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
///
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
const T = @TypeOf(expected, actual);
return expectApproxEqualInner(T, expected, actual);
}
fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
switch (@typeInfo(T)) {
// check approximate equality for floats
.float => {
const sqrt_eps = comptime std.math.sqrt(std.math.floatEps(T));
if (!std.math.approxEqRel(T, expected, actual, sqrt_eps)) {
print("expected approximately {any}, found {any}\n", .{ expected, actual });
return error.TestExpectedApproxEqual;
}
},
// recurse into containers
.array => {
const diff_index: usize = diff_index: {
const shortest = @min(expected.len, actual.len);
var index: usize = 0;
while (index < shortest) : (index += 1) {
expectApproxEqual(actual[index], expected[index]) catch break :diff_index index;
}
break :diff_index if (expected.len == actual.len) return else shortest;
};
print("slices not approximately equal. first significant difference occurs at index {d} (0x{X})\n", .{ diff_index, diff_index });
return error.TestExpectedApproxEqual;
},
.vector => |info| {
var i: usize = 0;
while (i < info.len) : (i += 1) {
expectApproxEqual(expected[i], actual[i]) catch {
print("index {d} incorrect. expected approximately {any}, found {any}\n", .{
i, expected[i], actual[i],
});
return error.TestExpectedApproxEqual;
};
}
},
.@"struct" => |structType| {
inline for (structType.fields) |field| {
try expectApproxEqual(@field(expected, field.name), @field(actual, field.name));
}
},
// unwrap unions, optionals, and error unions
.@"union" => |union_info| {
if (union_info.tag_type == null) {
// untagged unions can only be compared bitwise,
// so expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
}
const Tag = std.meta.Tag(@TypeOf(expected));
const expectedTag = @as(Tag, expected);
const actualTag = @as(Tag, actual);
std.testing.expectEqual(expectedTag, actualTag) catch {
return error.TestExpectedApproxEqual;
};
// we only reach this switch if the tags are equal
switch (expected) {
inline else => |val, tag| try expectApproxEqual(val, @field(actual, @tagName(tag))),
}
},
.optional, .error_union => {
if (expected) |expected_payload| if (actual) |actual_payload| {
return expectApproxEqual(expected_payload, actual_payload);
};
// we only reach this point if there's at least one null or error,
// in which case expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
},
// fall back to expectEqual for everything else
else => std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
},
}
}
/// Copy of std.testing.print (not public)
fn print(comptime fmt: []const u8, args: anytype) void {
if (@inComptime()) {
@compileError(std.fmt.comptimePrint(fmt, args));
} else if (std.testing.backend_can_print) {
std.debug.print(fmt, args);
}
}
// Tests based on the `expectEqual` tests in the Zig stdlib
test "expectApproxEqual.union(enum)" {
const T = union(enum) {
a: i32,
b: f32,
};
const b10 = T{ .b = 10.0 };
const b10plus = T{ .b = 10.000001 };
try expectApproxEqual(b10, b10plus);
}
test "expectApproxEqual nested array" {
const a = [2][2]f32{
[_]f32{ 1.0, 0.0 },
[_]f32{ 0.0, 1.0 },
};
const b = [2][2]f32{
[_]f32{ 1.000001, 0.0 },
[_]f32{ 0.0, 0.999999 },
};
try expectApproxEqual(a, b);
}
test "expectApproxEqual vector" {
const a: @Vector(4, f32) = @splat(4.0);
const b: @Vector(4, f32) = @splat(4.000001);
try expectApproxEqual(a, b);
}
test "expectApproxEqual struct" {
const a = .{ 1, @as(f32, 1.0) };
const b = .{ 1, @as(f32, 0.999999) };
try expectApproxEqual(a, b);
}

View File

@@ -19,6 +19,7 @@ const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const config = @import("../config.zig"); const config = @import("../config.zig");
const comparison = @import("../datastruct/comparison.zig");
const font = @import("main.zig"); const font = @import("main.zig");
const options = font.options; const options = font.options;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@@ -1199,7 +1200,7 @@ test "metrics" {
try c.updateMetrics(); try c.updateMetrics();
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 8, .cell_width = 8,
// The cell height is 17 px because the calculation is // The cell height is 17 px because the calculation is
// //
@@ -1229,12 +1230,12 @@ test "metrics" {
.icon_height = 12.24, .icon_height = 12.24,
.face_width = 8.0, .face_width = 8.0,
.face_height = 16.784, .face_height = 16.784,
.face_y = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value .face_y = -0.04,
}, c.metrics); }, c.metrics);
// Resize should change metrics // Resize should change metrics
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 16, .cell_width = 16,
.cell_height = 34, .cell_height = 34,
.cell_baseline = 6, .cell_baseline = 6,
@@ -1249,7 +1250,7 @@ test "metrics" {
.icon_height = 24.48, .icon_height = 24.48,
.face_width = 16.0, .face_width = 16.0,
.face_height = 33.568, .face_height = 33.568,
.face_y = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value .face_y = -0.08,
}, c.metrics); }, c.metrics);
} }
@@ -1493,29 +1494,7 @@ test "face metrics" {
.{ narrowMetricsExpected, wideMetricsExpected }, .{ narrowMetricsExpected, wideMetricsExpected },
.{ narrowMetrics, wideMetrics }, .{ narrowMetrics, wideMetrics },
) |metricsExpected, metricsActual| { ) |metricsExpected, metricsActual| {
inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| { try comparison.expectApproxEqual(metricsExpected, metricsActual);
const expected = @field(metricsExpected, field.name);
const actual = @field(metricsActual, field.name);
// Unwrap optional fields
const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) {
.optional => {
if (expected) |expectedValue| if (actual) |actualValue| {
break :unwrap .{ expectedValue, actualValue };
};
// Null values can be compared directly
try std.testing.expectEqual(expected, actual);
continue;
},
else => break :unwrap .{ expected, actual },
};
// All non-null values are floats
const eps = std.math.floatEps(@TypeOf(actualValue - expectedValue));
try std.testing.expectApproxEqRel(
expectedValue,
actualValue,
std.math.sqrt(eps),
);
}
} }
// Verify estimated metrics. icWidth() should equal the smaller of // Verify estimated metrics. icWidth() should equal the smaller of