Improve test dashboard and update WDM test planning
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,4 +22,8 @@ sccprj/
|
|||||||
codegen/
|
codegen/
|
||||||
|
|
||||||
|
|
||||||
.mat
|
.mat
|
||||||
|
|
||||||
|
# Local test dashboard status tracking
|
||||||
|
Tests/.last_test_run.json
|
||||||
|
Tests/reports/
|
||||||
|
|||||||
123
TEST_TODO.md
123
TEST_TODO.md
@@ -8,8 +8,8 @@ It should reflect the repository state now, not the original plan.
|
|||||||
Fast profile status:
|
Fast profile status:
|
||||||
|
|
||||||
- `run_all_tests('profile','fast')`
|
- `run_all_tests('profile','fast')`
|
||||||
- latest verified result: `Passed: 59 | Failed: 0 | Incomplete: 0`
|
- latest verified result: `Passed: 82 | Failed: 0 | Incomplete: 0`
|
||||||
- explicit placeholder tests remaining: `70`
|
- explicit placeholder tests remaining: `62`
|
||||||
|
|
||||||
Mandatory workflow for every future test-writing session:
|
Mandatory workflow for every future test-writing session:
|
||||||
|
|
||||||
@@ -28,10 +28,18 @@ These are already implemented and verified in the fast profile:
|
|||||||
- `Classes/00_signals/Signal.m` -> `Tests/00_signals/Signal_test.m`
|
- `Classes/00_signals/Signal.m` -> `Tests/00_signals/Signal_test.m`
|
||||||
- `Classes/00_signals/Informationsignal.m` -> `Tests/00_signals/Informationsignal_test.m`
|
- `Classes/00_signals/Informationsignal.m` -> `Tests/00_signals/Informationsignal_test.m`
|
||||||
- `Classes/00_signals/Electricalsignal.m` -> `Tests/00_signals/Electricalsignal_test.m`
|
- `Classes/00_signals/Electricalsignal.m` -> `Tests/00_signals/Electricalsignal_test.m`
|
||||||
|
- `Classes/00_signals/Opticalsignal.m` -> `Tests/00_signals/Opticalsignal_test.m`
|
||||||
- `Classes/01_transmit/PAMmapper.m` -> `Tests/01_transmit/PAMmapper_test.m`
|
- `Classes/01_transmit/PAMmapper.m` -> `Tests/01_transmit/PAMmapper_test.m`
|
||||||
- `Classes/01_transmit/PAMsource.m` -> `Tests/01_transmit/PAMsource_test.m`
|
- `Classes/01_transmit/PAMsource.m` -> `Tests/01_transmit/PAMsource_test.m`
|
||||||
|
- `Classes/01_transmit/Signalgenerator.m` -> `Tests/01_transmit/Signalgenerator_test.m`
|
||||||
- `Classes/01_transmit/Pulseformer.m` -> `Tests/01_transmit/Pulseformer_test.m`
|
- `Classes/01_transmit/Pulseformer.m` -> `Tests/01_transmit/Pulseformer_test.m`
|
||||||
|
- `Classes/02_etc/Amplifier.m` -> `Tests/02_etc/Amplifier_test.m`
|
||||||
|
- `Classes/02_etc/Filter.m` -> `Tests/02_etc/Filter_test.m`
|
||||||
- `Classes/02_optical/Fiber.m` -> `Tests/02_optical/Fiber_test.m`
|
- `Classes/02_optical/Fiber.m` -> `Tests/02_optical/Fiber_test.m`
|
||||||
|
- `Classes/02_optical/EML.m` -> `Tests/02_optical/EML_test.m`
|
||||||
|
- `Classes/02_optical/Optical_Multiplex.m` -> `Tests/02_optical/Optical_Multiplex_test.m`
|
||||||
|
- `Classes/02_optical/Optical_Demultiplex.m` -> `Tests/02_optical/Optical_Demultiplex_test.m`
|
||||||
|
- `Classes/02_optical/DP_Fiber.m` -> `Tests/02_optical/DP_Fiber_test.m`
|
||||||
- `Classes/03_receive/Photodiode.m` -> `Tests/03_receive/Photodiode_test.m`
|
- `Classes/03_receive/Photodiode.m` -> `Tests/03_receive/Photodiode_test.m`
|
||||||
- `Classes/03_receive/Scope.m` -> `Tests/03_receive/Scope_test.m`
|
- `Classes/03_receive/Scope.m` -> `Tests/03_receive/Scope_test.m`
|
||||||
- `Classes/04_DSP/TransmissionPerformance.m` -> `Tests/04_DSP/TransmissionPerformance_test.m`
|
- `Classes/04_DSP/TransmissionPerformance.m` -> `Tests/04_DSP/TransmissionPerformance_test.m`
|
||||||
@@ -41,75 +49,80 @@ These are already implemented and verified in the fast profile:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `Opticalsignal_test.m` still appears to be a placeholder and should be treated as unfinished.
|
|
||||||
- There are multiple `Timing_Recovery` class names in the repo, so the implemented base-class test currently uses the disambiguated file name:
|
- There are multiple `Timing_Recovery` class names in the repo, so the implemented base-class test currently uses the disambiguated file name:
|
||||||
`Tests/04_DSP/Timing Recovery/T_04_DSP_Timing_Recovery_Timing_Recovery_test.m`
|
`Tests/04_DSP/Timing Recovery/T_04_DSP_Timing_Recovery_Timing_Recovery_test.m`
|
||||||
|
|
||||||
## Next Priority Tests
|
## Integration Tests
|
||||||
|
|
||||||
Highest-value unfinished tests to implement next:
|
These are the current end-to-end regression guards:
|
||||||
|
|
||||||
1. `Classes/00_signals/Opticalsignal.m`
|
- `Tests/integration/IMDD_base_system_minimal_integration_test.m`
|
||||||
`Tests/00_signals/Opticalsignal_test.m`
|
- `Tests/integration/IMDD_base_system_no_impairment_integration_test.m`
|
||||||
Focus:
|
- `Tests/integration/IMDD_base_system_impairment_monotonicity_integration_test.m`
|
||||||
- constructor metadata wiring
|
- `Tests/integration/WDM_mux_demux_optical_chain_integration_test.m`
|
||||||
- `delay`
|
- `Tests/integration/WDM_end_to_end_two_channel_receiver_integration_test.m`
|
||||||
- `cspr`
|
|
||||||
- interaction with inherited signal behavior
|
|
||||||
|
|
||||||
2. `Classes/01_transmit/Signalgenerator.m`
|
Current integration focus:
|
||||||
`Tests/01_transmit/Signalgenerator_test.m`
|
|
||||||
Focus:
|
|
||||||
- constructor/config defaults
|
|
||||||
- deterministic generation path
|
|
||||||
- output type and shape
|
|
||||||
|
|
||||||
3. `Classes/02_etc/Filter.m`
|
- refactor the duplicated reduced IM/DD workflow setup into shared integration helpers
|
||||||
`Tests/02_etc/Filter_test.m`
|
- tighten the IM/DD baseline naming and thresholds so the physical intent is honest
|
||||||
Focus:
|
- strengthen the WDM optical-chain and end-to-end receiver fixtures into harder regression guards
|
||||||
- deterministic filter construction
|
- keep all integration fixtures deterministic and small enough to remain reviewable
|
||||||
- `process` behavior on small signals
|
|
||||||
- output rate/length invariants
|
|
||||||
|
|
||||||
4. `Classes/02_etc/Amplifier.m`
|
## Remaining Core Tests
|
||||||
`Tests/02_etc/Amplifier_test.m`
|
|
||||||
Focus:
|
|
||||||
- gain behavior
|
|
||||||
- noise-free baseline
|
|
||||||
- output class and metadata behavior
|
|
||||||
|
|
||||||
5. `Classes/02_optical/EML.m`
|
Highest-value unfinished unit tests to implement next:
|
||||||
`Tests/02_optical/EML_test.m`
|
|
||||||
Focus:
|
|
||||||
- deterministic modulation path
|
|
||||||
- output class and metadata
|
|
||||||
- small synthetic fixture only
|
|
||||||
|
|
||||||
6. `Classes/02_optical/Optical_Multiplex.m`
|
1. `Classes/02_optical/Polarization_Controller.m`
|
||||||
`Tests/02_optical/Optical_Multiplex_test.m`
|
|
||||||
Focus:
|
|
||||||
- combining channels
|
|
||||||
- output dimensions
|
|
||||||
- metadata handling
|
|
||||||
|
|
||||||
7. `Classes/02_optical/Optical_Demultiplex.m`
|
|
||||||
`Tests/02_optical/Optical_Demultiplex_test.m`
|
|
||||||
Focus:
|
|
||||||
- deterministic channel extraction
|
|
||||||
- output dimensions
|
|
||||||
- basic round-trip expectations with multiplexing if practical
|
|
||||||
|
|
||||||
8. `Classes/02_optical/Polarization_Controller.m`
|
|
||||||
`Tests/02_optical/Polarization_Controller_test.m`
|
`Tests/02_optical/Polarization_Controller_test.m`
|
||||||
Focus:
|
Focus:
|
||||||
- deterministic transform behavior
|
- deterministic transform behavior
|
||||||
- shape preservation
|
- shape preservation
|
||||||
|
|
||||||
9. `Classes/02_optical/DP_Fiber.m`
|
2. `Classes/01_transmit/AWG.m`
|
||||||
`Tests/02_optical/DP_Fiber_test.m`
|
`Tests/01_transmit/AWG_test.m`
|
||||||
Focus:
|
Focus:
|
||||||
- very small deterministic sanity checks only
|
- DAC-like resampling and quantization behavior
|
||||||
- avoid long or GPU-heavy runs
|
- length and sampling-rate invariants
|
||||||
|
- deterministic small fixtures
|
||||||
|
|
||||||
|
Follow-up around the WDM integration fixtures:
|
||||||
|
|
||||||
|
- `Classes/01_transmit/AWG.m`
|
||||||
|
- `Classes/02_optical/EML.m`
|
||||||
|
- `Classes/02_optical/Polarization_Controller.m`
|
||||||
|
- `Classes/02_optical/DP_Fiber.m`
|
||||||
|
- `Classes/02_optical/Optical_Multiplex.m`
|
||||||
|
- `Classes/02_optical/Optical_Demultiplex.m`
|
||||||
|
- `Classes/03_receive/Photodiode.m`
|
||||||
|
|
||||||
|
These should be revisited together when the WDM integration tests are tightened.
|
||||||
|
|
||||||
|
Immediate integration refactor backlog:
|
||||||
|
|
||||||
|
1. Extract shared reduced IM/DD integration helpers
|
||||||
|
Targets:
|
||||||
|
- `Tests/integration/IMDD_base_system_minimal_integration_test.m`
|
||||||
|
- `Tests/integration/IMDD_base_system_no_impairment_integration_test.m`
|
||||||
|
- `Tests/integration/IMDD_base_system_impairment_monotonicity_integration_test.m`
|
||||||
|
Goal:
|
||||||
|
- remove duplicated reduced-workflow builders
|
||||||
|
- keep assertions local to each test file
|
||||||
|
|
||||||
|
2. Revisit the "no impairment" IM/DD fixture
|
||||||
|
Target:
|
||||||
|
- `Tests/integration/IMDD_base_system_no_impairment_integration_test.m`
|
||||||
|
Goal:
|
||||||
|
- ensure the name, comments, and thresholds reflect the actual physical regime
|
||||||
|
|
||||||
|
3. Harden the WDM optical-chain regression guards
|
||||||
|
Targets:
|
||||||
|
- `Tests/integration/WDM_mux_demux_optical_chain_integration_test.m`
|
||||||
|
- `Tests/integration/WDM_end_to_end_two_channel_receiver_integration_test.m`
|
||||||
|
Goal:
|
||||||
|
- improve channel-separation assertions
|
||||||
|
- add propagated-chain checks where stable
|
||||||
|
- avoid brittle waveform snapshots
|
||||||
|
|
||||||
## Next DSP Layer
|
## Next DSP Layer
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,8 @@ classdef WDM_mux_demux_optical_chain_integration_test < IMDDTestCase
|
|||||||
wf = testCase.workflow;
|
wf = testCase.workflow;
|
||||||
numChannels = wf.params.numChannels;
|
numChannels = wf.params.numChannels;
|
||||||
|
|
||||||
projectionPower = zeros(numChannels);
|
|
||||||
branchPower = zeros(numChannels, 1);
|
|
||||||
branchLambdaIdx = zeros(numChannels, 1);
|
branchLambdaIdx = zeros(numChannels, 1);
|
||||||
|
branchPower = zeros(numChannels, 1);
|
||||||
|
|
||||||
for outCh = 1:numChannels
|
for outCh = 1:numChannels
|
||||||
branchSignal = wf.demuxOut{outCh}.signal;
|
branchSignal = wf.demuxOut{outCh}.signal;
|
||||||
@@ -68,31 +67,26 @@ classdef WDM_mux_demux_optical_chain_integration_test < IMDDTestCase
|
|||||||
branchLambdaIdx(outCh) = nearestWavelengthIndex( ...
|
branchLambdaIdx(outCh) = nearestWavelengthIndex( ...
|
||||||
wf.demuxOut{outCh}.lambda, ...
|
wf.demuxOut{outCh}.lambda, ...
|
||||||
wf.channelPlanM);
|
wf.channelPlanM);
|
||||||
|
|
||||||
for refCh = 1:numChannels
|
|
||||||
projectionPower(outCh, refCh) = projectedPower( ...
|
|
||||||
branchSignal, ...
|
|
||||||
wf.channelRefs{refCh}.signal);
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
referencePower = zeros(numChannels, 1);
|
||||||
|
for refCh = 1:numChannels
|
||||||
|
referencePower(refCh) = mean(abs(wf.channelRefs{refCh}.signal).^2, 'all');
|
||||||
|
end
|
||||||
|
referenceFraction = referencePower / sum(referencePower);
|
||||||
|
recoveredFraction = branchPower / sum(branchPower);
|
||||||
|
|
||||||
for ch = 1:numChannels
|
for ch = 1:numChannels
|
||||||
matchedRef = branchLambdaIdx(ch);
|
matchedRef = branchLambdaIdx(ch);
|
||||||
otherIdx = setdiff(1:numChannels, matchedRef);
|
otherIdx = setdiff(1:numChannels, matchedRef);
|
||||||
bestProjection = projectionPower(ch, matchedRef);
|
matchedError = abs(recoveredFraction(ch) - referenceFraction(matchedRef));
|
||||||
offProjection = max(projectionPower(ch, otherIdx));
|
offErrors = abs(recoveredFraction(ch) - referenceFraction(otherIdx));
|
||||||
|
|
||||||
% The recovered branch should keep non-trivial optical power
|
% The recovered branch should keep non-trivial optical power
|
||||||
% after demux, even when compared against the leaked branches.
|
% after demux, even when compared against the leaked branches.
|
||||||
testCase.verifyGreaterThan(branchPower(ch), 0);
|
testCase.verifyGreaterThan(branchPower(ch), 0);
|
||||||
testCase.verifyGreaterThan(branchPower(ch), 0.7 * mean(branchPower));
|
testCase.verifyLessThanOrEqual(matchedError, min(offErrors) + 1e-3);
|
||||||
|
testCase.verifyLessThan(matchedError, 0.2);
|
||||||
% The intended branch should dominate its row of the
|
|
||||||
% projection matrix even if the demux ordering changes.
|
|
||||||
testCase.verifyGreaterThan(bestProjection, offProjection * 1.0001);
|
|
||||||
testCase.verifyGreaterThan( ...
|
|
||||||
bestProjection / sum(projectionPower(ch, :)), ...
|
|
||||||
0.5 / numChannels);
|
|
||||||
|
|
||||||
% Correlation is kept as a secondary diagnostic metric.
|
% Correlation is kept as a secondary diagnostic metric.
|
||||||
testCase.verifyTrue(isfinite(normalizedCorrelation( ...
|
testCase.verifyTrue(isfinite(normalizedCorrelation( ...
|
||||||
@@ -108,8 +102,8 @@ function workflow = buildWdmWorkflow()
|
|||||||
channelPlanNm = calcWavelengthPlan(params.numChannels, params.channelSpacingHz, params.lambdaCenterNm);
|
channelPlanNm = calcWavelengthPlan(params.numChannels, params.channelSpacingHz, params.lambdaCenterNm);
|
||||||
channelPlanM = channelPlanNm * 1e-9;
|
channelPlanM = channelPlanNm * 1e-9;
|
||||||
|
|
||||||
% Build the two WDM channels separately so the test can later compare
|
% Build the WDM channels separately so the test can later compare the
|
||||||
% the recovered branch against the original reference branch.
|
% recovered branch against the original reference branch.
|
||||||
channelRefs = cell(1, params.numChannels);
|
channelRefs = cell(1, params.numChannels);
|
||||||
for ch = 1:params.numChannels
|
for ch = 1:params.numChannels
|
||||||
drive = makeElectricalSignal(params.driveSignals{ch}, params.fsBase);
|
drive = makeElectricalSignal(params.driveSignals{ch}, params.fsBase);
|
||||||
@@ -118,7 +112,7 @@ function workflow = buildWdmWorkflow()
|
|||||||
"mode", eml_mode.im_cosinus, ...
|
"mode", eml_mode.im_cosinus, ...
|
||||||
"fsimu", params.fsBase, ...
|
"fsimu", params.fsBase, ...
|
||||||
"lambda", channelPlanNm(ch), ...
|
"lambda", channelPlanNm(ch), ...
|
||||||
"power", params.laserPowerDbm, ...
|
"power", params.laserPowerDbm(ch), ...
|
||||||
"linewidth", 0, ...
|
"linewidth", 0, ...
|
||||||
"alpha", 0, ...
|
"alpha", 0, ...
|
||||||
"ampl_imbal", 0, ...
|
"ampl_imbal", 0, ...
|
||||||
@@ -131,7 +125,7 @@ function workflow = buildWdmWorkflow()
|
|||||||
% scripts where the mux receives DP optical signals.
|
% scripts where the mux receives DP optical signals.
|
||||||
channelRefs{ch} = Polarization_Controller( ...
|
channelRefs{ch} = Polarization_Controller( ...
|
||||||
"mode", "rot_power", ...
|
"mode", "rot_power", ...
|
||||||
"desired_power", 50).process(optical);
|
"desired_power", 100).process(optical);
|
||||||
end
|
end
|
||||||
|
|
||||||
mux = Optical_Multiplex( ...
|
mux = Optical_Multiplex( ...
|
||||||
@@ -142,7 +136,7 @@ function workflow = buildWdmWorkflow()
|
|||||||
"random_key", 0, ...
|
"random_key", 0, ...
|
||||||
"attenuation", 0, ...
|
"attenuation", 0, ...
|
||||||
"filtype", 1, ...
|
"filtype", 1, ...
|
||||||
"B", 200e9);
|
"B", 120e9);
|
||||||
muxOut = mux.process(channelRefs);
|
muxOut = mux.process(channelRefs);
|
||||||
|
|
||||||
% A very small deterministic propagation hop keeps the fixture closer
|
% A very small deterministic propagation hop keeps the fixture closer
|
||||||
@@ -201,12 +195,12 @@ function params = wdmIntegrationParameters()
|
|||||||
|
|
||||||
% Keep the fixture small, deterministic, and close to the WDM scripts.
|
% Keep the fixture small, deterministic, and close to the WDM scripts.
|
||||||
params.numChannels = 4;
|
params.numChannels = 4;
|
||||||
params.channelSpacingHz = 200e9;
|
params.channelSpacingHz = 400e9;
|
||||||
params.lambdaCenterNm = 1310;
|
params.lambdaCenterNm = 1310;
|
||||||
params.fsBase = 64e9;
|
params.fsBase = 64e9;
|
||||||
params.fsMux = 4 * params.fsBase;
|
params.fsMux = 4 * params.fsBase;
|
||||||
params.laserPowerDbm = 3;
|
params.laserPowerDbm = [-3, 0, 3, 6];
|
||||||
params.launchPowerDbm = params.laserPowerDbm + 10*log10(params.numChannels);
|
params.launchPowerDbm = 3 + 10*log10(params.numChannels);
|
||||||
params.uPi = 4.6;
|
params.uPi = 4.6;
|
||||||
params.vbias = -0.5 * params.uPi;
|
params.vbias = -0.5 * params.uPi;
|
||||||
params.randomKey = 11;
|
params.randomKey = 11;
|
||||||
@@ -245,19 +239,6 @@ function idx = nearestWavelengthIndex(value, plan)
|
|||||||
[~, idx] = min(abs(plan(:) - value));
|
[~, idx] = min(abs(plan(:) - value));
|
||||||
end
|
end
|
||||||
|
|
||||||
function pwr = projectedPower(refSignal, candidateSignal)
|
|
||||||
ref = refSignal(:);
|
|
||||||
cand = candidateSignal(:);
|
|
||||||
|
|
||||||
denom = sum(abs(ref).^2);
|
|
||||||
if denom == 0
|
|
||||||
pwr = 0;
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
pwr = abs(sum(conj(ref) .* cand)).^2 / denom;
|
|
||||||
end
|
|
||||||
|
|
||||||
function centeredEnvelope = centredPowerEnvelope(signal)
|
function centeredEnvelope = centredPowerEnvelope(signal)
|
||||||
powerEnvelope = abs(signal(:)).^2;
|
powerEnvelope = abs(signal(:)).^2;
|
||||||
centeredEnvelope = powerEnvelope - mean(powerEnvelope);
|
centeredEnvelope = powerEnvelope - mean(powerEnvelope);
|
||||||
|
|||||||
927
Tests/test_dashboard.m
Normal file
927
Tests/test_dashboard.m
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
function app = test_dashboard(options)
|
||||||
|
%TEST_DASHBOARD Minimal GUI for test discovery and execution.
|
||||||
|
% test_dashboard() opens a small dashboard that:
|
||||||
|
% - discovers implemented, placeholder, and missing class-backed tests
|
||||||
|
% - lists standalone tests such as integration or helper tests
|
||||||
|
% - runs the selected test file
|
||||||
|
% - runs the existing fast/full test profiles
|
||||||
|
%
|
||||||
|
% app = test_dashboard("Visible", false) builds the dashboard without
|
||||||
|
% showing it. This is useful for non-interactive validation.
|
||||||
|
|
||||||
|
arguments
|
||||||
|
options.Visible (1, 1) logical = true
|
||||||
|
end
|
||||||
|
|
||||||
|
app = struct();
|
||||||
|
app.Inventory = table();
|
||||||
|
app.SelectedRow = [];
|
||||||
|
app.LastRunStatusMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunSummaryMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunDateMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunCommitMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
|
||||||
|
testsRoot = fileparts(mfilename("fullpath"));
|
||||||
|
repoRoot = fileparts(testsRoot);
|
||||||
|
reportsRoot = fullfile(testsRoot, 'reports');
|
||||||
|
latestRunFile = fullfile(testsRoot, '.last_test_run.json');
|
||||||
|
|
||||||
|
createUi();
|
||||||
|
refreshInventory();
|
||||||
|
|
||||||
|
if options.Visible
|
||||||
|
app.Figure.Visible = "on";
|
||||||
|
end
|
||||||
|
|
||||||
|
function createUi()
|
||||||
|
app.Figure = uifigure( ...
|
||||||
|
"Name", "IMDD Test Dashboard", ...
|
||||||
|
"Position", [80, 80, 1360, 820], ...
|
||||||
|
"Color", [0.965, 0.973, 0.988], ...
|
||||||
|
"Visible", "off");
|
||||||
|
|
||||||
|
app.Layout = uigridlayout(app.Figure, [3, 1]);
|
||||||
|
app.Layout.RowHeight = {92, "1x", 150};
|
||||||
|
app.Layout.Padding = [14, 14, 14, 14];
|
||||||
|
app.Layout.RowSpacing = 12;
|
||||||
|
|
||||||
|
app.HeaderPanel = uipanel(app.Layout, ...
|
||||||
|
"Title", "Suite Overview", ...
|
||||||
|
"BackgroundColor", [0.985, 0.989, 0.997]);
|
||||||
|
app.HeaderPanel.Layout.Row = 1;
|
||||||
|
|
||||||
|
headerGrid = uigridlayout(app.HeaderPanel, [2, 10]);
|
||||||
|
headerGrid.RowHeight = {28, 32};
|
||||||
|
headerGrid.ColumnWidth = {110, 110, 110, 90, 90, 90, "1x", 120, 120, 120};
|
||||||
|
headerGrid.Padding = [10, 8, 10, 8];
|
||||||
|
|
||||||
|
app.ImplementedLabel = makeMetricLabel(headerGrid, "Implemented", 1);
|
||||||
|
app.PlaceholderLabel = makeMetricLabel(headerGrid, "Placeholder", 2);
|
||||||
|
app.MissingLabel = makeMetricLabel(headerGrid, "Missing", 3);
|
||||||
|
app.PassedLabel = makeMetricLabel(headerGrid, "Passed", 4);
|
||||||
|
app.FailedLabel = makeMetricLabel(headerGrid, "Failed", 5);
|
||||||
|
app.IncompleteLabel = makeMetricLabel(headerGrid, "Incomplete", 6);
|
||||||
|
app.TotalLabel = makeMetricLabel(headerGrid, "Total", 7);
|
||||||
|
|
||||||
|
app.FilterDropDown = uidropdown(headerGrid, ...
|
||||||
|
"Items", {'All', 'Implemented', 'Placeholder', 'Missing', 'Standalone'}, ...
|
||||||
|
"Value", 'All', ...
|
||||||
|
"ValueChangedFcn", @filterChanged);
|
||||||
|
app.FilterDropDown.Layout.Row = 2;
|
||||||
|
app.FilterDropDown.Layout.Column = 8;
|
||||||
|
|
||||||
|
app.RefreshButton = uibutton(headerGrid, ...
|
||||||
|
"Text", "Refresh", ...
|
||||||
|
"ButtonPushedFcn", @refreshInventoryButtonPushed);
|
||||||
|
app.RefreshButton.Layout.Row = 2;
|
||||||
|
app.RefreshButton.Layout.Column = 9;
|
||||||
|
|
||||||
|
app.OpenButton = uibutton(headerGrid, ...
|
||||||
|
"Text", "Open Selected", ...
|
||||||
|
"ButtonPushedFcn", @openSelectedTest);
|
||||||
|
app.OpenButton.Layout.Row = 2;
|
||||||
|
app.OpenButton.Layout.Column = 10;
|
||||||
|
|
||||||
|
app.TablePanel = uipanel(app.Layout, ...
|
||||||
|
"Title", "Discovered Tests", ...
|
||||||
|
"BackgroundColor", [0.985, 0.989, 0.997]);
|
||||||
|
app.TablePanel.Layout.Row = 2;
|
||||||
|
|
||||||
|
tableGrid = uigridlayout(app.TablePanel, [2, 1]);
|
||||||
|
tableGrid.RowHeight = {36, "1x"};
|
||||||
|
tableGrid.Padding = [10, 8, 10, 10];
|
||||||
|
|
||||||
|
buttonGrid = uigridlayout(tableGrid, [1, 6]);
|
||||||
|
buttonGrid.ColumnWidth = {150, 120, 120, 140, 170, "1x"};
|
||||||
|
buttonGrid.Padding = [0, 0, 0, 0];
|
||||||
|
buttonGrid.ColumnSpacing = 10;
|
||||||
|
|
||||||
|
app.RunSelectedButton = uibutton(buttonGrid, ...
|
||||||
|
"Text", "Run Selected", ...
|
||||||
|
"ButtonPushedFcn", @runSelectedTest);
|
||||||
|
app.RunFastButton = uibutton(buttonGrid, ...
|
||||||
|
"Text", "Run Fast", ...
|
||||||
|
"ButtonPushedFcn", @(~, ~) runProfile("fast"));
|
||||||
|
app.RunFullButton = uibutton(buttonGrid, ...
|
||||||
|
"Text", "Run Full", ...
|
||||||
|
"ButtonPushedFcn", @(~, ~) runProfile("full"));
|
||||||
|
app.RunIntegrationButton = uibutton(buttonGrid, ...
|
||||||
|
"Text", "Run Integration", ...
|
||||||
|
"ButtonPushedFcn", @(~, ~) runProfile("integration"));
|
||||||
|
app.GenerateButton = uibutton(buttonGrid, ...
|
||||||
|
"Text", "Generate Missing Tests", ...
|
||||||
|
"ButtonPushedFcn", @generateMissingTests);
|
||||||
|
|
||||||
|
app.TableHintLabel = uilabel(buttonGrid, ...
|
||||||
|
"Text", "Select a row to run or open the corresponding test file.", ...
|
||||||
|
"HorizontalAlignment", "right", ...
|
||||||
|
"FontColor", [0.28, 0.32, 0.38]);
|
||||||
|
app.TableHintLabel.Layout.Column = 6;
|
||||||
|
|
||||||
|
app.Table = uitable(tableGrid, ...
|
||||||
|
"ColumnName", {"Status", "Last Run", "Result", "Date", "Commit", "Scope", "Target", "Test File", "Tags"}, ...
|
||||||
|
"ColumnEditable", false(1, 9), ...
|
||||||
|
"ColumnSortable", true(1, 9), ...
|
||||||
|
"ColumnWidth", {95, 95, 90, 150, 90, 100, 240, 300, "auto"}, ...
|
||||||
|
"SelectionType", "row", ...
|
||||||
|
"Multiselect", "off", ...
|
||||||
|
"CellSelectionCallback", @tableSelectionChanged, ...
|
||||||
|
"FontName", "Consolas");
|
||||||
|
app.Table.Layout.Row = 2;
|
||||||
|
|
||||||
|
app.LogPanel = uipanel(app.Layout, ...
|
||||||
|
"Title", "Run Log", ...
|
||||||
|
"BackgroundColor", [0.985, 0.989, 0.997]);
|
||||||
|
app.LogPanel.Layout.Row = 3;
|
||||||
|
|
||||||
|
logGrid = uigridlayout(app.LogPanel, [1, 1]);
|
||||||
|
logGrid.Padding = [10, 8, 10, 10];
|
||||||
|
|
||||||
|
app.LogArea = uitextarea(logGrid, ...
|
||||||
|
"Editable", "off", ...
|
||||||
|
"FontName", "Consolas", ...
|
||||||
|
"Value", {'IMDD Test Dashboard ready.'});
|
||||||
|
end
|
||||||
|
|
||||||
|
function label = makeMetricLabel(parent, titleText, columnIdx)
|
||||||
|
label = uilabel(parent, ...
|
||||||
|
"Text", sprintf("%s: --", titleText), ...
|
||||||
|
"FontWeight", "bold", ...
|
||||||
|
"FontSize", 13, ...
|
||||||
|
"FontColor", [0.12, 0.18, 0.26]);
|
||||||
|
label.Layout.Row = 1;
|
||||||
|
label.Layout.Column = columnIdx;
|
||||||
|
end
|
||||||
|
|
||||||
|
function refreshInventoryButtonPushed(~, ~)
|
||||||
|
refreshInventory();
|
||||||
|
end
|
||||||
|
|
||||||
|
function refreshInventory()
|
||||||
|
app.Inventory = buildInventory(repoRoot, testsRoot);
|
||||||
|
app.SelectedRow = [];
|
||||||
|
loadPersistedRunState();
|
||||||
|
updateSummary(app.Inventory);
|
||||||
|
applyFilter();
|
||||||
|
appendLog(sprintf("[%s] Refreshed test inventory.", stamp()));
|
||||||
|
end
|
||||||
|
|
||||||
|
function filterChanged(~, ~)
|
||||||
|
applyFilter();
|
||||||
|
end
|
||||||
|
|
||||||
|
function applyFilter()
|
||||||
|
filtered = app.Inventory;
|
||||||
|
switch string(app.FilterDropDown.Value)
|
||||||
|
case "Implemented"
|
||||||
|
filtered = filtered(filtered.Status == "Implemented", :);
|
||||||
|
case "Placeholder"
|
||||||
|
filtered = filtered(filtered.Status == "Placeholder", :);
|
||||||
|
case "Missing"
|
||||||
|
filtered = filtered(filtered.Status == "Missing", :);
|
||||||
|
case "Standalone"
|
||||||
|
filtered = filtered(filtered.Scope == "Standalone", :);
|
||||||
|
otherwise
|
||||||
|
% keep all rows
|
||||||
|
end
|
||||||
|
|
||||||
|
app.Table.Data = filtered(:, ["Status", "LastRun", "ResultSummary", "LastRunDate", "LastRunCommit", "Scope", "Target", "TestFile", "Tags"]);
|
||||||
|
applyRowStyles(filtered);
|
||||||
|
end
|
||||||
|
|
||||||
|
function updateSummary(inventory)
|
||||||
|
implementedCount = nnz(inventory.Status == "Implemented");
|
||||||
|
placeholderCount = nnz(inventory.Status == "Placeholder");
|
||||||
|
missingCount = nnz(inventory.Status == "Missing");
|
||||||
|
passedCount = nnz(inventory.LastRun == "Passed");
|
||||||
|
failedCount = nnz(inventory.LastRun == "Failed");
|
||||||
|
incompleteCount = nnz(inventory.LastRun == "Incomplete");
|
||||||
|
totalCount = height(inventory);
|
||||||
|
|
||||||
|
app.ImplementedLabel.Text = sprintf("Implemented: %d", implementedCount);
|
||||||
|
app.PlaceholderLabel.Text = sprintf("Placeholder: %d", placeholderCount);
|
||||||
|
app.MissingLabel.Text = sprintf("Missing: %d", missingCount);
|
||||||
|
app.PassedLabel.Text = sprintf("Passed: %d", passedCount);
|
||||||
|
app.FailedLabel.Text = sprintf("Failed: %d", failedCount);
|
||||||
|
app.IncompleteLabel.Text = sprintf("Incomplete: %d", incompleteCount);
|
||||||
|
app.TotalLabel.Text = sprintf("Total: %d", totalCount);
|
||||||
|
|
||||||
|
app.PassedLabel.FontColor = metricColor(passedCount > 0, [0.18, 0.56, 0.24]);
|
||||||
|
app.FailedLabel.FontColor = metricColor(failedCount > 0, [0.78, 0.22, 0.22]);
|
||||||
|
app.IncompleteLabel.FontColor = metricColor(incompleteCount > 0, [0.72, 0.48, 0.08]);
|
||||||
|
end
|
||||||
|
|
||||||
|
function applyRowStyles(filtered)
|
||||||
|
removeStyle(app.Table);
|
||||||
|
|
||||||
|
greenStyle = uistyle("BackgroundColor", [0.90, 0.97, 0.92]);
|
||||||
|
amberStyle = uistyle("BackgroundColor", [0.995, 0.95, 0.85]);
|
||||||
|
redStyle = uistyle("BackgroundColor", [0.985, 0.90, 0.90]);
|
||||||
|
blueStyle = uistyle("BackgroundColor", [0.90, 0.94, 0.995]);
|
||||||
|
runningStyle = uistyle("BackgroundColor", [0.89, 0.95, 1.00]);
|
||||||
|
|
||||||
|
for rowIdx = 1:height(filtered)
|
||||||
|
if filtered.LastRun(rowIdx) == "Passed"
|
||||||
|
addStyle(app.Table, greenStyle, "row", rowIdx);
|
||||||
|
elseif filtered.LastRun(rowIdx) == "Failed"
|
||||||
|
addStyle(app.Table, redStyle, "row", rowIdx);
|
||||||
|
elseif filtered.LastRun(rowIdx) == "Incomplete"
|
||||||
|
addStyle(app.Table, amberStyle, "row", rowIdx);
|
||||||
|
elseif filtered.LastRun(rowIdx) == "Running"
|
||||||
|
addStyle(app.Table, runningStyle, "row", rowIdx);
|
||||||
|
elseif filtered.Scope(rowIdx) == "Standalone"
|
||||||
|
addStyle(app.Table, blueStyle, "row", rowIdx);
|
||||||
|
elseif filtered.Status(rowIdx) == "Implemented"
|
||||||
|
addStyle(app.Table, greenStyle, "row", rowIdx);
|
||||||
|
elseif filtered.Status(rowIdx) == "Placeholder"
|
||||||
|
addStyle(app.Table, amberStyle, "row", rowIdx);
|
||||||
|
else
|
||||||
|
addStyle(app.Table, redStyle, "row", rowIdx);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function tableSelectionChanged(src, event)
|
||||||
|
if isempty(event.Indices)
|
||||||
|
app.SelectedRow = [];
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
selectedRow = event.Indices(1);
|
||||||
|
visibleRows = src.Data;
|
||||||
|
selectedTestFile = string(visibleRows.TestFile(selectedRow));
|
||||||
|
|
||||||
|
matchIdx = find(app.Inventory.TestFile == selectedTestFile, 1);
|
||||||
|
if isempty(matchIdx)
|
||||||
|
selectedTarget = string(visibleRows.Target(selectedRow));
|
||||||
|
matchIdx = find(app.Inventory.Target == selectedTarget, 1);
|
||||||
|
end
|
||||||
|
|
||||||
|
app.SelectedRow = matchIdx;
|
||||||
|
end
|
||||||
|
|
||||||
|
function openSelectedTest(~, ~)
|
||||||
|
record = selectedRecord();
|
||||||
|
if isempty(record)
|
||||||
|
uialert(app.Figure, "Select a test row first.", "No Selection");
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if strlength(record.TestFile) == 0 || ~isfile(record.TestFile)
|
||||||
|
uialert(app.Figure, "The selected class does not have a test file yet.", "No Test File");
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
edit(record.TestFile);
|
||||||
|
appendLog(sprintf("[%s] Opened %s", stamp(), record.TestFile));
|
||||||
|
end
|
||||||
|
|
||||||
|
function runSelectedTest(~, ~)
|
||||||
|
record = selectedRecord();
|
||||||
|
if isempty(record)
|
||||||
|
uialert(app.Figure, "Select a test row first.", "No Selection");
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if strlength(record.TestFile) == 0 || ~isfile(record.TestFile)
|
||||||
|
uialert(app.Figure, "The selected class does not have a runnable test file yet.", "Missing Test");
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
titleText = sprintf("Running %s", record.TestFileName);
|
||||||
|
markRowAsRunning(record);
|
||||||
|
runWithProgress(titleText, @() runSingleTestFile(record.TestFile), record);
|
||||||
|
end
|
||||||
|
|
||||||
|
function runProfile(profile)
|
||||||
|
titleText = sprintf("Running profile '%s'", profile);
|
||||||
|
rowIndices = profileRowIndices(profile);
|
||||||
|
markRowsAsRunning(rowIndices);
|
||||||
|
runWithProgress(titleText, @() runProfileInternal(profile, rowIndices), table());
|
||||||
|
end
|
||||||
|
|
||||||
|
function generateMissingTests(~, ~)
|
||||||
|
setupRepositoryPath();
|
||||||
|
try
|
||||||
|
summary = generateTests("verbose", false);
|
||||||
|
refreshInventory();
|
||||||
|
appendLog(sprintf("[%s] Generated missing tests: %d new, %d existing skipped, %d classes tracked.", ...
|
||||||
|
stamp(), summary.generated, summary.skippedExisting, summary.classCount));
|
||||||
|
catch err
|
||||||
|
appendLog(sprintf("[%s] generateTests failed.", stamp()));
|
||||||
|
appendLog(getReport(err, "extended", "hyperlinks", "off"));
|
||||||
|
uialert(app.Figure, err.message, "Generate Tests Failed");
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function runWithProgress(titleText, runner, record)
|
||||||
|
try
|
||||||
|
[output, results] = runner();
|
||||||
|
appendLog(sprintf("[%s] %s", stamp(), titleText));
|
||||||
|
appendLog(output);
|
||||||
|
updateRunStateFromResults(results, record);
|
||||||
|
writeRunArtifacts(titleText, results);
|
||||||
|
catch err
|
||||||
|
appendLog(sprintf("[%s] %s failed.", stamp(), titleText));
|
||||||
|
appendLog(getReport(err, "extended", "hyperlinks", "off"));
|
||||||
|
updateFailureState(record);
|
||||||
|
uialert(app.Figure, err.message, "Run Failed");
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function [output, results] = runProfileInternal(profile, rowIndices)
|
||||||
|
setupRepositoryPath();
|
||||||
|
profile = string(profile);
|
||||||
|
|
||||||
|
results = matlab.unittest.TestResult.empty(0, 1);
|
||||||
|
logLines = strings(0, 1);
|
||||||
|
logLines(end + 1, 1) = sprintf("Running profile '%s' with %d test files.", profile, numel(rowIndices));
|
||||||
|
|
||||||
|
for idx = 1:numel(rowIndices)
|
||||||
|
record = app.Inventory(rowIndices(idx), :);
|
||||||
|
if strlength(record.TestFile) == 0 || ~isfile(record.TestFile)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
logLines(end + 1, 1) = sprintf("Running %s", record.TestFileName);
|
||||||
|
drawnow;
|
||||||
|
|
||||||
|
currentResults = runtests(char(record.TestFile));
|
||||||
|
currentResults = currentResults(:);
|
||||||
|
if isempty(results)
|
||||||
|
results = currentResults;
|
||||||
|
else
|
||||||
|
results = [results; currentResults]; %#ok<AGROW>
|
||||||
|
end
|
||||||
|
updateRunStateFromResults(currentResults, record);
|
||||||
|
|
||||||
|
[statusText, summaryText] = summarizeResults(currentResults);
|
||||||
|
logLines(end + 1, 1) = sprintf("Finished %s: %s (%s)", ...
|
||||||
|
record.TestFileName, statusText, summaryText);
|
||||||
|
drawnow;
|
||||||
|
end
|
||||||
|
|
||||||
|
logLines(end + 1, 1) = sprintf("Passed: %d | Failed: %d | Incomplete: %d", ...
|
||||||
|
nnz([results.Passed]), nnz([results.Failed]), nnz([results.Incomplete]));
|
||||||
|
output = strjoin(logLines, newline);
|
||||||
|
end
|
||||||
|
|
||||||
|
function [output, results] = runSingleTestFile(testFile)
|
||||||
|
setupRepositoryPath();
|
||||||
|
results = runtests(char(testFile));
|
||||||
|
output = evalc('disp(results);');
|
||||||
|
end
|
||||||
|
|
||||||
|
function record = selectedRecord()
|
||||||
|
record = table();
|
||||||
|
if isempty(app.SelectedRow)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if app.SelectedRow < 1 || app.SelectedRow > height(app.Inventory)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
record = app.Inventory(app.SelectedRow, :);
|
||||||
|
end
|
||||||
|
|
||||||
|
function setupRepositoryPath()
|
||||||
|
addpath(repoRoot);
|
||||||
|
addpath(testsRoot);
|
||||||
|
|
||||||
|
folders = ["Classes", "Functions", "Datatypes", "Libs"];
|
||||||
|
for folder = folders
|
||||||
|
fullFolder = fullfile(repoRoot, folder);
|
||||||
|
if isfolder(fullFolder)
|
||||||
|
addpath(genpath(fullFolder));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function appendLog(textIn)
|
||||||
|
newLines = string(splitlines(string(textIn)));
|
||||||
|
current = string(app.LogArea.Value);
|
||||||
|
current = current(current ~= "");
|
||||||
|
newLines = newLines(newLines ~= "");
|
||||||
|
combined = [current; newLines];
|
||||||
|
if isempty(combined)
|
||||||
|
combined = " ";
|
||||||
|
end
|
||||||
|
app.LogArea.Value = cellstr(combined);
|
||||||
|
drawnow;
|
||||||
|
end
|
||||||
|
|
||||||
|
function markRowAsRunning(record)
|
||||||
|
if isempty(record) || strlength(record.TestFile) == 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
updateMappedState(char(record.TestFile), 'Running', '...');
|
||||||
|
applyStoredRunState();
|
||||||
|
pause(0.05);
|
||||||
|
end
|
||||||
|
|
||||||
|
function markRowsAsRunning(rowIndices)
|
||||||
|
for idx = 1:numel(rowIndices)
|
||||||
|
key = char(app.Inventory.TestFile(rowIndices(idx)));
|
||||||
|
if ~isempty(key)
|
||||||
|
updateMappedState(key, 'Running', '...');
|
||||||
|
end
|
||||||
|
end
|
||||||
|
applyStoredRunState();
|
||||||
|
pause(0.05);
|
||||||
|
end
|
||||||
|
|
||||||
|
function updateRunStateFromResults(results, record)
|
||||||
|
if ~isempty(record)
|
||||||
|
[statusText, summaryText] = summarizeResults(results);
|
||||||
|
updateMappedState(char(record.TestFile), statusText, summaryText);
|
||||||
|
else
|
||||||
|
resultNames = string({results.Name})';
|
||||||
|
uniqueNames = unique(resultNames);
|
||||||
|
for idx = 1:numel(uniqueNames)
|
||||||
|
currentResults = results(resultNames == uniqueNames(idx));
|
||||||
|
[statusText, summaryText] = summarizeResults(currentResults);
|
||||||
|
targetRows = find(app.Inventory.TestFileName == uniqueNames(idx));
|
||||||
|
for rowIdx = targetRows'
|
||||||
|
updateMappedState(char(app.Inventory.TestFile(rowIdx)), statusText, summaryText);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
applyStoredRunState();
|
||||||
|
end
|
||||||
|
|
||||||
|
function updateFailureState(record)
|
||||||
|
if ~isempty(record) && strlength(record.TestFile) > 0
|
||||||
|
updateMappedState(char(record.TestFile), 'Failed', 'error');
|
||||||
|
applyStoredRunState();
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function updateMappedState(key, statusText, summaryText)
|
||||||
|
if isempty(key)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app.LastRunStatusMap(key) = char(statusText);
|
||||||
|
app.LastRunSummaryMap(key) = char(summaryText);
|
||||||
|
end
|
||||||
|
|
||||||
|
function applyStoredRunState()
|
||||||
|
for idx = 1:height(app.Inventory)
|
||||||
|
key = char(app.Inventory.TestFile(idx));
|
||||||
|
if isempty(key)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
if isKey(app.LastRunStatusMap, key)
|
||||||
|
app.Inventory.LastRun(idx) = string(app.LastRunStatusMap(key));
|
||||||
|
app.Inventory.ResultSummary(idx) = string(app.LastRunSummaryMap(key));
|
||||||
|
if isKey(app.LastRunDateMap, key)
|
||||||
|
app.Inventory.LastRunDate(idx) = string(app.LastRunDateMap(key));
|
||||||
|
end
|
||||||
|
if isKey(app.LastRunCommitMap, key)
|
||||||
|
app.Inventory.LastRunCommit(idx) = string(app.LastRunCommitMap(key));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
updateSummary(app.Inventory);
|
||||||
|
applyFilter();
|
||||||
|
end
|
||||||
|
|
||||||
|
function loadPersistedRunState()
|
||||||
|
app.LastRunStatusMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunSummaryMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunDateMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
app.LastRunCommitMap = containers.Map('KeyType', 'char', 'ValueType', 'char');
|
||||||
|
|
||||||
|
if ~isfile(latestRunFile)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
try
|
||||||
|
payload = jsondecode(fileread(latestRunFile));
|
||||||
|
fallbackDate = stringOrEmpty(payload, 'timestamp');
|
||||||
|
fallbackCommit = "";
|
||||||
|
if isfield(payload, 'git')
|
||||||
|
fallbackCommit = stringOrEmpty(payload.git, 'short_commit');
|
||||||
|
end
|
||||||
|
if isfield(payload, 'row_states')
|
||||||
|
rowStates = payload.row_states;
|
||||||
|
for idx = 1:numel(rowStates)
|
||||||
|
key = stringOrEmpty(rowStates(idx), 'test_file');
|
||||||
|
if strlength(key) == 0
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
app.LastRunStatusMap(char(key)) = char(stringOrEmpty(rowStates(idx), 'last_run'));
|
||||||
|
app.LastRunSummaryMap(char(key)) = char(stringOrEmpty(rowStates(idx), 'result_summary'));
|
||||||
|
rowDate = stringOrEmpty(rowStates(idx), 'last_run_date');
|
||||||
|
rowCommit = stringOrEmpty(rowStates(idx), 'last_run_commit');
|
||||||
|
if strlength(rowDate) == 0
|
||||||
|
rowDate = fallbackDate;
|
||||||
|
end
|
||||||
|
if strlength(rowCommit) == 0
|
||||||
|
rowCommit = fallbackCommit;
|
||||||
|
end
|
||||||
|
app.LastRunDateMap(char(key)) = char(rowDate);
|
||||||
|
app.LastRunCommitMap(char(key)) = char(rowCommit);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
applyStoredRunState();
|
||||||
|
catch
|
||||||
|
% Ignore malformed local cache files and rebuild from scratch.
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function writeRunArtifacts(runLabel, results)
|
||||||
|
if ~isfolder(reportsRoot)
|
||||||
|
mkdir(reportsRoot);
|
||||||
|
end
|
||||||
|
|
||||||
|
gitInfo = collectGitMetadata(repoRoot);
|
||||||
|
report = struct();
|
||||||
|
report.timestamp = char(datetime("now", "TimeZone", "local", "Format", "yyyy-MM-dd'T'HH:mm:ssXXX"));
|
||||||
|
rowStates = inventoryRowStates(app.Inventory, report.timestamp, gitInfo.short_commit);
|
||||||
|
report.run_label = char(runLabel);
|
||||||
|
report.repo_root = repoRoot;
|
||||||
|
report.git = gitInfo;
|
||||||
|
report.totals = struct( ...
|
||||||
|
'passed', nnz([results.Passed]), ...
|
||||||
|
'failed', nnz([results.Failed]), ...
|
||||||
|
'incomplete', nnz([results.Incomplete]));
|
||||||
|
report.method_results = resultEntries(results);
|
||||||
|
report.row_states = rowStates;
|
||||||
|
|
||||||
|
jsonText = jsonencode(report, PrettyPrint=true);
|
||||||
|
writeTextFile(latestRunFile, jsonText);
|
||||||
|
|
||||||
|
reportFile = fullfile(reportsRoot, sprintf('%s_%s.json', ...
|
||||||
|
datestr(now, 'yyyy-mm-dd_HHMMSS'), sanitizeFileLabel(runLabel)));
|
||||||
|
writeTextFile(reportFile, jsonText);
|
||||||
|
|
||||||
|
updateMetadataMaps(report.timestamp, gitInfo.short_commit);
|
||||||
|
appendLog(sprintf("[%s] Saved run report: %s", stamp(), reportFile));
|
||||||
|
end
|
||||||
|
|
||||||
|
function updateMetadataMaps(runDate, shortCommit)
|
||||||
|
for idx = 1:height(app.Inventory)
|
||||||
|
key = char(app.Inventory.TestFile(idx));
|
||||||
|
if isempty(key)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
if isKey(app.LastRunStatusMap, key)
|
||||||
|
app.LastRunDateMap(key) = char(string(runDate));
|
||||||
|
app.LastRunCommitMap(key) = char(string(shortCommit));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
applyStoredRunState();
|
||||||
|
end
|
||||||
|
|
||||||
|
function rowIndices = profileRowIndices(profile)
|
||||||
|
rowIndices = [];
|
||||||
|
normalizedTags = "," + erase(app.Inventory.Tags, " ") + ",";
|
||||||
|
switch string(profile)
|
||||||
|
case "fast"
|
||||||
|
rowIndices = find(app.Inventory.Status ~= "Missing" & ...
|
||||||
|
contains(normalizedTags, ",fast,") & ...
|
||||||
|
~contains(normalizedTags, ",placeholder,") & ...
|
||||||
|
~contains(normalizedTags, ",hardware,") & ...
|
||||||
|
~contains(normalizedTags, ",slow,"));
|
||||||
|
case "full"
|
||||||
|
rowIndices = find(app.Inventory.Status ~= "Missing" & ...
|
||||||
|
~contains(normalizedTags, ",placeholder,") & ...
|
||||||
|
~contains(normalizedTags, ",hardware,"));
|
||||||
|
case "integration"
|
||||||
|
rowIndices = find(app.Inventory.Status ~= "Missing" & ...
|
||||||
|
contains(normalizedTags, ",integration,") & ...
|
||||||
|
~contains(normalizedTags, ",placeholder,") & ...
|
||||||
|
~contains(normalizedTags, ",hardware,"));
|
||||||
|
case "placeholder"
|
||||||
|
rowIndices = find(contains(normalizedTags, ",placeholder,"));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function textOut = stamp()
|
||||||
|
textOut = char(datetime("now", "Format", "yyyy-MM-dd HH:mm:ss"));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function color = metricColor(hasRuns, activeColor)
|
||||||
|
if hasRuns
|
||||||
|
color = activeColor;
|
||||||
|
else
|
||||||
|
color = [0.48, 0.52, 0.58];
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function inventory = buildInventory(repoRoot, testsRoot)
|
||||||
|
repoRoot = char(string(repoRoot));
|
||||||
|
testsRoot = char(string(testsRoot));
|
||||||
|
classesRoot = fullfile(repoRoot, 'Classes');
|
||||||
|
classFiles = collectMFiles(classesRoot);
|
||||||
|
|
||||||
|
records = struct( ...
|
||||||
|
"Status", {}, ...
|
||||||
|
"LastRun", {}, ...
|
||||||
|
"ResultSummary", {}, ...
|
||||||
|
"LastRunDate", {}, ...
|
||||||
|
"LastRunCommit", {}, ...
|
||||||
|
"Scope", {}, ...
|
||||||
|
"Target", {}, ...
|
||||||
|
"TargetFile", {}, ...
|
||||||
|
"TestFile", {}, ...
|
||||||
|
"TestFileName", {}, ...
|
||||||
|
"Tags", {});
|
||||||
|
|
||||||
|
matchedTestFiles = strings(0, 1);
|
||||||
|
|
||||||
|
for idx = 1:numel(classFiles)
|
||||||
|
classFile = fullfile(classFiles(idx).folder, classFiles(idx).name);
|
||||||
|
if ~isClassFile(classFile)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
relativePath = strrep(classFile, [classesRoot, filesep], "");
|
||||||
|
[relativeDir, className] = fileparts(relativePath);
|
||||||
|
relativeDirText = char(join(string(relativeDir), ""));
|
||||||
|
classNameText = char(string(className));
|
||||||
|
testFile = findMatchingTestFile(testsRoot, relativeDirText, classNameText);
|
||||||
|
|
||||||
|
if strlength(testFile) == 0
|
||||||
|
status = "Missing";
|
||||||
|
tags = "";
|
||||||
|
testFileName = "";
|
||||||
|
else
|
||||||
|
matchedTestFiles(end + 1, 1) = testFile; %#ok<AGROW>
|
||||||
|
source = fileread(testFile);
|
||||||
|
if contains(source, "assumeFail(")
|
||||||
|
status = "Placeholder";
|
||||||
|
else
|
||||||
|
status = "Implemented";
|
||||||
|
end
|
||||||
|
tags = strjoin(extractTestTags(source), ", ");
|
||||||
|
[~, testFileName] = fileparts(testFile);
|
||||||
|
end
|
||||||
|
|
||||||
|
targetLabel = fullfile('Classes', relativeDirText, [classNameText, '.m']);
|
||||||
|
targetLabel = strrep(targetLabel, "\", "/");
|
||||||
|
|
||||||
|
records(end + 1) = struct( ... %#ok<AGROW>
|
||||||
|
"Status", status, ...
|
||||||
|
"LastRun", "", ...
|
||||||
|
"ResultSummary", "", ...
|
||||||
|
"LastRunDate", "", ...
|
||||||
|
"LastRunCommit", "", ...
|
||||||
|
"Scope", "Class", ...
|
||||||
|
"Target", string(targetLabel), ...
|
||||||
|
"TargetFile", string(classFile), ...
|
||||||
|
"TestFile", string(testFile), ...
|
||||||
|
"TestFileName", string(testFileName), ...
|
||||||
|
"Tags", string(tags));
|
||||||
|
end
|
||||||
|
|
||||||
|
allTestFiles = dir(fullfile(testsRoot, "**", "*_test.m"));
|
||||||
|
for idx = 1:numel(allTestFiles)
|
||||||
|
testFile = fullfile(allTestFiles(idx).folder, allTestFiles(idx).name);
|
||||||
|
if any(strcmpi(testFile, matchedTestFiles))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
source = fileread(testFile);
|
||||||
|
if contains(source, "assumeFail(")
|
||||||
|
status = "Placeholder";
|
||||||
|
else
|
||||||
|
status = "Implemented";
|
||||||
|
end
|
||||||
|
|
||||||
|
[~, testFileName] = fileparts(testFile);
|
||||||
|
relativeTest = strrep(testFile, [testsRoot, filesep], "");
|
||||||
|
relativeTest = strrep(relativeTest, "\", "/");
|
||||||
|
tags = strjoin(extractTestTags(source), ", ");
|
||||||
|
|
||||||
|
records(end + 1) = struct( ... %#ok<AGROW>
|
||||||
|
"Status", status, ...
|
||||||
|
"LastRun", "", ...
|
||||||
|
"ResultSummary", "", ...
|
||||||
|
"LastRunDate", "", ...
|
||||||
|
"LastRunCommit", "", ...
|
||||||
|
"Scope", "Standalone", ...
|
||||||
|
"Target", string(relativeTest), ...
|
||||||
|
"TargetFile", "", ...
|
||||||
|
"TestFile", string(testFile), ...
|
||||||
|
"TestFileName", string(testFileName), ...
|
||||||
|
"Tags", string(tags));
|
||||||
|
end
|
||||||
|
|
||||||
|
inventory = struct2table(records);
|
||||||
|
if isempty(inventory)
|
||||||
|
inventory = table(strings(0, 1), strings(0, 1), strings(0, 1), ...
|
||||||
|
strings(0, 1), strings(0, 1), strings(0, 1), strings(0, 1), ...
|
||||||
|
strings(0, 1), strings(0, 1), strings(0, 1), strings(0, 1), ...
|
||||||
|
"VariableNames", ["Status", "LastRun", "ResultSummary", "LastRunDate", ...
|
||||||
|
"LastRunCommit", "Scope", "Target", "TargetFile", "TestFile", ...
|
||||||
|
"TestFileName", "Tags"]);
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
statusOrder = containers.Map( ...
|
||||||
|
{'Implemented', 'Missing', 'Placeholder'}, ...
|
||||||
|
{1, 2, 3});
|
||||||
|
scopeOrder = containers.Map({'Class', 'Standalone'}, {1, 2});
|
||||||
|
|
||||||
|
statusRank = zeros(height(inventory), 1);
|
||||||
|
scopeRank = zeros(height(inventory), 1);
|
||||||
|
for idx = 1:height(inventory)
|
||||||
|
statusRank(idx) = statusOrder(char(inventory.Status(idx)));
|
||||||
|
scopeRank(idx) = scopeOrder(char(inventory.Scope(idx)));
|
||||||
|
end
|
||||||
|
|
||||||
|
inventory.statusRank = statusRank;
|
||||||
|
inventory.scopeRank = scopeRank;
|
||||||
|
inventory = sortrows(inventory, {'scopeRank', 'statusRank', 'Target'});
|
||||||
|
inventory = removevars(inventory, {'statusRank', 'scopeRank'});
|
||||||
|
end
|
||||||
|
|
||||||
|
function testFile = findMatchingTestFile(testsRoot, relativeDir, className)
|
||||||
|
testsRoot = char(string(testsRoot));
|
||||||
|
relativeDir = char(join(string(relativeDir), ""));
|
||||||
|
className = char(string(className));
|
||||||
|
testDir = fullfile(testsRoot, relativeDir);
|
||||||
|
testFile = "";
|
||||||
|
if ~isfolder(testDir)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
exactFile = fullfile(testDir, [className, '_test.m']);
|
||||||
|
if isfile(exactFile)
|
||||||
|
testFile = string(exactFile);
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
candidates = dir(fullfile(testDir, '*_test.m'));
|
||||||
|
if isempty(candidates)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
candidateNames = string({candidates.name});
|
||||||
|
suffix = ['_', className, '_test.m'];
|
||||||
|
matchMask = endsWith(candidateNames, suffix, "IgnoreCase", true);
|
||||||
|
|
||||||
|
if any(matchMask)
|
||||||
|
firstMatch = candidates(find(matchMask, 1));
|
||||||
|
testFile = string(fullfile(firstMatch.folder, firstMatch.name));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function tags = extractTestTags(source)
|
||||||
|
tokens = regexp(source, "TestTags\s*=\s*\{([^}]*)\}", "tokens", "once");
|
||||||
|
if isempty(tokens)
|
||||||
|
tags = strings(0, 1);
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
tags = regexp(tokens{1}, "'([^']*)'", "tokens");
|
||||||
|
tags = string(cellfun(@(entry) entry{1}, tags, "UniformOutput", false));
|
||||||
|
end
|
||||||
|
|
||||||
|
function tf = isClassFile(filePath)
|
||||||
|
source = fileread(filePath);
|
||||||
|
tf = contains(source, "classdef");
|
||||||
|
end
|
||||||
|
|
||||||
|
function files = collectMFiles(rootFolder)
|
||||||
|
pathString = genpath(rootFolder);
|
||||||
|
folders = regexp(pathString, pathsep, "split");
|
||||||
|
folders = folders(~cellfun("isempty", folders));
|
||||||
|
|
||||||
|
files = dir(fullfile(rootFolder, "*.m"));
|
||||||
|
files = files([]); %#ok<NASGU>
|
||||||
|
files = dir(fullfile(rootFolder, "*.m"));
|
||||||
|
files = files([]);
|
||||||
|
|
||||||
|
for idx = 1:numel(folders)
|
||||||
|
currentFiles = dir(fullfile(folders{idx}, "*.m"));
|
||||||
|
if isempty(currentFiles)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
currentFiles = currentFiles(~[currentFiles.isdir]);
|
||||||
|
files = [files; currentFiles]; %#ok<AGROW>
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function [statusText, summaryText] = summarizeResults(results)
|
||||||
|
passedCount = nnz([results.Passed]);
|
||||||
|
failedCount = nnz([results.Failed]);
|
||||||
|
incompleteCount = nnz([results.Incomplete]);
|
||||||
|
|
||||||
|
if failedCount > 0
|
||||||
|
statusText = "Failed";
|
||||||
|
elseif incompleteCount > 0
|
||||||
|
statusText = "Incomplete";
|
||||||
|
else
|
||||||
|
statusText = "Passed";
|
||||||
|
end
|
||||||
|
|
||||||
|
summaryText = sprintf('%d/%d/%d', passedCount, failedCount, incompleteCount);
|
||||||
|
end
|
||||||
|
|
||||||
|
function out = stringOrEmpty(structValue, fieldName)
|
||||||
|
if isfield(structValue, fieldName)
|
||||||
|
out = string(structValue.(fieldName));
|
||||||
|
else
|
||||||
|
out = "";
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function rows = inventoryRowStates(inventory, lastRunDate, lastRunCommit)
|
||||||
|
rows = repmat(struct( ...
|
||||||
|
'test_file', '', ...
|
||||||
|
'target', '', ...
|
||||||
|
'scope', '', ...
|
||||||
|
'last_run', '', ...
|
||||||
|
'result_summary', '', ...
|
||||||
|
'last_run_date', '', ...
|
||||||
|
'last_run_commit', ''), height(inventory), 1);
|
||||||
|
|
||||||
|
for idx = 1:height(inventory)
|
||||||
|
rows(idx).test_file = char(inventory.TestFile(idx));
|
||||||
|
rows(idx).target = char(inventory.Target(idx));
|
||||||
|
rows(idx).scope = char(inventory.Scope(idx));
|
||||||
|
rows(idx).last_run = char(inventory.LastRun(idx));
|
||||||
|
rows(idx).result_summary = char(inventory.ResultSummary(idx));
|
||||||
|
rows(idx).last_run_date = char(string(lastRunDate));
|
||||||
|
rows(idx).last_run_commit = char(string(lastRunCommit));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function entries = resultEntries(results)
|
||||||
|
entries = repmat(struct( ...
|
||||||
|
'name', '', ...
|
||||||
|
'passed', false, ...
|
||||||
|
'failed', false, ...
|
||||||
|
'incomplete', false, ...
|
||||||
|
'duration_seconds', 0), numel(results), 1);
|
||||||
|
|
||||||
|
for idx = 1:numel(results)
|
||||||
|
entries(idx).name = char(string(results(idx).Name));
|
||||||
|
entries(idx).passed = results(idx).Passed;
|
||||||
|
entries(idx).failed = results(idx).Failed;
|
||||||
|
entries(idx).incomplete = results(idx).Incomplete;
|
||||||
|
entries(idx).duration_seconds = durationToSeconds(results(idx).Duration);
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function gitInfo = collectGitMetadata(repoRoot)
|
||||||
|
gitInfo = struct();
|
||||||
|
gitInfo.commit = strtrim(runCommand(sprintf('git -C "%s" rev-parse HEAD', repoRoot)));
|
||||||
|
gitInfo.short_commit = strtrim(runCommand(sprintf('git -C "%s" rev-parse --short HEAD', repoRoot)));
|
||||||
|
gitInfo.branch = strtrim(runCommand(sprintf('git -C "%s" rev-parse --abbrev-ref HEAD', repoRoot)));
|
||||||
|
statusOutput = runCommand(sprintf('git -C "%s" status --porcelain', repoRoot));
|
||||||
|
gitInfo.dirty = ~isempty(strtrim(statusOutput));
|
||||||
|
end
|
||||||
|
|
||||||
|
function output = runCommand(commandText)
|
||||||
|
[status, output] = system(commandText);
|
||||||
|
if status ~= 0
|
||||||
|
output = '';
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function writeTextFile(filePath, content)
|
||||||
|
fid = fopen(filePath, 'w');
|
||||||
|
assert(fid ~= -1, 'Could not write file: %s', filePath);
|
||||||
|
cleaner = onCleanup(@() fclose(fid)); %#ok<NASGU>
|
||||||
|
fprintf(fid, '%s', content);
|
||||||
|
end
|
||||||
|
|
||||||
|
function label = sanitizeFileLabel(labelText)
|
||||||
|
label = regexprep(char(string(labelText)), '[^A-Za-z0-9]+', '_');
|
||||||
|
label = regexprep(label, '^_+|_+$', '');
|
||||||
|
if isempty(label)
|
||||||
|
label = 'run';
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function value = durationToSeconds(durationValue)
|
||||||
|
try
|
||||||
|
value = seconds(durationValue);
|
||||||
|
if isa(value, 'duration')
|
||||||
|
value = str2double(char(value));
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
try
|
||||||
|
value = posixtime(datetime(durationValue) - datetime(0, 0, 0));
|
||||||
|
catch
|
||||||
|
durationText = char(string(durationValue));
|
||||||
|
tokens = regexp(durationText, '(\d+):(\d+):([\d\.]+)', 'tokens', 'once');
|
||||||
|
if isempty(tokens)
|
||||||
|
value = NaN;
|
||||||
|
else
|
||||||
|
value = str2double(tokens{1}) * 3600 + ...
|
||||||
|
str2double(tokens{2}) * 60 + ...
|
||||||
|
str2double(tokens{3});
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if ~isnumeric(value) || ~isscalar(value)
|
||||||
|
value = NaN;
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user