import { describe, expect, it } from "vitest"; import { boundNamesIn, complexityOffenders, lineCounts, MAX_COMPLEXITY, MAX_LINES, shippedFiles, } from "./sourceSize.testkit"; // The size / complexity RATCHET for the shipped frontend (E32, owner 2026-10-03 — "class size" from // the quality checklist, measured rather than argued). Two limits, each with today's offenders as a // CEILING: // • a file over 800 lines — the listed ones may not grow past their line count; any other // file may not cross the limit; // • a function over complexity 20 (eslint `complexity`, cyclomatic) — a listed one, by its // NAME, may not rise; no other function may cross the limit. (Ranked per file until the E32 // review: one function could grow by what another in the same file lost.) A rename reads as a // new offender plus a stale entry: move its row. // Shrinking is free; a file or function that drops UNDER the limit must be taken off its list (red // until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with // the reason in docs/gates.md. JSX branches count toward complexity, so a component scores high by // construction — the limit catches the outliers, it does not grade style. const BIG_FILES: Record = { "components/AdminUsers.tsx": 993, "components/Channels.tsx": 904, "components/Feed.tsx": 934, "components/Home.tsx": 1136, "components/IssueComposer.tsx": 861, "components/PlayerModal.tsx": 1142, "components/Playlists.tsx": 1069, "components/PlexPlayer.tsx": 1984, "components/PlexPlaylistView.tsx": 830, "components/plexViews.tsx": 941, "components/SettingsPanel.tsx": 885, "components/Welcome.tsx": 944, "lib/plexPlayerHooks.ts": 922, }; const COMPLEX: Record = { "App.tsx::App": 28, "components/AccountPanel.tsx::AccountPanel": 29, "components/AdminLogs.tsx::AdminLogs": 35, "components/ChannelPage.tsx::ChannelPage": 91, "components/Channels.tsx::Channels": 53, "components/ConfigPanel.tsx::ConfigPanel": 24, "components/ConfigPanel.tsx::Field": 28, "components/DownloadCenter.tsx::DownloadCenter": 36, "components/Feed.tsx::Feed": 64, "components/Home.tsx::Hero": 32, "components/Home.tsx::Home": 34, "components/Home.tsx::useHomeCardMenu": 21, "components/IssueComposer.tsx::ComposerBody": 34, "components/NotificationsPanel.tsx::NotificationRow": 28, "components/OnboardingWizard.tsx::OnboardingWizard": 21, "components/PersonalViewsModal.tsx::PersonalViewsModal": 22, "components/PlayerModal.tsx::PlayerModal": 98, "components/Playlists.tsx::Playlists": 66, "components/PlexBrowse.tsx::PlexBrowse": 45, "components/PlexCollectionView.tsx::PlexCollectionView": 41, "components/PlexDiscover.tsx::PlexDiscover": 21, "components/PlexInfo.tsx::PlexInfo": 65, "components/PlexPlayer.tsx::onKey": 22, "components/PlexPlayer.tsx::PlexPlayer": 103, "components/PlexPlaylistView.tsx::PlexPlaylistView": 22, "components/PlexSidebar.tsx::PlexSidebar": 32, "components/PlexStatsOverlay.tsx::sample": 25, "components/Scheduler.tsx::JobRow": 21, "components/Scheduler.tsx::Scheduler": 21, "components/SetupWizard.tsx::SetupWizard": 21, "components/SyncStatus.tsx::SyncStatus": 22, "components/VideoCard.tsx::VideoCard": 39, "components/VideoEditor.tsx::VideoEditor": 49, "components/WatchPage.tsx::WatchPage": 30, "components/Welcome.tsx::AuthCard": 39, "components/Welcome.tsx::onSubmit": 24, "components/auditColumns.tsx::localDetails": 24, "components/downloadRow.tsx::DownloadRow": 25, "components/plexCards.tsx::PlexPosterCard": 58, "components/plexViews.tsx::PlexPersonView": 30, "components/plexViews.tsx::PlexSeasonView": 21, "components/plexViews.tsx::PlexShowView": 50, "components/ui/AnchoredPopover.tsx::PopoverSurfaceInner": 22, "components/ui/Combobox.tsx::Combobox": 22, "components/ui/DataTable.tsx::DataTable": 42, "components/ui/PanelGroup.tsx::PanelGroup": 23, "components/ui/SidePanel.tsx::SidePanel": 45, "components/ui/SortControl.tsx::SortControl": 23, "components/ui/anchoredPopoverHooks.ts::useAnchoredPopover": 30, "components/usePlaylistActions.tsx::pushSteps": 23, "components/usePlexActiveFilters.ts::buildPlexActiveFilters": 25, "components/useVideoActions.tsx::items": 25, "lib/api/core.ts::localizeDetail": 48, "lib/api/core.ts::req": 22, "lib/api/plex.ts::appendPlexFilters": 26, "lib/diagnostics.ts::applyDiagnostics": 22, "lib/keymap.ts::dispatch": 28, "lib/logHooks.ts::controlAttrs": 21, "lib/plexDetail.ts::useDetailAside": 24, "lib/plexPlayerHooks.ts::usePlexTracks": 30, "lib/urlState.ts::filtersToParams": 24, "lib/urlState.ts::paramsToFilters": 26, "lib/useActiveFilters.ts::useActiveFilters": 25, }; describe("file size", () => { const lines = lineCounts(shippedFiles()); it(`no file crosses ${MAX_LINES} lines, and a listed one does not grow`, () => { const over = Object.entries(lines) .filter(([f, n]) => n > (BIG_FILES[f] ?? MAX_LINES)) .map(([f, n]) => `${f}: ${n} lines (ceiling ${BIG_FILES[f] ?? MAX_LINES})`); expect(over).toEqual([]); }); it("lists no file that is back under the limit (delete it)", () => { const back = Object.keys(BIG_FILES).filter((f) => (lines[f] ?? 0) <= MAX_LINES); expect(back).toEqual([]); }); }); describe("function complexity", async () => { const actual = await complexityOffenders(); it(`no function crosses ${MAX_COMPLEXITY}, and a listed one does not rise`, () => { const worse = Object.entries(actual) .filter(([fn, v]) => v > (COMPLEX[fn] ?? MAX_COMPLEXITY)) .map(([fn, v]) => `${fn}: ${v} (ceiling ${COMPLEX[fn] ?? MAX_COMPLEXITY})`); expect(worse).toEqual([]); }); it("lists no function that is back under the limit, or gone (delete its row)", () => { expect(Object.keys(COMPLEX).filter((fn) => !(fn in actual))).toEqual([]); }); }); describe("function names (the ratchet's keys)", () => { it("names an unnamed function by what it is bound to, through a wrapping call", () => { const code = [ "const onSave = useCallback(() => 1);", "useEffect(() => {});", "const o = { k: function () {} };", "const v = useMemo(() => g(() => 2));", ].join(" "); expect(boundNamesIn(code)).toEqual(["onSave", "useEffect callback", "k", "v", "g callback"]); }); });