Add JSON rubric editor and modernize grading UI with additive scoring - #16
Merged
Merged
Conversation
New: - vhgradeloadrubric.m: reads a JSON rubric into the classic Parameters struct plus new Rubric / Comment_bank / Shared_comment_bank fields. - vhgradesaverubric.m: writes the same shape back to JSON, for the forthcoming rubric editor. - vhgradesandboxrun.m: runs student code in a function-scoped workspace so it never touches base; returns the resulting variables as a struct. Changed: - vhgradequestion.m: runs the student's Code through the sandbox and reads autograder variables from the returned struct. Autograder checks now record per-check pass/fail and (when the JSON provides per-check points) award partial credit up front. Rubric / Comment_bank are passed through to the GUI on grade. - vhgraderesponsegui.m: rewritten as a uifigure with a rubric-criteria checkbox panel and an additive comment-bank checkbox panel (item bank first, then the assignment-shared bank). Selected criteria and comment deltas sum into an auto-computed Points earned that is still editable. Two free-text comment boxes remain for anything the bank does not cover. Falls back gracefully when no rubric/bank is supplied. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
A uifigure app that opens, browses, edits, and saves JSON rubric files consumed by vhgradeloadrubric. Layout: - Toolbar: Open / Save / Save As / New item / Delete item. - Left column: items list, assignment metadata (name, url, points model), and the assignment-wide shared comment bank. - Middle column: editor for the selected item — name, subfolder, filename, points possible, description, skills, code, code files, the autograder spec (type dropdown; response_name field OR a checks table for vartest/anyvartest with per-check points), the rubric criteria table, and the item-specific comment bank. - Right column: live totals per item plus a validation panel that flags items whose rubric criteria don't sum to points_possible. Save/Save As write via vhgradesaverubric. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Tests (tests/): a self-contained JSON fixture (tiny_rubric.json) plus
four test files, all using MATLAB's matlab.unittest function-based
form so they can be picked up by runtests or matlab-actions/run-tests:
- test_vhgradeloadrubric : metadata, item count, response_name and
{any}vartest translation, rubric parsing,
per-item and shared comment banks.
- test_vhgradesaverubric : load → save → reload round-trip preserves
metadata, items, weighted checks, and the
response_name field.
- test_vhgradesandboxrun : empty code, simple variables, error path,
base-workspace isolation, pwd restoration
(including on error), and non-leakage of
internal reserved names.
- test_vhgradequestion_auto: end-to-end all-checks-pass branch (which
bypasses the GUI) — verifies full credit,
grade caching, and no base-workspace leak.
Failing / partial-credit branches of vhgradequestion open the GUI and
would block a headless runner, so they are intentionally not covered.
runalltests.m collects and runs everything for interactive use.
CI (.github/workflows/matlab-tests.yml): runs setup-matlab and
run-tests on ubuntu-latest, uploads the JUnit report as an artifact.
Uses MATLAB's free CI license (public repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
CI (#16, run #1) surfaced a real bug: every end-to-end vhgradequestion test failed with Output argument "ws" (and possibly others) not assigned a value in the execution with "vhgradesandboxrun" function. Root cause: vhgradesandboxrun ran `eval(codeText)` in its own function scope. When the item's Code contained `clear` (as PS 1.1's Q2 does — "close all; clear; q2;") the clear wiped the sandbox function's own ws/err/startDir locals, so on return MATLAB found the output arguments unassigned. Fix: move the eval and the workspace snapshot into a nested subfunction (i_evalAndSnapshot). Because it is a separate function scope, a `clear` inside the student code only wipes the subfunction's own locals — the caller's ws/err/startDir/cleanup are safe. The subfunction rebuilds its snapshot state after the eval so its own outputs are also robust. Also added test_vhgradesandboxrun/testStudentClearDoesNotBreakOutputs as a targeted regression test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
MATLAB rejects Layout.Column = [N N] with "must be a positive integer or a 1x2 increasing array". The response_name edit field only needs one column; use Column = 4. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
The action buttons (Save / Full credit / Missing / Cancel) were four stacked entries inside a 60px-high bottom row, which squeezed them to invisibility on standard displays. Move them to their own dedicated row 5 as a horizontal strip spanning both columns, and grow the fig from 720 to 820 tall so the two free-text comment boxes and the points panel get real room in row 4 as well. Also give the points panel explicit padding/spacing and a fixed 80px editfield width so it doesn't collapse to one line. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
…edit
vhgradeoverview(parentDir, items):
- Dashboard uifigure with an item-completion summary and a
students × items uitable. Each cell shows "N of M" or "—" and is
color-coded (red = ungraded, green ≥90%, yellow ≥50%, orange below).
- Toolbar buttons operate on the current selection:
* Grade this cell (single student × single item, force=1)
* Resume item (ungraded) (walk only ungraded students for the
selected item column)
* Regrade full item (force-regrade every student for the
selected item column)
* Change directory (uigetdir; rescans)
* Refresh (rescan disk; useful after external edits)
- Each grading action writes a fresh summary.txt via vhgradesummary
and then rescans so the table reflects reality.
vhgraderesponsegui: Save and Full-credit now refuse to persist unless
the grader has left at least one indication of a comment — a rubric
criterion checked, a canned comment ticked, or free text in either of
the two comment boxes. If none, a uialert explains and the Missing/0
button remains the correct choice for items the student didn't do.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
MATLAB's uitable addStyle takes (style, targetType, targetIndex), not (style, row, col). Batch cell indices per color bucket and issue one addStyle call per bucket — same visual, one call each instead of n*m calls, and no more "Third argument must be 'cell', 'row', 'column', or 'table'" error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Two fixes for the "Grade this cell does nothing" symptom: 1. vhgradeoverview: initialize selRow/selCol to 0 so the guard alert fires when the user hasn't clicked a cell yet. Update the toolbar selection label to prompt "(click an item cell in the table first)" in that state, instead of silently grading student 1 × item 1. 2. vhgradequestion: call figure(h) on the response GUI right before uiwait so it comes to the front. When launched from vhgradeoverview the new grader window was opening behind the overview and looked like nothing had happened. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
The action buttons used cached selRow/selCol values updated by CellSelectionCallback. After a Save + refreshScan reset the table Data, the callback did not always fire again on the next click in R2024b uifigures, so the cached selection went stale and the buttons either did nothing or acted on the previously-graded cell. Fix: switch the table to SelectionType='cell', Multiselect='off', and read tbl.Selection directly in a small liveSelection helper each time a button is pressed. That way the buttons act on whatever is actually highlighted in the table right now, regardless of callback timing. Also swap CellSelectionCallback for SelectionChangedFcn (its modern equivalent) purely for updating the label. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Two related fixes for cells/columns that felt like "nothing happened":
1. vhgradequestion now sets grade.Autograded_only = true on the two
paths that save a grade and return WITHOUT opening the manual GUI
(all autograder checks passed → full credit; or a response_name
section was missing in response.md → 0 with an error comment).
2. vhgradeoverview:
* Renamed and colored buttons so their purpose is unambiguous:
- "Grade one cell (student × item)"
- "Grade this item for all ungraded students" [blue]
- "Regrade this item for ALL students (force)"
Each has an explanatory Tooltip.
* After onGradeCell finishes, if the saved grade is flagged
Autograded_only, pop an info uialert that says "saved without
opening the manual grader — result: N/M — reason: …". So an
auto-graded cell no longer looks like a dead click.
* Split the top strip into a proper toolbar row plus a hint row
showing the parent directory on the left and the current cell
selection on the right (fixes an earlier aliasing mistake where
pathLbl and selLbl pointed at the same label).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
itemKind(item) classifies each rubric item by what the grader will
actually see:
resp - written response only (response_name, no Code)
code - runs student Code and/or checks variables
code+resp - runs Code AND asks the grader to read a response
manual - grader must judge with no autograder or code
The tag prefixes the column headers ("[resp] Q1.1 Table of runs") and
every summary-panel line, and a legend row explains the tags. So a
grader can tell at a glance whether a question needs them to read
prose, look at code output, or both.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Shared helper: - vhgradeitemkind.m: extracted the classifier so both the overview and the grader GUI use the same rules. Removed the duplicate local copy from vhgradeoverview. Overview toolbar row 2 (new): - "Refresh all summaries" walks every student folder and rewrites GRADING/summary.txt via vhgradesummary, with a uiprogressdlg. - "Open selected student's summary" opens that student's summary.txt in the OS default text app (falls back to the MATLAB editor). - "Export cohort CSV…" writes a wide CSV: one row per student, one column per rubric item plus totals and percent, at a location the user picks with uiputfile. Values are csvEscape'd. Grader window: - vhgraderesponsegui: tag the window title bar and the big item header with the kind, e.g. "Grading [code+resp] Q3.1-3.4 Examining graphs / advise your friend", so the grader can tell at a glance what they're being asked to look at. Note on summary triggering: vhgradesummary was already re-run after every Grade-cell and each iteration of Grade-column; there was no button to regenerate it manually. The Refresh-all button covers the "I edited things outside the GUI" case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
The pre-GUI code-preview msgbox called text2cellstr(inputitem.CodeFiles{i})
with a bare filename. That used to work because vhgradequestion cd'd
into the student's subfolder up front — but after the sandbox rewrite,
the cd lives inside vhgradesandboxrun and pwd is restored before the
GUI section runs. Result: for every anyvartest item with a Code file
(e.g. Q2.2 q2.m), grading errored with "Could not open file q2.m".
Fix: build the full path from vhgradedirname + grade.Subfolder +
filename, check isfile first, and put a friendly placeholder in the
code window if the student didn't include the file at all.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
When the autograder ran and some checks failed, vhgradequestion set
inputitem_alt.Comment_1_default to grade.Comment_1, which is a cell
array of per-check summary lines. vhgraderesponsegui then wrapped it
with char(), producing a 2×N char matrix, and splitLines fed that to
regexp — which only accepts a single char row, a cellstr, or a
string array. That crashed the grader window on partial autograder
credit (e.g. Q2.2 when one variable was missing).
Fix:
* Don't wrap Comment_1_default / Comment_2_default with char() —
hand them to splitLines as-is.
* Teach splitLines to handle all five shapes (empty, char row,
char matrix, cellstr, string array).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
When the sandbox raises, the grader now sees the error in two places:
1. The Code window (msgbox) that opens alongside the grader now
starts with a "*** CODE ERROR ***" section listing the failure
before the source of the file(s). Grader sees the error immediately.
2. Comment 1 (the first free-text box in the grader) is prefilled
with the same message on its own lines instead of being flattened
into a semicolon-separated single line. Because Save concatenates
the free-text box into grade.Comment_1, the error naturally leads
the final saved comment unless the grader edits it out.
The formatter (formatCodeError) uses the exception message and the
first stack frame ("At: name (line N)") when available.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
vhgradeassignment used to addpath(genpath(studentDir)) around each
student, then rmpath, so any helper files the student wrote (or the
data files their code loads) were found. My overview callbacks
skipped that dance and called vhgradequestion directly, so student
scripts errored on missing helpers even though the files were in
their repo.
Fix: introduce withStudentOnPath(parentDir, studentDir), which
1. Removes every path entry under parentDir first (so an earlier
student's helpers cannot shadow this one's).
2. Adds genpath(studentDir) for the duration of the returned
onCleanup handle.
onGradeCell and onGradeColumn both use it around each grading call,
in a try/catch so the cleanup runs on error too. The path is
restored (student folder rmpath'd) as soon as grading of that
student finishes, so nothing leaks between students or after the
overview session ends.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
When student code errored AND the item had an autograder, Step 3 built the checks summary and overwrote inputitem_alt.Comment_1_default with it — clobbering the code-error prefill set by Step 1. As a result the Additional Comments box only showed the checks summary and the grader had to retype (or worse, screenshot) the error message they saw in the code preview window. Fix: compose the Comment 1 prefill from both pieces — the code-error message first (each line preserved), a blank line, then the checks summary. The error message now lands in the free-text comment box and flows into the saved grade.Comment_1 (and therefore into GRADING/summary.txt) unless the grader edits it out. Nothing new about the msgbox — the error was already at the top of the code window. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Grader window (vhgraderesponsegui): - New "Insert canned comment" dropdown + "Insert → Comment 2" button in the Additional-comments panel. Picking a bank entry and clicking Insert appends the FULL comment text to the second free-text box so the grader can tweak it before Save. Delta stays with the checkbox behavior; the insert is for customising the wording only. - New "Skip this student" button (closes window without saving, batch continues). - Renamed / colored "Cancel" as "Cancel (stop batch)" — it now sets a vhgrade_cancelBatch flag on groot and closes. The OS window-close (X) is routed through the same handler so an accidental close cannot silently move on to the next student. - Tooltips on every action button spelling out what each does. Batch runners honor the cancel flag: - vhgradeoverview.onGradeColumn checks the flag after each student and breaks with a uialert "Batch cancelled" toast. Resets the flag at start and end. - vhgradeassignment now also checks the flag after each item and after each student, prints a stopped-by-user message, and resets the flag at start and end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
vhgradequestion:
- Snapshots findall(groot,'Type','figure') at entry and installs an
onCleanup that closes only the newly-created figures on exit — via
close(h,'force'), then delete(h) as a fallback. So any plot the
student's code opened (or the msgbox code preview) is closed when
grading of that item finishes, on every path (autograder short
circuit, GUI save/cancel/error). Figures that existed before the
grade — including the vhgradeoverview dashboard uifigure — are
left alone.
vhgradeoverview.onGradeCell:
- Autograded_only cases no longer pop a uialert; they log a single
line to the command window in the form:
[vhgradeoverview] auto-saved (no UI needed) — <student> / <item>
: N of M
<comment_1 flattened to one line>
So batch grading through auto-passing students doesn't require
clicking an OK on every one.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
msgbox renders its text as a static label, so the grader could not select or copy the student's code. Replace it with a small uifigure holding a uitextarea in the platform's fixed-width font, Editable off. Editable off in uitextarea still permits selection and Cmd/Ctrl-C, so the grader can copy the code (or the error message at the top) into a message. Interface is the same as before — makeCodeWindow returns the fig handle, which vhgradequestion still delete()s after the grade saves. The figsBefore/onCleanup snapshot in vhgradequestion already covers this window if the grader ever leaves it lingering. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
vhgraderesponsegui.m uses the no-end-on-local-functions convention. I added `end` on the three new locals (onCancelBatch, insertCanned, ternaryStr). MATLAB rejects mixing the two conventions in one file: The function "ternaryStr" was closed with an 'end', but at least one other function definition was not. Remove the ends on those three locals so the file loads again. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
A long comment bank (item + shared entries) could overflow the fixed right-hand panel and the trailing entries were invisible with no way to reach them. Make both the Rubric and Comment-bank panels scroll: - Set Scrollable='on' on the enclosing uipanel and on its uigridlayout child. - Give each row RowHeight 'fit' so every checkbox takes its natural height (previously equal '1x' rows squeezed everything). Grader can now scroll the panels independently when a bank grows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Two related fixes for code items where the student's code doesn't run or produces wrong values: 1. needsGui: For a code item with weighted autograder checks and no rubric, a partial failure previously left grade.CodeError=0 and needsGui=false — so the grade was saved as partial credit silently and the grader never got to see the code or override it. Track autograderNotFullyPassed in Step 3 and include it in the needsGui expression. Now any autograder run that didn't cleanly pass opens the grader window. 2. Code window: was opened unconditionally whenever needsGui fired, even for pure response items (uifigure with just a space in it). Only open it when the item actually has code files or a code error to display — haveAnyCode gates the makeCodeWindow call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
CloseRequestFcn is wired to onCancelBatch so hitting the OS window X stops the batch (previously an accidental close moved on silently). But close(fig) ALSO invokes CloseRequestFcn, so every Save / Full credit / Missing / Skip call was routing through onCancelBatch too — grade was saved but the batch stopped after each student. Switch those handlers to delete(fig), which bypasses CloseRequestFcn. Cancel still calls onCancelBatch (which itself uses delete). The X button still fires CloseRequestFcn and stops the batch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Adds a WindowKeyPressFcn (onShortcut) that dispatches to the existing ButtonPushedFcn callbacks: ⌘/Ctrl-S → Save ⌘/Ctrl-F → Full credit ⌘/Ctrl-M → Missing / 0 ⌘/Ctrl-K → Skip this student Escape → Cancel (stop batch) Detects Cmd on macOS and Ctrl on Windows/Linux via the Modifier list provided by the key-press event. Button labels are platform-aware too — they render as "Save (⌘S)" on Mac and "Save (Ctrl+S)" elsewhere, so shortcuts are discoverable without opening a help. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
With seven-column-wide rubrics the Total column ends up far from the leftmost Student column, so reading which student a total belongs to requires scrolling back. Append a second Student column after Total, showing the same folder name, so a total is always next to a student name no matter how wide the table gets. liveSelection now recognises both Student columns and the Total column as "not an item cell" and gives an appropriate hint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
Item cell content is short — "N / M" or "—" — but 'auto' column width
in R2024b uifigure uitables measures the header string ("[code+resp]
Q3.1-3.4 Examining graphs / advise your friend") instead, blowing
each column out to ~250 px. With 7 items that pushes the trailing
Student column past the visible area and the user has to scroll
right to see it.
Pin each item column to a fixed 105 px so seven items + Student +
Total + Student all fit comfortably in the 1300 px window with room
to spare. Column labels wrap or truncate within the header cell,
which is fine because the assignment metadata pane at the top of the
window carries the full item names.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
…nfirm
Wraps vhgradepost2github behind a button on the overview's second
toolbar row (styled orange like Cancel). The push is intentionally
hard to trigger by accident:
1. Prompt (inputdlg) for the GitHub repository prefix — defaults to
"vanhooser-data-analysis-stats-workshop/", editable.
2. uiconfirm shows a warning-icon dry-run:
* Lists the git commands that will run in each student folder.
* Prints the example push URL that would go to the first
student, so the user can double-check the prefix.
* Options: Cancel (default) / Commit only (rehearsal) / Commit
& push. "Commit only" runs the add+commit path with push=0
so you can dry-run the commit before releasing.
While the loop runs, a uiprogressdlg tells the user the git output
is going to the Command Window. On completion a success alert
reports the number of student folders touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces a complete JSON-based rubric system for the vhlab grading toolbox, including a new interactive rubric editor, modernized grading UI with additive scoring and comment banks, and supporting infrastructure for loading/saving rubrics.
Key Changes
New Features
vhgraderubriceditor.m – Full-featured uifigure-based editor for JSON rubric files with:
vhgradeloadrubric.m – Parses JSON rubric files into struct arrays compatible with the grading pipeline, extracting:
vhgradesaverubric.m – Inverse of vhgradeloadrubric; writes struct arrays back to pretty-printed JSON
vhgradesandboxrun.m – Isolated code execution environment that runs student code in a function-scoped workspace, preventing pollution of the base workspace while capturing all created variables
UI Modernization
Core Grading Updates
Testing & CI
Implementation Details
https://claude.ai/code/session_01GdicP8VMU1mSYczRqZmAo1