diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m index 7e54bd8..0c106cd 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m @@ -33,30 +33,30 @@ end function t = chronoAlignedTime(item) - if isfield(item, 'tAligned') && ~isempty(item.tAligned) - t = item.tAligned(:); - elseif isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) + if isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) t = item.tAligned_s(:); + elseif isfield(item, 'tAligned') && ~isempty(item.tAligned) + t = item.tAligned(:); else t = []; end end function v = chronoVoltage(item) - if isfield(item, 'Vf') && ~isempty(item.Vf) - v = item.Vf(:); - elseif isfield(item, 'Vf_V') && ~isempty(item.Vf_V) + if isfield(item, 'Vf_V') && ~isempty(item.Vf_V) v = item.Vf_V(:); + elseif isfield(item, 'Vf') && ~isempty(item.Vf) + v = item.Vf(:); else v = []; end end function i = chronoCurrent(item) - if isfield(item, 'Im') && ~isempty(item.Im) - i = item.Im(:); - elseif isfield(item, 'Im_A') && ~isempty(item.Im_A) + if isfield(item, 'Im_A') && ~isempty(item.Im_A) i = item.Im_A(:); + elseif isfield(item, 'Im') && ~isempty(item.Im) + i = item.Im(:); else i = []; end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m index 7e63827..ebb7800 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m @@ -54,10 +54,10 @@ end function t = chronoTime(item) - if isfield(item, 't') && ~isempty(item.t) - t = item.t; - elseif isfield(item, 't_s') && ~isempty(item.t_s) + if isfield(item, 't_s') && ~isempty(item.t_s) t = item.t_s; + elseif isfield(item, 't') && ~isempty(item.t) + t = item.t; else t = []; end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m index 6d3be16..ec46784 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m @@ -74,20 +74,20 @@ function plotVTIT(axV, axI, items, opts) end function v = chronoVoltage(item) - if isfield(item, 'Vf') && ~isempty(item.Vf) - v = item.Vf(:); - elseif isfield(item, 'Vf_V') && ~isempty(item.Vf_V) + if isfield(item, 'Vf_V') && ~isempty(item.Vf_V) v = item.Vf_V(:); + elseif isfield(item, 'Vf') && ~isempty(item.Vf) + v = item.Vf(:); else v = []; end end function i = chronoCurrent(item) - if isfield(item, 'Im') && ~isempty(item.Im) - i = item.Im(:); - elseif isfield(item, 'Im_A') && ~isempty(item.Im_A) + if isfield(item, 'Im_A') && ~isempty(item.Im_A) i = item.Im_A(:); + elseif isfield(item, 'Im') && ~isempty(item.Im) + i = item.Im(:); else i = []; end @@ -105,10 +105,10 @@ function plotVTIT(axV, axI, items, opts) end function t = chronoAlignedTime(item) - if isfield(item, 'tAligned') && ~isempty(item.tAligned) - t = item.tAligned(:); - elseif isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) + if isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) t = item.tAligned_s(:); + elseif isfield(item, 'tAligned') && ~isempty(item.tAligned) + t = item.tAligned(:); else t = []; end diff --git a/apps/electrochem/eis/+eis/+export/buildExportTable.m b/apps/electrochem/eis/+eis/+export/buildExportTable.m index 4594de3..2e84c10 100644 --- a/apps/electrochem/eis/+eis/+export/buildExportTable.m +++ b/apps/electrochem/eis/+eis/+export/buildExportTable.m @@ -32,8 +32,8 @@ end function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) - x = valuesForAxis(item, xName); - y = valuesForAxis(item, yName); + x = eis.ops.valuesForAxis(item, xName); + y = eis.ops.valuesForAxis(item, yName); valid = isfinite(x) & isfinite(y); x = x(valid); y = y(valid); @@ -49,35 +49,6 @@ end end -function values = valuesForAxis(item, axisName) - switch axisName - case 'Freq (Hz)' - values = item.Freq; - case 'log10(Freq)' - values = log10(item.Freq); - case 'Time (s)' - values = item.Time; - case 'Point #' - values = item.Pt; - case 'Zreal (ohm)' - values = item.Zreal; - case 'Zimag (ohm)' - values = item.Zimag; - case '-Zimag (ohm)' - values = item.negZimag; - case 'Zmod (ohm)' - values = item.Zmod; - case 'Zphz (deg)' - values = item.Zphz; - case 'Idc (A)' - values = item.Idc; - case 'Vdc (V)' - values = item.Vdc; - otherwise - error('Unsupported axis selection: %s', axisName); - end -end - function padded = padWithNaN(v, n) padded = NaN(n, 1); if isempty(v) diff --git a/apps/electrochem/eis/+eis/+ops/valuesForAxis.m b/apps/electrochem/eis/+eis/+ops/valuesForAxis.m index 8feb8ec..53d76a4 100644 --- a/apps/electrochem/eis/+eis/+ops/valuesForAxis.m +++ b/apps/electrochem/eis/+eis/+ops/valuesForAxis.m @@ -4,28 +4,38 @@ function values = valuesForAxis(item, axisName) switch axisName case 'Freq (Hz)' - values = item.Freq; + values = itemField(item, 'freq_Hz', 'Freq'); case 'log10(Freq)' - values = log10(item.Freq); + values = log10(itemField(item, 'freq_Hz', 'Freq')); case 'Time (s)' - values = item.Time; + values = itemField(item, 'time_s', 'Time'); case 'Point #' - values = item.Pt; + values = itemField(item, 'point', 'Pt'); case 'Zreal (ohm)' - values = item.Zreal; + values = itemField(item, 'Zreal_ohm', 'Zreal'); case 'Zimag (ohm)' - values = item.Zimag; + values = itemField(item, 'Zimag_ohm', 'Zimag'); case '-Zimag (ohm)' - values = item.negZimag; + values = itemField(item, 'negZimag_ohm', 'negZimag'); case 'Zmod (ohm)' - values = item.Zmod; + values = itemField(item, 'Zmod_ohm', 'Zmod'); case 'Zphz (deg)' - values = item.Zphz; + values = itemField(item, 'Zphz_deg', 'Zphz'); case 'Idc (A)' - values = item.Idc; + values = itemField(item, 'Idc_A', 'Idc'); case 'Vdc (V)' - values = item.Vdc; + values = itemField(item, 'Vdc_V', 'Vdc'); otherwise error('Unsupported axis selection: %s', axisName); end end + +function values = itemField(item, canonicalName, legacyName) + if isfield(item, canonicalName) && ~isempty(item.(canonicalName)) + values = item.(canonicalName); + elseif isfield(item, legacyName) && ~isempty(item.(legacyName)) + values = item.(legacyName); + else + values = []; + end +end diff --git a/apps/electrochem/eis/+eis/+view/axisModeForSelection.m b/apps/electrochem/eis/+eis/+view/axisModeForSelection.m new file mode 100644 index 0000000..0e63725 --- /dev/null +++ b/apps/electrochem/eis/+eis/+view/axisModeForSelection.m @@ -0,0 +1,11 @@ +% Expected caller: EIS app view code and unit tests. Inputs are the selected +% X/Y axis labels. Output is the MATLAB axes mode needed for that pairing. + +function mode = axisModeForSelection(xName, yName) + if strcmp(xName, 'Zreal (ohm)') && ... + (strcmp(yName, '-Zimag (ohm)') || strcmp(yName, 'Zimag (ohm)')) + mode = "equal"; + else + mode = "normal"; + end +end diff --git a/apps/electrochem/eis/+eis/+view/buildSummary.m b/apps/electrochem/eis/+eis/+view/buildSummary.m index 8c0fff1..f3411f5 100644 --- a/apps/electrochem/eis/+eis/+view/buildSummary.m +++ b/apps/electrochem/eis/+eis/+view/buildSummary.m @@ -5,13 +5,24 @@ summary = cell(0, 1); summary{end+1} = sprintf('Loaded files: %d', numel(items)); for i = 1:numel(items) - fmin = min(items(i).Freq, [], 'omitnan'); - fmax = max(items(i).Freq, [], 'omitnan'); + freq = itemField(items(i), 'freq_Hz', 'Freq'); + fmin = min(freq, [], 'omitnan'); + fmax = max(freq, [], 'omitnan'); summary{end+1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... items(i).name, items(i).n, fmin, fmax, ternary(items(i).freqDesc, 'high->low', 'low->high/mixed')); end end +function values = itemField(item, canonicalName, legacyName) + if isfield(item, canonicalName) && ~isempty(item.(canonicalName)) + values = item.(canonicalName); + elseif isfield(item, legacyName) && ~isempty(item.(legacyName)) + values = item.(legacyName); + else + values = []; + end +end + function txt = ternary(cond, a, b) if cond txt = a; diff --git a/apps/electrochem/eis/+eis/+view/plotOverlay.m b/apps/electrochem/eis/+eis/+view/plotOverlay.m index 0713a22..c9bcac4 100644 --- a/apps/electrochem/eis/+eis/+view/plotOverlay.m +++ b/apps/electrochem/eis/+eis/+view/plotOverlay.m @@ -48,7 +48,7 @@ legend(ax, 'off'); end - if isNyquistSelection(opts.xName, opts.yName) + if eis.view.axisModeForSelection(opts.xName, opts.yName) == "equal" axis(ax, 'equal'); end end @@ -88,8 +88,8 @@ end function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) - x = valuesForAxis(item, xName); - y = valuesForAxis(item, yName); + x = eis.ops.valuesForAxis(item, xName); + y = eis.ops.valuesForAxis(item, yName); valid = isfinite(x) & isfinite(y); x = x(valid); y = y(valid); @@ -105,40 +105,6 @@ end end -function values = valuesForAxis(item, axisName) - switch axisName - case 'Freq (Hz)' - values = item.Freq; - case 'log10(Freq)' - values = log10(item.Freq); - case 'Time (s)' - values = item.Time; - case 'Point #' - values = item.Pt; - case 'Zreal (ohm)' - values = item.Zreal; - case 'Zimag (ohm)' - values = item.Zimag; - case '-Zimag (ohm)' - values = item.negZimag; - case 'Zmod (ohm)' - values = item.Zmod; - case 'Zphz (deg)' - values = item.Zphz; - case 'Idc (A)' - values = item.Idc; - case 'Vdc (V)' - values = item.Vdc; - otherwise - error('Unsupported axis selection: %s', axisName); - end -end - -function tf = isNyquistSelection(xName, yName) - tf = strcmp(xName, 'Zreal (ohm)') && ... - (strcmp(yName, '-Zimag (ohm)') || strcmp(yName, 'Zimag (ohm)')); -end - function txt = pluralS(n) if n == 1 txt = ''; diff --git a/docs/testing.md b/docs/testing.md index 3995bc2..d82b8b3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -194,6 +194,17 @@ Shared setup, structural GUI assertions, and focused support routines live under Architecture guardrails are split by concern under `tests/integration/project/`: public package surface, reusable package dependency boundaries, app entrypoint boundaries, and app-owned workflow boundaries. These guardrails may require workflow code to remain under the owning app tree, but they should not require GUI-free helpers to stay in the public app entry-point file. App-owned packages are checked by boundary rules rather than exact file-list assertions. +Compatibility bridge behavior should be isolated in named compatibility tests. +For example, DTA legacy bridge fields such as `t`, `Vf`, `Im`, `Freq`, and +`Zreal` are covered by `DtaCompatibilityBridgeTest`; ordinary DTA and app +tests should use canonical unit-explicit fields and direct app-owned package +functions. + +Unit app tests should not read app source text to prove behavior. Source-string +scans belong in project guardrails; app behavior tests should call package +functions directly or use GUI structural tests when the behavior is layout or +callback wiring. + When a suite file becomes broad enough that unrelated changes must read hundreds of lines, add a narrower `test_*.m` file in the same suite instead of appending more coverage to the broad file. ## GUI Validation diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 6aafd36..9504cc8 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -19,6 +19,8 @@ Tests mirror source ownership. Do not create a parallel runner framework unless - Use `tests/support/` for official-runner setup, artifact paths, structured trace capture, GUI fixture setup, and component snapshots. - Do not move app-specific formulas, expected scientific values, result schemas, or export columns into shared test helpers. +- Keep compatibility bridge assertions isolated in named compatibility tests. Ordinary app and facade tests should prefer current canonical fields and direct package functions. +- Unit app tests should not read app source text to prove behavior. Keep source-string scans in project guardrails. - Boundary tests may require app-owned logic to stay under the owning app tree, but should not require GUI-free helpers to remain inside the public app entry-point file or assert exact app-private helper file lists. - Runner-migration tests should not rely only on GUI structural launches. When migration creates an app-owned package for DIC or wearable apps, add unit diff --git a/tests/integration/project/ProjectDebtGuardrailTest.m b/tests/integration/project/ProjectDebtGuardrailTest.m index 7deebe1..5e70931 100644 --- a/tests/integration/project/ProjectDebtGuardrailTest.m +++ b/tests/integration/project/ProjectDebtGuardrailTest.m @@ -8,19 +8,19 @@ function legacyTestBackdoorDebtDoesNotGrow(testCase) testCommandFiles = uniqueMatchedFiles(root, {'apps', '+labkit'}, ... '__labkit_test__'); testCase.verifyEmpty(testCommandFiles, ... - ['legacy app test command references must not remain after Phase 4. Files: ' ... + ['legacy app test command references must not remain. Files: ' ... strjoin(cellstr(testCommandFiles), ', ')]); handlerFiles = uniqueMatchedFiles(root, {'apps'}, ... 'function\s+handlers\s*=\s*\w*[Aa]ppTestHandlers'); testCase.verifyEmpty(handlerFiles, ... - ['legacy app test handler functions must not remain after Phase 4. Files: ' ... + ['legacy app test handler functions must not remain. Files: ' ... strjoin(cellstr(handlerFiles), ', ')]); diagnosticsFiles = uniqueMatchedFiles(root, {'apps'}, ... 'loadFileDiagnostics|parse\w*LoadDiagnosticsRequest|collectLoadDiagnostics'); testCase.verifyEmpty(diagnosticsFiles, ... - ['hidden load diagnostics must not remain after Phase 4. Files: ' ... + ['hidden load diagnostics must not remain. Files: ' ... strjoin(cellstr(diagnosticsFiles), ', ')]); fprintf('Legacy backdoor inventory: %d test-command files, %d handler files, %d diagnostics files.\n', ... @@ -31,7 +31,7 @@ function oversizedAppEntrypointDebtIsRemoved(testCase) root = setupLabKitTestPath(); actual = collectOversizedEntrypoints(root, 500); testCase.verifyEmpty(actual, ... - ['app entrypoints must remain at or below 500 lines after Phase 5. Files: ' ... + ['app entrypoints must remain at or below 500 lines. Files: ' ... strjoin(cellstr(actual), ', ')]); fprintf('Entrypoint size debt inventory: %d files over 500 lines.\n', numel(actual)); end @@ -55,9 +55,9 @@ function oldRunnerDependenciesAreRemoved(testCase) root = setupLabKitTestPath(); testCase.verifyFalse(isfolder(fullfile(root, 'tests', 'suites')), ... - 'tests/suites must not remain after Phase 6 official-test migration.'); + 'tests/suites must not remain after the official-test migration.'); testCase.verifyFalse(isfile(fullfile(root, 'tests', 'run_all_tests.m')), ... - 'tests/run_all_tests.m must not remain after Phase 6 official-test migration.'); + 'tests/run_all_tests.m must not remain after the official-test migration.'); dependencyFiles = uniqueMatchedFiles(root, ... {'.github', 'scripts', 'docs', 'tests', 'buildfile.m', ... @@ -66,7 +66,7 @@ function oldRunnerDependenciesAreRemoved(testCase) dependencyFiles = setdiff(dependencyFiles, ... "tests/integration/project/ProjectDebtGuardrailTest.m"); testCase.verifyEmpty(dependencyFiles, ... - ['old custom-runner dependencies must not remain after Phase 6. Files: ' ... + ['old custom-runner dependencies must not remain. Files: ' ... strjoin(cellstr(dependencyFiles), ', ')]); fprintf('Old runner dependency inventory: %d files.\n', numel(dependencyFiles)); diff --git a/tests/integration/project/ProjectDocumentationGuardrailTest.m b/tests/integration/project/ProjectDocumentationGuardrailTest.m index 7d20cb1..6ce854c 100644 --- a/tests/integration/project/ProjectDocumentationGuardrailTest.m +++ b/tests/integration/project/ProjectDocumentationGuardrailTest.m @@ -72,38 +72,17 @@ function publicLibraryFunctionsDocumentAppFacingContracts(testCase) function privateHelperContractDebtDoesNotGrow(testCase) root = setupLabKitTestPath(); - expectedDebt = struct( ... - 'folder', { ... - '+labkit/+biosignal/private', ... - '+labkit/+dta/private', ... - '+labkit/+ui/+app/private', ... - '+labkit/+ui/+tool/private', ... - '+labkit/+ui/+view/private'}, ... - 'missingCount', {15, 20, 4, 11, 23}); - + expectedFiles = expectedPrivateContractDebtFiles(); actual = collectPrivateContractDebt(root); - expectedFolders = sort(string({expectedDebt.folder})); - actualFolders = sort(string({actual.folder})); - unexpectedFolders = setdiff(actualFolders, expectedFolders); - testCase.verifyTrue(isempty(unexpectedFolders), ... - ['expected-debt: new private-helper folders without implementation contracts: ' ... - strjoin(cellstr(unexpectedFolders), ', ')]); - - for k = 1:numel(expectedDebt) - folder = expectedDebt(k).folder; - idx = find(actualFolders == string(folder), 1); - actualCount = 0; - if ~isempty(idx) - actualCount = actual(idx).missingCount; - end - testCase.verifyTrue(actualCount <= expectedDebt(k).missingCount, ... - sprintf(['expected-debt: private helper implementation contract debt grew in %s. ' ... - 'Current %d, expected <= %d.'], folder, actualCount, expectedDebt(k).missingCount)); - end + unexpectedFiles = setdiff(actual, expectedFiles); + testCase.verifyTrue(isempty(unexpectedFiles), ... + ['expected-debt: new private helpers without implementation contracts: ' ... + strjoin(cellstr(unexpectedFiles), ', ')]); + testCase.verifyLessThanOrEqual(numel(actual), numel(expectedFiles), ... + 'Private helper implementation contract debt should only shrink.'); - totalMissing = sum([actual.missingCount]); fprintf('Private helper contract debt inventory: %d files missing top-of-file contracts.\n', ... - totalMissing); + numel(actual)); end function appOwnedPackageHelpersDocumentImplementationContracts(testCase) @@ -197,26 +176,98 @@ function appOwnedPackageHelpersDocumentImplementationContracts(testCase) privateDirs = [ ... collectPrivateDirs(fullfile(root, '+labkit')), ... collectPrivateDirs(fullfile(root, 'apps'))]; - actual = struct('folder', {}, 'missingCount', {}); + actual = strings(1, 0); for k = 1:numel(privateDirs) folder = privateDirs(k); if ~isTrackedPrivateScope(root, folder) continue; end files = dir(fullfile(char(folder), '*.m')); - missing = 0; for f = 1:numel(files) filepath = fullfile(files(f).folder, files(f).name); if ~hasTopFileContract(filepath) - missing = missing + 1; + actual(end+1) = string(relativePath(root, filepath)); %#ok end end - if missing > 0 - actual(end+1) = struct( ... %#ok - 'folder', relativePath(root, folder), ... - 'missingCount', missing); - end end + actual = unique(actual); +end + +function files = expectedPrivateContractDebtFiles() + files = [ ... + "+labkit/+biosignal/private/detectEcgPeaksImpl.m", ... + "+labkit/+biosignal/private/fillVectorMissing.m", ... + "+labkit/+biosignal/private/inferSampleRate.m", ... + "+labkit/+biosignal/private/inferTableTime.m", ... + "+labkit/+biosignal/private/isTimeLikeName.m", ... + "+labkit/+biosignal/private/makeRecording.m", ... + "+labkit/+biosignal/private/makeSignalStruct.m", ... + "+labkit/+biosignal/private/numericColumn.m", ... + "+labkit/+biosignal/private/optionValue.m", ... + "+labkit/+biosignal/private/readCsvRecording.m", ... + "+labkit/+biosignal/private/readDelimitedTable.m", ... + "+labkit/+biosignal/private/readMatRecording.m", ... + "+labkit/+biosignal/private/resolveSignalColumns.m", ... + "+labkit/+biosignal/private/timeColumnName.m", ... + "+labkit/+biosignal/private/timeToSeconds.m", ... + "+labkit/+dta/private/addItemsToSession.m", ... + "+labkit/+dta/private/defaultPulseOptions.m", ... + "+labkit/+dta/private/detectPulseCore.m", ... + "+labkit/+dta/private/emptyPulse.m", ... + "+labkit/+dta/private/findDTAFilesRecursive.m", ... + "+labkit/+dta/private/isDataLike.m", ... + "+labkit/+dta/private/makeChronoItem.m", ... + "+labkit/+dta/private/makeEISItem.m", ... + "+labkit/+dta/private/makeSessionStruct.m", ... + "+labkit/+dta/private/nextNonEmpty.m", ... + "+labkit/+dta/private/normalizeExpectedKind.m", ... + "+labkit/+dta/private/parseCVCTDTA.m", ... + "+labkit/+dta/private/parseChronoDTA.m", ... + "+labkit/+dta/private/parseEISDTA.m", ... + "+labkit/+dta/private/pulsesFromCurrent.m", ... + "+labkit/+dta/private/pulsesFromMetadata.m", ... + "+labkit/+dta/private/removeFilesFromSession.m", ... + "+labkit/+dta/private/removeSelectedSessionItems.m", ... + "+labkit/+dta/private/selectItemsByNames.m", ... + "+labkit/+dta/private/splitTabs.m", ... + "+labkit/+ui/+app/private/addRowResizeHandle.m", ... + "+labkit/+ui/+app/private/attachColumnResize.m", ... + "+labkit/+ui/+app/private/createTabbedWorkbenchShell.m", ... + "+labkit/+ui/+app/private/disableAxesInteractivity.m", ... + "+labkit/+ui/+tool/private/addOrInsertAnchor.m", ... + "+labkit/+ui/+tool/private/anchorCurvePoints.m", ... + "+labkit/+ui/+tool/private/createLabeledDropdown.m", ... + "+labkit/+ui/+tool/private/createLabeledEditField.m", ... + "+labkit/+ui/+tool/private/createLabeledSpinner.m", ... + "+labkit/+ui/+tool/private/createReadOnlyInfoRow.m", ... + "+labkit/+ui/+tool/private/createReadOnlyTextField.m", ... + "+labkit/+ui/+tool/private/defaultScaleBarUnits.m", ... + "+labkit/+ui/+tool/private/drawScaleBarOverlay.m", ... + "+labkit/+ui/+tool/private/normalizeScaleBarUnit.m", ... + "+labkit/+ui/+tool/private/scaleBarPanel.m", ... + "+labkit/+ui/+view/private/appendLog.m", ... + "+labkit/+ui/+view/private/clearAxes.m", ... + "+labkit/+ui/+view/private/createLabeledDropdown.m", ... + "+labkit/+ui/+view/private/createLabeledEditField.m", ... + "+labkit/+ui/+view/private/createLabeledSpinner.m", ... + "+labkit/+ui/+view/private/createReadOnlyInfoRow.m", ... + "+labkit/+ui/+view/private/createReadOnlyTextField.m", ... + "+labkit/+ui/+view/private/enablePopout.m", ... + "+labkit/+ui/+view/private/fileSelectionPanel.m", ... + "+labkit/+ui/+view/private/layoutRow.m", ... + "+labkit/+ui/+view/private/logPanel.m", ... + "+labkit/+ui/+view/private/plotOptionsPanel.m", ... + "+labkit/+ui/+view/private/plotXY.m", ... + "+labkit/+ui/+view/private/popoutAxes.m", ... + "+labkit/+ui/+view/private/refreshListboxItems.m", ... + "+labkit/+ui/+view/private/refreshListboxSelection.m", ... + "+labkit/+ui/+view/private/resetAxes.m", ... + "+labkit/+ui/+view/private/resultTable.m", ... + "+labkit/+ui/+view/private/setTopBottomPlotSelections.m", ... + "+labkit/+ui/+view/private/showImage.m", ... + "+labkit/+ui/+view/private/swapTopBottomPlotSelections.m", ... + "+labkit/+ui/+view/private/textPanel.m", ... + "+labkit/+ui/+view/private/topBottomPlotControls.m"]; end function files = collectAppOwnedPackageFiles(root) diff --git a/tests/integration/project/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m index 6e29196..0f282b9 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -136,14 +136,11 @@ function imageMeasurementAppsUseOwnedPackageNamespaces(testCase) root = setupLabKitTestPath(); assertImageMeasurementPackageLayout(testCase, root, ... - 'batch_crop', 'batch_crop', ... - {'+export', '+io', '+ops', '+state', '+ui', '+view'}); + 'batch_crop', 'batch_crop', 'labkit_BatchImageCrop_app.m'); assertImageMeasurementPackageLayout(testCase, root, ... - 'curvature', 'curvature', ... - {'+export', '+ops', '+state', '+ui', '+view'}); + 'curvature', 'curvature', 'labkit_CurvatureMeasurement_app.m'); assertImageMeasurementPackageLayout(testCase, root, ... - 'focus_stack', 'focus_stack', ... - {'+export', '+io', '+ops', '+state', '+view'}); + 'focus_stack', 'focus_stack', 'labkit_FocusStack_app.m'); end function electrochemAppsUseOwnedPackageNamespaces(testCase) @@ -156,20 +153,15 @@ function electrochemAppsUseOwnedPackageNamespaces(testCase) 'Electrochem apps should not keep string-dispatch workflow adapters.'); assertElectrochemPackageLayout(testCase, root, ... - 'chrono_overlay', 'chrono_overlay', ... - {'+export', '+ops', '+ui', '+view'}); + 'chrono_overlay', 'chrono_overlay'); assertElectrochemPackageLayout(testCase, root, ... - 'cic', 'cic', ... - {'+export', '+ops', '+ui', '+view'}); + 'cic', 'cic'); assertElectrochemPackageLayout(testCase, root, ... - 'csc', 'csc', ... - {'+ops', '+ui'}); + 'csc', 'csc'); assertElectrochemPackageLayout(testCase, root, ... - 'eis', 'eis', ... - {'+export', '+ops', '+ui', '+view'}); + 'eis', 'eis'); assertElectrochemPackageLayout(testCase, root, ... - 'vt_resistance', 'vt_resistance', ... - {'+export', '+ops', '+ui', '+view'}); + 'vt_resistance', 'vt_resistance'); end function sensitiveSampleHygieneScansTrackedText(testCase) @@ -213,7 +205,7 @@ function startupPathKeepsPrivateHelpersPrivate(testCase) end end -function assertElectrochemPackageLayout(testCase, root, appFolder, packageName, componentDirs) +function assertElectrochemPackageLayout(testCase, root, appFolder, packageName) appDir = fullfile(root, 'apps', 'electrochem', appFolder); packageDir = fullfile(appDir, ['+' packageName]); @@ -228,17 +220,8 @@ function assertElectrochemPackageLayout(testCase, root, appFolder, packageName, workflowFiles = dir(fullfile(appDir, '*Workflow.m')); testCase.verifyTrue(isempty(workflowFiles), ... ['Electrochem app should not keep workflow dispatch adapters: ' appFolder]); - testCase.verifyTrue(isfolder(packageDir), ... - ['Missing electrochem app-owned package namespace: ' relativePath(root, packageDir)]); - - packageFiles = dir(fullfile(packageDir, '**', '*.m')); - testCase.verifyFalse(isempty(packageFiles), ... - ['Electrochem app-owned package should contain helper files: ' relativePath(root, packageDir)]); - for iDir = 1:numel(componentDirs) - testCase.verifyTrue(isfolder(fullfile(packageDir, componentDirs{iDir})), ... - ['Missing electrochem component package ' componentDirs{iDir} ... - ' under ' relativePath(root, packageDir)]); - end + assertAppOwnedPackageCapability(testCase, root, appDir, packageDir, ... + 'electrochem', packageName); end function name = appEntrypointName(appFolder) @@ -258,12 +241,14 @@ function assertElectrochemPackageLayout(testCase, root, appFolder, packageName, end end -function assertImageMeasurementPackageLayout(testCase, root, appFolder, packageName, componentDirs) +function assertImageMeasurementPackageLayout(testCase, root, appFolder, packageName, entrypointName) appDir = fullfile(root, 'apps', 'image_measurement', appFolder); packageDir = fullfile(appDir, ['+' packageName]); testCase.verifyTrue(isfolder(appDir), ... ['Missing image-measurement app folder: apps/image_measurement/' appFolder]); + testCase.verifyTrue(isfile(fullfile(appDir, entrypointName)), ... + ['Missing image-measurement app entrypoint under ' relativePath(root, appDir)]); testCase.verifyFalse(isfolder(fullfile(appDir, 'private')), ... ['Image-measurement app should use an app-owned package, not private/: ' appFolder]); testCase.verifyFalse(isfolder(fullfile(appDir, '+app')), ... @@ -271,16 +256,64 @@ function assertImageMeasurementPackageLayout(testCase, root, appFolder, packageN workflowFiles = dir(fullfile(appDir, '*Workflow.m')); testCase.verifyTrue(isempty(workflowFiles), ... ['Image-measurement app should not keep workflow dispatch adapters: ' appFolder]); + assertAppOwnedPackageCapability(testCase, root, appDir, packageDir, ... + 'image_measurement', packageName); +end + +function assertAppOwnedPackageCapability(testCase, root, appDir, packageDir, family, packageName) testCase.verifyTrue(isfolder(packageDir), ... ['Missing app-owned package namespace: ' relativePath(root, packageDir)]); + testCase.verifyFalse(isfolder(fullfile(packageDir, '+core')), ... + ['App-owned package should not route through +core: ' relativePath(root, packageDir)]); + testCase.verifyFalse(isfile(fullfile(packageDir, '+core', 'dispatch.m')), ... + ['App-owned package should not keep +core/dispatch.m: ' relativePath(root, packageDir)]); packageFiles = dir(fullfile(packageDir, '**', '*.m')); testCase.verifyFalse(isempty(packageFiles), ... ['App-owned package should contain helper files: ' relativePath(root, packageDir)]); - for iDir = 1:numel(componentDirs) - testCase.verifyTrue(isfolder(fullfile(packageDir, componentDirs{iDir})), ... - ['Missing app-owned component package ' componentDirs{iDir} ... - ' under ' relativePath(root, packageDir)]); + testCase.verifyTrue(hasNonUiPackageComponent(packageDir), ... + ['App-owned package should expose directly testable non-UI behavior: ' ... + relativePath(root, packageDir)]); + testCase.verifyTrue(packageNamespaceHasDirectUnitTest(root, family, packageName), ... + ['App-owned non-UI package functions should have direct unit tests: ' ... + relativePath(root, packageDir)]); + + uiRunApp = fullfile(packageDir, '+ui', 'runApp.m'); + if isfile(uiRunApp) + testCase.verifyTrue(numel(packageFiles) > 1, ... + ['App-owned package should not be only a +ui/runApp.m wrapper: ' ... + relativePath(root, appDir)]); + end +end + +function tf = hasNonUiPackageComponent(packageDir) + componentNames = {'+ops', '+view', '+export', '+io', '+state'}; + tf = false; + for k = 1:numel(componentNames) + componentRoot = fullfile(packageDir, componentNames{k}); + files = dir(fullfile(componentRoot, '*.m')); + if isfolder(componentRoot) && any(~[files.isdir]) + tf = true; + return; + end + end +end + +function tf = packageNamespaceHasDirectUnitTest(root, family, packageName) + testRoot = fullfile(root, 'tests', 'unit', 'apps', family); + if ~isfolder(testRoot) + tf = false; + return; + end + + pattern = [packageName '\.(ops|view|export|io|state)\.']; + testFiles = collectTextFiles(testRoot); + tf = false; + for k = 1:numel(testFiles) + if ~isempty(regexp(fileread(testFiles{k}), pattern, 'once')) + tf = true; + return; + end end end diff --git a/tests/integration/project/TestCompatibilityDebtGuardrailTest.m b/tests/integration/project/TestCompatibilityDebtGuardrailTest.m new file mode 100644 index 0000000..048e14b --- /dev/null +++ b/tests/integration/project/TestCompatibilityDebtGuardrailTest.m @@ -0,0 +1,132 @@ +classdef TestCompatibilityDebtGuardrailTest < matlab.unittest.TestCase + %TESTCOMPATIBILITYDEBTGUARDRAILTEST Keep test migration debt isolated. + + methods (Test, TestTags = {'Integration', 'Style'}) + function appUnitTestsDoNotReadAppSourceForBehavior(testCase) + root = setupLabKitTestPath(); + files = collectMFiles(fullfile(root, 'tests', 'unit', 'apps')); + patterns = [ ... + "appEntryFile\s*\(", ... + "readAppOwnedSource", ... + "contains\s*\(\s*source", ... + "fileread\s*\(\s*appFile", ... + "sourceParts"]; + + findings = filesMatchingAnyPattern(root, files, patterns); + testCase.verifyTrue(isempty(findings), ... + ['Unit app tests should verify package behavior directly, not read ' ... + 'app source strings as a behavior proxy. Findings: ' ... + strjoin(cellstr(findings), ', ')]); + end + + function dtaLegacyBridgeAssertionsStayIsolated(testCase) + root = setupLabKitTestPath(); + files = [ ... + collectMFiles(fullfile(root, 'tests', 'unit', 'labkit', 'dta')), ... + collectMFiles(fullfile(root, 'tests', 'unit', 'apps', 'electrochem'))]; + allowed = string(fullfile(root, 'tests', 'unit', 'labkit', 'dta', ... + 'DtaCompatibilityBridgeTest.m')); + files = setdiff(files, allowed); + + legacyField = "(item|chronoItem|eisItem|aligned)\.(t|Vf|Im|alignTime|tAligned|Freq|Time|Pt|Zreal|Zimag|negZimag|Zmod|Zphz|Idc|Vdc)\b"; + legacyText = "stable-compatible|mirror legacy|Legacy .* should mirror"; + findings = filesMatchingAnyPattern(root, files, [legacyField, legacyText]); + testCase.verifyTrue(isempty(findings), ... + ['DTA legacy bridge assertions belong in DtaCompatibilityBridgeTest. ' ... + 'Ordinary DTA/app tests should use canonical fields. Findings: ' ... + strjoin(cellstr(findings), ', ')]); + end + + function projectDebtGuardrailsUseCurrentGovernanceLabels(testCase) + root = setupLabKitTestPath(); + files = collectMFiles(fullfile(root, 'tests', 'integration', 'project')); + findings = filesMatchingAnyPattern(root, files, "Phase\s+\d+"); + testCase.verifyTrue(isempty(findings), ... + ['Project guardrail messages should use current governance labels, ' ... + 'not historical roadmap phase names. Findings: ' ... + strjoin(cellstr(findings), ', ')]); + end + + function exactDebtInventoriesStayNamedAndNarrow(testCase) + root = setupLabKitTestPath(); + files = collectMFiles(fullfile(root, 'tests', 'integration', 'project')); + inventoryFunctions = strings(1, 0); + for k = 1:numel(files) + content = fileread(files(k)); + tokens = regexp(content, ... + '(?m)^function\s+\w+\s*=\s*(expected\w*Debt\w*)\s*\(', ... + 'tokens'); + for i = 1:numel(tokens) + inventoryFunctions(end+1) = string(relativePath(root, files(k))) + ... + " -> " + string(tokens{i}{1}); %#ok + end + end + + expected = [ ... + "tests/integration/project/ProjectDebtGuardrailTest.m -> expectedAppPrivateDebtFiles", ... + "tests/integration/project/ProjectDebtGuardrailTest.m -> expectedOversizedRunnerDebtFiles", ... + "tests/integration/project/ProjectDocumentationGuardrailTest.m -> expectedPrivateContractDebtFiles"]; + unexpected = setdiff(inventoryFunctions, expected); + testCase.verifyTrue(isempty(unexpected), ... + ['New exact debt inventories need an explicit governance reason. ' ... + 'Prefer capability guardrails for package layout and test quality. Findings: ' ... + strjoin(cellstr(unexpected), ', ')]); + end + + function trackedEditorNoiseFilesAreForbidden(testCase) + root = setupLabKitTestPath(); + files = gitTrackedFiles(root); + noise = files(endsWith(files, ".DS_Store") | endsWith(files, ".asv") | ... + endsWith(files, ".bak") | endsWith(files, "~")); + testCase.verifyTrue(isempty(noise), ... + ['Tracked editor or OS noise files are not allowed: ' ... + strjoin(cellstr(noise), ', ')]); + end + end +end + +function files = collectMFiles(folder) + if ~isfolder(folder) + files = strings(1, 0); + return; + end + entries = dir(fullfile(folder, '**', '*.m')); + files = strings(1, 0); + for k = 1:numel(entries) + if ~entries(k).isdir + files(end+1) = string(fullfile(entries(k).folder, entries(k).name)); %#ok + end + end + files = unique(files); +end + +function findings = filesMatchingAnyPattern(root, files, patterns) + findings = strings(1, 0); + for k = 1:numel(files) + content = fileread(files(k)); + for i = 1:numel(patterns) + if ~isempty(regexp(content, char(patterns(i)), 'once')) + findings(end+1) = string(relativePath(root, files(k))) + ... + " -> " + patterns(i); %#ok + end + end + end + findings = unique(findings); +end + +function files = gitTrackedFiles(root) + command = sprintf('git -C "%s" ls-files', root); + [status, output] = system(command); + assert(status == 0, 'Could not list tracked files with git.'); + files = string(splitlines(strtrim(output))).'; + files = files(strlength(files) > 0); +end + +function rel = relativePath(root, filepath) + rel = char(filepath); + prefix = [root filesep]; + if startsWith(rel, prefix) + rel = rel(numel(prefix)+1:end); + end + rel = strrep(rel, filesep, '/'); +end diff --git a/tests/unit/apps/electrochem/ChronoOverlayExportTest.m b/tests/unit/apps/electrochem/ChronoOverlayExportTest.m index fc100a0..f59ecd2 100644 --- a/tests/unit/apps/electrochem/ChronoOverlayExportTest.m +++ b/tests/unit/apps/electrochem/ChronoOverlayExportTest.m @@ -20,9 +20,9 @@ function verify_chronoOverlayExport() function checkGapCenterAlignment() item = struct(); item.name = 'synthetic chrono'; - item.t = (0:0.1:0.8).'; - item.Vf = zeros(size(item.t)); - item.Im = zeros(size(item.t)); + item.t_s = (0:0.1:0.8).'; + item.Vf_V = zeros(size(item.t_s)); + item.Im_A = zeros(size(item.t_s)); item.pulse = struct('ok', true, ... 'gap_start', 0.3, ... 'gap_end', 0.5, ... @@ -30,12 +30,10 @@ function checkGapCenterAlignment() [aligned, msg] = chrono_overlay.ops.alignByPulseGap(item); - assertClose(aligned.alignTime, 0.4, 1e-12, ... + assertClose(aligned.alignTime_s, 0.4, 1e-12, ... 'Chrono overlay gap-center align time'); - assertClose(aligned.tAligned, item.t - 0.4, 1e-12, ... + assertClose(aligned.tAligned_s, item.t_s - 0.4, 1e-12, ... 'Chrono overlay gap-center aligned vector'); - assertClose(aligned.tAligned_s, aligned.tAligned, ... - 'Chrono overlay aligned-time alias'); assert(contains(msg, 'blank center'), ... 'Alignment message should report gap-center alignment.'); end @@ -43,16 +41,16 @@ function checkGapCenterAlignment() function checkFallbackAlignment() item = struct(); item.name = 'synthetic fallback chrono'; - item.t = (2:4).'; - item.Vf = zeros(size(item.t)); - item.Im = zeros(size(item.t)); + item.t_s = (2:4).'; + item.Vf_V = zeros(size(item.t_s)); + item.Im_A = zeros(size(item.t_s)); item.pulse = struct('ok', false, 'message', 'synthetic pulse not found'); [aligned, msg] = chrono_overlay.ops.alignByPulseGap(item); - assertClose(aligned.alignTime, 2, 1e-12, ... + assertClose(aligned.alignTime_s, 2, 1e-12, ... 'Chrono overlay fallback align time'); - assertClose(aligned.tAligned, [0; 1; 2], 1e-12, ... + assertClose(aligned.tAligned_s, [0; 1; 2], 1e-12, ... 'Chrono overlay fallback aligned vector'); assert(contains(msg, 'fallback to first sample'), ... 'Fallback alignment message should explain the first-sample fallback.'); @@ -99,10 +97,9 @@ function checkMergedExportInterpolation() function item = makeOverlayItem(name, tAligned, Vf, Im) item = struct(); item.name = name; - item.tAligned = tAligned(:); item.tAligned_s = tAligned(:); - item.Vf = Vf(:); - item.Im = Im(:); + item.Vf_V = Vf(:); + item.Im_A = Im(:); end function assertHasColumn(T, name) diff --git a/tests/unit/apps/electrochem/EisOverlayExportTest.m b/tests/unit/apps/electrochem/EisOverlayExportTest.m index 462e8cb..c5301d9 100644 --- a/tests/unit/apps/electrochem/EisOverlayExportTest.m +++ b/tests/unit/apps/electrochem/EisOverlayExportTest.m @@ -12,7 +12,6 @@ function test_eisOverlayExport(testCase) function verify_eisOverlayExport() %TEST_EISOVERLAYEXPORT Verify EIS item schema and export/plot contracts. - root = testRepoRoot(); fixture = dtaFixturePath('eis_potentiostatic_zcurve.DTA'); [item, status] = labkit.dta.loadFile(fixture, "eis"); @@ -21,65 +20,51 @@ function verify_eisOverlayExport() assert(strcmp(item.name, 'eis_potentiostatic_zcurve.DTA'), 'EIS item name should use fixture file name.'); assert(strcmp(item.message, 'Using table: ZCURVE'), 'EIS item message should preserve ZCURVE selection wording.'); assert(isequal(item.zcurve, item.curve), 'EIS item should expose a normalized zcurve alias.'); - assert(numel(item.Freq) == item.n, 'EIS item n should match filtered data length.'); - assert(abs(item.Freq(1) - 0.999041) < 1e-12, 'EIS item Freq should match fixture.'); - assert(abs(item.Zreal(1) - 138.7798) < 1e-12, 'EIS item Zreal should match fixture.'); - assert(abs(item.negZimag(1) - 2.786225) < 1e-12, 'EIS item negZimag should be derived from Zimag.'); + assert(numel(item.freq_Hz) == item.n, 'EIS item n should match filtered data length.'); + assert(abs(item.freq_Hz(1) - 0.999041) < 1e-12, 'EIS item frequency should match fixture.'); + assert(abs(item.Zreal_ohm(1) - 138.7798) < 1e-12, 'EIS item real impedance should match fixture.'); + assert(abs(item.negZimag_ohm(1) - 2.786225) < 1e-12, 'EIS item negative imaginary impedance should be derived.'); assert(~item.freqDesc, 'Fixture frequency order should preserve low-to-high/mixed summary behavior.'); assert(isstruct(item.analysis) && isempty(fieldnames(item.analysis)), ... 'EIS item should initialize an empty analysis struct.'); - assertClose(item.point, item.Pt, 'EIS normalized point alias'); - assertClose(item.time_s, item.Time, 'EIS normalized time alias'); - assertClose(item.freq_Hz, item.Freq, 'EIS normalized frequency alias'); - assertClose(item.Zreal_ohm, item.Zreal, 'EIS normalized Zreal alias'); - assertClose(item.Zimag_ohm, item.Zimag, 'EIS normalized Zimag alias'); - assertClose(item.negZimag_ohm, item.negZimag, 'EIS normalized -Zimag alias'); - assertClose(item.Zmod_ohm, item.Zmod, 'EIS normalized Zmod alias'); - assertClose(item.Zphz_deg, item.Zphz, 'EIS normalized Zphz alias'); - assertClose(item.Idc_A, item.Idc, 'EIS normalized Idc alias'); - assertClose(item.Vdc_V, item.Vdc, 'EIS normalized Vdc alias'); - appFile = appEntryFile(root, 'labkit_EIS_app'); - source = readAppOwnedSource(appFile); - assert(contains(source, '''Freq (Hz)''') && contains(source, '''Zreal (ohm)''') && ... - contains(source, '''-Zimag (ohm)'''), ... - 'EIS app should preserve stable axis labels.'); - assert(contains(source, 'RowIndex') && contains(source, 'X_%s_%s') && contains(source, 'Y_%s_%s'), ... - 'EIS app should preserve stable export column naming logic.'); - assert(contains(source, 'axis(ax, ''equal'')'), ... - 'EIS app should preserve equal-axis Nyquist plot behavior.'); + canonicalItem = removeLegacyEisFields(item); + zreal = eis.ops.valuesForAxis(canonicalItem, 'Zreal (ohm)'); + assertClose(zreal, item.Zreal_ohm, 'EIS app axis-value hook should preserve Zreal values'); + logFreq = eis.ops.valuesForAxis(canonicalItem, 'log10(Freq)'); + assertClose(logFreq, log10(item.freq_Hz), 'EIS app log-frequency axis values'); - zreal = eis.ops.valuesForAxis(item, 'Zreal (ohm)'); - assertClose(zreal, item.Zreal, 'EIS app axis-value hook should preserve Zreal values'); - T = eis.export.buildExportTable(item, ... + T = eis.export.buildExportTable(canonicalItem, ... 'Zreal (ohm)', '-Zimag (ohm)', false, false); assert(isequal(T.Properties.VariableNames(1), {'RowIndex'}), ... 'EIS export table hook should preserve RowIndex as the first column.'); -end + expectedX = matlab.lang.makeValidName(sprintf('X_%s_%s', ... + 'zreal_ohm', matlab.lang.makeValidName(item.name))); + expectedY = matlab.lang.makeValidName(sprintf('Y_%s_%s', ... + 'zimag_ohm', matlab.lang.makeValidName(item.name))); + assert(any(strcmp(T.Properties.VariableNames, expectedX)), ... + 'EIS export table should preserve axis/file-based X column names.'); + assert(any(strcmp(T.Properties.VariableNames, expectedY)), ... + 'EIS export table should preserve axis/file-based Y column names.'); -function source = readAppOwnedSource(appFile) - appDir = fileparts(appFile); - sourceParts = {fileread(appFile)}; + summary = eis.view.buildSummary(canonicalItem); + assert(numel(summary) == 2 && contains(summary{2}, item.name) && ... + contains(summary{2}, sprintf('N=%d', item.n)) && ... + contains(summary{2}, 'Freq') && contains(summary{2}, 'Hz') && ... + contains(summary{2}, 'low->high/mixed'), ... + 'EIS summary should report canonical item details.'); - privateDir = fullfile(appDir, 'private'); - if exist(privateDir, 'dir') == 7 - fileEntries = dir(fullfile(privateDir, '*.m')); - fileNames = sort({fileEntries.name}); - for iFile = 1:numel(fileNames) - sourceParts{end+1} = fileread(fullfile(privateDir, fileNames{iFile})); %#ok - end - end + assert(eis.view.axisModeForSelection('Zreal (ohm)', '-Zimag (ohm)') == "equal", ... + 'Nyquist EIS selection should use equal axes.'); + assert(eis.view.axisModeForSelection('Freq (Hz)', 'Zmod (ohm)') == "normal", ... + 'Non-Nyquist EIS selection should use normal axes.'); +end - packageEntries = dir(fullfile(appDir, '+*')); - packageDirs = packageEntries([packageEntries.isdir]); - for iDir = 1:numel(packageDirs) - packageDir = fullfile(packageDirs(iDir).folder, packageDirs(iDir).name); - fileEntries = dir(fullfile(packageDir, '**', '*.m')); - filePaths = sort(fullfile({fileEntries.folder}, {fileEntries.name})); - for iFile = 1:numel(filePaths) - sourceParts{end+1} = fileread(filePaths{iFile}); %#ok - end +function item = removeLegacyEisFields(item) + legacyFields = {'Pt', 'Time', 'Freq', 'Zreal', 'Zimag', 'negZimag', ... + 'Zmod', 'Zphz', 'Idc', 'Vdc'}; + present = legacyFields(isfield(item, legacyFields)); + if ~isempty(present) + item = rmfield(item, present); end - - source = strjoin(sourceParts, newline); end diff --git a/tests/unit/labkit/dta/DtaCompatibilityBridgeTest.m b/tests/unit/labkit/dta/DtaCompatibilityBridgeTest.m new file mode 100644 index 0000000..1f6f557 --- /dev/null +++ b/tests/unit/labkit/dta/DtaCompatibilityBridgeTest.m @@ -0,0 +1,49 @@ +classdef DtaCompatibilityBridgeTest < matlab.unittest.TestCase + %DTACOMPATIBILITYBRIDGETEST Verify legacy DTA item bridge fields. + + methods (Test, TestTags = {'Unit'}) + function legacyBridgeFieldsMirrorCanonicalFields(testCase) + setupLabKitTestPath(); + + chronoFixture = dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA'); + [chronoItem, chronoStatus] = labkit.dta.loadFile(chronoFixture, "chrono"); + testCase.assertTrue(chronoStatus.ok, chronoStatus.message); + + assertClose(chronoItem.t, chronoItem.t_s, ... + 'Legacy chrono t should mirror canonical t_s'); + assertClose(chronoItem.Vf, chronoItem.Vf_V, ... + 'Legacy chrono Vf should mirror canonical Vf_V'); + assertClose(chronoItem.Im, chronoItem.Im_A, ... + 'Legacy chrono Im should mirror canonical Im_A'); + testCase.verifyTrue(isnan(chronoItem.alignTime) && isnan(chronoItem.alignTime_s), ... + 'Legacy chrono alignTime should mirror canonical alignTime_s.'); + assertClose(chronoItem.tAligned, chronoItem.tAligned_s, ... + 'Legacy chrono tAligned should mirror canonical tAligned_s'); + + eisFixture = dtaFixturePath('eis_potentiostatic_zcurve.DTA'); + [eisItem, eisStatus] = labkit.dta.loadFile(eisFixture, "eis"); + testCase.assertTrue(eisStatus.ok, eisStatus.message); + + assertClose(eisItem.Pt, eisItem.point, ... + 'Legacy EIS Pt should mirror canonical point'); + assertClose(eisItem.Time, eisItem.time_s, ... + 'Legacy EIS Time should mirror canonical time_s'); + assertClose(eisItem.Freq, eisItem.freq_Hz, ... + 'Legacy EIS Freq should mirror canonical freq_Hz'); + assertClose(eisItem.Zreal, eisItem.Zreal_ohm, ... + 'Legacy EIS Zreal should mirror canonical Zreal_ohm'); + assertClose(eisItem.Zimag, eisItem.Zimag_ohm, ... + 'Legacy EIS Zimag should mirror canonical Zimag_ohm'); + assertClose(eisItem.negZimag, eisItem.negZimag_ohm, ... + 'Legacy EIS negZimag should mirror canonical negZimag_ohm'); + assertClose(eisItem.Zmod, eisItem.Zmod_ohm, ... + 'Legacy EIS Zmod should mirror canonical Zmod_ohm'); + assertClose(eisItem.Zphz, eisItem.Zphz_deg, ... + 'Legacy EIS Zphz should mirror canonical Zphz_deg'); + assertClose(eisItem.Idc, eisItem.Idc_A, ... + 'Legacy EIS Idc should mirror canonical Idc_A'); + assertClose(eisItem.Vdc, eisItem.Vdc_V, ... + 'Legacy EIS Vdc should mirror canonical Vdc_V'); + end + end +end diff --git a/tests/unit/labkit/dta/DtaFacadeTest.m b/tests/unit/labkit/dta/DtaFacadeTest.m index 8fb79a6..411cc17 100644 --- a/tests/unit/labkit/dta/DtaFacadeTest.m +++ b/tests/unit/labkit/dta/DtaFacadeTest.m @@ -37,10 +37,10 @@ function verify_dtaFacade() assert(chronoStatus.ok, chronoStatus.message); assert(chronoStatus.kind == "chrono", 'Chrono status kind should be chrono.'); assert(chronoItem.type == "chrono", 'Chrono item type should be preserved.'); - assert(isfield(chronoItem, 't') && isfield(chronoItem, 'Vf') && isfield(chronoItem, 'Im'), ... - 'Chrono facade should preserve stable-compatible vectors.'); + assert(isfield(chronoItem, 't_s') && isfield(chronoItem, 'Vf_V') && isfield(chronoItem, 'Im_A'), ... + 'Chrono facade should expose canonical unit-explicit vectors.'); [pulse, pulseMsg] = labkit.dta.detectPulses( ... - chronoItem.t, chronoItem.Im, chronoItem.meta, "Metadata first, then auto"); + chronoItem.t_s, chronoItem.Im_A, chronoItem.meta, "Metadata first, then auto"); assert(pulse.ok, pulseMsg); assert(isfield(pulse, 'gap_start') && isfinite(pulse.gap_start), ... 'DTA facade should expose chrono pulse detection without app code calling analysis directly.'); @@ -55,8 +55,8 @@ function verify_dtaFacade() assert(eisStatus.ok, eisStatus.message); assert(eisStatus.kind == "eis", 'Auto-loaded EIS status kind should be eis.'); assert(eisItem.type == "eis", 'EIS item type should be preserved.'); - assert(isfield(eisItem, 'Freq') && isfield(eisItem, 'Zreal') && isfield(eisItem, 'Zimag'), ... - 'EIS facade should preserve stable-compatible impedance vectors.'); + assert(isfield(eisItem, 'freq_Hz') && isfield(eisItem, 'Zreal_ohm') && isfield(eisItem, 'Zimag_ohm'), ... + 'EIS facade should expose canonical unit-explicit impedance vectors.'); [cvctItem, cvctStatus] = labkit.dta.loadFile(cvctFile, "cvct"); assert(cvctStatus.ok, cvctStatus.message); diff --git a/tests/unit/labkit/dta/MakeChronoItemTest.m b/tests/unit/labkit/dta/MakeChronoItemTest.m index fa61024..674e8b6 100644 --- a/tests/unit/labkit/dta/MakeChronoItemTest.m +++ b/tests/unit/labkit/dta/MakeChronoItemTest.m @@ -20,12 +20,9 @@ function verify_makeChronoItem() assert(strcmp(item.type, "chrono"), 'Chrono item type should be set.'); assert(strcmp(item.name, 'chrono_chronopot_current_pulse_0p2ms.DTA'), 'Chrono item name should use the file name.'); assert(item.controlMode == "current", 'Chrono item should expose current-controlled metadata.'); - assert(numel(item.t) == 244 && numel(item.Vf) == 244 && numel(item.Im) == 244, ... - 'stable-compatible chrono vectors should be populated.'); - assert(isequal(item.t, item.t_s), 'Unit-explicit t_s should mirror legacy t.'); - assert(isequal(item.Vf, item.Vf_V), 'Unit-explicit Vf_V should mirror legacy Vf.'); - assert(isequal(item.Im, item.Im_A), 'Unit-explicit Im_A should mirror legacy Im.'); - assert(item.n == numel(item.t), 'Item sample count should match the time vector.'); + assert(numel(item.t_s) == 244 && numel(item.Vf_V) == 244 && numel(item.Im_A) == 244, ... + 'Canonical chrono vectors should be populated.'); + assert(item.n == numel(item.t_s), 'Item sample count should match the canonical time vector.'); assert(strcmp(item.message, 'Using table: Curve'), 'Main-curve message should preserve stable wording.'); assert(item.pulse.ok, item.pulseMessage);