refactor(panel): add a pure viewModel beside render #8
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/t5a-viewmodel"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Wave 4, first of two.
render()is 256 lines doing three jobs: deriving displayvalues from module state, deciding layout, and plumbing props. This PR extracts
the first job only, into a pure
viewModel(), and proves it agrees with whatrender()computes today. It does not changerender()— that swap is thenext PR, deliberately separated because it is where layout regressions hide.
Additive proof
Two hunks, both pure insertions, zero deletions. Function bodies extracted from
mainand from this branch and compared byte for byte:render()cb49497f54832becdayColumns()e5db7af47407e3a9activityRows()b7424253c42d3969recentButtons()2e0e8629102155c8barPeak()6604fef8dddc0f8dtagLabel()10af6912f94b5536passesFilter()d5a548161463d92conOpen()816c6d73ac83970aonIpc()9c6e7fd75a962c0eloadDay()5402ea6eaae8a4afsetFilter()cf121eac21a975dfgotoPage()c46d111e90c78d7efilterFromRow()8c7079f31c01003frefresh()81f79932057a0b99Stronger still: delete the two inserted hunks from this branch's
panel.luauand the result is
main's byte for byte — both hash1a14f5964482f6d1.Where it sits, and why not higher
The brief said "after the pure core, before the UI builders". It is one slot
lower than that: immediately after
barPeak, beforedayColumns.barPeakisa state derivation the model owns too, a Luau local is only in scope after its
definition, and moving
barPeakup would have made this a non-additive diff.Copying its six-line loop to satisfy a section boundary would have left two
spellings of it. So
recentButtonsandbarPeaksit above the new section;dayColumns,activityRowsandrender— the three builders that build whatthe model describes — sit below. Challenge this if you disagree; the
alternative is moving
barPeak, which costs the additive proof above.Field inventory
Every field, and what it replaces in
render.Clock / subject card
isActiveactive, which picks the clock colour, the glyph, the variant, the tooltip and the handler (1124, 1130-1136)clockTextif not active / elapsedText / Fmt.clock(os.time() - startEpoch)chain (1089-1092)subjectText+subjectIsKeytagLabel(tags)(1101) — key, not translated texttodayTotaltotal, the valuepanel.todaysubstitutes (1127)draft,draftKey,focusOncevalue,key = "tag-"..draftKey,focus(1104-1110)Search and filter
search,searchKeyvalueandkey = "search-"..searchKey(1221-1222)filterScope switch
scopemode, which tints one of the two scope buttons (1180)dayTitle+dayTitleIsKeyFmt.dayLabel(days[selected], selected)and thetr-or-not on 1069dayTotalTextif day then Fmt.span(day.seconds) else "0:00"(1070)allTotalTextallTotalscopeTotalTextif mode == "all" then allTotal else dayTotal(1195)Chart —
days[], oldest first (render order), pluschartPeakchartPeakbarPeak()(957)secondsday.seconds, the numerator ofmath.floor(BAR_H * day.seconds / peak)(960)fill,letterColorQuery.barRoles(mode, filter, day.seconds, isSelected)(963)isSelectedi == selected and mode == "day"(961)letterstring.sub(day.dow, 1, 1)(983)handler"onDay" .. i(972, 979)seconds+chartPeak, not a pixel height:BAR_His layout and stays inrender.List —
rows[], theQuery.windowslicewhenif mode == "all" then row.date .. " " .. row.at else row.at(1012)tagText+tagIsKeytagLabel(row.tag)(1031)durationrow.duration(1053)dotColourQuery.tagColor(row.tag, TAG_COLORS)(1025)tooltipTagpanel.filter_bysubstitutes;nilon the untagged row (1047)isFilterTargetfilterFromRow'srow.tag ~= UNTAGGEDguard (802)isFilteredselected(1038)handler"onRow" .. i(1026, 1048)isFilterTargetandisFilteredare two different questions and both are inthe model: an untagged row is neither; a tagged row is always a target and is
tinted only while it matches the filter.
Pager
pagesQuery.pageCount(matching, ROWS_MAX)(1238);pages > 1is the row's visibilitycurrentQuery.pageOf(listOffset, ROWS_MAX)(1240)pageBase,strip[]Query.pageStrip(pages, current, PAGE_BUTTONS)and thefor p = firstShown, lastShownloop (1251-1275); each entry is{ page, isCurrent, handler }firstShown,lastShown,totalpanel.page_range's three numbers (1293-1295)Chips
recent[] = { tag, handler }— the loop inrecentButtons(916-927). The tagis both the button text and what
panel.start_tagsubstitutes.Deliberate omissions
Named so a reviewer can challenge each one rather than hunt for it:
chipText, and the wholepanel.filter_chip/panel.trend_tag/panel.today/panel.filter_by/panel.start_tag/panel.page_rangefamily. These keys take substitutions, so an
isKeyflag buys nothing — thebuilder would need an args table too. The model carries the value (
filter,todayTotal,tooltipTag, a chip'stag, the three pager numbers) and thebuilder keeps the literal key.
Fmt.dayLabel's(value, isKey)contract isused only where it fits: substitution-free keys.
panel.title,panel.recent,panel.no_tags,panel.no_history,panel.nothing_day,panel.scope_all,panel.clear_filter,panel.search_placeholder,panel.tag_placeholder,panel.start/panel.stop,panel.newer/panel.older, and the‹/›literals. Nothing about them varies with state. The empty states are the
builder reading an empty
days/rows/recentlist.pager.visible. It ispages > 1, one comparison in the builder.BAR_H,RIGHT_W,LEAD_W,DUR_W,ROW_H,CHIP_H, gaps,alignment, which
ui.*node carries a value. Not derived from state.Renamed from the brief's sketch:
tooltip→tooltipTag(it is the tag, not asentence), and
todayText→todayTotalfor the same reason.Equivalence evidence
render()is untouched, so there is no second rendering to diff. Instead:drive the module into a state, take
viewModel(), read the treerender()built from that same state, and compare field by field, resolving keys through
timew/translations/en.jsonas a table.13 states, 1332 field comparisons, 0 mismatches. The states:
RECENT_MAXcapsubjectIsKeyis truelistOffsetpast the match count — the backwards pager rangeTwo fields are not on the tree, and both are accounted for, not waved past:
rows[i].isFilterTargethas no prop. It isfilterFromRow'sUNTAGGEDguard, and what the panel shows of it is the tooltip's presence. Pinned
against the tooltip in every state, and against the sentinel directly in state
8. Not a bug and not layout — a real derivation whose only visible
consequence is the tooltip.
days[i].isSelectedhas no prop either: it is one ofQuery.barRoles' fourinputs, and the two roles it decides (
fill,letterColor) are both on thetree. Asserted as
barRoles(scope, filter, seconds, isSelected)against themodel's own pair, and both
hasTagbranches are exercised (states 5 and 7).allTotalTextanddayTotalTextare each on the tree only in the scope thatshows them; the pick is asserted every state and both branches are taken.
The deferred pager bug is reproduced, not fixed
gotoPageclamps against thematchingof the last render. State 13 pages tothe end of a 20-row day, then lets a background refresh reload the same day with
12 rows —
loadDayrewinds the pager only on a real day change, solistOffsetstays at 16. The model reports what
renderdraws:Clamping in the model would have hidden it from the change that fixes it.
Contract asserts, and their mutation proof
New asserts beyond the field comparison:
noctalia,uiandpaneleach swapped for a table thatraises on any read for the duration of one
viewModel()call, sotr,runAsync,state.setandui.*are all covered by one thing. The trap isitself asserted to be able to fail.
rendereither side of them that must notmove — a mutation to any module local shows up as a different tree.
type(handler) == "string"on every row, day, page and chip.isKeytrue,tooltip == nil,isFilterTarget == false, with the tagged row beside it as the control.pager.pages/current/pageBase/#stripagainstQuery.pageCount,Query.pageOfandQuery.pageStripon the same inputs.grep -c 'getfenv\|setfenv' timew/panel.luau→0, and theviewModelbody greps clean ofnoctalia.,ui.,runAsync.Mutation battery: 61 mutations run. First pass of 25 found 23 live and 2
dead. Declaring both, and how they were closed:
subjectIsKey = falsepassedfalsebranchchartPeak = 3600passed1:00:002:31:07so no plausible constant matches the peakBoth were missing states, not bendable asserts — a state the harness never
reaches is the same blind spot as a missing assert, so the state list grew from
11 to 13 (the second gap also motivated state 5, day-scope-with-a-filter, which
turned out to be the only state that makes the model's window predicate live).
Second pass of 51 mutations plus 10 follow-ups: 61 live, 0 dead. A sample:
Each mutation was applied to a copy of the file, run, then restored from the
in-memory original and re-run; the suite is green after every restore and the
final file hashes identically to the pre-mutation source.
Gate
What a reviewer should challenge
barPeakrather than aboverecentButtons. Reasoned above; the alternative costs the additive proof.en.jsoncopied intotest.luau. The luau CLI ships noiolibrary(probed:
type(io)isnil), so the file cannot be read from the suite.The copy is kept whole rather than trimmed to the keys used so it diffs by
eye, and
trraises on a missing key — which is what caught two mutationsthat emitted a raw tag where a key was expected. It can still drift from the
shipped file; nothing here prevents that.
DAYS,ROWS_MAX,PAGE_BUTTONSas literals intest.luau. They aremodule locals with no export.
DAYS = 14is already pinned by theseq 0 13command literal;
ROWS_MAXandPAGE_BUTTONSare pinned by the pagerasserts — a different
ROWS_MAXgives a different page count for the same 45rows, and a different
PAGE_BUTTONSa different strip width on a 6-pagelist.
BAR_His not mirrored: it is read back off the tree aspadHeight + barHeight.focusOnce.onOpensets it and its ownrenderclears it, so thetruecase is not observable from outsideonOpen— by the time the modelcan be called it is already
false. The suite asserts exactly thatasymmetry (
onOpen's tree hasfocus == true, the model that follows saysfalse) rather than pretending to verify the branch. That write insiderenderis wave 5's problem.clockText'selapsedTextbranch is untested.elapsedTextis only setby a
noctalia.state.watchcallback and the suite'sstate.watchis ano-op, so the middle branch of the three never runs. The other two do.
os.time()insideviewModel. It is a clock read, not a mutation andnot a host call, and it is exactly what
renderdoes. The suite freezes itso the model and the render that follows cannot straddle a second.
viewModelrecomputes the window rather than readingshown/matching.It has to — writing them is
activityRows' job and the model writes nothing.Same source, same predicate, same cap, and states 4-13 compare the two
answers row for row.
Not merging; a reviewer comments first.
Verdict: APPROVE
The claim this PR makes is the one I tried hardest to break, and it holds.
viewModel()attimew/panel.luau:974reads module state and returns plain data: noui.*, nonoctalia.*, norunAsync, no assignment to a module local. Additivity is real —HEAD'spanel.luauminus the two inserted hunks is byte-identical tomain's, sorender()and every builder are untouched and the 155 new lines cannot regress a pixel. The fidelity spot-checks below all came back clean, and the one field I want removed is a duplicate, not a mistake.Merging this as-is is safe. The three findings are for the next PR, which is when purity stops being decorative and starts being load-bearing.
Findings
1.
viewModel()can write module state and both suites stay green — P1timew/panel.luau:1041Drop the
localand nothing fails.pageBasethen resolves to the module-levellocal pageBase = 1at:240— the variableonPage1()…onPage5()read at click time (:861-865) — andstripLastbecomes a true global:Both dead. It survives even
test.luau:1064—— which is aimed at exactly this and lands just short:
render()rewritespageBase = firstShownitself at:1455whenever it draws a pager, so the write is value-identical and the tree never moves. It survives idempotence for the same reason:pageBaseis recomputed frompages/currenton every call.Today the mutant is latent. It stops being latent the moment
render()consumes the model, because then "ask what the panel shows" and "decide where a click goes" become the same call, in an order nothing pins.Luau ships no
debug.getupvalue(debug.getupvalue: nil,debug.info: function), so thepageBasehalf is unreachable by reflection. ThestripLasthalf is not — it lands in the module envtest.luau:484already holds:pageBasereadsnilin both: a module-levellocalis an upvalue, not an env entry.The smallest assert that catches it, and the whole class of accidental globals with it. Placement matters: it must sit after the harness installs
panelEnv.os(test.luau:993) and before the firstopen(), becauseopenitself callsviewModel()attest.luau:1010.Insert after
test.luau:993:and at the end of the viewModel section:
Verified both ways against the real source:
This does not cover the
pageBasehalf. Nothing at Luau level can. The structural fix is for the next PR to makem.pager.pageBasethe only spelling and delete the module-levellocal pageBaseat:240outright, so there is no longer a variable forviewModelto clobber.2.
clockText's middle branch is never entered by the suite — P2timew/panel.luau:1058Replacing
elapsedTextwith"MUTANT"does not fail the suite:This is a missing state, not a missing assert.
elapsedTextis only ever non-empty via the watcher at:609-610, and the panel harness stubs the subscription out attest.luau:502:so the callback is never registered and
elapsedTextstays""across all thirteen states. The bar harness attest.luau:324already does the right thing (watch = function(_, fn)captures it). Capturing it on the panel side too is enough to kill the mutant:A fourteenth state that publishes an elapsed string and asserts
clockTexttakes it verbatim closes it. Worth doing: this is the branch that keeps the panel from drifting against the bar, and it is currently the onlyviewModelbranch no state reaches.3.
rows[].isFilterTargetis a second spelling oftooltipTag ~= nil— delete it — P2timew/panel.luau:1000-1001Two fields, one predicate, and the suite says so itself at
test.luau:945:That is the only structural assert it has — every other model field is pinned against a rendered prop, and this one is pinned against another model field, because it has no prop. The header comment concedes it: "the one field with no prop".
The stated consumer does not consume it.
filterFromRow(:800-806) reads module state directly:It takes an index and reads
shown, not a model row — and the next PR rewiresrender(), not the handlers, so it will still readshown. SoisFilterTargetis a field the next PR must carry, diff, and wire to nothing.isFilteredis a genuinely different question and stays: it is the tag button'sselectedprop at:1192(filter ~= "" and Timew.tagMatches(row.tag, filter)), which needs a filter to be on, whereas a tagged row is a target regardless. Confirmed both directions — a tagged row with no filter is a target but untinted; an untagged row with a filter active is neither. Neither field is the other in disguise. ButisFilterTargetistooltipTag ~= nilin disguise, andtooltipTagis the one with a prop.Delete
:1001, and foldtest.luau:1134-1135into thetooltipTagassertions that already sit beside them.Verified
Re-ran myself, against
5a5f035:cmp head_stripped.luau main_panel.luau— identical.render()and every builder untouched, zero deletions. (inherited artifact, re-ran thecmp)luau test.luau→viewModel: 13 states, 1332 field comparisons / ok, exit 0, in the real worktree.localat:1041: author's suite green, salvaged probe green, both quoted above. Then built and verified the assert that does catch it, firing on the mutant and silent on clean source.:1058→"MUTANT": author's suite green; probe with the elapsed value seeded fails withclockText -- "7:41" vs "MUTANT". Traced the cause to the no-opwatchstub attest.luau:502.clockTextchain.not active→"--:--"; else publishedelapsedText; elseFmt.clock(os.time() - startEpoch)— character-for-character whatrender()does at:1243-1246. The future-start-epoch clamp ismath.max(0, math.floor(seconds))insideFmt.clockitself, so a negative delta yields"0:00"in both and the model correctly does not re-clamp.days[].secondscarries seconds, not pixels.BAR_Hoccurs at:39(the constant),:1114,:1121,:1124— all insidedayColumns. Its only occurrence insideviewModel's range is the comment at:1021. The height arithmeticmath.max(2, math.floor(BAR_H * day.seconds / peak))stayed in the builder, and the test reconstructsBAR_Hoff the tree (pad.height + bar.height) rather than copying it — so the split is real, not asserted into existence.firstShown == 17,lastShown == 16,current == 3againstpages == 2.lastShown = listOffset + #rowswith an empty window gives16; nothing clamps. Matchesrender()'slast = listOffset + #shown. Reproduced, not fixed — correct for this PR.noctalia.trcall inrenderand the builders (18 of them). Each is either a constant key selected by a boolean the model already carries (panel.stop/panel.startbyisActive, the placeholders,panel.newer/panel.older,panel.no_tags/panel.no_history/panel.nothing_day) or a substitution whose value the model carries (panel.today←todayTotal,panel.filter_chip/panel.trend_tag←filter,panel.filter_by←tooltipTag,panel.start_tag←recent[].tag,panel.page_range←firstShown/lastShown/total).panel.trend's{days}is theDAYSconstant. No derivation is hiding in a builder.pager.visibleis genuinely absent rather than missing:pages > 1is the predicate, andtest.luau:955assertspages <= 1⇒ no pager row.barPeak. Correct, and I would not move it. A local is only in scope after its definition, so reachingbarPeak(:937) fromviewModelrequires sitting below it. The alternatives are worse: hoistingbarPeakedits an existing line and forfeits the zero-deletion property that makes this PR reviewable in the first place, and a forward declaration does the same. Inlining the peak loop would leave two spellings. The one residue is thatdayColumns:1110still callsbarPeak()itself while the model exposeschartPeak— the next PR should have the builder readm.chartPeakand drop the second call.pager.pages is Query.pageCount's,days[i].fill is Query.barRoles' answer); those can only fail if the model swapped helpers. Low value, but each is paired with a real tree comparison, so they are a redundant leg rather than a substitute. Not padding.getfenv/setfenvintimew/panel.luauandtimew/bar.luau. Everyhandleris a literal string, asserted per-family. Model carries no closures./tmp.git -C .worktrees/t5a-viewmodel status --porcelainis empty.Not checked
noctalia plugins lint timew— inherited from the prior review pass (0 errors, 0 warnings); not re-run here, to avoid touching the running shell.ui.*prop validation and the 25 ms per-handler budget are unmeasured forviewModel. It is a pure table build over at most 14 + 8 + 5 entries, so I do not expect a budget issue, but I did not time it.render()displays is missing from the model. The suite walks model fields outward to props, so a field the model forgot is invisible to it; I covered the translated strings exhaustively via thetraudit above and spot-checked the untranslated props (colours,selected,enabled,focus, keys), but this is the one direction neither the suite nor I checked mechanically. It is the failure mode the next PR will surface immediately.Review 45 actioned in
6e7e3f2. Findings 1 and 2 applied as written, finding 3taken — deleted, and the argument I made in the PR body was answering a
different question than the one you asked.
1.
viewModel()can write module state — appliedReproduced first, on
5a5f035, so the assert is not being written against amutant I only assume is live:
Your assert, at the placement you specified — key set snapshotted after the
panelEnv.osinstall and before the firstopen(), checked at the end of thesection.
:1041is:1042after the finding-3 deletion below.You are right that this covers only the global half. Recorded for T5b in
REFACTOR.mdunder## Deferred, in the terms you set:m.pager.pageBasebecomes the only spelling and the module-level
local pageBase = 1at:240goes, so there is no variable left to clobber. Not done here — it edits an
existing line and forfeits the zero-deletion property.
2.
clockText's middle branch is unreachable — appliedReproduced on
5a5f035:The panel harness now captures the callbacks the way the bar harness does. It
keys them, because the panel registers four subscriptions where the bar
registers one. State 14 publishes on
timew.elapsedText— which callsrender()from inside the watcher, so the tree the comparison reads is the onethe published value produced — and pins
clockTextto it.Same mutation, now:
Three more mutations, because a state that only kills the model half is worth
less than it looks:
M8 is why the two asserts I first wrote are one: the frozen clock puts the
fallback at
Fmt.clock(4600 - 1000)="1:00", and a published string thatcoincided with it would let the branch below pass for this one. M7 says the
capture is load-bearing rather than decorative — remove it and the state cannot
be written at all.
3.
rows[].isFilterTarget— deleted, you are rightThe PR body defended
isFilterTargetagainstisFiltered. You agree with thatone and were asking a different question, which the body does not answer:
isFilterTargetagainsttooltipTag ~= nil. It has no answer. The two are thesame predicate over the same value, in the same table constructor:
tooltipTag ~= nildiverges fromrow.tag ~= UNTAGGEDonly ifrow.tagcan benil, and it cannot.Parse.interval(:150-161) is the only producer of arow —
:374is its only caller, feeding bothdayRows(:673) andallRows(
:692) — its(.*)capture is a string whenever the match succeeds,:152returns
nilwhen it does not, and:159rewrites""toUNTAGGEDbeforeanything else sees it.
Parse.unquotereturnsstring.match(...) or s, so itcannot introduce a
nileither. There is no row whosetagis not a string, sothere is no state in which the two fields differ.
Your consumer argument holds independently:
filterFromRow(:800-805) takesan index and reads
shown, not a model row, and T5b rewiresrender()ratherthan the handlers. So it would have been a field to carry, diff and wire to
nothing.
Deleted, along with the structural assert at
test.luau:945that pinned itagainst
tooltipTag.test.luau:1134-1135folded into the twotooltipTagpins that already sat beside them — the predicates are unchanged, only the
messages now carry the filter-target meaning. Three comments were claiming
things that stopped being true and were rewritten: the "one answer, two fields"
note beside
tooltipTag, theisFilterednote that contrasted itself againstisFilterTarget, and the section header attest.luau:721-723announcing thatevery model field is asserted against a prop except this one. That exception
is now gone, so the header says the plain thing instead.
isFilteredstays, for the reason you gave.Gate
The one deletion outside
panel.luauistest.luau'swatch = function() end,which is finding 2's fix.
Additivity re-verified against the new HEAD, not inherited:
Still two insert-only hunks, still zero deletions, and the hash is the same
1a14f596the PR body claims. The new section is 155 lines rather than 154:finding 3 removed one field and its comment grew by two lines.
Field comparisons went 1332 → 1385, and both directions are real: state 14 adds
96, deleting the
isFilterTargetstructural assert costs one per row, 43 acrossthe fourteen states. The same suite with state 14 cut out reports
13 states, 1292 field comparisons, which is the 1332 minus those 40.All mutation ran in a throwaway copy under
/tmp, every restorecmp-cleanagainst the pre-mutation file.
Merging.