refactor: tear down the courseware Redux slice - #2074
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2074 +/- ##
==========================================
- Coverage 93.78% 93.64% -0.14%
==========================================
Files 367 366 -1
Lines 6048 5917 -131
Branches 1433 1400 -33
==========================================
- Hits 5672 5541 -131
- Misses 359 360 +1
+ Partials 17 16 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a9f536d to
bdd7fe9
Compare
bdd7fe9 to
a4356bf
Compare
a4356bf to
5d07ae7
Compare
5d07ae7 to
ba2ba3a
Compare
ba2ba3a to
a918db1
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Pre-approved with a minor test concern.
| navigate, | ||
| isPreview: false, | ||
| }); | ||
| const expectedUrl = '/preview/course/courseId/section_1/unit_1'; |
There was a problem hiding this comment.
The waitFor calls in these async-path tests are never awaited, so their assertions never run - needs the same treatment as 65dbeb8.
There was a problem hiding this comment.
It appears this has been a problem for a while. I made an issue for it #2078, and then added a layer below this in the stack to address it #2079 so they can be reviewed independently. The changes made it into this layer in https://github.com/openedx/frontend-app-learning/compare/09a0e12f48e72b0707acb18e3dece0717da4da74..e88040a5c69c90e5b3fe6d5dff8fa190eaedbfdc
a918db1 to
7a1eca9
Compare
7a1eca9 to
b42ec75
Compare
b42ec75 to
3b47c69
Compare
3b47c69 to
0af5ec5
Compare
0af5ec5 to
84ef0a4
Compare
84ef0a4 to
395384d
Compare
395384d to
572be47
Compare
572be47 to
9d60d5f
Compare
4854a68 to
9e016fb
Compare
9e016fb to
0dbb28e
Compare
0dbb28e to
e45bd00
Compare
e45bd00 to
09a0e12
Compare
09a0e12 to
e88040a
Compare
|
Still approved. |
The slice's writers were the transitional statusBridge hooks and its readers were CoursewareContainer and TabPage; both convert here, so the bridge, the slice, and the reducer registration are deleted, along with the setupTest seeding, TabPage's transitional string branch (and its slice reads, the sliceError param, the StatusValue type, and the now-orphaned DENIED constant), and the dead Redux-era TabContainer wrapper. The container reads route params and the queries directly: statuses via useIsCourseLoaded and the sequence query, model lookups parameterized by the route ids with the selectors' exact null semantics, and the exported redirect helpers reshaped from positional status-string params to per-helper typed options objects — memoized with shallowEqual so they stay fire-once guards (their bodies unchanged beyond destructuring). The ids-match race guard is deleted with the race it defended against — which also fixes the latent stale-bail where an in-app navigate to /course/:courseId left the old sequence rendered and the resume redirect never ran. Outline policy is the container's own: on outline failure with access granted it renders the home redirect the bridge's denied produced; outline-pending needs no handling (children tolerate a not-yet-loaded outline), so page chrome can render for the tail of the outline fetch where the bridge showed a full-page spinner. TabPage passes through its first typed caller: CourseStatus's metadataQuery is now typed with RequestError, the two-slot object is the whole contract, and deriveView is unary. Closes #1976. Part of #1946. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thirteen redirect-rule tests in CoursewareContainer.test.jsx called waitFor without awaiting it, so their assertions ran after the test had already passed and any failure was dropped. Most also asserted on local jest.fn() mocks (getSequenceForUnitDeprecated, getResumeBlock) that were never wired to the code under test and so could never have been called. Await the waitFor calls, assert on the request the rule actually makes (axiosMock.history) instead of the unwired mocks, and correct the four expectations that never matched the rule: the unit-to-sequence redirect keeps the unit id and the /preview prefix; the resume "returns" case needs no fallback sequence id; the isPreview-false resume case has no /preview prefix; and the isPreview-false unit-to-sequence block mocked a response shape the api has not read since #803. Test-only. Pre-existing since #1501; surfaced in review of #2074, which moves these tests to redirects.test.ts. Closes #2078 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
e88040a to
8c3076e
Compare
Summary
The teardown proper — layer B (of six) of #1976 (plan), stacked on the B-prep 403-detail fix #2073. The
coursewareRedux slice, itsstatusBridgewriters, and its reducer registration delete;CoursewareContainerderives everything from route params and queries; the redirect machinery extracts whole tosrc/courseware/redirects.ts(ADR 0008's "liberal courseware path handling") as seven typed, memoized rules behind oneuseCoursewareRedirects()hook; TabPage's transitional string branch retires, leaving the generic two-slot query contract. Closes #1976.What changed
CoursewareContainer.tsxshrinks to its rendering job: route params, the three queries,useCoursewareRedirects(), model reads for the celebration handler, the save-position guard, and theTabPage/Coursetree. The ids-match race guard deletes with the race it defended against (fixing a latent stale-bail: after an in-app navigate to/course/:courseId, the slice kept the stalesequenceIdand the resume redirect never ran).src/courseware/redirects.ts(new): the six redirect rules move out of the container, drop their class-eracheckprefix, and take typed options objects (booleans instead of status strings) memoized withdefaultMemoize(fn, shallowEqual)to keep fire-once guard semantics; the outline-failure bounce (outline failed + access granted → course home) becomes the seventh rule instead of an inline<Navigate>. Their unit tests move toredirects.test.ts.TabPage.tsx: the string branch, the transitionalsliceErrorparam, and both slice reads delete together (as B-prep planned);CourseStatusis the two-slot query object only, now typed withRequestError.courseware/data/slice.js,courseware/data/statusBridge.ts(+ test),tab-page/TabContainer.jsx(+ test, export, stalejest.mock), thecoursewarereducer fromstore.tsand the test store, theStatusValuetype andDENIEDconstant — and the courseHome slice's deadfetchTabFailurereducer, whose sole exercisers were the deleted TabPage string-branch tests (flagged by the zero-tolerance codecov project check; no production dispatcher since refactor: convert the live tab from Redux to React Query #2006).getTestStoreIdshelper insetupTest.jsreplaces ~30 slice-convenience id reads across 19 suites;Sequence.test's load-failure case now states its failure with an explicit 500 mock instead of passing by accident.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (110 suites, 1111 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below (8 of 10 checks, including the full redirect matrix and the stale-bail fix). A running review log was kept alongside the decision doc during pre-submit review of this layer; its durable outcomes are folded into the decision log below.Decisions
Full decision log
Decisions — courseware Redux slice teardown (#1976, layer B)
CoursewareContainerconverts whole. Route params are the id source(
courseId/sequenceId/unitIdstraight fromuseParams); statuses comefrom
useIsCourseLoaded(courseId)and the sequence query(
isSuccess/isError+ thesequenceMightBeUnitpredicate); the fivecreateSelectors become plainuseSelectormodel lookups parameterized bythe route ids, preserving the exact null semantics (
?? null— storedreference or null, stable under default equality;
useModel's{}wouldbreak the helpers'
section &&guards).nextSequenceId/firstSequenceIdderive inline with the same guards, including thefaithful
indexOf === -1quirk of the oldnextSequenceSelector. Onedeliberate leniency: the old
firstSequenceIdSelectorcrashed if asection model was missing while loaded (impossible in practice — the
outline seeds sections); the inline version yields null instead. The
model store is untyped JS until Dissolve the model-store normalized cache #1977, so the three maps this file reads
are named once (
CoursewareModels+readModels) rather thanas any-ingat each read.
The redirect machinery moves to
src/courseware/redirects.ts—review-driven, in three rounds. Round one kept positional params with the
statuses reshaped to booleans; rejected as illegible (6–9 positional args,
now anonymous booleans). Round two gave each rule a typed options object
(a base
RedirectArgsBase+ per-rule extenders; the two section-resolvingrules share
SectionRedirectArgs, withsectionToSequenceRedirectaccepting-but-ignoring
isPreview— that rule has never preview-prefixedits navigations, a pre-existing gap in the A4 jumpnav family, untouched).
Round three extracted the subsystem whole: it is ADR 0008's "liberal
courseware path handling", so the module carries that name and story, the
six rules drop their class-era
checkprefix (resumeRedirect,sectionUnitToUnitRedirect, …) and keep their route-diagram commentsattached, and a self-contained
useCoursewareRedirects()hook owns theno-dep-array effect plus the redirect-only derivations
(
sectionViaSequenceId,firstSequenceId) — the container shrinks to itsrendering job and calls one hook. The stale "move this into the thunks
layer (TNL-8182)" comment dies (the thunks layer no longer exists).
readModels/CoursewareModelslive indata/modelReader.ts, shared bythe container and the hook (both die at Dissolve the model-store normalized cache #1977).
The load-bearing detail: the rules are fire-once guards invoked from a
no-dep-array effect, and
defaultMemoize's per-arg===never matches afresh object literal — a naive object arg would fire the guards every
render (an HTTP GET per render from
resumeRedirect, repeatednavigations). They are therefore memoized as
defaultMemoize(fn, shallowEqual): one-level field-wise===on the argsobject, exactly the comparison depth the positional args had, including
reference equality on the
sequence/sectionmodel objects — correctbecause RTK's immutable updates make reference-change ⇔ content-change
(an optimistic
activeUnitIndexwrite re-fires the sequence redirectrule, as it does today). Constraint for future edits: no args field may
be a per-render fresh literal (an inline array/object would quietly
defeat the guard) — stated once in the module header.
sequencestaysanyin the interfaces — the untyped model reality named byCoursewareModels— because its null-ness is guarded by the siblingisSequenceLoadedboolean, which TS cannot narrow across fields;sectiontypes honestly and narrows via its own&&guard. The rules'tests move to
redirects.test.ts; the TS move surfaced slice-erasequenceId: nulltest values (the runtime is route-paramundefined)and a
URLobject passed to a mock matcher where its.hrefwas meant.The ids-match race guard is deleted with the race. Statuses now derive
from queries keyed by the route ids, so the "redux ids lag the route"
condition it defended against cannot occur. This also fixes the latent
stale-bail: after an in-app navigate to
/course/:courseId(theinvalid-sequence fallbacks), the slice kept the stale
sequenceId, theguard bailed forever, and the resume redirect never ran; with
route-derived ids the redirect checks run and the page self-heals.
Outline policy lives with the redirect rules, not TabPage (the B-prep
decision, landed here; review moved it from an inline container block into
redirects.tsas the seventh rule,outlineFailureRedirect). TabPagereceives the standard two-slot shape — identical to CourseExit's:
{ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery }. Onoutline failure with access granted, the rule navigates to
/course/:courseId/home— the destination the bridge'sdeniedproducedthrough
getAccessDeniedRedirectUrl's default branch; thehasAccessgate keeps a real access denial's specific redirect (TabPage's) winning.
A 403 outline means no access, which courseHomeMeta also reports, so the
TabPage denied path covers it. Timing: as an effect-time rule (like its
six siblings) rather than a render-time
<Navigate>, the outline-erroredge can paint one frame of chrome before redirecting; the bridge-era
DENIED redirected pre-paint. Outline-pending needs no handling post-A3:
everything under
Coursetolerates a not-yet-loaded outline(
useSequenceIds→[], nav defaults,Sequence's spinner rides theindependent sequence query). Behavior delta: page chrome can render for
the tail of the outline fetch where the bridge showed a full-page spinner.
TabPage's transitional era ends. The string branch, the
sliceErrorparam, the fenced slice-reads block, and the
react-redux/RootState/constants imports all delete together;
deriveViewis unary andCourseStatusis the two-slot object only. ItsmetadataQuerymember isnow typed with
RequestErroras the error type — the container is thefirst typed caller (every tab is
.jsx), which surfaced that thedefault
ErrorTError rejecteduseCourseHomeMeta's actual type.TabContainerdeletes (+ its test, itsindex.jsexport, and a stalejest.mockinsrc/index.test.jsx) — the Redux-era generic tab wrapper(dispatches a
fetchthunk prop, readsstate[slice]string status),renderer-less since the tab conversions. The
index.test.jsxmock datedfrom when
index.jsxwrapped tab routes inTabContaineritself; the tabconversions moved TabPage usage inside the tab components (all mocked in
that suite), so the mock stubbed a module nothing in the tested tree
imports — and with
TabContainergone it would have declaredtab-page'sshape as an export that no longer exists. Found in the pre-B audit as the
only other string-status passer; verified not a plugin surface (internal
module, no docs references). The
StatusValuetype and theDENIEDconstant delete from
constants.tswith their last consumer (TabPage'sstring branch — found in review);
LOADING/LOADED/FAILEDstay withreal consumers outside the retired vocabulary (
course-exit/track.jsusestwo as analytics labels,
preferences-unsubscribeas local componentstate).
The slice infrastructure deletes:
statusBridge.ts(+ its test — thematrix was ported to hook/TabPage tests in A2/A3/B-prep),
slice.js, thecoursewarereducer fromstore.tsand fromsetupTest.js's test store,the
fetchCourseSuccess/fetchSequenceSuccessseeding dispatches (modelseeding stays), the
courseware/data/index.jsreducer export, andCourseExit's
useCourseExitStatusBridgecall (its last two jobs —CourseRecommendations' id, TabPage's message — were retired in A1 and
B-prep).
apiHooks.ts's mutation-vars types move from| null("fromthe still-untyped Redux slice") to
| undefined, includinguseSaveSequencePosition's internalsetPosition: the ids are routeparams now, typed
string | undefined. Deliberately| undefinedratherthan
?— the callers always pass these arguments (possibly holdingundefined), and
?would additionally let a caller omit one entirelywithout a type error.
The courseHome slice's
fetchTabFailurereducer deletes too — flaggedby the codecov project check (this repo's zero-tolerance
threshold: 0%):the reducer's sole exercisers were TabPage's deleted string-branch tests,
which hand-dispatched
course-home/fetchTabFailureto seed the message.In production it has been dead since refactor: convert the live tab from Redux to React Query #2006 deleted the shared
fetchTabthunk, its last dispatcher (the fact B-prep's 403-detail fix rests on);
nothing imports it, nothing dispatches its string type, and it is not
re-exported from
course-home/data/index.js. Same posture as the live-tablayer's orphaned-reducer deletion: remove the dead code the test deletion
orphaned, don't pad coverage. The rest of the courseHome slice (its
now-reader-less
errorMessage/errorCode/courseStatusfields) staysfor Reduce the Redux store to the external specialExams reducer #1978, the slice's own teardown.
Tests.
The redirect-helper unit tests transform positionally to booleans
(
'loaded'→true,'loading'→false,'failed'→truein thefailed slot), with titles renamed to boolean phrasing; the container's
integration suite (real store, bridged client, real routes) passed
unchanged — the strongest evidence the rewrite is faithful.
TabPage's five string-branch cases delete;
mockData.courseStatusisquery-shaped. Removed test → coverage now:
courseStatus="loading"shows the spinnercourseStatus="other"shows the generic errorCourseStatusis the two-slot object), the fall-through-to-error behavior is what the queryisErrorcases assertcourse-home/fetchTabFailuredispatchfetchTabFailuresince the tab conversions; the test kept it alive by hand-dispatching); B-prep'sgetErrorDetailcarries the learner-facing message off the query error insteadcourseware/fetchCourseFailuredispatchSequence.test's load-failure case now mocks an explicit 500 for itssequence URL — it had been passing by accident since A2 (the dead
fetchSequenceFailuredispatch did nothing; the failure came from theunmatched GET hitting
logUnhandledRequests'200 {}fallback, whichthrows in
normalizeSequenceMetadata).Convenience-id sweep: ~15 suites read
state.courseware/getState().coursewarepurely to learn ids; all re-source via agetTestStoreIds(store)helper insetupTest.jsreturning{ courseId, sequenceId }as the first keys of themodels.coursewareMeta/models.sequencesmaps (key order followsoutline insertion, equal to the slice's value for these single-sequence
factories — stated once at the helper instead of implied at every call
site). Process
note for the record: three successive greps under-counted these (a
trailing-dot pattern, a path filter that excluded
src/courseware/'s ownfiles, and a
head-truncated list) — the definitive inventory came fromthe type checker plus the failing suites, which is what a teardown's
compile/test wall is for.
Behavior deltas: the outline-pending chrome tail (4); the stale-bail
fix (3); TabPage surfaces courseware errors as soon as the relevant query
fails instead of after all three settle (transient ordering, same terminal
state); the standard mount-refetch posture for the container's
subscriptions. Otherwise faithful: same redirect rules on the same inputs,
same denied/failed routing, same messages (via B-prep's
getErrorDetail).Closes #1976.
arbrandes). The 13 async-rule tests moved into
redirects.test.tshadnever awaited their
waitForcalls, so their assertions were vacuous, andmost asserted on
jest.fn()mocks nothing ever called — pre-existing sincefeat: add functionality to see unit draft preview #1501 (2024). Rather than fold a test rewrite into this move, the fix is its
own layer under this one (test: make the courseware redirect-rule tests actually assert #2079, closes Make the courseware redirect-rule tests actually assert: un-awaited
waitForand unwired mocks from #1501 #2078):CoursewareContainer.test.jsxis corrected there, and this PR's move carries the corrected tests across.
Net effect on this PR's diff:
redirects.test.tsnow differs from itsprevious version by the same corrections (awaits, request assertions in
place of unwired mocks, four expectations that never matched the rule);
the rule code is untouched. Rationale for each correction is in test: make the courseware redirect-rule tests actually assert #2079's
decision log.
Manual testing
Manual testing — courseware Redux slice teardown (#1976, layer B)
In-browser verification for the teardown, against tutor local
(
http://apps.local.openedx.io:2000/learning, DemoX). This is the plan's fullB smoke matrix: the container now derives everything from route params and
queries, the redirect rules run on booleans, and the
coursewarereducer isgone from the store.
Verify by hand
console errors; Redux DevTools (if open) shows no
coursewareslice./course/{courseId}: lands on thelast-active (or first) unit.
/course/{courseId}/{sectionId}: redirects tothe section's first sequence, then its unit.
/course/{courseId}/{unitId}:spinner, then lands on
/course/{courseId}/{sequenceId}/{unitId}(the 422→ parent-lookup path).
/course/{courseId}/{garbage-id}:bounces to the course root and resumes (this exercises the stale-bail fix —
the old code could hang here after in-app navigation).
/firstand/lastmarkers —/course/{courseId}/{sequenceId}/firstand/lastland on the first/lastunit.
save_unit_positionenabled (or just verify no errors on unit navigation).course-home tabs and courseware redirect per the access rules.
/preview/course/...loads; redirectskeep the
/previewprefix (resume, section, first/last).Results
8 of 10 checks pass against tutor local (DemoX), including the full redirect
matrix (resume, section, unit-as-sequence, invalid-id stale-bail fix,
/first//last, preview prefix). Not exercised: sequence-position save(needs
save_unit_positionenabled on a sequence) and denied learner(needs a restricted-access account/course).
🤖 Generated with Claude Code