diff --git a/src/datastruct/comparison.zig b/src/datastruct/comparison.zig new file mode 100644 index 000000000..4427c143c --- /dev/null +++ b/src/datastruct/comparison.zig @@ -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); +} 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