Skip to content

Commit bf25915

Browse files
authored
Merge pull request #41 from HanSur94/feat/dashboard-speed-serialization-and-tests
feat(dashboard): .m serialization, integration tests, Octave compat fixes
2 parents 7db8fd1 + da5e7a3 commit bf25915

7 files changed

Lines changed: 300 additions & 36 deletions

libs/Dashboard/DashboardEngine.m

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
obj.Layout = DashboardLayout();
6464
end
6565

66-
function addWidget(obj, type, varargin)
66+
function w = addWidget(obj, type, varargin)
6767
switch type
6868
case 'fastsense'
6969
w = FastSenseWidget(varargin{:});
@@ -794,28 +794,40 @@ function onClose(obj)
794794
end
795795
end
796796

797-
config = DashboardSerializer.load(filepath);
798-
obj = DashboardEngine(config.name);
799-
if isfield(config, 'theme')
800-
obj.Theme = config.theme;
801-
end
802-
if isfield(config, 'liveInterval')
803-
obj.LiveInterval = config.liveInterval;
804-
end
805-
obj.FilePath = filepath;
806-
if isfield(config, 'infoFile')
807-
obj.InfoFile = config.infoFile;
808-
end
797+
[~, ~, ext] = fileparts(filepath);
798+
799+
if strcmp(ext, '.m')
800+
% .m function file returns a DashboardEngine directly
801+
[fdir, funcname] = fileparts(filepath);
802+
addpath(fdir);
803+
cleanupPath = onCleanup(@() rmpath(fdir));
804+
obj = feval(funcname);
805+
obj.FilePath = filepath;
806+
else
807+
% Legacy JSON path
808+
config = DashboardSerializer.load(filepath);
809+
obj = DashboardEngine(config.name);
810+
if isfield(config, 'theme')
811+
obj.Theme = config.theme;
812+
end
813+
if isfield(config, 'liveInterval')
814+
obj.LiveInterval = config.liveInterval;
815+
end
816+
obj.FilePath = filepath;
817+
if isfield(config, 'infoFile')
818+
obj.InfoFile = config.infoFile;
819+
end
809820

810-
widgets = DashboardSerializer.configToWidgets(config, resolver);
811-
for i = 1:numel(widgets)
812-
w = widgets{i};
813-
existingPositions = cell(1, numel(obj.Widgets));
814-
for j = 1:numel(obj.Widgets)
815-
existingPositions{j} = obj.Widgets{j}.Position;
821+
widgets = DashboardSerializer.configToWidgets(config, resolver);
822+
for i = 1:numel(widgets)
823+
w = widgets{i};
824+
existingPositions = cell(1, numel(obj.Widgets));
825+
for j = 1:numel(obj.Widgets)
826+
existingPositions{j} = obj.Widgets{j}.Position;
827+
end
828+
w.Position = obj.Layout.resolveOverlap(w.Position, existingPositions);
829+
obj.Widgets{end+1} = w;
816830
end
817-
w.Position = obj.Layout.resolveOverlap(w.Position, existingPositions);
818-
obj.Widgets{end+1} = w;
819831
end
820832
end
821833
end

libs/Dashboard/DashboardSerializer.m

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,106 @@
33

44
methods (Static)
55
function save(config, filepath)
6-
%SAVE Write dashboard config struct to JSON file.
6+
%SAVE Write dashboard config as a MATLAB function file.
7+
% The output is a function returning a DashboardEngine.
8+
[~, funcname] = fileparts(filepath);
9+
10+
% Generate the script body (reuse exportScript logic)
11+
lines = {};
12+
lines{end+1} = sprintf('function d = %s()', funcname);
13+
lines{end+1} = sprintf('%%%s Recreate dashboard.', upper(funcname));
14+
lines{end+1} = sprintf('%% d = %s() returns a DashboardEngine.', funcname);
15+
lines{end+1} = '';
16+
lines{end+1} = sprintf(' d = DashboardEngine(''%s'');', strrep(config.name, '''', ''''''));
17+
if isfield(config, 'theme')
18+
lines{end+1} = sprintf(' d.Theme = ''%s'';', config.theme);
19+
end
20+
if isfield(config, 'liveInterval')
21+
lines{end+1} = sprintf(' d.LiveInterval = %g;', config.liveInterval);
22+
end
23+
if isfield(config, 'infoFile') && ~isempty(config.infoFile)
24+
lines{end+1} = sprintf(' d.InfoFile = ''%s'';', strrep(config.infoFile, '''', ''''''));
25+
end
26+
lines{end+1} = '';
27+
28+
% Write widget calls (indented, with return value)
29+
for i = 1:numel(config.widgets)
30+
ws = config.widgets{i};
31+
pos = sprintf('[%d %d %d %d]', ws.position.col, ws.position.row, ...
32+
ws.position.width, ws.position.height);
33+
34+
switch ws.type
35+
case 'fastsense'
36+
if isfield(ws, 'source')
37+
switch ws.source.type
38+
case 'sensor'
39+
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
40+
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
41+
lines{end+1} = sprintf(' ''Sensor'', SensorRegistry.get(''%s''));', ws.source.name);
42+
case 'file'
43+
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
44+
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
45+
lines{end+1} = sprintf(' ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'');', ...
46+
ws.source.path, ws.source.xVar, ws.source.yVar);
47+
case 'data'
48+
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
49+
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
50+
lines{end+1} = sprintf(' ''XData'', %s, ''YData'', %s);', ...
51+
mat2str(ws.source.x), mat2str(ws.source.y));
52+
otherwise
53+
lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
54+
end
55+
else
56+
lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
57+
end
58+
case 'number'
59+
line = sprintf(' d.addWidget(''number'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
60+
if isfield(ws, 'units') && ~isempty(ws.units)
61+
line = [line, sprintf(', ...\n ''Units'', ''%s''', ws.units)];
62+
end
63+
lines{end+1} = [line, ');'];
64+
case 'status'
65+
line = sprintf(' d.addWidget(''status'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
66+
lines{end+1} = [line, ');'];
67+
case 'text'
68+
line = sprintf(' d.addWidget(''text'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
69+
if isfield(ws, 'content') && ~isempty(ws.content)
70+
line = [line, sprintf(', ...\n ''Content'', ''%s''', ws.content)];
71+
end
72+
lines{end+1} = [line, ');'];
73+
case 'gauge'
74+
line = sprintf(' d.addWidget(''gauge'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
75+
if isfield(ws, 'range')
76+
line = [line, sprintf(', ...\n ''Range'', [%g %g]', ws.range(1), ws.range(2))];
77+
end
78+
if isfield(ws, 'units') && ~isempty(ws.units)
79+
line = [line, sprintf(', ...\n ''Units'', ''%s''', ws.units)];
80+
end
81+
lines{end+1} = [line, ');'];
82+
case 'group'
83+
line = sprintf(' d.addWidget(''group'', ''Label'', ''%s'', ''Position'', %s', ws.label, pos);
84+
if isfield(ws, 'mode') && ~isempty(ws.mode)
85+
line = [line, sprintf(', ...\n ''Mode'', ''%s''', ws.mode)];
86+
end
87+
lines{end+1} = [line, ');'];
88+
otherwise
89+
lines{end+1} = sprintf(' d.addWidget(''%s'', ''Title'', ''%s'', ''Position'', %s);', ws.type, ws.title, pos);
90+
end
91+
lines{end+1} = '';
92+
end
93+
94+
lines{end+1} = 'end';
95+
96+
fid = fopen(filepath, 'w');
97+
if fid == -1
98+
error('DashboardSerializer:fileError', 'Cannot open file: %s', filepath);
99+
end
100+
fprintf(fid, '%s\n', lines{:});
101+
fclose(fid);
102+
end
103+
104+
function saveJSON(config, filepath)
105+
%SAVEJSON Legacy: write dashboard config struct to JSON file.
7106
% Widgets may have heterogeneous fields, so encode each
8107
% widget individually and assemble the JSON array by hand.
9108
parts = cell(1, numel(config.widgets));
@@ -26,19 +125,33 @@ function save(config, filepath)
26125
fclose(fid);
27126
end
28127

29-
function config = load(filepath)
30-
%LOAD Read dashboard config from JSON file.
128+
function result = load(filepath)
129+
%LOAD Load dashboard config from file.
130+
% For .m files: uses feval to execute the function and return the engine.
131+
% For .json files: uses legacy JSON parsing.
31132
if ~exist(filepath, 'file')
32133
error('DashboardSerializer:fileNotFound', 'File not found: %s', filepath);
33134
end
34135

136+
[fdir, funcname, ext] = fileparts(filepath);
137+
138+
if strcmp(ext, '.json')
139+
result = DashboardSerializer.loadJSON(filepath);
140+
return;
141+
end
142+
143+
% .m function file
144+
addpath(fdir);
145+
cleanupPath = onCleanup(@() rmpath(fdir));
146+
result = feval(funcname);
147+
end
148+
149+
function config = loadJSON(filepath)
150+
%LOADJSON Legacy: read dashboard config from JSON file.
35151
fid = fopen(filepath, 'r');
36152
jsonStr = fread(fid, '*char')';
37153
fclose(fid);
38-
39154
config = jsondecode(jsonStr);
40-
41-
% Ensure widgets is a cell array
42155
if isstruct(config.widgets)
43156
wa = config.widgets;
44157
config.widgets = cell(1, numel(wa));

tests/suite/TestDashboardEngine.m

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function testSaveAndLoad(testCase)
6565
d.addWidget('fastsense', 'Title', 'Temp', ...
6666
'Position', [1 1 12 3], 'XData', 1:10, 'YData', [1:10]);
6767

68-
filepath = fullfile(tempdir, 'test_save_dashboard.json');
68+
filepath = fullfile(tempdir, 'test_save_dashboard.m');
6969
testCase.addTeardown(@() delete(filepath));
7070
d.save(filepath);
7171

@@ -87,8 +87,8 @@ function testExportScript(testCase)
8787
d.exportScript(filepath);
8888

8989
content = fileread(filepath);
90-
testCase.verifyTrue(contains(content, 'DashboardEngine'));
91-
testCase.verifyTrue(contains(content, 'Pressure'));
90+
testCase.verifyFalse(isempty(strfind(content, 'DashboardEngine')));
91+
testCase.verifyFalse(isempty(strfind(content, 'Pressure')));
9292
end
9393

9494
function testLiveStartStop(testCase)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
classdef TestDashboardMSerializer < matlab.unittest.TestCase
2+
methods (TestClassSetup)
3+
function addPaths(testCase)
4+
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..'));
5+
install();
6+
end
7+
end
8+
9+
methods (Test)
10+
function testSaveProducesMFile(testCase)
11+
d = DashboardEngine('SaveTest');
12+
d.Theme = 'dark';
13+
d.LiveInterval = 3;
14+
d.addWidget('fastsense', 'Title', 'Temp', ...
15+
'Position', [1 1 12 3], 'XData', 1:10, 'YData', 1:10);
16+
17+
filepath = fullfile(tempdir, 'test_save_dash.m');
18+
testCase.addTeardown(@() delete(filepath));
19+
d.save(filepath);
20+
21+
testCase.verifyTrue(exist(filepath, 'file') == 2);
22+
content = fileread(filepath);
23+
testCase.verifyFalse(isempty(strfind(content, 'DashboardEngine')));
24+
testCase.verifyFalse(isempty(strfind(content, 'function')));
25+
end
26+
27+
function testLoadFromMFile(testCase)
28+
d = DashboardEngine('LoadTest');
29+
d.Theme = 'dark';
30+
d.LiveInterval = 3;
31+
d.addWidget('fastsense', 'Title', 'Temp', ...
32+
'Position', [1 1 12 3], 'XData', 1:10, 'YData', 1:10);
33+
34+
filepath = fullfile(tempdir, 'test_load_dash.m');
35+
testCase.addTeardown(@() delete(filepath));
36+
d.save(filepath);
37+
38+
d2 = DashboardEngine.load(filepath);
39+
testCase.verifyEqual(d2.Name, 'LoadTest');
40+
testCase.verifyEqual(d2.Theme, 'dark');
41+
testCase.verifyEqual(d2.LiveInterval, 3);
42+
testCase.verifyEqual(numel(d2.Widgets), 1);
43+
end
44+
45+
function testAddWidgetReturnsHandle(testCase)
46+
d = DashboardEngine('ReturnTest');
47+
w = d.addWidget('number', 'Title', 'RPM', ...
48+
'Position', [1 1 6 1]);
49+
testCase.verifyClass(w, 'NumberWidget');
50+
testCase.verifyEqual(w.Title, 'RPM');
51+
end
52+
end
53+
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
classdef TestDashboardPerformance < matlab.unittest.TestCase
2+
methods (TestClassSetup)
3+
function addPaths(testCase)
4+
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..'));
5+
install();
6+
end
7+
end
8+
9+
methods (Test)
10+
function testLiveTickOnlyRefreshesDirtyWidgets(testCase)
11+
d = DashboardEngine('PerfTest');
12+
for k = 1:10
13+
d.addWidget('number', 'Title', sprintf('N%d', k), ...
14+
'Position', [mod((k-1)*6, 24)+1, ceil(k*6/24), 6, 1], ...
15+
'ValueFcn', @() k);
16+
end
17+
d.render();
18+
testCase.addTeardown(@() close(d.hFigure));
19+
20+
% Clear all dirty flags
21+
for i = 1:numel(d.Widgets)
22+
d.Widgets{i}.Dirty = false;
23+
end
24+
25+
% Mark only 2 of 10 dirty
26+
d.Widgets{1}.markDirty();
27+
d.Widgets{5}.markDirty();
28+
29+
% Live tick should only refresh dirty widgets
30+
d.onLiveTick();
31+
32+
% All should be clean after tick
33+
for i = 1:numel(d.Widgets)
34+
testCase.verifyFalse(d.Widgets{i}.Dirty);
35+
end
36+
end
37+
38+
function testSaveLoadRoundTripWithMFile(testCase)
39+
d = DashboardEngine('RoundTrip');
40+
d.Theme = 'dark';
41+
d.LiveInterval = 2;
42+
d.addWidget('fastsense', 'Title', 'Temp', ...
43+
'Position', [1 1 12 3], 'XData', 1:100, 'YData', rand(1,100));
44+
d.addWidget('number', 'Title', 'RPM', ...
45+
'Position', [13 1 6 1]);
46+
47+
filepath = fullfile(tempdir, 'perf_roundtrip.m');
48+
testCase.addTeardown(@() delete(filepath));
49+
d.save(filepath);
50+
51+
d2 = DashboardEngine.load(filepath);
52+
testCase.verifyEqual(d2.Name, 'RoundTrip');
53+
testCase.verifyEqual(d2.Theme, 'dark');
54+
testCase.verifyEqual(numel(d2.Widgets), 2);
55+
end
56+
57+
function testWidgetsRealizedAfterRender(testCase)
58+
d = DashboardEngine('RealizeTest');
59+
d.addWidget('number', 'Title', 'N1', ...
60+
'Position', [1 1 12 1]);
61+
d.addWidget('number', 'Title', 'N2', ...
62+
'Position', [13 1 12 1]);
63+
d.render();
64+
testCase.addTeardown(@() close(d.hFigure));
65+
66+
for i = 1:numel(d.Widgets)
67+
testCase.verifyTrue(d.Widgets{i}.Realized);
68+
end
69+
end
70+
71+
function testResizeMarksDirtyAndRealizeBatch(testCase)
72+
d = DashboardEngine('ResizePerfTest');
73+
d.addWidget('number', 'Title', 'N1', ...
74+
'Position', [1 1 24 1]);
75+
d.render();
76+
testCase.addTeardown(@() close(d.hFigure));
77+
78+
for i = 1:numel(d.Widgets)
79+
d.Widgets{i}.Dirty = false;
80+
end
81+
82+
d.onResize();
83+
testCase.verifyTrue(d.Widgets{1}.Dirty);
84+
end
85+
end
86+
end

tests/suite/TestDashboardSerializer.m

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ function testSaveAndLoadRoundTrip(testCase)
3232
'source', struct('type', 'data', 'x', 1:10, 'y', rand(1,10)));
3333

3434
filepath = fullfile(testCase.TempDir, 'test_dashboard.json');
35-
DashboardSerializer.save(config, filepath);
35+
DashboardSerializer.saveJSON(config, filepath);
3636

3737
testCase.verifyTrue(exist(filepath, 'file') == 2, ...
3838
'JSON file should exist');
3939

40-
loaded = DashboardSerializer.load(filepath);
40+
loaded = DashboardSerializer.loadJSON(filepath);
4141
testCase.verifyEqual(loaded.name, 'Test Dashboard');
4242
testCase.verifyEqual(loaded.theme, 'dark');
4343
testCase.verifyEqual(loaded.liveInterval, 5);

0 commit comments

Comments
 (0)