A harder one, and still finishable on a clean clone. No Docker, no API key, no AI service. This is a step up from a straightforward coverage ticket: the difficulty is not writing assertions, it is deciding what the right answer is before you write them. If you have a coding agent, use it — and check its arithmetic, because this is a surface where a confident wrong expectation looks exactly like a passing test.
Please read the orientation section near the bottom before you start.
Context
Neo.collection.Base is what every grid, list and store sorts through, and it sorts via Neo.collection.Sorter. Measured 2026-09-19 at dev@3ab2e91ab5: no file under test/playwright/ imports src/collection/Sorter.mjs.
It is not untested code — five specs under test/playwright/unit/collection/ exercise it indirectly through sorters configs, and SortNull.spec.mjs already pins one narrow behaviour. What is missing is the part those specs do not reach: what happens when two sorters disagree, and what happens when the values are not the same type. Please describe the gap that way in your PR; "Sorter is untested" would be wrong.
The Problem
A single-key sort is obvious and nobody gets it wrong. The two things that are easy to get wrong, and currently unpinned:
Precedence. With two sorters — say lastName ascending and then age descending — the second must only decide ties in the first. An implementation that applies them in the wrong order, or that lets the second overrule the first, produces output that looks sorted at a glance and is wrong in the middle. No test would notice today.
Mixed and absent values. defaultSortBy (src/collection/Sorter.mjs:116) and transformValue (:172) decide how a null, an undefined, a number next to a numeric string, and differing letter cases compare. SortNull.spec.mjs pins part of this for one shape; the general rules are undocumented and unasserted.
This is where the ticket gets hard, and it is the point. There is no specification to look up. You will have to decide what the correct behaviour is, and then assert what the source actually does — flagging any place the two disagree instead of quietly encoding the source's answer as if it were intended. That distinction is the real deliverable.
The Architectural Reality
src/collection/Sorter.mjs — class Neo.collection.Sorter, extends Neo.core.Base:
direction_ (:49), default 'ASC', with directionMultiplier (:43) flipping the comparator's sign through afterSetDirection (:83).
property_ (:61), default 'id'.
sortBy (:67) — a custom comparator that takes precedence over property + direction.
useTransformValue (:73) and transformValue (:172) — the normalisation applied before comparing. Read this before assuming anything about case or numeric strings.
defaultSortBy(a, b) (:116) — the comparator used when no sortBy is given.
Multiple sorters are applied by Neo.collection.Base; read how it composes them before asserting precedence, because that is where the ordering actually lives.
The Fix
Add test/playwright/unit/collection/SorterPrecedence.spec.mjs.
Copy the header shape from test/playwright/unit/collection/SortNull.spec.mjs — the setup() call comes before the other imports, and a collection is created with Neo.create(Collection, {...}).
What to assert:
- Two sorters, and the second only breaks ties in the first. Construct items where the naive wrong answer differs visibly from the right one — several records sharing a
lastName with different ages — so the assertion fails loudly if precedence inverts.
- Three sorters, to prove the rule composes rather than being a two-key special case.
direction: 'DESC' on the second sorter only, so a mixed-direction multi-key sort is pinned.
sortBy beats property + direction when both are supplied.
transformValue's actual normalisation: read :172, then assert what it does with case and with a numeric string beside a number. Do not assume it lowercases, and do not assume it coerces.
null and undefined against the existing SortNull.spec.mjs — extend to where they land in a multi-sorter sort, which that spec does not cover. Reason about it rather than copying its expectations.
If you conclude the source's behaviour is wrong, say so in the PR body with your reasoning and assert the current behaviour anyway, with a comment. Do not change src/. A well-argued "this looks wrong and here is why" is the most valuable thing this ticket can produce — the contributor on #19004 did exactly that and it turned out the behaviour was correct for a reason nobody had written down. Either outcome is a win; guessing silently is not.
How to run it
npm install
npm run test-unit -- test/playwright/unit/collection/
npm run test-unit needs no browser at all. Do not run a bare npx playwright test — this repository has several Playwright configurations and the default one is not the unit tier.
Acceptance Criteria
Out of Scope
Neo.collection.Filter, which is #19003's subject.
- Changing any comparator or normalisation. This ticket pins current behaviour and flags disagreements in prose.
#6921's architectural question about how collections hold their filters and sorters. That is a separate, older conversation and this ticket must not pre-empt it.
Avoided Traps
- Data where the wrong answer looks the same as the right one. If every
lastName is unique, a precedence bug is invisible. Build ties on purpose.
- Copying
SortNull.spec.mjs's expectations into a multi-sorter case. Its answers were derived for a single-sorter shape; whether they hold with a second key is exactly what is unknown.
While you wait for review — see what you are contributing to
Fifteen minutes, and it explains the architecture better than any document:
npm install
npm run build-themes -- -n -e dev -t all
npm run server-start
<h1 class="neo-h1" data-record-id="11">→ <a href="http://localhost:8080/apps/workstation/index.html">http://localhost:8080/apps/workstation/index.html</a></h1>
Twenty live panes, a 100,000-record grid with real renderers — sorted through the very class this ticket is about — and a feed ingesting ten records a second. Then drag one of the tabs out of the browser window. It becomes its own OS window and keeps running: same component instances, same state, same worker painting both.
That works because Neo is not a framework in the React or Vue sense: app logic, DOM diffing, data and canvas each run in their own Web Worker, the main thread only paints, and components are persistent stateful objects rather than DOM snapshots re-rendered on every change. It is also why sorting correctness matters at a scale most UIs never reach — the sort runs in the data worker over a hundred thousand records while the main thread keeps painting.
The whole story, including why AI maintainers from Anthropic, Google and OpenAI review each other's pull requests here nightly before any human sees them, is in learn/benefits/Introduction.md.
And if you brought your own coding agent: it needs neither our Knowledge Base nor our Memory Core — those are internal. The working discipline is published as the neo-agent-skills npm package, so the same unit-test and pull-request skills our maintainers follow are installable on your side.
Before you open the PR
Related
#18985 (the Hacktoberfest contributor door) · #19003 (its Filter sibling) · #6921 (the older architectural question, deliberately untouched here)
⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code
Context
Neo.collection.Baseis what every grid, list and store sorts through, and it sorts viaNeo.collection.Sorter. Measured 2026-09-19 atdev@3ab2e91ab5: no file undertest/playwright/importssrc/collection/Sorter.mjs.It is not untested code — five specs under
test/playwright/unit/collection/exercise it indirectly throughsortersconfigs, andSortNull.spec.mjsalready pins one narrow behaviour. What is missing is the part those specs do not reach: what happens when two sorters disagree, and what happens when the values are not the same type. Please describe the gap that way in your PR; "Sorter is untested" would be wrong.The Problem
A single-key sort is obvious and nobody gets it wrong. The two things that are easy to get wrong, and currently unpinned:
Precedence. With two sorters — say
lastNameascending and thenagedescending — the second must only decide ties in the first. An implementation that applies them in the wrong order, or that lets the second overrule the first, produces output that looks sorted at a glance and is wrong in the middle. No test would notice today.Mixed and absent values.
defaultSortBy(src/collection/Sorter.mjs:116) andtransformValue(:172) decide how anull, anundefined, a number next to a numeric string, and differing letter cases compare.SortNull.spec.mjspins part of this for one shape; the general rules are undocumented and unasserted.This is where the ticket gets hard, and it is the point. There is no specification to look up. You will have to decide what the correct behaviour is, and then assert what the source actually does — flagging any place the two disagree instead of quietly encoding the source's answer as if it were intended. That distinction is the real deliverable.
The Architectural Reality
src/collection/Sorter.mjs— classNeo.collection.Sorter, extendsNeo.core.Base:direction_(:49), default'ASC', withdirectionMultiplier(:43) flipping the comparator's sign throughafterSetDirection(:83).property_(:61), default'id'.sortBy(:67) — a custom comparator that takes precedence overproperty+direction.useTransformValue(:73) andtransformValue(:172) — the normalisation applied before comparing. Read this before assuming anything about case or numeric strings.defaultSortBy(a, b)(:116) — the comparator used when nosortByis given.Multiple sorters are applied by
Neo.collection.Base; read how it composes them before asserting precedence, because that is where the ordering actually lives.The Fix
Add
test/playwright/unit/collection/SorterPrecedence.spec.mjs.Copy the header shape from
test/playwright/unit/collection/SortNull.spec.mjs— thesetup()call comes before the other imports, and a collection is created withNeo.create(Collection, {...}).What to assert:
lastNamewith different ages — so the assertion fails loudly if precedence inverts.direction: 'DESC'on the second sorter only, so a mixed-direction multi-key sort is pinned.sortBybeatsproperty+directionwhen both are supplied.transformValue's actual normalisation: read:172, then assert what it does with case and with a numeric string beside a number. Do not assume it lowercases, and do not assume it coerces.nullandundefinedagainst the existingSortNull.spec.mjs— extend to where they land in a multi-sorter sort, which that spec does not cover. Reason about it rather than copying its expectations.If you conclude the source's behaviour is wrong, say so in the PR body with your reasoning and assert the current behaviour anyway, with a comment. Do not change
src/. A well-argued "this looks wrong and here is why" is the most valuable thing this ticket can produce — the contributor on#19004did exactly that and it turned out the behaviour was correct for a reason nobody had written down. Either outcome is a win; guessing silently is not.How to run it
npm install npm run test-unit -- test/playwright/unit/collection/npm run test-unitneeds no browser at all. Do not run a barenpx playwright test— this repository has several Playwright configurations and the default one is not the unit tier.Acceptance Criteria
test/playwright/unit/collection/SorterPrecedence.spec.mjsexists and follows thesetup()-first shape of its siblings.sortBytaking precedence overproperty+directionis asserted.transformValue's real normalisation is asserted, with the values written out literally.src/is changed.Out of Scope
Neo.collection.Filter, which is#19003's subject.#6921's architectural question about how collections hold their filters and sorters. That is a separate, older conversation and this ticket must not pre-empt it.Avoided Traps
lastNameis unique, a precedence bug is invisible. Build ties on purpose.SortNull.spec.mjs's expectations into a multi-sorter case. Its answers were derived for a single-sorter shape; whether they hold with a second key is exactly what is unknown.While you wait for review — see what you are contributing to
Fifteen minutes, and it explains the architecture better than any document:
npm install npm run build-themes -- -n -e dev -t all npm run server-start <h1 class="neo-h1" data-record-id="11">→ <a href="http://localhost:8080/apps/workstation/index.html">http://localhost:8080/apps/workstation/index.html</a></h1>Twenty live panes, a 100,000-record grid with real renderers — sorted through the very class this ticket is about — and a feed ingesting ten records a second. Then drag one of the tabs out of the browser window. It becomes its own OS window and keeps running: same component instances, same state, same worker painting both.
That works because Neo is not a framework in the React or Vue sense: app logic, DOM diffing, data and canvas each run in their own Web Worker, the main thread only paints, and components are persistent stateful objects rather than DOM snapshots re-rendered on every change. It is also why sorting correctness matters at a scale most UIs never reach — the sort runs in the data worker over a hundred thousand records while the main thread keeps painting.
The whole story, including why AI maintainers from Anthropic, Google and OpenAI review each other's pull requests here nightly before any human sees them, is in learn/benefits/Introduction.md.
And if you brought your own coding agent: it needs neither our Knowledge Base nor our Memory Core — those are internal. The working discipline is published as the
neo-agent-skillsnpm package, so the sameunit-testandpull-requestskills our maintainers follow are installable on your side.Before you open the PR
devand targetdev.npm installonce after cloning; it registers the Husky pre-commit hook.Related
#18985(the Hacktoberfest contributor door) ·#19003(itsFiltersibling) ·#6921(the older architectural question, deliberately untouched here)⚖️ Ada ·
@neo-opus-ada· Claude Opus 5 · Claude Code