RFC: Unit Tests #1458
Replies: 1 comment
Reply to RFC: Unit Tests open questions1. CI placement: 2. Determinism: Assert structurally and ignore 3. Phase 3 |
Reply to RFC: Unit Tests open questions1. CI placement: 2. Determinism: Assert structurally and ignore 3. Phase 3 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Unit Tests RFC
Summary
Shoko-WebUI has zero unit test infrastructure today: no runner, no
*.test.*files, notestscript inpackage.json, and no CI coverage gate. This RFC proposes adding a minimal, focused test layer aimed exclusively at regression protection for the modules that carry real, low-visibility logic. Tests exist to stop these complex things from breaking — never for coverage metrics; no coverage tooling or threshold will ever be introduced:nodeenvironment for pure-function tests,happy-domonly for Phase 3 effect tests.src/core/utilities/filterTree.ts(filter-tree parse/build round-trip),src/core/utilities/auto-match-logic.ts+auto-match-regexes.ts(the filename-parsing engine and its regex rule set, includingCrc32Regex), and — as Phase 3 — the effect orchestration ofuseLinkWorkflow,useReleaseInfoForm,LinkFilesWithProviders.tsx, andRenamer.tsx.CleanDescriptionregexes, and the deprecatedLinkFilesTab.tsx. These are either trivial one-liners or coupled to the Redux/axios/queryClient graph, and add maintenance cost without proportionate risk coverage. If they ever justify testing, they can be added incrementally under the same harness.Motivation
Two modules concentrate the highest-risk, lowest-visibility logic in the codebase:
src/core/utilities/filterTree.tsis the round-trip boundary between the server'sFilterConditionshape (used byCollection.tsx, theFilterSidebar, and saved presets) and the editableGroupNode/LeafNodetree held in Redux state. It is genuinely non-trivial: recursive parse/build,Notnegation folding, same-expression sibling merging, unsupported-node preservation, and root normalization. A subtle break silently corrupts saved presets or the live collection query.src/core/utilities/auto-match-logic.ts+auto-match-regexes.tsis a full filename-parsing engine (detectShow) driving a 13-rule regex set (PathMatchRuleSet) with named capture groups and atransform/validatepost-processing pipeline (defaultTransform): drive-letter detection, episode-range parsing (E1-12), reversed-range swapping, season/version/crc32 extraction, show-name/release-group inheritance across parent/grandparent directories, and anull-vs-falsetransform control-flow contract.findMostCommonShowName(frequency counting + longest-common-prefix fallback) is the second substantive function. A subtle break here silently mislinks files to the wrong episodes/shows.The neighboring
filter.ts(buildFilter) is a 7-line recursion that semantically duplicatesbuildGroupChaininsidefilterTree.ts— it fails our own triviality filter and is out of scope.useLinkWorkflow,useReleaseInfoForm,LinkFilesWithProviders.tsx, andRenamer.tsx. These components host subtleuseEffect/useEffectEventlogic whose correctness depends on when effects fire, not just what their pure helpers return: dependency arrays that deliberately omit values, one-time init guards (initialClear,settingsRevision), debounced side-effects, and in-flight state-machine bookkeeping. A refactor that silently changes a dependency array or trigger condition is exactly the class of regression thatpnpm lint/tscheckcannot catch.Today a refactor to any of these has no safety net —
pnpm lint(dprint + oxlint + stylelint) andpnpm tscheckcatch syntax/types only, not behavior. Regressions are found by manual clicking.Design
1. Tooling (minimal footprint)
vitest). No@vitest/coverage-v8. Phases 1–2 run in a purenodeenvironment with zero mocking (both in-scope modules import only types, plus lodashevery/reduceinauto-match-logic.ts).@testing-library/react(renderHook+act),happy-dom,vi.useFakeTimers, and boundary mocks forqueryClient/ Redux store /toast.vitest.config.ts(or atestblock invite.config.mjs) with a singleresolve.aliasentry for@/→src/. Defaultenvironment: 'node'.test→vitest runtest:watch→vitestpnpm teststep to the existingLint-PR.ymlworkflow (decided — see Open questions: no new workflow, no extra install step sincepnpm/setupalready runspnpm installwhenpackage.jsonis present). No coverage tooling or threshold — tests are regression guards only, never about coverage.2. In scope, precisely
A.
filterTree.ts. Tests assert returned values for representative inputs, including edge cases already observed in the wild:getWidgetKind—tag/multiPair/multi(Parameter === 'Number') /booleanselection.createLeafNode/createEmptyGroupNode— shape per widget kind (assert structurally, ignoreid).parseFilterTree:undefined→nullAndAndNot(And(...))→ groupnegatetoggle (rather than an explicitNotwrapper)Not(atomic)→ negation absorbed per widget kind (boolean value inversion; multi/multiPairnegateflag; tagisExcluded)Left/Rightslots →unsupportednode, raw condition preserved byte-for-bytemergeGroupChildren(HasTag(a) And HasTag(b)→ single tag leaf with two tags)List<string>parameters (e.g.VideoExtensions) — leafParameterunwrappingbuildFilterTree:null/ empty-children group →undefinedparseFilterTree → buildFilterTreeround-trip equals the input for representative presetsNot(Left: ...)matchoperator (And vs Or) → correct chain nesting shapefindNodeById/findGroupById/removeNodeById— root vs nested lookup/removal, missing-id behavior.B.
auto-match-logic.ts+auto-match-regexes.ts. Tests drivedetectShow/findMostCommonShowNamethrough representative filename inputs, covering every rule inPathMatchRuleSetand every helper regex indefaultTransform:Crc32Regex(exported, tested directly) — 8-hex-digit capture with surrounding non-alphanumeric boundaries; match / no-match / case-insensitivity; embedded in a filename viadefaultTransform(crc32field extraction).anti-timestamp— timestamp-formatted filename → invalidated (detectShowreturnsnull)raws-1/raws-2/raws-3— space/dot/dash-separated raw release naming; resolution + release-group extractiontrash-anime—Show (Year) - S01E01 - 01 - EpisodeName [hash]shape;isSpecial(S00) handlingtrailing-native-title— CR-style releases with a native title trailing after the codecdefault/fallback— the general-case rules;isMovie/isMovie2(gekijouban,... the movie),part I/II, year capture, multi-episode ranges, version suffixforeign-1—Show - 1 「Title」 (sub)brackets-1/brackets-2/brackets-3—[Group][Show][Year][Episode]bracket shapesreversed-1—05 - Show NamedefaultTransformpost-processing:E1-12→12-1)Special(episodeStart/episodeEnd→0)detectEpisodeTypeprecedence (isSpecial→isThemeSong→isOther→isTrailer→Episode)OP/ED/NC), trailer (CM/PV/trailer), extra (menu/web preview) detectionS\d+season extraction, roman-numeral season (IV), year →(Year)parenthesization,TV/- TV/TVsuffix normalization,S2fold-in when no year//hack,TrimShowNameRegex/ReStitchRegexmovie-name re-stitchingepisodeStart/episodeEnd→1)DriveLetterRegex— drives the path-segment clearing indetectShow(trailing-newline / segment behavior).findMostCommonShowName— most-frequent name; all-once → longest-common-prefix viafindSharedShowName; prefix fallback to first name; empty/nullinputs.C. Effect orchestration (Phase 3). Tests render a hook/component with
renderHookand drive its dependencies withactto assert when effects fire — run counts, trigger conditions, dependency-array correctness, ordering, and the resulting dispatch/setLinkspayloads. Allowed assertions are behavioral (not UI):toHaveBeenCalledTimes/toHaveBeenCalledWithon mockeddispatch,waitForstate convergence, and explicit "did not re-fire on X change" negative assertions. No DOM text/structure queries, no render-output assertions.useLinkWorkflow(src/hooks/utilities/useLinkWorkflow.ts) — thesearching/submitting/fetchingstate machine:linksiteration dispatch, in-flightSetdedup (no double-fire for an already-in-flight link), transition payloads on.then/.catch, and unmount cleanup clearing the in-flight sets.useReleaseInfoForm(src/hooks/utilities/useReleaseInfoForm.ts) —initFormfires exactly whenshowflips true (not on every render);isBulk-gatedhasDifferentderivation (allSameper field);initialSeriesNameviafindMostCommonShowName.LinkFilesWithProviders.tsx—initializeLinksgates onisSettingsLoaded(settingsRevision > 0) and runscreateLinksFromFilesonce beforesetInitialized(true); thelinks.length === 0 && initialized→ navigate guard; andhandleSaveReleaseInfo's auto-match / range-fill episode assignment (specials counter,AUTO_MATCH_EPISODE_ID/RANGE_FILL_EPISODE_IDpaths).Renamer.tsx— the debounced-config effect:clearResultsfires exactly once per debounced config change and zero times on initial mount (theinitialClearskip); the preset-change effect: fires onrelocationPresetsQuery.data/isSuccess/settingschanges but not when only the presetNamechanges. (Confirmed — see Open questions:changeSelectedPresetEventis a stableuseEffectEventdeliberately omitted from deps, so testing when the effect fires on its real dependencies is the intended target.)3. Explicitly out of scope (never required)
Per the RFC brief, reviewers should reject scope creep into:
disabled/enabledstate, text matching, snapshots, visual regression. This includes Phase 3: tests assert dispatched payloads and run counts, never rendered output or DOM text.filter.ts(buildFilter),util.tstime/parsing helpers,anidbUtils.tslink builders,getEpisodePrefix.ts,getEd2kLink.ts,releaseInfoHelpers.ts,releaseManagementHelpers.ts,CleanDescription.tsx(its BBCode/cruft-stripping regexes are deliberately excluded and remain untested), and the deprecatedLinkFilesTab.tsx(parseLinks,autoFill,makeLinks).4. Conventions
src, in a top-leveltests/tree mirroringsrc/paths (tests/core/utilities/filterTree.test.ts,tests/core/utilities/auto-match-logic.test.ts,tests/hooks/utilities/useLinkWorkflow.test.ts,tests/pages/utilities/Renamer.test.ts). No colocated*.test.*files, no__tests__dirs insidesrc.describe('<fn>', ...)/it('...', ...); one file per module under test.queryClient/@/core/store/toast; never mock the module under test. If effect logic can't be tested without mocking its own internals, that's a signal the effect is over-coupled and should be simplified or its derived data extracted.idfields) to avoid flaky assertions-by-id (decided — see Open questions). Do not stubcrypto.randomUUID: that couples tests togenerateNodeId's implementation rather than its contract and breaks silently if the mechanism changes. Usevi.useFakeTimers()for debounced effects.@/alias imports, arrow functions,typeoverinterface.tests/to be treated as first-class code:tsconfig.jsoncurrently has"include": ["./src/"]and"rootDir": "./src/". Add a dedicated test tsconfig (or extend include and droprootDir) with the@/alias sopnpm tschecktype-checkstests/..oxlintrc.jsonhas"ignorePatterns": ["*", "!src/", "!src/**"]. Extend to include!tests/and!tests/**sopnpm lintlints test files..dprint.jsonincludesis"src/**". Extend totests/**.lint-staged.config.jsglobs (*.{ts,tsx}) are path-agnostic and will matchtests/**automatically once the oxlint/dprint scopes above are fixed.5. Rollout (phased, low-risk)
vitestdevDep,vitest.config.ts+@/alias,testscripts, CI step, and the tsconfig/oxlint/dprint scope extensions. Zero behavior change. Verifiable bypnpm testpassing on an empty suite plus one smoke test (e.g.parseFilterTree(undefined, [])→null).filterTree.ts: cover the round-trip, folding, negation, and navigation cases listed in §2A. This is the primary success criterion.auto-match-logic.ts+auto-match-regexes.ts: cover the per-rule filename parsing,defaultTransformpost-processing, andfindMostCommonShowNamecases in §2B. Needed before any refactor of the auto-match engine.useLinkWorkflow,useReleaseInfoForm,LinkFilesWithProviders.tsx, andRenamer.tsx(in that order — cheapest mock surface first). Start withuseLinkWorkflowto prove the harness before expanding; this is where over-mocking risk lives, so stop and retro after the first two. Tooling deferred: the exact harness (@testing-library/react+happy-dom+vi.useFakeTimers, withqueryClient/store/toastmocked at the boundary) is deferred until Phases 1–2 prove out; finalize it when Phase 3 is actually started.FormSchemaType/$refresolution /x-uiDefinition) will change completely. Testing the currentresolveSchemaRefs/Dynamic*renderer would pin a contract that is about to be discarded. Scope and targets will be defined once the new server schema lands.Open questions (resolved per hidden4003's reply)
Lint-PR.ymlvs. a newTest-PR.ymlworkflow? — Decided: extendLint-PR.yml.pnpm/setup@v1already runspnpm installwhenpackage.jsonis present, so no extra install step is needed; this reuses the same trigger and setup.crypto.randomUUIDvs. assert-structurally-ignoring-id? — Decided: structural assertion, as proposed. Stubbing couples tests togenerateNodeId's implementation instead of its contract and breaks silently if the mechanism ever changes.React Compilerinteraction:useEffectEventcallbacks (e.g.changeSelectedPresetEvent) are stable by design and omitted from dependency arrays — confirmed that testing effect behavior (not effect re-runs caused by handler identity) is the intended assertion target.Renamer.tsx's preset-change effect deliberately omitschangeSelectedPresetEventfrom its deps (with an existingoxlint-disablecomment), so there is no reactive identity to test.Alternatives considered
CleanDescriptionregexes,buildFilter): rejected as over-broad — most candidates are trivial one-liners or coupled to Redux/axios, adding maintenance cost without proportionate risk.LinkFilesTab.tsx(parseLinks/autoFill/makeLinks): excluded — the file is deprecated and superseded byLinkFilesWithProviders.tsx. No tests will be written for it.resolveSchemaRefs,Dynamic*components) right now: not testable — the schema contract is being re-written server-side and will change completely. Revisit as Phase 4 once the new contract lands.All reactions