From 26b70e3125d04f48ce76a49b6b22b08d06619404 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 30 Sep 2025 12:28:33 -0700 Subject: [PATCH 1/2] Implement and use generic approx equality tester --- src/datastruct/comparison.zig | 145 ++++++++++++++++++++++++++++++++++ src/font/Collection.zig | 33 ++------ 2 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 src/datastruct/comparison.zig diff --git a/src/datastruct/comparison.zig b/src/datastruct/comparison.zig new file mode 100644 index 000000000..61d540353 --- /dev/null +++ b/src/datastruct/comparison.zig @@ -0,0 +1,145 @@ +const std = @import("std"); + +/// Generic, recursive equality testing utility using approximate comparison for +/// floats and equality for everything else +/// +/// Based on the source code of `std.testing.expectEqual`, +/// `std.testing.expectEqualSlices`, and `std.meta.eql`, as of Zig 0.15.1. +/// +/// 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) as of Zig 0.15.1 +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); + } +} + +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); +} diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 04b9882dc..e91fe03ae 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -19,6 +19,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const config = @import("../config.zig"); +const comparison = @import("../datastruct/comparison.zig"); const font = @import("main.zig"); const options = font.options; const DeferredFace = font.DeferredFace; @@ -1199,7 +1200,7 @@ test "metrics" { try c.updateMetrics(); - try std.testing.expectEqual(font.Metrics{ + try comparison.expectApproxEqual(font.Metrics{ .cell_width = 8, // The cell height is 17 px because the calculation is // @@ -1229,12 +1230,12 @@ test "metrics" { .icon_height = 12.24, .face_width = 8.0, .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); // Resize should change metrics try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); - try std.testing.expectEqual(font.Metrics{ + try comparison.expectApproxEqual(font.Metrics{ .cell_width = 16, .cell_height = 34, .cell_baseline = 6, @@ -1249,7 +1250,7 @@ test "metrics" { .icon_height = 24.48, .face_width = 16.0, .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); } @@ -1493,29 +1494,7 @@ test "face metrics" { .{ narrowMetricsExpected, wideMetricsExpected }, .{ narrowMetrics, wideMetrics }, ) |metricsExpected, metricsActual| { - inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| { - 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), - ); - } + try comparison.expectApproxEqual(metricsExpected, metricsActual); } // Verify estimated metrics. icWidth() should equal the smaller of From 3feff75c9971a40e8f9e4a5938ff7aa7308e8f8a Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 30 Sep 2025 13:57:57 -0700 Subject: [PATCH 2/2] Add proper Zig stdlib attribution --- src/datastruct/comparison.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/datastruct/comparison.zig b/src/datastruct/comparison.zig index 61d540353..4427c143c 100644 --- a/src/datastruct/comparison.zig +++ b/src/datastruct/comparison.zig @@ -1,10 +1,11 @@ +// 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 the source code of `std.testing.expectEqual`, -/// `std.testing.expectEqualSlices`, and `std.meta.eql`, as of Zig 0.15.1. +/// 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 { @@ -95,7 +96,7 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void { } } -/// Copy of std.testing.print (not public) as of Zig 0.15.1 +/// Copy of std.testing.print (not public) fn print(comptime fmt: []const u8, args: anytype) void { if (@inComptime()) { @compileError(std.fmt.comptimePrint(fmt, args)); @@ -104,6 +105,7 @@ fn print(comptime fmt: []const u8, args: anytype) void { } } +// Tests based on the `expectEqual` tests in the Zig stdlib test "expectApproxEqual.union(enum)" { const T = union(enum) { a: i32,