mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-09 03:16:33 +00:00
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:
147
src/datastruct/comparison.zig
Normal file
147
src/datastruct/comparison.zig
Normal 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);
|
||||||
|
}
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user