From c656536d3f322be31fffd88daa9a43683b11f693 Mon Sep 17 00:00:00 2001 From: Manlio Perillo Date: Sun, 9 Apr 2023 18:33:59 +0200 Subject: [PATCH] build: simplify code and add tests Simplify the code finding the exercise number from the exercise index, when the -Dn option is set. This is now possible since the exercise numbers have no holes. Add the validate_exercises function to check that exercise number are in the correct order, and call it at the start of the build function. Add tests, with 2 test cases. --- build.zig | 36 ++++++++--- test/tests.zig | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 test/tests.zig diff --git a/build.zig b/build.zig index dbda63e..3281ed4 100644 --- a/build.zig +++ b/build.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const compat = @import("src/compat.zig"); +const tests = @import("test/tests.zig"); const Build = compat.Build; const Step = compat.build.Step; @@ -8,7 +9,7 @@ const Step = compat.build.Step; const assert = std.debug.assert; const print = std.debug.print; -const Exercise = struct { +pub const Exercise = struct { /// main_file must have the format key_name.zig. /// The key will be used as a shorthand to build /// just one example. @@ -511,6 +512,7 @@ const exercises = [_]Exercise{ pub fn build(b: *Build) !void { if (!compat.is_compatible) compat.die(); + if (!validate_exercises()) std.os.exit(1); use_color_escapes = false; if (std.io.getStdErr().supportsAnsiEscapeCodes()) { @@ -558,15 +560,12 @@ pub fn build(b: *Build) !void { const header_step = PrintStep.create(b, logo, std.io.getStdErr()); if (exno) |i| { - const ex = blk: { - for (exercises) |ex| { - if (ex.number() == i) break :blk ex; - } - + if (i == 0 or i > exercises.len - 1) { print("unknown exercise number: {}\n", .{i}); std.os.exit(1); - }; + } + const ex = exercises[i - 1]; const base_name = ex.baseName(); const file_path = std.fs.path.join(b.allocator, &[_][]const u8{ if (use_healed) "patches/healed" else "exercises", ex.main_file, @@ -667,6 +666,9 @@ pub fn build(b: *Build) !void { } } ziglings_step.dependOn(prev_step); + + const test_step = b.step("test", "Run all the tests"); + test_step.dependOn(tests.addCliTests(b, &exercises)); } var use_color_escapes = false; @@ -921,3 +923,23 @@ const SkipStep = struct { } } }; + +// Check that each exercise number, excluding the last, forms the sequence `[1, exercise.len)`. +fn validate_exercises() bool { + // Don't use the "multi-object for loop" syntax, in order to avoid a syntax error with old Zig + // compilers. + var i: usize = 0; + for (exercises[0 .. exercises.len - 1]) |ex| { + i += 1; + if (ex.number() != i) { + print( + "exercise {s} has an incorrect number: expected {}, got {s}\n", + .{ ex.main_file, i, ex.key() }, + ); + + return false; + } + } + + return true; +} diff --git a/test/tests.zig b/test/tests.zig new file mode 100644 index 0000000..3de42db --- /dev/null +++ b/test/tests.zig @@ -0,0 +1,167 @@ +const std = @import("std"); +const root = @import("../build.zig"); + +const debug = std.debug; +const fs = std.fs; + +const Build = std.build; +const Step = Build.Step; +const RunStep = std.Build.RunStep; + +const Exercise = root.Exercise; + +pub fn addCliTests(b: *std.Build, exercises: []const Exercise) *Step { + const step = b.step("test-cli", "Test the command line interface"); + + // We should use a temporary path, but it will make the implementation of + // `build.zig` more complex. + const outdir = "patches/healed"; + + fs.cwd().makePath(outdir) catch |err| { + debug.print("unable to make '{s}': {s}\n", .{ outdir, @errorName(err) }); + + return step; + }; + + { + const case_step = createCase(b, "case-1"); + + // Test that `zig build -Dn=n -Dhealed test` selects the nth exercise. + var i: usize = 0; + for (exercises[0 .. exercises.len - 1]) |ex| { + i += 1; + if (ex.skip) continue; + + const patch = PatchStep.create(b, ex, outdir); + + const cmd = b.addSystemCommand( + &.{ b.zig_exe, "build", b.fmt("-Dn={}", .{i}), "-Dhealed", "test" }, + ); + cmd.setName(b.fmt("zig build -D={} -Dhealed test", .{i})); + cmd.expectExitCode(0); + cmd.step.dependOn(&patch.step); + + // Some exercise output has an extra space character. + if (ex.check_stdout) + expectStdOutMatch(cmd, ex.output) + else + expectStdErrMatch(cmd, ex.output); + + case_step.dependOn(&cmd.step); + } + + step.dependOn(case_step); + } + + { + const case_step = createCase(b, "case-2"); + + // Test that `zig build -Dn=n -Dhealed test` skips disabled esercises. + var i: usize = 0; + for (exercises[0 .. exercises.len - 1]) |ex| { + i += 1; + if (!ex.skip) continue; + + const cmd = b.addSystemCommand( + &.{ b.zig_exe, "build", b.fmt("-Dn={}", .{i}), "-Dhealed", "test" }, + ); + cmd.setName(b.fmt("zig build -D={} -Dhealed test", .{i})); + cmd.expectExitCode(0); + cmd.expectStdOutEqual(""); + expectStdErrMatch(cmd, b.fmt("{s} skipped", .{ex.main_file})); + + case_step.dependOn(&cmd.step); + } + + step.dependOn(case_step); + } + + const cleanup = b.addRemoveDirTree(outdir); + step.dependOn(&cleanup.step); + + return step; +} + +fn createCase(b: *Build, name: []const u8) *Step { + const case_step = b.allocator.create(Step) catch @panic("OOM"); + case_step.* = Step.init(.{ + .id = .custom, + .name = name, + .owner = b, + }); + + return case_step; +} + +// Apply a patch to the specified exercise. +const PatchStep = struct { + const join = fs.path.join; + + const exercises_path = "exercises"; + const patches_path = "patches/patches"; + + step: Step, + exercise: Exercise, + outdir: []const u8, + + pub fn create(owner: *Build, exercise: Exercise, outdir: []const u8) *PatchStep { + const self = owner.allocator.create(PatchStep) catch @panic("OOM"); + self.* = .{ + .step = Step.init(.{ + .id = .custom, + .name = "Patch", + .owner = owner, + .makeFn = make, + }), + .exercise = exercise, + .outdir = outdir, + }; + + return self; + } + + fn make(step: *Step, _: *std.Progress.Node) !void { + const b = step.owner; + const self = @fieldParentPtr(PatchStep, "step", step); + const exercise = self.exercise; + const name = exercise.baseName(); + + // Use the POSIX patch variant. + const file = join(b.allocator, &.{ exercises_path, exercise.main_file }) catch + @panic("OOM"); + const patch = join(b.allocator, &.{ patches_path, b.fmt("{s}.patch", .{name}) }) catch + @panic("OOM"); + const output = join(b.allocator, &.{ self.outdir, exercise.main_file }) catch + @panic("OOM"); + + const argv = &.{ "patch", "-i", patch, "-o", output, file }; + + var child = std.process.Child.init(argv, b.allocator); + child.stdout_behavior = .Ignore; // the POSIX standard says that stdout is not used + _ = try child.spawnAndWait(); + } +}; + +// +// Missing functions from std.Build.RunStep +// + +/// Adds a check for stderr match. Does not add any other checks. +pub fn expectStdErrMatch(self: *RunStep, bytes: []const u8) void { + const new_check: RunStep.StdIo.Check = .{ + .expect_stderr_match = self.step.owner.dupe(bytes), + }; + self.addCheck(new_check); +} + +/// Adds a check for stdout match as well as a check for exit code 0, if +/// there is not already an expected termination check. +pub fn expectStdOutMatch(self: *RunStep, bytes: []const u8) void { + const new_check: RunStep.StdIo.Check = .{ + .expect_stdout_match = self.step.owner.dupe(bytes), + }; + self.addCheck(new_check); + if (!self.hasTermCheck()) { + self.expectExitCode(0); + } +}