Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c302012
JSON rubric loader, sandbox runner, additive-scoring GUI
claude Sep 5, 2026
9fe4059
Add JSON rubric editor GUI (vhgraderubriceditor)
claude Sep 5, 2026
8a9d6ab
Add function-based test harness + MATLAB CI
claude Sep 5, 2026
9857c5b
Fix sandbox output-argument crash when student code calls clear
claude Sep 5, 2026
7cc30a6
Editor: fix invalid Layout.Column [4 4] on autograder response_name row
claude Sep 5, 2026
6ec8a93
Grader GUI: give action buttons their own row so they stop rolling off
claude Sep 5, 2026
3967954
Add grading overview dashboard; require a comment before Save/Full cr…
claude Sep 5, 2026
d16567a
Overview: fix addStyle signature (must be 'cell', [row col] pairs)
claude Sep 5, 2026
925ab48
Overview: require a real cell click; pop grader window to front
claude Sep 5, 2026
9deedc0
Overview: read live uitable Selection at press time, not cached state
claude Sep 5, 2026
d115eb7
Overview: clearer button labels, autograded-only alert, tooltips
claude Sep 5, 2026
ef64f45
Overview: tag every item as [resp] / [code] / [code+resp] / [manual]
claude Sep 5, 2026
54d6ea6
Add summary refresh / open / CSV export buttons + tag grader title
claude Sep 5, 2026
df05dc3
Fix code-file lookup: use full path, tolerate a missing file
claude Sep 5, 2026
b0980df
Grader GUI: robust text-to-lines conversion (fix regexp crash)
claude Sep 5, 2026
217dd42
Surface student code errors to the grader (msgbox + prefilled comment)
claude Sep 5, 2026
bd4d4ac
Overview: put the student's own folder on the path during grading
claude Sep 5, 2026
6693aec
Keep code-error message in Comment 1 (was overwritten by checks summary)
claude Sep 5, 2026
2474c27
Skip vs Cancel semantics, canned-comment insert into Comment 2
claude Sep 5, 2026
cf05c52
Autograded: fprintf instead of uialert; clean up student-created figures
claude Sep 5, 2026
101ba56
Code preview: selectable/copyable uifigure+uitextarea (was msgbox)
claude Sep 5, 2026
66affc5
Grader GUI: drop stray function-end tokens (match file convention)
claude Sep 5, 2026
d897fda
Grader GUI: scrollable rubric and comment-bank panels
claude Sep 5, 2026
99b8e1c
Show grader window + code preview when autograder fails
claude Sep 5, 2026
878750b
Grader: use delete(fig), not close(fig), on normal-exit handlers
claude Sep 5, 2026
0f9d9ca
Grader: keyboard shortcuts for the five action buttons
claude Sep 5, 2026
1c9dfe8
Overview: repeat Student column at the right of the table
claude Sep 5, 2026
da9aad2
Overview: fixed narrow item columns so the trailing Student is visible
claude Sep 5, 2026
86948c8
Overview: "Commit & push grades to students" button with two-stage co…
claude Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/matlab-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: MATLAB tests

on:
push:
branches: [master, main, 'claude/**']
pull_request:
workflow_dispatch:

jobs:
test:
name: Run function-based tests
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up MATLAB
uses: matlab-actions/setup-matlab@v2
with:
release: latest

- name: Run tests
uses: matlab-actions/run-tests@v2
with:
source-folder: .
select-by-folder: tests
test-results-junit: test-results/junit.xml

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-junit
path: test-results/junit.xml
66 changes: 66 additions & 0 deletions tests/fixtures/tiny_rubric.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"assignment_name": "Tiny Test Rubric",
"assignment_url": "http://example.invalid/tiny",
"points_model": "rubric_additive",
"shared_comment_bank": [
{ "label": "Nice", "text": "Nice work.", "points_delta": 0 },
{ "label": "Missing", "text": "Missing / incomplete.", "points_delta": -1 }
],
"items": [
{
"item_name": "Q1 response text",
"subfolder": "question1",
"item_filename": "q1.mat",
"points_possible": 5,
"description": "Answer Q1 in response.md.",
"skills": ["writing"],
"code": "",
"code_files": [],
"autograder": { "type": "response_name", "response_name": "(\\s*)#(\\s*)Q(\\s*)1" },
"rubric": [
{ "name": "Attempted", "points": 3 },
{ "name": "Reasoned", "points": 2 }
],
"comment_bank": [
{ "label": "Too short", "text": "Response is too brief.", "points_delta": -1 }
]
},
{
"item_name": "Q2 anyvartest",
"subfolder": "question2",
"item_filename": "q2.mat",
"points_possible": 10,
"description": "Set variables to expected values.",
"skills": ["code"],
"code": "close all; clear; q2;",
"code_files": ["q2.m"],
"autograder": {
"type": "anyvartest",
"checks": [
{ "criterion": "answer_a", "value": 3.5, "tolerance": 0.001, "points": 6 },
{ "criterion": "answer_b", "value": 17, "tolerance": 0.001, "points": 4 }
]
},
"rubric": [
{ "name": "answer_a correct", "points": 6 },
{ "name": "answer_b correct", "points": 4 }
],
"comment_bank": []
},
{
"item_name": "Q3 manual only",
"subfolder": "question3",
"item_filename": "q3.mat",
"points_possible": 4,
"description": "Manual only.",
"skills": [],
"code": "",
"code_files": [],
"autograder": { "type": "manual" },
"rubric": [
{ "name": "Whole thing", "points": 4 }
],
"comment_bank": []
}
]
}
18 changes: 18 additions & 0 deletions tests/runalltests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
function results = runalltests()
% RUNALLTESTS - run every function-based test in this folder.
%
% RESULTS = RUNALLTESTS()
%
% Adds the parent (toolbox root) to the path, then runs every test_*.m
% file in this directory via matlab.unittest. Returns the
% TestResult array so callers can inspect failures.

here = fileparts(mfilename('fullpath'));
addpath(fileparts(here));

import matlab.unittest.TestSuite
suite = TestSuite.fromFolder(here);
results = run(suite);

disp(table(results));
end
90 changes: 90 additions & 0 deletions tests/test_vhgradeloadrubric.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
function tests = test_vhgradeloadrubric
% TEST_VHGRADELOADRUBRIC - function-based tests for vhgradeloadrubric.
%
% Run with: results = runtests('test_vhgradeloadrubric')
tests = functiontests(localfunctions);
end

function setupOnce(tc)
tc.TestData.here = fileparts(mfilename('fullpath'));
tc.TestData.fixture = fullfile(tc.TestData.here, 'fixtures', 'tiny_rubric.json');
addpath(fileparts(tc.TestData.here));
end

function testMetadata(tc)
[~, meta] = vhgradeloadrubric(tc.TestData.fixture);
verifyEqual(tc, meta.assignment_name, 'Tiny Test Rubric');
verifyEqual(tc, meta.assignment_url, 'http://example.invalid/tiny');
verifyEqual(tc, meta.points_model, 'rubric_additive');
verifyEqual(tc, numel(meta.shared_comment_bank), 2);
verifyEqual(tc, meta.shared_comment_bank(1).label, 'Nice');
verifyEqual(tc, meta.shared_comment_bank(2).points_delta, -1);
end

function testItemCountAndTopFields(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
verifyEqual(tc, numel(items), 3);
verifyEqual(tc, items(1).Item_name, 'Q1 response text');
verifyEqual(tc, items(2).Points_possible, 10);
verifyEqual(tc, items(3).Item_filename, 'q3.mat');
verifyEqual(tc, items(1).Skills, {'writing'});
verifyEqual(tc, items(2).CodeFiles, {'q2.m'});
end

function testResponseNameAutograder(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
p = items(1).Parameters;
verifyEqual(tc, lower(char(p(1).type)), 'response_name');
verifyEqual(tc, p(1).response_name, '(\s*)#(\s*)Q(\s*)1');
end

function testAnyVartestExpandedToChecks(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
p = items(2).Parameters;
verifyEqual(tc, numel(p), 2);
verifyEqual(tc, lower(char(p(1).type)), 'anyvartest');
verifyEqual(tc, p(1).criterion, 'answer_a');
verifyEqual(tc, p(1).value, 3.5);
verifyEqual(tc, p(1).tolerance, 0.001);
verifyEqual(tc, p(1).points, 6);
verifyEqual(tc, p(2).points, 4);
end

function testManualAutograder(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
verifyEqual(tc, lower(char(items(3).Parameters(1).type)), 'manual');
end

function testRubricParsed(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
r = items(1).Rubric;
verifyEqual(tc, numel(r), 2);
verifyEqual(tc, r(1).name, 'Attempted');
verifyEqual(tc, r(1).points, 3);
verifyEqual(tc, r(2).points, 2);
end

function testCommentBankAndShared(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
verifyEqual(tc, numel(items(1).Comment_bank), 1);
verifyEqual(tc, items(1).Comment_bank(1).points_delta, -1);

% Shared bank propagates to every item.
for i = 1:numel(items)
verifyEqual(tc, numel(items(i).Shared_comment_bank), 2, ...
sprintf('shared bank size on item %d', i));
verifyEqual(tc, items(i).Shared_comment_bank(1).label, 'Nice');
end
end

function testRubricSumsMatchPointsPossible(tc)
% Every item in the fixture is designed so rubric points sum to
% points_possible; this test catches accidental fixture drift.
items = vhgradeloadrubric(tc.TestData.fixture);
for i = 1:numel(items)
if isempty(items(i).Rubric), continue; end
s = sum([items(i).Rubric.points]);
verifyEqual(tc, s, items(i).Points_possible, ...
sprintf('item %d rubric sum', i));
end
end
87 changes: 87 additions & 0 deletions tests/test_vhgradequestion_auto.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
function tests = test_vhgradequestion_auto
% TEST_VHGRADEQUESTION_AUTO - end-to-end autograder happy-path tests.
%
% Only exercises the branch where every autograder check passes, since
% that branch saves the grade and returns without opening the GUI.
% Failing / partial-credit paths open vhgraderesponsegui and would block
% a headless test run.
tests = functiontests(localfunctions);
end

function setupOnce(tc)
tc.TestData.here = fileparts(mfilename('fullpath'));
tc.TestData.fixture = fullfile(tc.TestData.here, 'fixtures', 'tiny_rubric.json');
addpath(fileparts(tc.TestData.here));
end

function setup(tc)
tc.TestData.parentDir = tempname;
mkdir(tc.TestData.parentDir);
end

function teardown(tc)
if isfield(tc.TestData,'parentDir') && isfolder(tc.TestData.parentDir)
rmdir(tc.TestData.parentDir, 's');
end
end

function testAllChecksPassAwardsFullCredit(tc)
items = vhgradeloadrubric(tc.TestData.fixture);
item = items(2); % anyvartest, 10 points

qdir = fullfile(tc.TestData.parentDir, item.Subfolder);
mkdir(qdir);
writeQ2(qdir, 3.5, 17);

grade = vhgradequestion(tc.TestData.parentDir, item);

verifyEqual(tc, grade.Points_earned, item.Points_possible, ...
'all checks pass → full credit');
verifyEqual(tc, grade.CodeError, 0, 'no code error expected');
verifyTrue(tc, isfile(fullfile(tc.TestData.parentDir,'GRADING',item.Item_filename)), ...
'grade .mat should be written');
verifyTrue(tc, all(grade.Autograder_score.checks_passed), ...
'every check should pass');
end

function testCachedGradeIsReturnedUnchanged(tc)
% Second call without forceRegrade must NOT re-run the sandbox; it should
% just reload the saved grade.
items = vhgradeloadrubric(tc.TestData.fixture);
item = items(2);
qdir = fullfile(tc.TestData.parentDir, item.Subfolder);
mkdir(qdir);
writeQ2(qdir, 3.5, 17);

g1 = vhgradequestion(tc.TestData.parentDir, item);

% Overwrite q2.m so the code would fail if it re-ran:
writeQ2(qdir, 0, 0);
g2 = vhgradequestion(tc.TestData.parentDir, item);

verifyEqual(tc, g2.Points_earned, g1.Points_earned, ...
'cached grade must be reused when forceRegrade is 0');
end

function testDoesNotPolluteBaseWorkspace(tc)
% Belt-and-braces: make sure the autograder path leaves no trace in base.
items = vhgradeloadrubric(tc.TestData.fixture);
item = items(2);
qdir = fullfile(tc.TestData.parentDir, item.Subfolder);
mkdir(qdir);
writeQ2(qdir, 3.5, 17);

evalin('base', 'clear answer_a answer_b');
vhgradequestion(tc.TestData.parentDir, item);
baseNames = evalin('base', 'who');
verifyFalse(tc, any(strcmp(baseNames,'answer_a')), 'answer_a leaked to base');
verifyFalse(tc, any(strcmp(baseNames,'answer_b')), 'answer_b leaked to base');
end


% ----- helpers -----
function writeQ2(qdir, a, b)
fid = fopen(fullfile(qdir,'q2.m'),'w');
c = onCleanup(@() fclose(fid));
fprintf(fid, 'answer_a = %.6f;\nanswer_b = %.6f;\n', a, b);
end
Loading
Loading