Skip to content

Commit 578819e

Browse files
authored
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., ghostty-org/ghostty#8563 (review)). How's this?
2 parents 97280ea + e2919b1 commit 578819e

2 files changed

Lines changed: 153 additions & 27 deletions

File tree

src/datastruct/comparison.zig

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
2+
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
3+
const std = @import("std");
4+
5+
/// Generic, recursive equality testing utility using approximate comparison for
6+
/// floats and equality for everything else
7+
///
8+
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
9+
///
10+
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
11+
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
12+
const T = @TypeOf(expected, actual);
13+
return expectApproxEqualInner(T, expected, actual);
14+
}
15+
16+
fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
17+
switch (@typeInfo(T)) {
18+
// check approximate equality for floats
19+
.float => {
20+
const sqrt_eps = comptime std.math.sqrt(std.math.floatEps(T));
21+
if (!std.math.approxEqRel(T, expected, actual, sqrt_eps)) {
22+
print("expected approximately {any}, found {any}\n", .{ expected, actual });
23+
return error.TestExpectedApproxEqual;
24+
}
25+
},
26+
27+
// recurse into containers
28+
.array => {
29+
const diff_index: usize = diff_index: {
30+
const shortest = @min(expected.len, actual.len);
31+
var index: usize = 0;
32+
while (index < shortest) : (index += 1) {
33+
expectApproxEqual(actual[index], expected[index]) catch break :diff_index index;
34+
}
35+
break :diff_index if (expected.len == actual.len) return else shortest;
36+
};
37+
print("slices not approximately equal. first significant difference occurs at index {d} (0x{X})\n", .{ diff_index, diff_index });
38+
return error.TestExpectedApproxEqual;
39+
},
40+
.vector => |info| {
41+
var i: usize = 0;
42+
while (i < info.len) : (i += 1) {
43+
expectApproxEqual(expected[i], actual[i]) catch {
44+
print("index {d} incorrect. expected approximately {any}, found {any}\n", .{
45+
i, expected[i], actual[i],
46+
});
47+
return error.TestExpectedApproxEqual;
48+
};
49+
}
50+
},
51+
.@"struct" => |structType| {
52+
inline for (structType.fields) |field| {
53+
try expectApproxEqual(@field(expected, field.name), @field(actual, field.name));
54+
}
55+
},
56+
57+
// unwrap unions, optionals, and error unions
58+
.@"union" => |union_info| {
59+
if (union_info.tag_type == null) {
60+
// untagged unions can only be compared bitwise,
61+
// so expectEqual is all we need
62+
std.testing.expectEqual(expected, actual) catch {
63+
return error.TestExpectedApproxEqual;
64+
};
65+
}
66+
67+
const Tag = std.meta.Tag(@TypeOf(expected));
68+
69+
const expectedTag = @as(Tag, expected);
70+
const actualTag = @as(Tag, actual);
71+
72+
std.testing.expectEqual(expectedTag, actualTag) catch {
73+
return error.TestExpectedApproxEqual;
74+
};
75+
76+
// we only reach this switch if the tags are equal
77+
switch (expected) {
78+
inline else => |val, tag| try expectApproxEqual(val, @field(actual, @tagName(tag))),
79+
}
80+
},
81+
.optional, .error_union => {
82+
if (expected) |expected_payload| if (actual) |actual_payload| {
83+
return expectApproxEqual(expected_payload, actual_payload);
84+
};
85+
// we only reach this point if there's at least one null or error,
86+
// in which case expectEqual is all we need
87+
std.testing.expectEqual(expected, actual) catch {
88+
return error.TestExpectedApproxEqual;
89+
};
90+
},
91+
92+
// fall back to expectEqual for everything else
93+
else => std.testing.expectEqual(expected, actual) catch {
94+
return error.TestExpectedApproxEqual;
95+
},
96+
}
97+
}
98+
99+
/// Copy of std.testing.print (not public)
100+
fn print(comptime fmt: []const u8, args: anytype) void {
101+
if (@inComptime()) {
102+
@compileError(std.fmt.comptimePrint(fmt, args));
103+
} else if (std.testing.backend_can_print) {
104+
std.debug.print(fmt, args);
105+
}
106+
}
107+
108+
// Tests based on the `expectEqual` tests in the Zig stdlib
109+
test "expectApproxEqual.union(enum)" {
110+
const T = union(enum) {
111+
a: i32,
112+
b: f32,
113+
};
114+
115+
const b10 = T{ .b = 10.0 };
116+
const b10plus = T{ .b = 10.000001 };
117+
118+
try expectApproxEqual(b10, b10plus);
119+
}
120+
121+
test "expectApproxEqual nested array" {
122+
const a = [2][2]f32{
123+
[_]f32{ 1.0, 0.0 },
124+
[_]f32{ 0.0, 1.0 },
125+
};
126+
127+
const b = [2][2]f32{
128+
[_]f32{ 1.000001, 0.0 },
129+
[_]f32{ 0.0, 0.999999 },
130+
};
131+
132+
try expectApproxEqual(a, b);
133+
}
134+
135+
test "expectApproxEqual vector" {
136+
const a: @Vector(4, f32) = @splat(4.0);
137+
const b: @Vector(4, f32) = @splat(4.000001);
138+
139+
try expectApproxEqual(a, b);
140+
}
141+
142+
test "expectApproxEqual struct" {
143+
const a = .{ 1, @as(f32, 1.0) };
144+
const b = .{ 1, @as(f32, 0.999999) };
145+
146+
try expectApproxEqual(a, b);
147+
}

src/font/Collection.zig

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const std = @import("std");
1919
const assert = std.debug.assert;
2020
const Allocator = std.mem.Allocator;
2121
const config = @import("../config.zig");
22+
const comparison = @import("../datastruct/comparison.zig");
2223
const font = @import("main.zig");
2324
const options = font.options;
2425
const DeferredFace = font.DeferredFace;
@@ -1199,7 +1200,7 @@ test "metrics" {
11991200

12001201
try c.updateMetrics();
12011202

1202-
try std.testing.expectEqual(font.Metrics{
1203+
try comparison.expectApproxEqual(font.Metrics{
12031204
.cell_width = 8,
12041205
// The cell height is 17 px because the calculation is
12051206
//
@@ -1229,12 +1230,12 @@ test "metrics" {
12291230
.icon_height = 12.24,
12301231
.face_width = 8.0,
12311232
.face_height = 16.784,
1232-
.face_y = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value
1233+
.face_y = -0.04,
12331234
}, c.metrics);
12341235

12351236
// Resize should change metrics
12361237
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
1237-
try std.testing.expectEqual(font.Metrics{
1238+
try comparison.expectApproxEqual(font.Metrics{
12381239
.cell_width = 16,
12391240
.cell_height = 34,
12401241
.cell_baseline = 6,
@@ -1249,7 +1250,7 @@ test "metrics" {
12491250
.icon_height = 24.48,
12501251
.face_width = 16.0,
12511252
.face_height = 33.568,
1252-
.face_y = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value
1253+
.face_y = -0.08,
12531254
}, c.metrics);
12541255
}
12551256

@@ -1493,29 +1494,7 @@ test "face metrics" {
14931494
.{ narrowMetricsExpected, wideMetricsExpected },
14941495
.{ narrowMetrics, wideMetrics },
14951496
) |metricsExpected, metricsActual| {
1496-
inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| {
1497-
const expected = @field(metricsExpected, field.name);
1498-
const actual = @field(metricsActual, field.name);
1499-
// Unwrap optional fields
1500-
const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) {
1501-
.optional => {
1502-
if (expected) |expectedValue| if (actual) |actualValue| {
1503-
break :unwrap .{ expectedValue, actualValue };
1504-
};
1505-
// Null values can be compared directly
1506-
try std.testing.expectEqual(expected, actual);
1507-
continue;
1508-
},
1509-
else => break :unwrap .{ expected, actual },
1510-
};
1511-
// All non-null values are floats
1512-
const eps = std.math.floatEps(@TypeOf(actualValue - expectedValue));
1513-
try std.testing.expectApproxEqRel(
1514-
expectedValue,
1515-
actualValue,
1516-
std.math.sqrt(eps),
1517-
);
1518-
}
1497+
try comparison.expectApproxEqual(metricsExpected, metricsActual);
15191498
}
15201499

15211500
// Verify estimated metrics. icWidth() should equal the smaller of

0 commit comments

Comments
 (0)