diff --git a/README.md b/README.md index 854dd2f..7821921 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], CodeCoveragePl plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], CleanupFcn = @() bdclose('all')); ``` +6. Enable incremental build support by specifying `SourceFiles`. The task will only re-run when source or example files change: +```matlab +plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], SourceFiles = "src"); +``` +Without `SourceFiles`, the task runs every time (same as the built-in `TestTask`). + ## License The license is available in the [LICENSE.txt](license.txt) file within this repository diff --git a/buildfile.m b/buildfile.m index 41d177f..eab405c 100644 --- a/buildfile.m +++ b/buildfile.m @@ -16,7 +16,9 @@ % Run MATLAB scripts from specified folder and generate a code coverage report reportFormat = matlab.unittest.plugins.codecoverage.CoverageReport('coverage-report'); covPlugin = matlab.unittest.plugins.CodeCoveragePlugin.forFolder("toolbox/sampleToolbox/code", "Producing", reportFormat); -plan("runExample") = ExampleDrivenTesterTask("toolbox/sampleToolbox/examples", CodeCoveragePlugin = covPlugin); +plan("runExample") = ExampleDrivenTesterTask("toolbox/sampleToolbox/examples", ... + SourceFiles = "toolbox/sampleToolbox/code", ... + CodeCoveragePlugin = covPlugin); plan.DefaultTasks = "test"; diff --git a/tests/tExampleDrivenTesterTask.m b/tests/tExampleDrivenTesterTask.m new file mode 100644 index 0000000..2138828 --- /dev/null +++ b/tests/tExampleDrivenTesterTask.m @@ -0,0 +1,195 @@ +classdef tExampleDrivenTesterTask < matlab.unittest.TestCase + % Test verifies ExampleDrivenTesterTask buildtool integration + % including incremental build support. + + properties (Access=private) + ExamplesFolder string + SourceFolder string + end + + methods (TestClassSetup) + function pathSetup(testCase) + import matlab.unittest.fixtures.PathFixture; + import matlab.unittest.fixtures.CurrentFolderFixture + testCase.applyFixture(PathFixture("../toolbox")); + testCase.applyFixture(PathFixture(fullfile("../toolbox", "internal"))); + testCase.applyFixture(CurrentFolderFixture("tExamplesTester_files")); + testCase.applyFixture(PathFixture("code")); + testCase.ExamplesFolder = fullfile(pwd, "examples"); + testCase.SourceFolder = fullfile(pwd, "source"); + end + end + + methods (TestMethodSetup) + function ensurePath(testCase) %#ok + % buildplan/run may remove paths when it opens/closes the project. + % Re-add them before each test to ensure ExampleDrivenTesterTask + % is always on the path. + toolboxDir = fullfile(fileparts(fileparts(mfilename('fullpath'))), "toolbox"); + internalDir = fullfile(toolboxDir, "internal"); + addpath(toolboxDir); + addpath(internalDir); + end + end + + methods (Test) + + function verifyInputsTrackFiles(testCase) + % Verify that task Inputs use file globs when SourceFiles is provided + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder); + inputPaths = task.Inputs.paths; + testCase.verifyGreaterThan(numel(inputPaths), 0, ... + "Task Inputs should resolve to actual files inside folders"); + end + + function verifyNoIncrementalBuildWithoutSourceFiles(testCase) + % Verify that without SourceFiles, incremental build is disabled + task = ExampleDrivenTesterTask(testCase.ExamplesFolder); + testCase.verifyEmpty(task.SourceFiles, ... + "SourceFiles should be empty when not provided"); + end + + function verifyOutputsSetWhenSourceFilesProvided(testCase) + % Verify that Outputs is set when SourceFiles is provided + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder); + testCase.verifyNotEmpty(task.Outputs, ... + "Task Outputs should be set when SourceFiles is provided"); + end + + function verifyStampFileCreatedAfterRun(testCase) + % Verify that the stamp file is created after task execution + % when SourceFiles is provided + import matlab.unittest.fixtures.TemporaryFolderFixture + testCase.applyFixture(TemporaryFolderFixture); + + outputPath = testCase.createTemporaryFolder; + plan = buildplan; + plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder, OutputPath=outputPath); + run(plan, "runExample"); + + stampFile = fullfile(outputPath, ".last_run"); + testCase.verifyEqual(exist(stampFile, "file"), 2, ... + "Stamp file should be created after task execution"); + end + + function verifyIncrementalBuildSkipsWhenUpToDate(testCase) + % Verify that the task is skipped on the second run when + % SourceFiles is provided and nothing has changed + import matlab.unittest.fixtures.TemporaryFolderFixture + testCase.applyFixture(TemporaryFolderFixture); + + outputPath = testCase.createTemporaryFolder; + plan = buildplan; + plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder, OutputPath=outputPath); + + % First run — should execute + result1 = run(plan, "runExample"); + testCase.assertFalse(result1.TaskResults.Skipped, ... + "Task should run on first execution"); + + % Second run — should be skipped (up-to-date) + result2 = run(plan, "runExample"); + testCase.verifyTrue(result2.TaskResults.Skipped, ... + "Task should be skipped on second run when inputs are unchanged"); + end + + function verifyTaskAlwaysRunsWithoutSourceFiles(testCase) + % Verify that without SourceFiles the task runs every time + import matlab.unittest.fixtures.TemporaryFolderFixture + testCase.applyFixture(TemporaryFolderFixture); + + outputPath = testCase.createTemporaryFolder; + plan = buildplan; + plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + OutputPath=outputPath); + + % First run + result1 = run(plan, "runExample"); + testCase.assertFalse(result1.TaskResults.Skipped, ... + "Task should run on first execution"); + + % Second run — should still run (no incremental build) + result2 = run(plan, "runExample"); + testCase.verifyFalse(result2.TaskResults.Skipped, ... + "Task should always run when SourceFiles is not provided"); + end + + function verifyInputsIncludeMlxFiles(testCase) + % Verify that task Inputs include both .m and .mlx files + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder); + inputPaths = task.Inputs.paths; + hasMlx = any(endsWith(inputPaths, ".mlx")); + hasM = any(endsWith(inputPaths, ".m")); + testCase.verifyTrue(hasM, "Task Inputs should include .m files"); + testCase.verifyTrue(hasMlx, "Task Inputs should include .mlx files"); + end + + function verifyOutputStampFileLocation(testCase) + % Verify the stamp file output path is correctly set + outputPath = testCase.createTemporaryFolder; + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder, OutputPath=outputPath); + outputPaths = task.Outputs.paths; + testCase.verifyTrue(any(endsWith(outputPaths, ".last_run")), ... + "Task Outputs should contain the .last_run stamp file"); + end + + function verifySourceFilesTrackedInInputs(testCase) + % Verify that SourceFiles are included in task Inputs + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder); + inputPaths = task.Inputs.paths; + hasSourceFile = any(contains(inputPaths, "source")); + testCase.verifyTrue(hasSourceFile, ... + "Task Inputs should include files from SourceFiles folders"); + end + + function verifySourceFilesPropertyStored(testCase) + % Verify that SourceFiles property is stored correctly + task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=testCase.SourceFolder); + testCase.verifyEqual(task.SourceFiles, testCase.SourceFolder, ... + "SourceFiles property should store the provided value"); + end + + function verifySourceFileChangeTriggersRerun(testCase) + % Verify that modifying a source file triggers task re-run + import matlab.unittest.fixtures.TemporaryFolderFixture + testCase.applyFixture(TemporaryFolderFixture); + + outputPath = testCase.createTemporaryFolder; + srcFolder = testCase.createTemporaryFolder; + srcFile = fullfile(srcFolder, "helper.m"); + fid = fopen(srcFile, 'w'); + fprintf(fid, 'function out = helper(x)\n out = x;\nend\n'); + fclose(fid); + + plan = buildplan; + plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ... + SourceFiles=srcFolder, OutputPath=outputPath); + + % First run + result1 = run(plan, "runExample"); + testCase.assertFalse(result1.TaskResults.Skipped, ... + "Task should run on first execution"); + + % Modify source file + pause(1); + fid = fopen(srcFile, 'w'); + fprintf(fid, 'function out = helper(x)\n out = x + 1;\nend\n'); + fclose(fid); + + % Second run — should re-run due to source change + result2 = run(plan, "runExample"); + testCase.verifyFalse(result2.TaskResults.Skipped, ... + "Task should re-run when source files change"); + end + + end + +end diff --git a/tests/tExamplesTester_files/source/myFunc.m b/tests/tExamplesTester_files/source/myFunc.m new file mode 100644 index 0000000..1343be2 --- /dev/null +++ b/tests/tExamplesTester_files/source/myFunc.m @@ -0,0 +1,3 @@ +function out = myFunc(x) + out = x + 1; +end diff --git a/toolbox/internal/ExampleDrivenTesterTask.m b/toolbox/internal/ExampleDrivenTesterTask.m index a324e4b..c906236 100644 --- a/toolbox/internal/ExampleDrivenTesterTask.m +++ b/toolbox/internal/ExampleDrivenTesterTask.m @@ -1,16 +1,18 @@ classdef ExampleDrivenTesterTask < matlab.buildtool.Task % Buildtool task to run example scripts with optional test & coverage reports. % Inputs: - % - Folders: string array of M-script locations + % - Folders: string array of M-script locations (test files) % Optional Inputs: - % - CreateTestReport (logical) - % - TestReportFormat (string) - % - ReportOutputFolder (string) - % - CodeCoveragePlugin (object) + % - SourceFiles (string) - Source code folders; enables incremental build when provided + % - CreateTestReport (logical) + % - TestReportFormat (string) + % - ReportOutputFolder (string) + % - CodeCoveragePlugin (object) % - CleanupFcn (function_handle) - Custom cleanup function executed after each test properties Folders (1,:) string + SourceFiles (1,:) string CreateTestReport (1,1) logical TestReportFormat (1,1) string OutputPath (1,1) string @@ -23,18 +25,17 @@ % Constructor arguments folders (1,:) string + options.SourceFiles (1,:) string = string.empty options.CreateTestReport (1,1) logical = true options.TestReportFormat (1,1) string {mustBeMember(options.TestReportFormat,["html", "pdf", "docx", "xml"])} = "html" - options.OutputPath(1,1) string = "reports_" + char(datetime('now', 'Format', 'yyyyMMdd_HHmmss')) + options.OutputPath(1,1) string = "test-report" options.CodeCoveragePlugin = [] options.CleanupFcn = [] end task.Description = "Run published examples"; - task.Inputs = folders; % Basic validation - % mustBeMember(options.TestReportFormat, ["html", "pdf", "docx", "xml"]); for f = folders if ~isfolder(f) error("ExampleDrivenTesterTask:FolderNotFound", ... @@ -43,16 +44,20 @@ end task.Folders = folders; + task.SourceFiles = options.SourceFiles; task.CreateTestReport = options.CreateTestReport; task.TestReportFormat = options.TestReportFormat; task.OutputPath= options.OutputPath; task.CodeCoveragePlugin= options.CodeCoveragePlugin; task.CleanupFcn = options.CleanupFcn; - if task.CreateTestReport - task.Outputs = task.OutputPath; - else - task.Outputs = string.empty; + % Incremental build is only enabled when SourceFiles is provided. + % Without SourceFiles, the task always runs (matches TestTask behavior). + if ~isempty(options.SourceFiles) + inputGlobs = [folders + "/**/*.m", folders + "/**/*.mlx", ... + options.SourceFiles + "/**/*.m", options.SourceFiles + "/**/*.mlx"]; + task.Inputs = inputGlobs; + task.Outputs = fullfile(task.OutputPath, ".last_run"); end end end @@ -60,7 +65,7 @@ methods (TaskAction, Sealed, Hidden) function runExampleTests(task, ~) - if task.CreateTestReport && ~isfolder(task.OutputPath) + if ~isfolder(task.OutputPath) mkdir(task.OutputPath); end @@ -72,6 +77,13 @@ function runExampleTests(task, ~) CodeCoveragePlugin = task.CodeCoveragePlugin, ... CleanupFcn = task.CleanupFcn); examplesRunner.executeTests; + + % Write stamp file only when incremental build is enabled + if ~isempty(task.SourceFiles) + stampFile = fullfile(task.OutputPath, ".last_run"); + fid = fopen(stampFile, 'w'); + fclose(fid); + end end end end