Works List Options (T-2302): a four-way sort and three single-value filters for the Works list, from one toolbar menu on iPhone, iPad and Mac. Nine commits over origin/main plus the review's own fixes.
make test-quick green after the fixes; the three Works-list UI suites re-run. The Mac menu's rendering is the owner's manual check.Ready to push
Every requirement in the smolspec is implemented and covered by a unit test, a UI journey, or both. The four review agents raised no blocking or major correctness findings; the fixes worth taking were applied in the working tree and verified with make test-quick and the three UI suites that cover the Works list. WorksListOptionsUITests, the works-options accessibility case and SearchUITests all passed on the simulator after the fixes. The Mac toolbar menu compiles but its rendering is the owner's manual check, as the spec requires.
bb21597 T-2302: Pre-push review fixes for the Works list options 8492a85 T-2302: changelog for phase 3 (Fixture and journeys) b9da70b T-2302: Close out the works-list-options verification pass 44b3999 T-2302: Seed a works-list fixture and drive the sort and filter journeys 96e7c05 T-2302: changelog for phase 2 (The list) f58a242 T-2302: Cover the Works filter's presentation logic f6feee6 T-2302: Sort and filter the Works list from one toolbar menu 2e82582 T-2302: changelog for phase 1 (Pure logic) aa2cb07 T-2302: Add the Works list's sort and filter logic 5b0f14a T-2302: works-list-options smolspec The Works tab lists every work (a novel, a webtoon, a series of articles) the reader has captured chapters of. Until now that list came in one fixed order, newest chapter first, and the only way to shorten it was to type in the search field.
This change adds one button beside "New Work" that opens a small menu. The menu has four rows for the order (Newest first, Oldest first, A to Z, Z to A) and three groups of rows for narrowing: pick one type ("novel", "webtoon"), one tag ("mystery"), or one site ("alpha.test"). Each group starts with "Any", which means "do not narrow on this".
When anything is narrowing the list, the button's icon fills in, small pills at the top of the list say what is chosen, and a "Clear" pill removes all of them at once. If the chosen combination matches nothing, the list is replaced by a message that names what was chosen, with the same Clear control.
The chosen order is remembered on the device. The narrowing is not: it lasts as long as the search text does and starts fresh next time.
A library of a few hundred works is hard to scan in one order. A reader who wants to find the oldest thing they never finished, or every mystery on one site, can now ask the list directly instead of scrolling or remembering titles.
App layer only; AsterismCore is untouched.
Asterism/Asterism/ViewModels/WorksListOptions.swift (new): WorksSort (four-case String enum, apply(to:), sectionsEmptyWorks), WorksFilter (three optionals, apply(to:), pruned(to:)), WorksTypeSelection (.untyped or .named(normalisedName)), WorksFilterOptions (the three vocabularies derived from a snapshot), WorksFilterPresentation (pill labels, empty-state sentence, menu row identifiers) and the storage key.Asterism/Asterism/ViewModels/AppLibraryModel.swift: builds WorksFilterOptions in the same block that builds workTitlesByID, once per snapshot publication; a new seeded UI-test fixture.Asterism/Asterism/Layout/AppScreens.swift: passes the options into WorksView.Asterism/Asterism/Views/WorksView.swift: @AppStorage sort, @State filter, the toolbar Menu with four inline pickers, the pill header, the filter empty state, and sectioning driven by the sort.Asterism/Asterism/ContentView.swift: a seeded launch removes the stored sort.Asterism/Asterism/UITestLaunchSupport.swift: the seeded-works-options scenario.Asterism/Asterism/ViewModels/SearchFilters.swift and specs/polish-and-export/requirements.md: wording only, pointing at this spec.WorksListOptionsTests (sort, filter, options, pruning), WorksFilterPresentationTests, WorksListOptionsUITests (the journey, a two-launch reset case, seeder reachability), one accessibility case in AccessibilityJourneyUITests, and two shared helpers in UIJourneySupport.The pipeline in WorksView.body is search, then filter, then sort, on snapshot.works, once per body evaluation. The result is partitioned into non-empty and empty works only when sort.sectionsEmptyWorks says so; under the title sorts the empty partition is [] and one section draws.
WorksSort.apply(to:) treats the repository order as the source of truth. Newest first returns the input. Oldest first partitions on entries.isEmpty, reverses each half and concatenates, so the empty works stay trailing. The title sorts use one comparator, localizedStandardCompare on displayTitle with the lowercased id string as tie-break, which is the comparator LibraryRepository.workDestinations uses. Z to A sorts by the inverted comparator rather than reversing the result.
Type identity is WorkTypeName.normalize of the displayed name, so works on either side of a type merge share an option without a directory fetch. WorksTypeSelection.selection(for:) is the one place a WorkTypeDisplay becomes a filter key; both the matcher and the option builder go through it. .none and .unresolved both map to .untyped, because both draw no pill.
WorksFilterOptions is a function of the full snapshot, never of the filtered one (options are not faceted). It is built in AppLibraryModel beside workTitlesByID and passed in, because WorksView.body runs per keystroke of the search field. A type option is dimmed only when every work under it wears .removed. When the options change, WorksView prunes the filter of any value the new options no longer offer.
The menu is one Menu holding four Pickers with .pickerStyle(.inline), so each picker's title renders as a section header. The three filter pickers are one generic helper, filterPicker, which supplies the "Any" row, the identifiers and the .tag(Optional) wiring. No #if os anywhere in it.
The pill row is the first section's header:, built as a FlowLayout of .constellationPill(.genreTag) texts plus the Clear button. worksSection spells two Sections (with and without the header) over one shared row body, because a Section with a header closure keeps header layout even when the closure yields nothing.
body, not per snapshot. Simpler, and the spec accepts it at the library sizes the app has. The efficiency review measured a localizedStandardCompare sort of 3,000 titles at 11 ms on a Mac, so at the top of the plausible range the title sorts could be felt while typing. The fix shape (derive the title order per snapshot, filter per keystroke) is recorded in this file's expert section rather than built.WorksSort(storedValue:) over the @AppStorage RawRepresentable overload (Q20). Three members in the view instead of one, in exchange for the fallback being a stated, unit-tested contract of the value type.recent-window-cap Req 3.2 to clear it.AsterismCore change out of scope. The duplication is noted for a follow-up.Ordering invariants. titleAscending is a strict total order (a tie on localizedStandardCompare is broken by a unique lowercased UUID string), so sorted { titleAscending($1, $0) } is exactly the reverse of sorted(by: titleAscending), tie-break included. Oldest first's "each section reversed" means the id tie-break also reverses within a section; the spec says so explicitly. The section boundary is entries.isEmpty in both the sort and the view, which matches the repository's (nil, nil) branch because a work's entries.first?.lastSharedAt is nil exactly when it has no entries.
localizedStandardCompare ties. Case variants are not ties: measured, "shonen" < "Shonen" and "manga" < "Manga" deterministically (lowercase first), so the tag and hostname vocabularies sort without a secondary key. Two distinct type keys comparing .orderedSame would need WorkTypeName.normalize to leave apart something Finder-style comparison folds; not observed. The one constructed tie, a type literally spelled "Untyped" against the untyped option, is broken by putting the untyped option last.
Type-option first spelling. The snapshot arrives in repository order, so "first seen" is the spelling on the work with the newest entry. A merge that re-spells a type changes the option's label on the next publication; the option's identity (the normalised key) and its identifier (works-filter-type-) do not move.
Pruning. .onChange(of: filterOptions) runs on every publication that changes any vocabulary. It drops only values the new options no longer carry, so a filter survives unrelated publications untouched. A type selection is pruned by key, not by spelling.
Identifier push-down. SwiftUI applies a container's accessibilityIdentifier to every child element. Naming the FlowLayout or the whole ContentUnavailableView overwrote works-filter-clear on the Clear button (verified from an element dump), so each pill carries works-filter-pills and the empty state's identifier sits on its title Label. An identifier on a Text inside an inline Picker does not reliably reach the generated menu item on iOS either; UIJourneySupport.worksOptionRow resolves by identifier first and falls back to the visible label.
Fixture determinism. works() orders on the newest entry's lastSharedAt, millisecond-quantized, with a random-identifier tie-break. The seeder creates each work whole (capture, create, move, metadata) before the next capture; the intervening writes are what keep the next capture in a later millisecond. Concurrent seeding would make the journey's four asserted orders flaky.
Seeded reset. ContentView.launchModel() removes the stored sort in its seeded branch. It runs inside the @State initialiser expression on AsterismApp, which evaluates once per creation of the app struct; a re-init would already discard and rebuild the whole AppLibraryModel, so the reset adds no new hazard.
AppLibraryModel now publishes a third snapshot-derived value (worksSnapshot, workTitlesByID, worksFilterOptions) in the same no-await block, so no extra observation invalidation. The quality review suggested bundling the three into one WorksPresentation value published as a unit, following recentPresentation; that is a reasonable follow-up and would take WorksView.init from thirteen defaulted parameters to eleven.WorksSearchFilter.apply(to: WorksSnapshot) no longer has a production caller; the view moved to the [WorkSnapshot] overload. It survives for its tests, and the spec forbids changing that type in this change.WorksFilterPresentation is nonisolated, matching the Sendable value types it formats, so tests need no actor annotation.Menu from the same source; PlatformSeamTests is untouched. Its rendering on the column toolbar is unverified until the owner's manual check.workTitlesByID and filter per keystroke (sort and filter commute because both narrowings only remove rows). Not done, because it adds an 11 ms sort to each of the ~45 publications a hydration makes.WorkTypeName.normalize per work per keystroke while a type filter is on. ~10 ms at 3,000 works on a phone. Fix shape: carry a per-work selection map in WorksFilterOptions.foregroundStyle on a menu row is largely ignored by AppKit menus; accepted by the spec.@State and .id(worksResetToken) and are not walked by a UI test. See the Completeness Assessment.Asterism/Asterism/ViewModels/WorksListOptions.swift
Why it matters. Every ordering and narrowing rule in the spec lives here and is unit-tested without a view; the view only composes them.
What to look at. WorksSort.apply(to:), WorksFilter.matches, WorksTypeSelection.selection(for:), WorksFilterOptions.init(works:)
Asterism/Asterism/Views/WorksView.swift
Why it matters. This is the one place the three narrowings and the sectioning rule meet; the empty-state precedence (filter before search) is decided here too.
What to look at. WorksView.body, displayedWorks, filterEmptyBranch
Asterism/Asterism/Views/WorksView.swift
Why it matters. The reader-facing control on every platform; the generic helper is where the "Any" row, the identifiers and the Optional tag wiring are stated once.
What to look at. optionsMenu, filterPicker(_:options:selection:anyIdentifier:tag:identifier:label:)
Asterism/Asterism/ViewModels/AppLibraryModel.swift
Why it matters. Keeps three sorted vocabularies over every work out of a body that runs per keystroke.
What to look at. worksFilterOptions, assigned beside workTitlesByID in the refresh cycle
Asterism/Asterism/Views/WorksView.swift
Why it matters. A container identifier is pushed down onto every child; naming the FlowLayout or the ContentUnavailableView took works-filter-clear off the Clear button.
What to look at. filterPills, filterEmptyBranch
Asterism/Asterism/ViewModels/AppLibraryModel.swift
Why it matters. The four sort orders the journey asserts are deterministic only because the writes between captures keep each capture in a later millisecond.
What to look at. seedWorksOptionsFixture(in:), seedWorksOptionsWork(...)
Asterism/Asterism/ContentView.swift
Why it matters. The first persisted preference a UI test can change; without the reset a test that picks A to Z leaks into every later launch.
What to look at. launchModel(), the .seeded branch
With key and direction stored separately, a reader on Z to A who switches to latest entry lands on Oldest first without asking for it.
A sort of the whole library is a preference set once; a filter is a question being asked now. Per-visit filters also keep recent-window-cap Req 3.2 true without a new route.
Works on either side of a merge share an option without a directory fetch. An unresolved type draws no pill, as an untyped work does. Untyped is an enum case, so a type literally named "Untyped" cannot collide.
Options come from the full snapshot and never narrow; the empty state names the filters so the dead end explains itself.
A Section built with a header closure keeps header layout even when the closure yields nothing; an unfiltered list must stay exactly the list it was.
Container identifiers are pushed down onto children and overwrote the Clear button's. The cost is that a journey cannot read one pill's text; the labels are unit-tested instead.
Both from this review. Pruning keeps the picker honest when a value leaves the library. The explicit WorksSort(storedValue:) is a stated, unit-tested contract worth one small binding.
The spec accepts it "at the library sizes the app has". Measured: 11 ms per 3,000 titles on a Mac. The per-snapshot alternative would add that cost to each of the ~45 publications a hydration makes.
The spec puts any AsterismCore change out of scope. Nothing enforces the two stay in step; a Core-published comparator is the follow-up.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | WorksView optionsMenu | The Type, Tag and Site pickers were the same seven lines three times, with the "Any" row and its identifier spelled three ways; the sort, tag and site row identifiers were interpolated inline while only the type one lived in the presentation enum. | One generic filterPicker helper supplies the Any row, the identifiers and the Optional tag wiring; all nine identifiers now come from WorksFilterPresentation. |
| minor | WorksView / WorksFilter | Nothing pruned the filter when a chosen value left the snapshot: the picker held a selection none of its rows carried, drew no checkmark, and the only way out was Clear. | WorksFilter.pruned(to:) drops values the new options no longer offer; WorksView applies it in onChange(of: filterOptions). Three unit tests added. Recorded as Q20. |
| minor | WorksFilterPresentation location | The presentation enum sat at the tail of the 700-line view file, diverging from the spec bullet that puts the pure logic in WorksListOptions.swift, and was MainActor-isolated only by omission, which is why some of its tests carried @MainActor and others did not. | Moved into WorksListOptions.swift beside the values it formats, as a nonisolated enum. Recorded as Q16. |
| minor | WorksFilterOptions.init | Two parallel dictionaries keyed identically were walked in lockstep and zipped back together with a fallback that could never fire; hostnames were read through a computed property that allocates an array per work, at both the options build and the per-keystroke match. | One record per option (name, allRemoved); both sites read memberships directly. |
| nit | WorksSort.apply Z to A | Sort-then-reverse allocated two arrays. | Sorted by the inverted comparator; identical order because the comparator is a total order. |
| nit | WorksFilterPresentation.emptyDescription | Trimmed the query twice. | Trimmed once. |
| major | Testing: Req 4 / Q8 | The journey claimed to prove the seeded-launch reset but always put the sort back to Newest first before ending, so no launch ever started with a non-default value stored. | New two-launch case testASeededLaunchDiscardsTheStoredSort chooses Z to A, relaunches, and asserts Newest first. The misleading comment was corrected. |
| minor | Documentation | specs/OVERVIEW.md said Done in the table and Planned in the section, and cited Q9 instead of Q5 for the filter lifetime. Four implementation choices (presentation enum location, two-Section spelling, shared pill identifier, seeding order) had no decision-log row. The picker-identifier fallback lesson lived only in a test-support doc comment. | Overview fixed; Q16 to Q20 added; docs/agent-notes/testing.md gained the inline-Picker identifier note. |
| major | Testing: Req 9 | The filter surviving a tab switch and a push, and being cleared by the truncation route, are three MUSTs with no journey. | Not built here. They rest on @State and the pre-existing .id(worksResetToken); listed as a follow-up in implementation.md. |
| minor | Efficiency: title sorts in body | A localizedStandardCompare sort of 3,000 titles measured 11 ms on a Mac (roughly 25 to 40 ms on a phone) per keystroke under A to Z or Z to A. Type filtering re-normalises each work's type name per keystroke, about 10 ms at 3,000 works. | Accepted by the spec at the library sizes the app has. The fix shapes (title order per snapshot, per-work selection map) are recorded in implementation.md. |
| minor | Reuse: title comparator | WorksSort.titleAscending duplicates the comparator in LibraryRepository.workDestinations rather than sharing it. | The spec puts any AsterismCore change out of scope. Follow-up noted. |
| minor | Reuse: seeder | seedWorksOptionsWork is the parameterised form of the capture-create-move-update sequence written out in seedCharactersFixture. | Generalising it would change a fixture another UI suite depends on and need the full UI run to verify. Follow-up noted. |
| minor | Quality: @AppStorage overload | The storedSort / sort / sortBinding trio could be one @AppStorage of the RawRepresentable type. | Kept: the unrecognised-value fallback is a stated, unit-tested contract of WorksSort. Recorded in Q20. |
| minor | Quality: WorksSearchFilter snapshot overload | apply(to: WorksSnapshot) no longer has a production caller. | Deleting it means deleting its tests, and the spec forbids changing WorksSearchFilter here. |
| minor | Quality: WorksPresentation bundle | Three snapshot-derived values are published separately and threaded through three parameters. | Broader than this review; follow-up noted in implementation.md. |
| nit | Test helpers | typed(_:) and work(_:) helpers are re-declared across the two unit-test files; listedTitles() in the UI test drops a membership-less row silently. | Test-file changes are outside this review's remit. |
Click to expand.
diff --git a/Asterism/AsterismTests/WorksFilterPresentationTests.swift b/Asterism/AsterismTests/WorksFilterPresentationTests.swiftnew file mode 100644index 0000000..0f8275a--- /dev/null+++ b/Asterism/AsterismTests/WorksFilterPresentationTests.swift@@ -0,0 +1,121 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// What the Works list says about its own filters (`works-list-options`+/// Reqs 7-8): the pills, the empty sentence and the menu identifiers, none of+/// which needs a screen to state.+@Suite("Works filter presentation")+struct WorksFilterPresentationTests {++ private static func typed(_ name: String) -> WorkTypeDisplay {+ WorkTypeDisplay(+ assignment: .configured(UUID()), name: name, kind: .active)+ }++ private static func work(+ typeDisplay: WorkTypeDisplay = .untyped, tags: [String] = [],+ hostnames: [String] = ["example.com"]+ ) -> WorkSnapshot {+ TestFixtures.makeWork(+ memberships: TestFixtures.makeMemberships(hostnames),+ typeDisplay: typeDisplay, genreTags: tags)+ }++ // MARK: - The active pills (Req 7)++ /// Menu order, not the order the reader happened to pick in: the pills read+ /// as the menu reads. The type wears the option's first-seen spelling, so+ /// a work filed under "manga" is still labelled "Manga" where the list met+ /// that spelling first.+ @Test("The pills name type, tag and site in menu order, in the option's spelling")+ @MainActor func pillsAreInMenuOrder() {+ let options = WorksFilterOptions(works: [+ Self.work(typeDisplay: Self.typed("Manga"), tags: ["shonen"], hostnames: ["a.example"]),+ Self.work(typeDisplay: Self.typed("manga"), tags: ["shonen"], hostnames: ["a.example"]),+ ])+ let filter = WorksFilter(+ type: .named(WorkTypeName.normalize("MANGA")), tag: "shonen", hostname: "a.example")++ #expect(+ WorksFilterPresentation.activeLabels(filter, options: options)+ == ["Manga", "shonen", "a.example"])+ }++ @Test("A dimension nobody chose draws no pill")+ @MainActor func unchosenDimensionsDrawNoPill() {+ let options = WorksFilterOptions(works: [Self.work(tags: ["shonen"])])+ #expect(+ WorksFilterPresentation.activeLabels(WorksFilter(tag: "shonen"), options: options)+ == ["shonen"])+ #expect(WorksFilterPresentation.activeLabels(WorksFilter(), options: options).isEmpty)+ }++ @Test("The untyped filter's pill reads Untyped")+ @MainActor func untypedPillReadsUntyped() {+ let options = WorksFilterOptions(works: [Self.work()])+ #expect(+ WorksFilterPresentation.activeLabels(WorksFilter(type: .untyped), options: options)+ == [WorksFilterOptions.untypedLabel])+ }++ // MARK: - Naming a selection the snapshot has lost++ /// The works under a chosen type can all leave between the pick and the+ /// redraw. The sentence naming the filter has to keep saying something, so+ /// the normalised name stands in for the spelling that left with them.+ @Test("A selection the options no longer offer falls back to the normalised name")+ func lostSelectionFallsBackToNormalisedName() {+ let name = WorkTypeName.normalize("Manga")+ #expect(WorksFilterOptions.empty.name(for: .named(name)) == name)+ #expect(WorksFilterOptions.empty.name(for: .untyped) == WorksFilterOptions.untypedLabel)+ }++ @Test("An offered selection is named by its option, not by its normalised name")+ func offeredSelectionKeepsItsSpelling() {+ let options = WorksFilterOptions(works: [Self.work(typeDisplay: Self.typed("Manga"))])+ #expect(options.name(for: .named(WorkTypeName.normalize("Manga"))) == "Manga")+ }++ // MARK: - The empty state (Req 8)++ @Test("With no query the sentence names the filters alone")+ @MainActor func emptySentenceWithoutAQuery() {+ #expect(+ WorksFilterPresentation.emptyDescription(labels: ["Manga", "shonen"], query: nil)+ == "No works match Manga, shonen.")+ }++ @Test("With a query the sentence names the filters and the query")+ @MainActor func emptySentenceWithAQuery() {+ #expect(+ WorksFilterPresentation.emptyDescription(labels: ["Manga"], query: " zephyr ")+ == "No works match Manga and “zephyr”.")+ }++ /// The search field holds a string, not an optional: a field the reader+ /// cleared to spaces is a field with nothing in it, and quoting the spaces+ /// back at them would be a dead end the sentence invented.+ @Test("A blank query is no query")+ @MainActor func blankQueryIsNoQuery() {+ let sentence = "No works match Manga."+ #expect(+ WorksFilterPresentation.emptyDescription(labels: ["Manga"], query: "") == sentence)+ #expect(+ WorksFilterPresentation.emptyDescription(labels: ["Manga"], query: " \n ") == sentence)+ }++ // MARK: - Menu row identifiers++ /// Keyed by the normalised name, so a UI test addressing a type row still+ /// finds it after the type is re-spelled.+ @Test("A type row is addressed by its selection")+ @MainActor func typeRowsAreAddressable() {+ #expect(+ WorksFilterPresentation.typeRowIdentifier(.untyped) == "works-filter-type-untyped")+ #expect(+ WorksFilterPresentation.typeRowIdentifier(.named(WorkTypeName.normalize("Manga")))+ == "works-filter-type-manga")+ }+}
diff --git a/Asterism/AsterismTests/WorksListOptionsTests.swift b/Asterism/AsterismTests/WorksListOptionsTests.swiftnew file mode 100644index 0000000..88398d8--- /dev/null+++ b/Asterism/AsterismTests/WorksListOptionsTests.swift@@ -0,0 +1,422 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// The Works list's sort and filters (`works-list-options` Reqs 1-5). Pure value+// types, so the whole ordering and narrowing requirement is testable here+// without a view.++/// Deterministic ids, so a tie-break on the lowercased id string is a stated+/// expectation rather than whatever `UUID()` happened to produce.+private func id(_ suffix: Int) -> UUID {+ UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", suffix))")!+}++private func typed(+ _ name: String?, kind: WorkTypeDisplay.Kind, assignment: WorkTypeAssignment = .configured(id(9))+) -> WorkTypeDisplay {+ WorkTypeDisplay(assignment: assignment, name: name, kind: kind)+}++private let unresolvedType = typed(nil, kind: .unresolved)++@Suite("Works sort")+struct WorksSortTests {++ /// A work with entries, so it lands in the non-empty section.+ private func work(_ index: Int, _ title: String) -> WorkSnapshot {+ TestFixtures.makeWork(+ id: id(index), displayTitle: title,+ entries: [TestFixtures.makeEntry(workID: id(index))])+ }++ private func emptyWork(_ index: Int, _ title: String) -> WorkSnapshot {+ TestFixtures.makeWork(id: id(index), displayTitle: title)+ }++ // MARK: - Storage (Reqs 3, 4)++ @Test("The default sort is newest first")+ func defaultIsNewestFirst() {+ #expect(WorksSort.default == .newest)+ }++ @Test("An unrecognised or absent stored value reads as the default")+ func unrecognisedStoredValueReadsAsDefault() {+ #expect(WorksSort(storedValue: nil) == .newest)+ #expect(WorksSort(storedValue: "") == .newest)+ #expect(WorksSort(storedValue: "byPhaseOfTheMoon") == .newest)+ #expect(WorksSort(storedValue: WorksSort.zToA.rawValue) == .zToA)+ }++ @Test("Every sort has a stable raw value")+ func rawValuesAreStable() {+ #expect(WorksSort.allCases.map(\.rawValue) == ["newest", "oldest", "aToZ", "zToA"])+ }++ // MARK: - Sectioning (Req 2, Q4)++ @Test("Only the date sorts section empty works")+ func onlyDateSortsSectionEmptyWorks() {+ #expect(WorksSort.newest.sectionsEmptyWorks)+ #expect(WorksSort.oldest.sectionsEmptyWorks)+ #expect(!WorksSort.aToZ.sectionsEmptyWorks)+ #expect(!WorksSort.zToA.sectionsEmptyWorks)+ }++ // MARK: - Ordering (Req 1)++ @Test("Newest first leaves the repository order alone")+ func newestFirstIsTheRepositoryOrder() {+ let works = [work(1, "C"), work(2, "A"), emptyWork(3, "B")]+ #expect(WorksSort.newest.apply(to: works).map(\.id) == works.map(\.id))+ }++ @Test("Oldest first reverses each section, the empty section included")+ func oldestFirstReversesEachSection() {+ let works = [+ work(1, "C"), work(2, "A"),+ emptyWork(3, "E"), emptyWork(4, "D"),+ ]+ #expect(+ WorksSort.oldest.apply(to: works).map(\.id)+ == [id(2), id(1), id(4), id(3)])+ }++ @Test("Oldest first keeps empty works behind the non-empty ones")+ func oldestFirstKeepsEmptyWorksTrailing() {+ let works = [work(1, "A"), emptyWork(2, "B"), emptyWork(3, "C")]+ let sorted = WorksSort.oldest.apply(to: works)+ #expect(sorted.map(\.id) == [id(1), id(3), id(2)])+ #expect(sorted.last?.entries.isEmpty == true)+ }++ @Test("A to Z orders display titles by localizedStandardCompare")+ func aToZUsesLocalizedStandardCompare() {+ // `localizedStandardCompare` orders these numerically; a plain string+ // comparison would put "Volume 10" before "Volume 2".+ let works = [work(1, "Volume 10"), work(2, "Volume 2"), work(3, "apple")]+ #expect(+ WorksSort.aToZ.apply(to: works).map(\.displayTitle)+ == ["apple", "Volume 2", "Volume 10"])+ }++ @Test("A to Z breaks a title tie on the lowercased id string")+ func aToZBreaksTiesOnID() {+ let works = [work(2, "Same Title"), work(1, "Same Title")]+ #expect(WorksSort.aToZ.apply(to: works).map(\.id) == [id(1), id(2)])+ }++ @Test("A to Z lists empty works among the rest, in one order")+ func aToZDoesNotSectionEmptyWorks() {+ let works = [work(1, "C"), emptyWork(2, "A"), work(3, "B")]+ #expect(+ WorksSort.aToZ.apply(to: works).map(\.displayTitle) == ["A", "B", "C"])+ }++ @Test("Z to A is the A to Z comparison reversed, tie-break included")+ func zToAIsTheReverse() {+ let works = [work(1, "Same Title"), work(2, "Same Title"), work(3, "Apple")]+ #expect(+ WorksSort.zToA.apply(to: works).map(\.id) == [id(2), id(1), id(3)])+ }++ @Test("An empty list sorts to an empty list under every sort")+ func emptyListSortsToEmpty() {+ for sort in WorksSort.allCases {+ #expect(sort.apply(to: []).isEmpty)+ }+ }+}++@Suite("Works filter")+struct WorksFilterTests {++ private func work(+ _ index: Int,+ title: String = "Work",+ typeDisplay: WorkTypeDisplay = .untyped,+ tags: [String] = [],+ hostnames: [String] = ["example.com"]+ ) -> WorkSnapshot {+ TestFixtures.makeWork(+ id: id(index), displayTitle: title,+ memberships: TestFixtures.makeMemberships(hostnames),+ typeDisplay: typeDisplay, genreTags: tags)+ }++ // MARK: - Activity (Req 5)++ @Test("A filter with nothing chosen is inactive and returns the works unchanged")+ func emptyFilterIsInactive() {+ let works = [work(1), work(2)]+ let filter = WorksFilter()+ #expect(!filter.isActive)+ #expect(filter.apply(to: works).map(\.id) == works.map(\.id))+ }++ @Test("Any one dimension makes the filter active")+ func anyDimensionActivates() {+ #expect(WorksFilter(type: .untyped).isActive)+ #expect(WorksFilter(tag: "shonen").isActive)+ #expect(WorksFilter(hostname: "example.com").isActive)+ }++ // MARK: - Type (Q7)++ @Test("A named type matches works on either side of a merge, by normalised name")+ func namedTypeMatchesByNormalisedName() {+ let works = [+ work(1, typeDisplay: typed("Manga", kind: .active)),+ work(2, typeDisplay: typed("manga", kind: .active)),+ work(3, typeDisplay: typed("Novel", kind: .active)),+ ]+ let filter = WorksFilter(type: .named(WorkTypeName.normalize("MANGA")))+ #expect(filter.apply(to: works).map(\.id) == [id(1), id(2)])+ }++ @Test("Untyped matches works with no type and works whose type has not arrived")+ func untypedMatchesNoneAndUnresolved() {+ let works = [+ work(1),+ work(2, typeDisplay: unresolvedType),+ work(3, typeDisplay: typed("Manga", kind: .active)),+ ]+ #expect(+ WorksFilter(type: .untyped).apply(to: works).map(\.id) == [id(1), id(2)])+ }++ @Test("A removed type is still matched by its name")+ func removedTypeStillMatches() {+ let works = [work(1, typeDisplay: typed("Manhwa", kind: .removed)), work(2)]+ let filter = WorksFilter(type: .named(WorkTypeName.normalize("Manhwa")))+ #expect(filter.apply(to: works).map(\.id) == [id(1)])+ }++ // MARK: - Tag++ @Test("A tag matches the stored spelling exactly")+ func tagMatchesStoredSpelling() {+ let works = [+ work(1, tags: ["shonen", "action"]),+ work(2, tags: ["Shonen"]),+ work(3),+ ]+ #expect(WorksFilter(tag: "shonen").apply(to: works).map(\.id) == [id(1)])+ }++ // MARK: - Site++ @Test("A site matches any of a work's memberships, not just the first")+ func siteMatchesAnyMembership() {+ let works = [+ work(1, hostnames: ["a.example", "b.example"]),+ work(2, hostnames: ["b.example"]),+ work(3, hostnames: ["c.example"]),+ ]+ #expect(+ WorksFilter(hostname: "b.example").apply(to: works).map(\.id) == [id(1), id(2)])+ }++ @Test("A work with no membership matches no site")+ func membershiplessWorkMatchesNoSite() {+ let works = [work(1, hostnames: [])]+ #expect(WorksFilter(hostname: "example.com").apply(to: works).isEmpty)+ }++ // MARK: - AND across dimensions (Q3)++ @Test("The three dimensions are ANDed")+ func dimensionsAreANDed() {+ let match = work(+ 1, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["a.example"])+ let wrongTag = work(+ 2, typeDisplay: typed("Manga", kind: .active), tags: ["seinen"],+ hostnames: ["a.example"])+ let wrongSite = work(+ 3, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["b.example"])+ let wrongType = work(4, tags: ["shonen"], hostnames: ["a.example"])+ let filter = WorksFilter(+ type: .named(WorkTypeName.normalize("Manga")), tag: "shonen",+ hostname: "a.example")+ #expect(+ filter.apply(to: [match, wrongTag, wrongSite, wrongType]).map(\.id) == [id(1)])+ }++ @Test("A combination nothing matches returns nothing")+ func noMatchReturnsNothing() {+ let works = [work(1, tags: ["shonen"], hostnames: ["a.example"])]+ #expect(WorksFilter(tag: "shonen", hostname: "b.example").apply(to: works).isEmpty)+ }++ @Test("Filtering preserves the order it was handed")+ func filteringPreservesOrder() {+ let works = [+ work(3, tags: ["t"]), work(1, tags: ["t"]), work(2, tags: ["t"]),+ ]+ #expect(WorksFilter(tag: "t").apply(to: works).map(\.id) == [id(3), id(1), id(2)])+ }++ // MARK: - Pruning against a new snapshot++ @Test("A value the options no longer offer is dropped, the others kept")+ func pruningDropsOnlyVanishedValues() {+ let options = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["a.example"])+ ])+ let filter = WorksFilter(+ type: .named(WorkTypeName.normalize("Manga")), tag: "seinen", hostname: "a.example")+ let pruned = filter.pruned(to: options)+ #expect(pruned.type == .named(WorkTypeName.normalize("Manga")))+ #expect(pruned.tag == nil)+ #expect(pruned.hostname == "a.example")+ }++ @Test("Pruning against an empty snapshot clears every value")+ func pruningAgainstEmptyClearsAll() {+ let filter = WorksFilter(type: .untyped, tag: "t", hostname: "h")+ #expect(filter.pruned(to: .empty) == WorksFilter())+ }++ @Test("A filter the options still cover is returned unchanged")+ func pruningKeepsCoveredFilter() {+ let options = WorksFilterOptions(works: [work(1, tags: ["t"], hostnames: ["h"])])+ let filter = WorksFilter(type: .untyped, tag: "t", hostname: "h")+ #expect(filter.pruned(to: options) == filter)+ }+}++@Suite("Works filter options")+struct WorksFilterOptionsTests {++ private func work(+ _ index: Int,+ typeDisplay: WorkTypeDisplay = .untyped,+ tags: [String] = [],+ hostnames: [String] = ["example.com"]+ ) -> WorkSnapshot {+ TestFixtures.makeWork(+ id: id(index), displayTitle: "Work \(index)",+ memberships: TestFixtures.makeMemberships(hostnames),+ typeDisplay: typeDisplay, genreTags: tags)+ }++ // MARK: - Types (Q7, Q12)++ @Test("Merged spellings collapse onto one option under the first spelling seen")+ func mergedTypesCollapse() {+ let options = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manga", kind: .active)),+ work(2, typeDisplay: typed("manga", kind: .active)),+ ])+ #expect(options.types.map(\.name) == ["Manga"])+ #expect(options.types.map(\.selection) == [.named(WorkTypeName.normalize("manga"))])+ }++ @Test("An Untyped option is offered only when a work draws no type pill")+ func untypedOptionOnlyWhenPresent() {+ let typedOnly = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manga", kind: .active))+ ])+ #expect(!typedOnly.types.contains { $0.selection == .untyped })++ let withUnresolved = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manga", kind: .active)),+ work(2, typeDisplay: unresolvedType),+ ])+ #expect(withUnresolved.types.contains { $0.selection == .untyped })+ #expect(+ withUnresolved.types.first { $0.selection == .untyped }?.name+ == WorksFilterOptions.untypedLabel)+ }++ @Test("A type is dimmed only when every work under it wears a removed type")+ func dimmedOnlyWhenEveryWorkIsRemoved() {+ let allRemoved = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manhwa", kind: .removed)),+ work(2, typeDisplay: typed("manhwa", kind: .removed)),+ ])+ #expect(allRemoved.types.map(\.isDimmed) == [true])++ let oneActive = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Manhwa", kind: .removed)),+ work(2, typeDisplay: typed("Manhwa", kind: .active)),+ ])+ #expect(oneActive.types.map(\.isDimmed) == [false])+ }++ @Test("The Untyped option is never dimmed")+ func untypedIsNeverDimmed() {+ let options = WorksFilterOptions(works: [work(1), work(2, typeDisplay: unresolvedType)])+ #expect(options.types.map(\.isDimmed) == [false])+ }++ @Test("Type options are ordered by localizedStandardCompare")+ func typeOptionsAreOrdered() {+ let options = WorksFilterOptions(works: [+ work(1, typeDisplay: typed("Novel", kind: .active)),+ work(2, typeDisplay: typed("Manga", kind: .active)),+ work(3, typeDisplay: typed("anthology", kind: .active)),+ ])+ #expect(options.types.map(\.name) == ["anthology", "Manga", "Novel"])+ }++ // MARK: - Tags and sites++ @Test("Tag options are the deduplicated stored spellings, ordered")+ func tagOptionsAreOrdered() {+ let options = WorksFilterOptions(works: [+ work(1, tags: ["shonen", "action"]),+ work(2, tags: ["Shonen", "action"]),+ ])+ // Deduplicated case-sensitively, so the two spellings are two options.+ // Which of the two `localizedStandardCompare` puts first is its own+ // business; that "action" leads is the ordering this asserts.+ #expect(options.tags.count == 3)+ #expect(options.tags.first == "action")+ #expect(Set(options.tags) == ["action", "shonen", "Shonen"])+ }++ @Test("Site options are every membership hostname, deduplicated and ordered")+ func siteOptionsAreOrdered() {+ let options = WorksFilterOptions(works: [+ work(1, hostnames: ["z.example", "a.example"]),+ work(2, hostnames: ["a.example"]),+ ])+ #expect(options.hostnames == ["a.example", "z.example"])+ }++ @Test("An empty snapshot offers no options")+ func emptySnapshotOffersNothing() {+ let options = WorksFilterOptions(works: [])+ #expect(options.types.isEmpty)+ #expect(options.tags.isEmpty)+ #expect(options.hostnames.isEmpty)+ #expect(options == .empty)+ }++ // MARK: - Not faceted (Q12)++ @Test("Options come from the whole snapshot, never from a filtered one")+ func optionsNeverNarrow() {+ let works = [+ work(1, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["a.example"]),+ work(2, typeDisplay: typed("Novel", kind: .active), tags: ["seinen"],+ hostnames: ["b.example"]),+ ]+ let all = WorksFilterOptions(works: works)+ let filtered = WorksFilter(hostname: "a.example").apply(to: works)+ #expect(filtered.count == 1)+ // The vocabularies the pickers offer are the full snapshot's, so a+ // reader can still pick "Novel" on a.example and land on the empty+ // state.+ #expect(all.types.map(\.name) == ["Manga", "Novel"])+ #expect(all.tags == ["seinen", "shonen"])+ #expect(all.hostnames == ["a.example", "b.example"])+ }+}
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex be0397d..982c548 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -260,6 +260,59 @@ final class AccessibilityJourneyUITests: XCTestCase { assertSystemControl(newWork, named: "New Work") } + /// `works-list-options` Req 11: at the largest Dynamic Type size the Works+ /// list's options menu still opens, and the rows underneath a filter's pill+ /// header are still reachable.+ ///+ /// The pills are the risk this pins. They sit in the first section's header+ /// (Q15) precisely so they scroll away with the content rather than costing+ /// the rows their space — and at `accessibility5` a filter's pill row wraps+ /// over several lines. So the filter is set first and the assertions are+ /// made with it on: the menu button in the bar, a work row under the pills,+ /// and the menu opening a second time on a list that is already filtered.+ @MainActor+ func testWorksOptionsMenuStaysUsableAtLargestDynamicType() {+ launchSeeded(+ scenario: "seeded-works-options",+ extraArguments: [+ "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"+ ]+ )++ let works = app.tabControl(.works)+ XCTAssertTrue(works.waitForExistence(timeout: 30), "The Works tab is reachable")+ works.tap()+ XCTAssertTrue(+ app.collectionViews["works-list"].waitForExistence(timeout: 15),+ "Works lists the seeded library")++ let menu = app.buttons["works-list-options-menu"]+ assertSystemControl(menu, named: "The sort and filter menu at largest Dynamic Type")++ // A site filter, which is two of the fixture's four works — chosen+ // rather than a sort, because only a filter draws the pill header this+ // case is about. The chooser opens the menu, which is the assertion that+ // it opens at all: it fails the case if no row is ever reachable.+ chooseWorksOption("works-filter-site-alpha.test", labelled: "alpha.test", in: app)++ assertSystemControl(menu, named: "The menu button under an active filter")+ XCTAssertEqual(+ menu.label, "Sort and filter works, filters active",+ "…and it says a filter is on")++ let row = app.elements(withIdentifierPrefix: "work-row-").firstMatch+ XCTAssertTrue(row.waitForExistence(timeout: 15), "The filtered list still draws rows")+ scrollToElement(row, attempts: 8)+ assertContentControl(row, named: "A work row under the filter pills")++ // The menu still opens with the pills on screen, which is the state a+ // reader is in when they go back to change or clear the filter.+ menu.tap()+ let sortRow = worksOptionRow("works-sort-newest", labelled: "Newest first", in: app)+ XCTAssertTrue(+ sortRow.waitForExistence(timeout: 10), "The menu reopens over a filtered list")+ }+ // MARK: - Constellation visual pass (Reqs 8–11) /// The Works tab and Work detail in dark, walked through the surfaces the@@ -449,8 +502,8 @@ final class AccessibilityJourneyUITests: XCTestCase { chapter.tap() } - private func launchSeeded(extraArguments: [String] = []) {- app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+ private func launchSeeded(scenario: String = "seeded-m1", extraArguments: [String] = []) {+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString app.launchArguments += extraArguments app.launch()
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftindex c3cb5f0..c7a5c8d 100644--- a/Asterism/AsterismUITests/UIJourneySupport.swift+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -288,6 +288,46 @@ extension XCTestCase { XCTFail(message, file: file, line: line) } + /// One row of the Works list's sort-and-filter menu (`works-list-options`).+ ///+ /// The identifiers are set on the `Text` inside each inline `Picker`, and+ /// SwiftUI does not always carry one onto the menu item it builds from it —+ /// the work editor's type picker is driven by its visible label for exactly+ /// that reason (`WorkDetailActionsUITests`). Preferring the identifier and+ /// falling back to the label lets either spelling drive the row.+ func worksOptionRow(+ _ identifier: String, labelled label: String, in app: XCUIApplication+ ) -> XCUIElement {+ let byIdentifier = app.anyElement(identifier)+ return byIdentifier.exists ? byIdentifier : app.buttons[label]+ }++ /// Opens the Works options menu and chooses one row.+ ///+ /// The row is re-resolved on every attempt rather than held, because a menu+ /// row below the fold is not in the accessibility tree at all — which is the+ /// ordinary case at the accessibility text sizes, where four sections of+ /// rows do not fit on a phone.+ func chooseWorksOption(+ _ identifier: String, labelled label: String, in app: XCUIApplication,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ scrollUntilTappableAndTap(+ app.anyElement("works-list-options-menu"), in: app,+ "The Works toolbar offers the sort and filter menu", file: file, line: line)+ for _ in 0..<8 {+ let row = worksOptionRow(identifier, labelled: label, in: app)+ if row.exists, row.isHittable {+ row.tap()+ waitUntilGone(+ row, "Choosing \(label) closes the menu", timeout: 10, file: file, line: line)+ return+ }+ app.swipeUp()+ }+ XCTFail("The menu offers \(label)", file: file, line: line)+ }+ /// The navigation bar's search field. /// /// Two things make a bare `app.searchFields.firstMatch` unreliable: iOS may
diff --git a/Asterism/AsterismUITests/WorksListOptionsUITests.swift b/Asterism/AsterismUITests/WorksListOptionsUITests.swiftnew file mode 100644index 0000000..e24b6ef--- /dev/null+++ b/Asterism/AsterismUITests/WorksListOptionsUITests.swift@@ -0,0 +1,218 @@+import XCTest++/// The Works list's sort and filter controls (`works-list-options`), driven+/// from app launch.+///+/// The ordering and the narrowing are pure value types with their own unit+/// tests (`WorksListOptionsTests`); what only a journey can prove is that the+/// toolbar menu is reachable, that choosing a row reorders or narrows the list+/// the reader is looking at, that the pills and the filled icon say a filter is+/// on, and that a filter matching nothing explains itself rather than showing a+/// blank list.+///+/// `seeded-works-options` is the fixture with something to sort: **Marrow+/// Lane** (alpha.test, tagged `mystery`, untyped), **Ashfall** (beta.test,+/// wearing the removed `webtoon` type) and **Zephyr Court** (alpha.test,+/// `novel`) captured in that order — so the date order is the reverse of it and+/// disagrees with the alphabet at every position — plus the entry-less **Quill+/// Harbour** (beta.test) and one unattached entry.+final class WorksListOptionsUITests: XCTestCase {+ let app = XCUIApplication()++ /// The four sorts over the fixture, in menu order. Every list is different+ /// from every other one, so an assertion here cannot pass against the wrong+ /// sort — and the empty work moves out of its own trailing section under+ /// the title sorts (Req 2).+ private let newestOrder = ["Zephyr Court", "Ashfall", "Marrow Lane", "Quill Harbour"]+ private let oldestOrder = ["Marrow Lane", "Ashfall", "Zephyr Court", "Quill Harbour"]+ private let aToZOrder = ["Ashfall", "Marrow Lane", "Quill Harbour", "Zephyr Court"]+ private let zToAOrder = ["Zephyr Court", "Quill Harbour", "Marrow Lane", "Ashfall"]++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Driving++ private func launch() {+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-works-options"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ private var workRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "work-row-")+ }++ /// The toolbar control, as a button rather than `anyElement`: Req 6's+ /// filled-icon state is asserted through this element's accessibility+ /// label, and a wrapper element carrying the same identifier would answer+ /// with something else.+ private var optionsMenu: XCUIElement {+ app.buttons["works-list-options-menu"]+ }++ /// The work titles the list is showing, in the order it is showing them.+ ///+ /// Read off the rows' accessibility labels rather than a title identifier:+ /// `work-title` is on the row's inner text and repeats per row, while+ /// `WorksRowPresentation.openLabel` gives each row button one label naming+ /// exactly the work it opens.+ private func listedTitles() -> [String] {+ let rows = workRows+ return (0..<rows.count).compactMap { index in+ let label = rows.element(boundBy: index).label+ guard let opened = label.range(of: "Open Work "), opened.lowerBound == label.startIndex,+ let site = label.range(of: " from ")+ else { return nil }+ return String(label[opened.upperBound..<site.lowerBound])+ }+ }++ /// Waits for the list to settle on an order rather than reading it once: a+ /// menu row's tap is followed by the menu's dismissal animation, and the+ /// rows are re-queried until they agree or the wait runs out.+ private func assertListed(+ _ expected: [String], _ message: String,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ let settled = XCTNSPredicateExpectation(+ predicate: NSPredicate { _, _ in self.listedTitles() == expected }, object: nil)+ guard XCTWaiter().wait(for: [settled], timeout: 15) == .completed else {+ XCTFail("\(message) — was \(listedTitles())", file: file, line: line)+ return+ }+ }++ /// Opens the menu and chooses one row. `chooseWorksOption` is shared with+ /// the accessibility journey (`UIJourneySupport`) and carries the+ /// identifier-or-label resolution and the scrolling a tall menu needs.+ private func chooseOption(+ _ identifier: String, labelled label: String,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ chooseWorksOption(identifier, labelled: label, in: app, file: file, line: line)+ }++ // MARK: - The journey++ /// Every sort, a filter, the pills, the hidden unattached group, Clear and+ /// both empty states, in one walk over one launch.+ ///+ /// One journey rather than six, because each of these is a state the one+ /// before it left the screen in: the filter is chosen on a list the sorts+ /// have been reordering, Clear is asserted against the empty state it is+ /// drawn in, and the query-only miss has to be the miss of a list with no+ /// filter left on it.+ func testTheOptionsMenuSortsFiltersAndExplainsAnEmptyList() {+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ waitFor(app.staticTexts["unattached-section-header"], "Unattached notes start visible")++ // Req 3: the list opens on Newest first. That it does so on a device+ // whose stored sort a previous launch changed is Q8's promise, proven+ // by `testASeededLaunchDiscardsTheStoredSort` below.+ waitFor(optionsMenu, "The Works toolbar offers the sort and filter menu")+ XCTAssertEqual(+ optionsMenu.label, "Sort and filter works",+ "Req 6: nothing is filtered yet, so the control does not claim to be")+ assertListed(newestOrder, "The list opens on Newest first")++ // Req 1: each sort, and the empty work leaving its own section for the+ // two title sorts (Req 2).+ chooseOption("works-sort-oldest", labelled: "Oldest first")+ assertListed(oldestOrder, "Oldest first reverses each section")+ chooseOption("works-sort-aToZ", labelled: "A to Z")+ assertListed(aToZOrder, "A to Z draws one alphabetical section")+ chooseOption("works-sort-zToA", labelled: "Z to A")+ assertListed(zToAOrder, "Z to A is that alphabet reversed")+ chooseOption("works-sort-newest", labelled: "Newest first")+ assertListed(newestOrder, "…and the reader can put it back")++ // Req 5: one site, which is two of the four works.+ chooseOption("works-filter-site-alpha.test", labelled: "alpha.test")+ assertListed(["Zephyr Court", "Marrow Lane"], "A site filter keeps that site's works")+ waitFor(app.anyElement("works-filter-pills"), "Req 7: the active filter is on a pill")+ waitFor(app.anyElement("works-filter-clear"), "…beside the control that removes it")+ XCTAssertEqual(+ optionsMenu.label, "Sort and filter works, filters active",+ "Req 6: the icon fills while a filter is active, and the label says so")+ // Req 7 / Q9: unattached entries carry no type, tag or membership, so no+ // filter can describe them and the group goes rather than emptying.+ waitUntilGone(+ app.staticTexts["unattached-section-header"],+ "An active filter hides the unattached group")++ // Req 8 / Q12: the options are not faceted, so a reader can reach a+ // combination no work carries — the removed type is on the other site.+ chooseOption("works-filter-type-webtoon", labelled: "webtoon")+ waitFor(+ app.anyElement("works-filter-empty"),+ "A filter that matches nothing names what is narrowing the list")+ XCTAssertEqual(workRows.count, 0, "Nothing is listed behind the message")++ // Req 7 / Q13: Clear removes every filter and nothing else.+ app.anyElement("works-filter-clear").tap()+ assertListed(newestOrder, "Clear returns the whole list, in the sort it was in")+ waitFor(+ app.staticTexts["unattached-section-header"],+ "…and the unattached group with it")+ XCTAssertEqual(+ optionsMenu.label, "Sort and filter works",+ "…and the control stops claiming a filter")++ // Req 8's other half: with no filter left, a query that matches nothing+ // still gets the search empty state it always had.+ let field = searchField(in: app)+ field.tap()+ field.typeText("zzqqxx")+ waitFor(+ app.anyElement("works-search-empty"),+ "A query-only miss keeps the existing search empty state")+ XCTAssertFalse(+ app.anyElement("works-filter-empty").exists,+ "…rather than the filter one, which would name filters nobody set")+ }++ /// Req 4's seeded-launch clause and Q8: a sort chosen in one launch is on+ /// the device for the next, and a seeded launch discards it, so every test+ /// starts from Newest first. Two launches in one case, because the first+ /// has to leave something behind for the second to discard — the main+ /// journey deliberately puts the sort back.+ func testASeededLaunchDiscardsTheStoredSort() {+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ assertListed(newestOrder, "The first launch opens on Newest first")+ chooseOption("works-sort-zToA", labelled: "Z to A")+ assertListed(zToAOrder, "Z to A is stored by the first launch")++ terminateAndWaitForExit(app)+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens again", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library again")+ assertListed(newestOrder, "A seeded launch discards the stored sort (Q8)")+ }++ /// The seeder itself, asserted from a launch rather than from a unit test+ /// (docs/agent-notes/testing.md): a fixture that throws at launch otherwise+ /// shows up only as a `waitForExistence` timeout in whichever journey ran+ /// first.+ func testWorksOptionsScenarioReachesRecent() {+ launch()+ waitFor(app.collectionViews["recent-list"], "The seeded library opens", timeout: 60)+ XCTAssertEqual(+ app.elements(withIdentifierPrefix: "recent-entry-").count, 4,+ "Three attached chapters and the unattached note")+ }+}
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 616db3f..949e731 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -122,6 +122,13 @@ struct ContentView: View { case .disabled: return productionModel() case .seeded(let configuration, let fixture):+ // `works-list-options` Q8: the Works list's sort is the first+ // preference a UI test can change, and it persists per device — so+ // a test that picks A to Z would leak into every launch after it.+ // Cleared here, where the seeded request is already resolved, rather+ // than through a per-run defaults suite that would need the run id+ // plumbed into `AsterismApp` and leave a plist per run behind.+ UserDefaults.standard.removeObject(forKey: WorksListStorageKey.sort) return AppLibraryModel(configuration: configuration, uiTestFixture: fixture) case .invalid(let message): return AppLibraryModel(startupFailureMessage: message)
diff --git a/Asterism/Asterism/Layout/AppScreens.swift b/Asterism/Asterism/Layout/AppScreens.swiftindex 75785f9..748c81c 100644--- a/Asterism/Asterism/Layout/AppScreens.swift+++ b/Asterism/Asterism/Layout/AppScreens.swift@@ -97,6 +97,9 @@ struct AppScreens { snapshot: model.worksSnapshot, duplicateWorkload: model.recentPresentation.duplicateWorkload, titlesByWorkID: model.workTitlesByID,+ // `works-list-options` Q14: derived once per snapshot publication+ // beside the titles, never in `WorksView.body`.+ filterOptions: model.worksFilterOptions, isAwaitingFirstSync: model.recentSyncPresentation.isAwaitingFirstSync, // Req 1.5 again, and gated for `recent()`'s reason. selectedWorkID: isWide ? navigation.selectedWorkID : nil,
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex 1ce2a67..0b9725c 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -51,6 +51,15 @@ enum UITestFixtureKind: Equatable { /// more than one, so without this the multi-band path is unreachable from a /// simulator UI test (`specs/stats-page/` Q49). case spanningMonths+ /// A small legal library the Works list's sort and filter controls have+ /// something to say about (`works-list-options`): four works over two+ /// hostnames whose titles order differently by letter than by date, one+ /// typed, one wearing a type the list no longer offers, one tagged, one+ /// with no entries — plus an unattached entry, so the group a filter hides+ /// is on screen to begin with. No other fixture has a second hostname, a+ /// tag or an empty work, so without this every filter dimension offers one+ /// value and every sort draws the same list.+ case worksOptions /// Whether the seeded shape needs a second open before its diagnoses are /// complete. `.invalidSiteTuple` is produced only by the full@@ -95,6 +104,8 @@ enum UITestLaunchSupport { static let seededSpanningMonthsScenario = "seeded-spanning-months" /// The two preserved captures the drain's Settings surfaces need. static let seededPendingCapturesScenario = "seeded-pending-captures"+ /// The four works the Works list's sort and filter journey runs over.+ static let seededWorksOptionsScenario = "seeded-works-options" /// One scenario per tolerated state, plus the illegal-tuple state that /// carries the re-teach route and the empty-library shape Q15 hoists the /// banner for. Keyed by the fixture's own raw value so a new shape needs no@@ -213,6 +224,8 @@ enum UITestLaunchSupport { fixture = .spanningMonths case seededPendingCapturesScenario: fixture = .pendingCaptures+ case seededWorksOptionsScenario:+ fixture = .worksOptions case let scenario where scenario.hasPrefix(seededScaleM4ToleratedPrefix): guard let state = M4ToleratedFixtureState( rawValue: String(scenario.dropFirst(seededScaleM4ToleratedPrefix.count)))
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 0e382c0..1b49a5f 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -27,6 +27,15 @@ public final class AppLibraryModel { /// rebuilt on every body evaluation — once per keystroke of the search /// field, over the whole library. public private(set) var workTitlesByID: [UUID: String] = [:]+ /// The values the Works list's three filter pickers offer, published with+ /// the snapshot they are derived from (`works-list-options` Req 5, Q14).+ ///+ /// Beside `workTitlesByID` for its reason: three sorted vocabularies over+ /// every work in the library are a function of the snapshot, and a computed+ /// property on `WorksView` would rebuild them on every body evaluation —+ /// once per keystroke of the search field. Internal rather than `public`+ /// because `WorksFilterOptions` is an app-layer presentation type.+ private(set) var worksFilterOptions: WorksFilterOptions = .empty /// How many refresh cycles have completed, bumped once per cycle with the /// snapshots it publishes (`stats-page` Q27, Q29, Q37). ///@@ -888,6 +897,9 @@ public final class AppLibraryModel { worksSnapshot = works workTitlesByID = Dictionary( works.works.map { ($0.id, $0.displayTitle) }, uniquingKeysWith: { first, _ in first })+ // Derived from the **full** snapshot, before any search or filter:+ // the options never narrow as other dimensions are chosen (Q12).+ worksFilterOptions = WorksFilterOptions(works: works.works) snapshotGeneration += 1 } catch { Self.logger.error("Snapshot refresh failed: \(String(describing: error), privacy: .public)")@@ -1715,6 +1727,90 @@ public final class AppLibraryModel { Self.logger.debug("Seeded the character-extraction UI test fixture") } + /// `works-list-options`: the smallest library the Works list's sort and+ /// filter controls have something to say about.+ ///+ /// Four works over two hostnames, whose titles order differently by letter+ /// than by date, so each of the four sorts draws a different list and a+ /// journey can tell them apart:+ ///+ /// | Work | Site | Captured | Type | Tags |+ /// |---|---|---|---|---|+ /// | Marrow Lane | alpha.test | first | — | mystery |+ /// | Ashfall | beta.test | second | webtoon, then removed | — |+ /// | Zephyr Court | alpha.test | third | novel | — |+ /// | Quill Harbour | beta.test | no entries | — | — |+ ///+ /// Plus one unattached entry, so the group a filter hides (Q9) is on screen+ /// before the filter is chosen.+ ///+ /// **The three works are seeded one whole work at a time**, rather than+ /// three captures and then three moves. `works()` orders on the newest+ /// entry's `lastSharedAt`, which the clock quantizes to a millisecond and+ /// which ties break on a random identifier — so the writes that create and+ /// attach each work are what keeps the next capture in a later millisecond+ /// and the date order deterministic.+ private func seedWorksOptionsFixture(in repo: LibraryRepository) async throws {+ let operation = "seeding the works-list-options UI test fixture"+ let types = try await repo.workTypeOptions()+ guard+ let novel = types.first(where: { $0.name == "novel" }),+ let webtoon = types.first(where: { $0.name == "webtoon" })+ else {+ throw LibraryRepositoryError.invalidInput(+ operation: operation, reason: "the seeded work types are missing")+ }++ try await seedWorksOptionsWork(+ title: "Marrow Lane", hostname: "alpha.test", slug: "marrow",+ type: .none, tags: ["mystery"], in: repo)+ try await seedWorksOptionsWork(+ title: "Ashfall", hostname: "beta.test", slug: "ashfall",+ type: .configured(webtoon.id), tags: [], in: repo)+ try await seedWorksOptionsWork(+ title: "Zephyr Court", hostname: "alpha.test", slug: "zephyr",+ type: .configured(novel.id), tags: [], in: repo)++ // After the assignment, so Ashfall keeps displaying a type the picker+ // no longer offers — the `.removed` display the dimmed filter row is+ // about (Req 5). Nothing about the work is edited by this.+ try await repo.removeWorkType(id: webtoon.id)++ _ = try await repo.createWork(+ NewWorkDraft(displayTitle: "Quill Harbour", hostname: "beta.test"))+ _ = try await repo.capture(CaptureDraft(+ captureTitle: "Loose Note",+ captureTitleSource: .host,+ rawURLString: "https://alpha.test/loose/1"))+ Self.logger.debug("Seeded the works-list-options UI test fixture")+ }++ /// One work of that fixture: a capture, a work on the same site, the move+ /// that puts one under the other, and the metadata the filters read.+ private func seedWorksOptionsWork(+ title: String, hostname: String, slug: String,+ type: WorkTypeAssignment, tags: [String], in repo: LibraryRepository+ ) async throws {+ let entry = try await repo.capture(CaptureDraft(+ captureTitle: "Chapter 1 - \(title)",+ captureTitleSource: .host,+ rawURLString: "https://\(hostname)/\(slug)/1"))+ let work = try await repo.createWork(+ NewWorkDraft(displayTitle: title, hostname: hostname))+ let assignment = try await repo.entry(id: entry.id)+ _ = try await repo.moveEntry(+ entry.id,+ basis: EntryAssignmentBasis(entry: assignment),+ to: .existing(work.id))++ let reloaded = try await repo.work(id: work.id)+ _ = try await repo.updateWork(+ id: work.id,+ basis: WorkEditBasis(work: reloaded),+ draft: WorkMetadataDraft(+ displayTitle: title, typeAssignment: type, genreTags: tags, genericNotes: ""))+ }+ /// Preserves two captures in the disposable root's spool, exactly as the /// share extension would have: one the drain commits, and one it can never /// commit and therefore sets aside (Req 6.4).@@ -1808,6 +1904,11 @@ public final class AppLibraryModel { return } + if fixture == .worksOptions {+ try await seedWorksOptionsFixture(in: repository)+ return+ }+ if fixture == .composed { // Production opens through the app-role opener, so the composed // fixture seeds through the ordinary bootstrap, which creates and
diff --git a/Asterism/Asterism/ViewModels/SearchFilters.swift b/Asterism/Asterism/ViewModels/SearchFilters.swiftindex 6109fe9..4fb81a6 100644--- a/Asterism/Asterism/ViewModels/SearchFilters.swift+++ b/Asterism/Asterism/ViewModels/SearchFilters.swift@@ -66,8 +66,10 @@ struct RecentSearchFilter: Equatable { /// The merge picker offers **every** other Work in the library now that a merge /// can cross sites, so a title search is what keeps the list usable (Q9). ///-/// Filtering only, never reordering, on either surface: the Works tab keeps its-/// order (empty works sinking included, 4.1), and Req 4.1's picker order —+/// Filtering only, never reordering, on either surface. On the Works tab the+/// order is the **reader's**: `WorksSort` is applied after this filter+/// (`works-list-options` Reqs 1-3), and this type hands on whatever order it+/// was given so the two never fight over the list. Req 4.1's picker order — /// shared hostname, then equal parsed title, then title — is /// `WorkMergePlanner.destinations`' and nowhere else's (Q63). A filter that /// re-sorted would be the second answer that decision exists to prevent.
diff --git a/Asterism/Asterism/ViewModels/WorksListOptions.swift b/Asterism/Asterism/ViewModels/WorksListOptions.swiftnew file mode 100644index 0000000..9cb9328--- /dev/null+++ b/Asterism/Asterism/ViewModels/WorksListOptions.swift@@ -0,0 +1,333 @@+import AsterismCore+import Foundation++// The Works list's sort and filters (`works-list-options` Reqs 1-5).+//+// Pure value types beside `WorksSearchFilter`, for its reason: the ordering and+// the narrowing are functions of one repository read, so the whole requirement+// is testable without a view. Applied in the app layer rather than in+// `LibraryRepository.works()` (Q6) — the repository's order stays the one+// "latest entry" order every other surface reads, and a reversed or title order+// is presentation.++/// The `@AppStorage` key the sort persists under (Req 4).+///+/// Named here rather than spelled in the view for `RestoreStorageKey`'s reason:+/// the view writes it and `ContentView.launchModel()` clears it for a seeded+/// UI-test launch (Q8), and a typo in either is a preference that silently+/// never persists — or never resets.+enum WorksListStorageKey {+ static let sort = "worksList.sort"+}++/// The reader's order over the Works list (Reqs 1-3, Q11).+///+/// One four-way choice rather than a key plus a direction: with the two stored+/// separately, a reader on Z to A who switches to latest entry lands on Oldest+/// first without asking for it.+enum WorksSort: String, CaseIterable, Identifiable, Sendable {+ /// The repository's own order, untouched (Req 1).+ case newest+ case oldest+ case aToZ+ case zToA++ var id: String { rawValue }++ /// What a reader who never opens the control sees (Req 3).+ static let `default`: WorksSort = .newest++ /// An unrecognised stored value reads as the default (Req 4) — a value+ /// written by a later build, or a defaults plist edited by hand.+ init(storedValue: String?) {+ guard let storedValue, let sort = WorksSort(rawValue: storedValue) else {+ self = .default+ return+ }+ self = sort+ }++ var label: String {+ switch self {+ case .newest: "Newest first"+ case .oldest: "Oldest first"+ case .aToZ: "A to Z"+ case .zToA: "Z to A"+ }+ }++ /// Whether empty works keep their own trailing section (Req 2, Q4).+ ///+ /// "No latest entry" is a property of the date ordering, which is why the+ /// section exists; a title has no such gap, and a second section under a+ /// title sort would break the alphabet in two.+ var sectionsEmptyWorks: Bool {+ switch self {+ case .newest, .oldest: true+ case .aToZ, .zToA: false+ }+ }++ /// The ordering itself, over the repository's order.+ ///+ /// Oldest first reverses each section separately, so the empty works stay+ /// behind the non-empty ones while their own `modifiedAt` order and id+ /// tie-break reverse with the rest (Req 1). The title sorts order everything+ /// together, because they draw one section.+ func apply(to works: [WorkSnapshot]) -> [WorkSnapshot] {+ switch self {+ case .newest:+ return works+ case .oldest:+ let nonEmpty = works.filter { !$0.entries.isEmpty }+ let empty = works.filter { $0.entries.isEmpty }+ return Array(nonEmpty.reversed()) + Array(empty.reversed())+ case .aToZ:+ return works.sorted(by: Self.titleAscending)+ case .zToA:+ // The comparison inverted rather than the result reversed: one+ // array instead of two, and the same order because the comparison+ // is a total one.+ return works.sorted { Self.titleAscending($1, $0) }+ }+ }++ /// Display titles by `localizedStandardCompare`, ties broken by the+ /// lowercased id string ascending — the tie-break the repository already+ /// uses, so two works sharing a title order the same way here as there.+ private static func titleAscending(_ left: WorkSnapshot, _ right: WorkSnapshot) -> Bool {+ switch left.displayTitle.localizedStandardCompare(right.displayTitle) {+ case .orderedAscending: return true+ case .orderedDescending: return false+ case .orderedSame:+ return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()+ }+ }+}++/// Which type a type filter is asking about (Q7).+///+/// Named types are keyed by `WorkTypeName.normalize` of the **displayed** name,+/// so works on either side of a type merge share one option without an+/// app-layer directory fetch. `untyped` is an enum case rather than a reserved+/// string, so a type literally named "Untyped" cannot collide with it.+enum WorksTypeSelection: Hashable, Sendable {+ /// A work that draws no type pill: untyped, or an entry that has not+ /// arrived on this device. Both read as untyped to the reader, and the+ /// unresolved one leaves the option when its row arrives.+ case untyped+ case named(String)++ /// The option a work falls under. The one place a `WorkTypeDisplay` becomes+ /// a selection, so matching and the option list cannot disagree.+ static func selection(for display: WorkTypeDisplay) -> WorksTypeSelection {+ switch display.kind {+ case .none, .unresolved:+ return .untyped+ case .active, .removed:+ guard let name = display.name else { return .untyped }+ return .named(WorkTypeName.normalize(name))+ }+ }+}++/// The Works list's three single-value filters (Req 5, Q3).+///+/// One value per dimension, ANDed across the three and with the search query.+/// View state with the query's lifetime rather than a stored preference (Q5): a+/// sort is a preference a reader sets once, a filter is a question they are+/// asking now.+struct WorksFilter: Equatable, Sendable {+ var type: WorksTypeSelection?+ var tag: String?+ var hostname: String?++ init(type: WorksTypeSelection? = nil, tag: String? = nil, hostname: String? = nil) {+ self.type = type+ self.tag = tag+ self.hostname = hostname+ }++ var isActive: Bool { type != nil || tag != nil || hostname != nil }++ func apply(to works: [WorkSnapshot]) -> [WorkSnapshot] {+ guard isActive else { return works }+ return works.filter(matches)+ }++ /// The same filter with any value the options no longer offer dropped —+ /// the last work carrying a chosen tag has left the library, say. Without+ /// this the picker holds a selection none of its rows carry, draws no+ /// checkmark anywhere in that section, and the reader's only way out is+ /// Clear.+ func pruned(to options: WorksFilterOptions) -> WorksFilter {+ var pruned = self+ if let type, !options.types.contains(where: { $0.selection == type }) { pruned.type = nil }+ if let tag, !options.tags.contains(tag) { pruned.tag = nil }+ if let hostname, !options.hostnames.contains(hostname) { pruned.hostname = nil }+ return pruned+ }++ private func matches(_ work: WorkSnapshot) -> Bool {+ if let type, WorksTypeSelection.selection(for: work.typeDisplay) != type { return false }+ // Genre tags are compared as stored — trimmed and deduplicated+ // case-sensitively on write — so two tags differing only in case are+ // two options and two questions.+ if let tag, !work.genreTags.contains(tag) { return false }+ // Any membership, not just the first: a multi-site work answers for+ // every site it is on. Read off the memberships directly rather than+ // through `hostnames`, which builds an array per call.+ if let hostname, !work.memberships.contains(where: { $0.hostname == hostname }) {+ return false+ }+ return true+ }+}++/// The values the three filter pickers offer, derived from the **full** works+/// snapshot before search and filters (Req 5, Q12, Q14).+///+/// Not faceted: the options never narrow as other dimensions are chosen, so a+/// reader can pick a tag no work on the chosen site carries and land on the+/// empty state, which names the filters and so explains the dead end itself.+///+/// Derived once per snapshot publication in `AppLibraryModel` and passed in,+/// never in `WorksView.body` — that runs once per keystroke of the search field.+struct WorksFilterOptions: Equatable, Sendable {++ /// What the reader sees for a work that draws no type pill.+ static let untypedLabel = "Untyped"++ struct TypeOption: Hashable, Identifiable, Sendable {+ let selection: WorksTypeSelection+ /// The first spelling seen for this type across the snapshot. Works on+ /// either side of a merge collapse onto one option, and the option wears+ /// the spelling the list first met.+ let name: String+ /// Drawn knocked down, the way `WorkTypePresentation.menuRowStyle` dims+ /// a removed type — only when *every* work under the option wears+ /// `.removed`, because a single active work makes it a live type.+ let isDimmed: Bool++ var id: WorksTypeSelection { selection }+ }++ let types: [TypeOption]+ let tags: [String]+ let hostnames: [String]++ /// What to call a type selection — the option's own spelling where the+ /// snapshot still offers it, so a pill and the menu row that set it read the+ /// same. A selection whose works have all left the library between the pick+ /// and the redraw falls back to the normalised name rather than vanishing+ /// from the sentence naming it.+ func name(for selection: WorksTypeSelection) -> String {+ if let option = types.first(where: { $0.selection == selection }) { return option.name }+ switch selection {+ case .untyped: return Self.untypedLabel+ case .named(let name): return name+ }+ }++ static let empty = WorksFilterOptions(types: [], tags: [], hostnames: [])++ private init(types: [TypeOption], tags: [String], hostnames: [String]) {+ self.types = types+ self.tags = tags+ self.hostnames = hostnames+ }++ init(works: [WorkSnapshot]) {+ // One record per option: the first spelling seen, and whether every+ // work so far wore a removed type.+ var typeRecords: [WorksTypeSelection: (name: String, allRemoved: Bool)] = [:]+ var tags: Set<String> = []+ var hostnames: Set<String> = []++ for work in works {+ let selection = WorksTypeSelection.selection(for: work.typeDisplay)+ let isRemoved = work.typeDisplay.kind == .removed+ if let record = typeRecords[selection] {+ typeRecords[selection] = (record.name, record.allRemoved && isRemoved)+ } else {+ let name = selection == .untyped+ ? Self.untypedLabel+ : (work.typeDisplay.name ?? Self.untypedLabel)+ typeRecords[selection] = (name, isRemoved)+ }+ tags.formUnion(work.genreTags)+ for membership in work.memberships { hostnames.insert(membership.hostname) }+ }++ self.types = typeRecords+ .map { selection, record in+ TypeOption(selection: selection, name: record.name, isDimmed: record.allRemoved)+ }+ .sorted { left, right in+ switch left.name.localizedStandardCompare(right.name) {+ case .orderedAscending: return true+ case .orderedDescending: return false+ // Only reachable between "Untyped" and a type spelled exactly+ // that. Ordering the untyped option last keeps it deterministic.+ case .orderedSame: return right.selection == .untyped+ }+ }+ self.tags = tags.sorted { $0.localizedStandardCompare($1) == .orderedAscending }+ self.hostnames = hostnames.sorted { $0.localizedStandardCompare($1) == .orderedAscending }+ }+}++/// What the Works list says about its own filters (`works-list-options` Reqs+/// 7-8, 11), stated once so the pills, the empty state and the menu's+/// accessibility identifiers cannot drift apart. Beside the values it formats+/// rather than in the view file (Q16), and `nonisolated` for the same reason+/// the values are `Sendable`: pure functions of value types.+nonisolated enum WorksFilterPresentation {++ /// The active values in menu order — type, then tag, then site — spelled as+ /// the pickers spell them. A type is named by the option it came from, so a+ /// merged type's pill wears the same first-seen spelling its menu row does.+ static func activeLabels(_ filter: WorksFilter, options: WorksFilterOptions) -> [String] {+ var labels: [String] = []+ if let type = filter.type { labels.append(options.name(for: type)) }+ if let tag = filter.tag { labels.append(tag) }+ if let hostname = filter.hostname { labels.append(hostname) }+ return labels+ }++ /// Req 8's sentence: the filters, and the query when there is one. A blank+ /// query is no query, as `WorksSearchFilter` reads it.+ static func emptyDescription(labels: [String], query: String?) -> String {+ let filters = labels.joined(separator: ", ")+ let needle = query?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""+ guard !needle.isEmpty else { return "No works match \(filters)." }+ return "No works match \(filters) and “\(needle)”."+ }++ // MARK: - Menu row identifiers (Req 11)++ static func sortRowIdentifier(_ sort: WorksSort) -> String {+ "works-sort-\(sort.rawValue)"+ }++ static let anyTypeRowIdentifier = "works-filter-type-any"+ static let anyTagRowIdentifier = "works-filter-tag-any"+ static let anySiteRowIdentifier = "works-filter-site-any"++ /// One type row in the filter menu. Keyed by the normalised name rather than+ /// the displayed one so the identifier survives a re-spelling of the type.+ static func typeRowIdentifier(_ selection: WorksTypeSelection) -> String {+ switch selection {+ case .untyped: "works-filter-type-untyped"+ case .named(let name): "works-filter-type-\(name)"+ }+ }++ static func tagRowIdentifier(_ tag: String) -> String {+ "works-filter-tag-\(tag)"+ }++ static func siteRowIdentifier(_ hostname: String) -> String {+ "works-filter-site-\(hostname)"+ }+}
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 4d0626a..24cb6fd 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -25,6 +25,14 @@ struct WorksView: View { /// and a computed property would rebuild it on every body evaluation, which /// is once per keystroke of the search field. let titlesByWorkID: [UUID: String]+ /// The values the three filter pickers offer (`works-list-options` Req 5),+ /// from the **full** snapshot before search and filters — so the options+ /// never narrow as other dimensions are chosen (Q12).+ ///+ /// Passed in for `titlesByWorkID`'s reason and Q14's: three sorted+ /// vocabularies over every work belong beside the titles in+ /// `AppLibraryModel`, not in a body that runs once per keystroke.+ let filterOptions: WorksFilterOptions /// `ipad-and-mac-layouts` Req 4.9: nothing has arrived from iCloud yet, so /// an empty list is still filling rather than empty. From /// `AppLibraryModel.recentSyncPresentation`, exactly as Recent's is.@@ -43,6 +51,7 @@ struct WorksView: View { snapshot: WorksSnapshot, duplicateWorkload: DuplicateWorkload = .empty, titlesByWorkID: [UUID: String] = [:],+ filterOptions: WorksFilterOptions = .empty, isAwaitingFirstSync: Bool = false, selectedWorkID: UUID? = nil, searchFocusRequest: Int = 0,@@ -56,6 +65,7 @@ struct WorksView: View { self.snapshot = snapshot self.duplicateWorkload = duplicateWorkload self.titlesByWorkID = titlesByWorkID+ self.filterOptions = filterOptions self.isAwaitingFirstSync = isAwaitingFirstSync self.selectedWorkID = selectedWorkID self.searchFocusRequest = searchFocusRequest@@ -71,12 +81,37 @@ struct WorksView: View { /// below (and with it empty works sinking) is unaffected by it. @State private var searchQuery = "" + /// `works-list-options` Reqs 3-4, Q5: a sort of the whole library is a+ /// preference a reader sets once, so it persists — one value per device,+ /// shared by the compact and wide trees, not synced.+ ///+ /// Stored as the raw string and read back through `WorksSort(storedValue:)`+ /// so a value written by a later build reads as the default rather than+ /// trapping.+ @AppStorage(WorksListStorageKey.sort) private var storedSort: String =+ WorksSort.default.rawValue++ /// `works-list-options` Q5: a filter is a question the reader is asking+ /// now, so it lives beside the query with the query's lifetime — cleared by+ /// the Recent truncation footer's route, which gives this view a new+ /// identity (`AppScreens.works()`'s `worksResetToken`).+ @State private var filter = WorksFilter()++ private var sort: WorksSort { WorksSort(storedValue: storedSort) }++ private var sortBinding: Binding<WorksSort> {+ Binding(get: { sort }, set: { storedSort = $0.rawValue })+ }+ private var searchFilter: WorksSearchFilter { WorksSearchFilter(query: searchQuery) } - private var displayedSnapshot: WorksSnapshot {- searchFilter.apply(to: snapshot)+ /// Search, then filter, then sort — the order the requirements state and the+ /// only one that reads: the sort orders what is left, and both narrowings+ /// are order-independent of each other.+ private var displayedWorks: [WorkSnapshot] {+ sort.apply(to: filter.apply(to: searchFilter.apply(to: snapshot.works))) } /// Req 4.9's branch, asked of the **unfiltered** snapshot: a library with@@ -102,17 +137,30 @@ struct WorksView: View { } var body: some View {- // Filtered and partitioned once per body evaluation — the filter runs- // per keystroke, and the empty check plus both sections consume it.- let displayedWorks = displayedSnapshot.works- let nonEmptyWorks = displayedWorks.filter { !$0.entries.isEmpty }- let emptyWorks = displayedWorks.filter { $0.entries.isEmpty }+ // Narrowed, sorted and partitioned once per body evaluation — the+ // narrowing runs per keystroke, and the empty check plus both sections+ // consume it.+ let displayedWorks = self.displayedWorks+ // `works-list-options` Req 2, Q4: empty works keep their own trailing+ // section under the date sorts only. A title sort draws one section,+ // because a second would break the alphabet in two.+ let nonEmptyWorks =+ sort.sectionsEmptyWorks ? displayedWorks.filter { !$0.entries.isEmpty } : displayedWorks+ let emptyWorks =+ sort.sectionsEmptyWorks ? displayedWorks.filter { $0.entries.isEmpty } : [] Group { if arrivingState == .arriving { arrivingBranch+ } else if filter.isActive && displayedWorks.isEmpty {+ // `works-list-options` Req 8: a filter that matches nothing,+ // with or without a query, names what is narrowing the list —+ // the options are not faceted (Q12), so this dead end has to+ // explain itself.+ filterEmptyBranch } else if searchFilter.isActive && displayedWorks.isEmpty {- // Req 4.3. The unattached group is hidden under a query (4.2),- // so with no matching work there is genuinely nothing to list.+ // Req 4.3, unchanged for the query-only miss (Q13). The+ // unattached group is hidden under a query (4.2), so with no+ // matching work there is genuinely nothing to list. ContentUnavailableView.search(text: searchQuery) .accessibilityIdentifier("works-search-empty") } else {@@ -132,7 +180,21 @@ struct WorksView: View { // ⌘F moves focus into. .listSearch( text: $searchQuery, prompt: "Search works", focusRequest: searchFocusRequest)+ // A chosen value the new snapshot no longer offers is dropped, so the+ // picker never holds a selection none of its rows carry.+ .onChange(of: filterOptions) { _, options in+ filter = filter.pruned(to: options)+ } .toolbar {+ // `works-list-options` Req 6, Q2: one menu beside New Work on every+ // platform, rather than a capsule above the list — the rows would+ // pay for that chrome at accessibility sizes, and a sort plus three+ // open vocabularies is not what a capsule is for. No `#if` here:+ // `Menu` and `Picker` are cross-platform, so the Mac gets the same+ // control from the same source.+ ToolbarItem(placement: .primaryAction) {+ optionsMenu+ } ToolbarItem(placement: .primaryAction) { Button { onNewWork()@@ -144,34 +206,188 @@ struct WorksView: View { } } + // MARK: - Sort and filter menu++ /// Req 6's one control. The pickers are inline rather than submenus (Q2):+ /// five submenus on a phone is five extra taps, and an inline picker renders+ /// its label as the section header the requirement asks for.+ private var optionsMenu: some View {+ Menu {+ Picker("Sort", selection: sortBinding) {+ ForEach(WorksSort.allCases) { option in+ Text(option.label)+ .accessibilityIdentifier(WorksFilterPresentation.sortRowIdentifier(option))+ .tag(option)+ }+ }+ .pickerStyle(.inline)++ filterPicker(+ "Type", options: filterOptions.types, selection: $filter.type,+ anyIdentifier: WorksFilterPresentation.anyTypeRowIdentifier,+ tag: \.selection,+ identifier: { WorksFilterPresentation.typeRowIdentifier($0.selection) }+ ) { option in+ Text(option.name)+ // Req 5: a type every one of whose works wears a removed+ // assignment is knocked down the way the editor's own type+ // menu knocks one down. AppKit menus largely ignore a row's+ // foreground style; that is accepted.+ .foregroundStyle(+ WorkTypePresentation.menuRowStyle(for: option.isDimmed ? .removed : .active))+ }++ filterPicker(+ "Tag", options: filterOptions.tags, selection: $filter.tag,+ anyIdentifier: WorksFilterPresentation.anyTagRowIdentifier,+ tag: \.self,+ identifier: WorksFilterPresentation.tagRowIdentifier+ ) { Text($0) }++ filterPicker(+ "Site", options: filterOptions.hostnames, selection: $filter.hostname,+ anyIdentifier: WorksFilterPresentation.anySiteRowIdentifier,+ tag: \.self,+ identifier: WorksFilterPresentation.siteRowIdentifier+ ) { Text($0) }+ } label: {+ // Req 6: filled while any filter is active. The sort alone does not+ // change it — every list has a sort, so a permanently filled icon+ // would say nothing.+ Label(+ "Sort and Filter",+ systemImage: filter.isActive+ ? "line.3.horizontal.decrease.circle.fill"+ : "line.3.horizontal.decrease.circle")+ }+ .accessibilityIdentifier("works-list-options-menu")+ .accessibilityLabel(+ filter.isActive ? "Sort and filter works, filters active" : "Sort and filter works")+ }++ /// One filter dimension: "Any" — the one selection that is not a value, so+ /// it cannot be drawn from the options — then every value the snapshot+ /// offers. Stated once for the three dimensions so the "Any" row and the+ /// identifiers cannot be spelled three ways.+ private func filterPicker<Option: Hashable, Tag: Hashable, RowLabel: View>(+ _ title: String,+ options: [Option],+ selection: Binding<Tag?>,+ anyIdentifier: String,+ tag: KeyPath<Option, Tag>,+ identifier: @escaping (Option) -> String,+ @ViewBuilder label: @escaping (Option) -> RowLabel+ ) -> some View {+ Picker(title, selection: selection) {+ Text("Any")+ .accessibilityIdentifier(anyIdentifier)+ .tag(Tag?.none)+ ForEach(options, id: \.self) { option in+ label(option)+ .accessibilityIdentifier(identifier(option))+ .tag(Tag?.some(option[keyPath: tag]))+ }+ }+ .pickerStyle(.inline)+ }++ // MARK: - Active filters++ /// What the pills and the empty state both name — the active values in menu+ /// order, spelled as the picker spells them.+ private var activeFilterLabels: [String] {+ WorksFilterPresentation.activeLabels(filter, options: filterOptions)+ }++ /// Req 7's pill row, drawn only where the caller has established that a+ /// filter is active — a section whose header is a *conditional* view is+ /// still a section with a header, and an unfiltered list must be exactly+ /// what it was before this feature.+ private var filterPills: some View {+ FlowLayout(spacing: 6) {+ ForEach(activeFilterLabels, id: \.self) { label in+ Text(label)+ // The neutral tag recipe, as the row's own asides use.+ .constellationPill(.genreTag)+ .lineLimit(1)+ // On each pill rather than on the row around them: an+ // identifier applied to a container is *pushed down* onto+ // every element inside it, so naming the `FlowLayout` gave+ // the Clear button this identifier too and took away its+ // own. Measured from a UI-test element dump, where the+ // button read `identifier: 'works-filter-pills', label:+ // 'Clear filters'` and `works-filter-clear` addressed+ // nothing in the pill row.+ .accessibilityIdentifier("works-filter-pills")+ }+ clearFiltersButton+ }+ }++ /// Req 7's Clear: it removes every filter and leaves the query alone (Q13).+ /// One meaning for Clear, on both surfaces that offer it — the search field+ /// stays on screen and editable, so a reader left on the search empty state+ /// after clearing can see why.+ private var clearFiltersButton: some View {+ Button {+ filter = WorksFilter()+ } label: {+ Text("Clear")+ .constellationPill(.attention)+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier("works-filter-clear")+ .accessibilityLabel("Clear filters")+ }++ /// Req 8's empty state, distinct from `works-search-empty`: a filtered miss+ /// has to name what is narrowing the list, because the option lists are not+ /// faceted (Q12) and a reader can reach a combination no work carries.+ private var filterEmptyBranch: some View {+ ContentUnavailableView {+ Label("No matching works", systemImage: "line.3.horizontal.decrease.circle")+ // On the state's own title rather than on the whole view, for+ // the reason the pill row records: a container's identifier is+ // pushed down onto every element inside it, and on the whole+ // state it took `works-filter-clear` off the Clear button in+ // the actions below — leaving Req 7's one meaningful control+ // addressable only by its label.+ .accessibilityIdentifier("works-filter-empty")+ } description: {+ Text(+ WorksFilterPresentation.emptyDescription(+ labels: activeFilterLabels,+ query: searchFilter.isActive ? searchQuery : nil))+ } actions: {+ clearFiltersButton+ }+ }+ private func worksList( nonEmptyWorks: [WorkSnapshot], emptyWorks: [WorkSnapshot], titles: [UUID: String] ) -> some View { List { // Non-empty works section if !nonEmptyWorks.isEmpty {- Section {- ForEach(nonEmptyWorks, id: \.id) { work in- workButton(work, titles: titles)- .constellationListRow()- }- }+ worksSection(nonEmptyWorks, titles: titles, showsFilterPills: filter.isActive) } // Empty works section if !emptyWorks.isEmpty {- Section {- ForEach(emptyWorks, id: \.id) { work in- workButton(work, titles: titles)- .constellationListRow()- }- }+ // The pills belong to the *first* section, whichever that is: a+ // filter leaving only empty works still has to say so.+ worksSection(+ emptyWorks, titles: titles,+ showsFilterPills: filter.isActive && nonEmptyWorks.isEmpty) } // Unattached notes group. Hidden outright while a query is active // (Req 4.2, Q12): these entries have no work title to search, and // an empty group under a query reads as "your search cleared them".- if !searchFilter.isActive {+ // A filter hides it for the same reason (`works-list-options` Q9):+ // unattached entries have no type, tag or work-site membership, so+ // no filter can describe them.+ if !searchFilter.isActive && !filter.isActive { Section { unattachedGroup .constellationListRow()@@ -199,6 +415,39 @@ struct WorksView: View { .accessibilityIdentifier("works-list") } + /// One section of work rows, with or without Req 7's pill header.+ ///+ /// Two spellings rather than one with a conditional header (Q15): a section+ /// built with a header is a section with a header even when that header+ /// resolves to nothing, and an unfiltered list has to stay exactly the list+ /// it was. The rows themselves are written once.+ @ViewBuilder+ private func worksSection(+ _ works: [WorkSnapshot], titles: [UUID: String], showsFilterPills: Bool+ ) -> some View {+ if showsFilterPills {+ Section {+ workRows(works, titles: titles)+ } header: {+ // The pills scroll with the content rather than sitting fixed+ // above the list, for Q2's reason — chrome above the list costs+ // the rows their space at accessibility sizes.+ filterPills+ }+ } else {+ Section {+ workRows(works, titles: titles)+ }+ }+ }++ private func workRows(_ works: [WorkSnapshot], titles: [UUID: String]) -> some View {+ ForEach(works, id: \.id) { work in+ workButton(work, titles: titles)+ .constellationListRow()+ }+ }+ /// §7's unattached-notes group: one dashed, lower-fill container around the /// rows rather than a dashed edge per row — the group is what the style /// guide marks.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex e11298f..d258e4a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **The Works list's sort and filters are proven from launch+ (works-list-options, phase "Fixture and journeys", T-2302 — spec+ complete).** A `seeded-works-options` scenario seeds four works over+ two hostnames whose titles order differently by letter than by date+ (one tagged, one typed, one wearing a removed type, one with no+ entries) plus an unattached entry; a seeded launch removes the stored+ sort so every test starts from Newest first (Q8).+ `WorksListOptionsUITests` walks the four sorts, a site filter, the+ pills, the filled icon, the hidden unattached group, the filter empty+ state, Clear and the query-only empty state; the iPhone+ `AccessibilityJourneyUITests` class asserts the menu opens and a work+ row stays hittable at `AccessibilityXXXL` with a filter active. The+ journey caught two identifiers pushed down from their containers onto+ the Clear button (`works-filter-pills` on the `FlowLayout`,+ `works-filter-empty` on the whole `ContentUnavailableView`); each now+ sits on the element it names, and `docs/agent-notes/testing.md`+ records the shape. `make test-quick` and the full `make test-ui` green+ (the three pre-existing `M4ScaleRecentPerformanceUITests` cases+ excepted, reproduced in isolation), no new warnings on an unwrapped+ iOS or Mac build. The Mac toolbar menu compiles but its rendering is+ the owner's manual check.++- **The Works list sorts and filters from one toolbar menu+ (works-list-options, phase "The list", T-2302).** `AppLibraryModel`+ derives `WorksFilterOptions` once per snapshot publication beside+ `workTitlesByID` and `AppScreens.works()` passes it in (Q14).+ `WorksView` holds the sort in `@AppStorage` under `worksList.sort`,+ read back so an unrecognised value is Newest first, and the filter in+ `@State` beside the query so the Recent truncation route clears both+ (Q5). `body` applies search, then filter, then sort, and sections empty+ works only under the two date sorts (Q4). One `Menu` beside New Work+ holds four inline pickers under their own headers (Q2); its icon fills+ while any filter is active, and a type every one of whose works wears a+ removed assignment is dimmed with `WorkTypePresentation.menuRowStyle`.+ Active filters draw as `.genreTag` pills in a `FlowLayout` as the first+ section's header with a Clear that leaves the query alone; the+ Unattached Notes group hides under a filter as under a query (Q9); a+ filtered miss shows `works-filter-empty` naming the filters and the+ query, and a query-only miss keeps `works-search-empty` (Q13).+ `WorksFilterPresentation` states the labels, the empty sentence and the+ row identifiers once, with its own unit tests. `polish-and-export`+ Req 4.1 and `WorksSearchFilter`'s doc comment now say the list's order+ is the reader's. `make test-quick`, `SearchUITests`,+ `WorksAndAssignmentUITests` and `AccessibilityJourneyUITests` green with+ no new warnings; the full `make test-ui` is owed at task 10.++- **The Works list's sort and filter logic (works-list-options, phase+ "Pure logic", T-2302).** `WorksListOptions.swift` adds the value types+ the list's toolbar menu will drive, with no view change yet.+ `WorksSort` is the four-way choice (Q11): Newest first is the+ repository's order untouched, Oldest first reverses the non-empty and+ empty sections separately so empty works stay trailing, and A to Z /+ Z to A order display titles by `localizedStandardCompare` with the+ repository's lowercased-id tie-break; the value says whether empty+ works are sectioned (Q4) and an unrecognised stored value reads as the+ default. `WorksFilter` ANDs one type, one tag and one site (Q3), with+ type selections keyed by `WorkTypeName.normalize` of the displayed+ name and `untyped` an enum case covering `.none` and `.unresolved`+ (Q7). `WorksFilterOptions` derives the three vocabularies from a full+ snapshot, ordered with `localizedStandardCompare`, with an Untyped+ option only when such a work exists and a type dimmed only when every+ work under it wears `.removed` (Q12). 28 unit tests in+ `WorksListOptionsTests.swift`; `make test-quick` green with no new+ warnings. `AsterismCore`, `WorksSearchFilter` and the merge picker are+ untouched.+ - **Three defects from the first real Mac run (ipad-and-mac-layouts, T-2286).** The detail column follows a second selection — the entry routes were identity-stable, so `EntryDetailView`'s `@State` models
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 9357765..113fb3d 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -356,6 +356,22 @@ rather than on the container. Cost one UI-test debugging cycle in `pending-capture-queue` task 19; `app.debugDescription` written to a file is what showed it (xcbeautify swallows `print`). +**Containers you do not think of as containers count too.** `works-list-options`+task 8 hit it twice in one journey: an identifier on a `FlowLayout` of pills took+`works-filter-clear` off the Clear button standing beside them, and an identifier+on a whole `ContentUnavailableView` did the same to the Clear in its `actions:`+closure. Both were fixed by naming the state's own title text and each pill+rather than the view around them. Run the raw `xcodebuild` (`PIPE_PRETTY=`) when+dumping `app.debugDescription` from an `XCTFail` — xcbeautify truncates the+message to its first line.++**An identifier on a `Text` inside an inline `Picker` does not reliably reach+the menu item XCUI sees.** The Works list's sort and filter rows carry one each,+but on iOS the generated menu item often exposes only the visible label, so+`UIJourneySupport.worksOptionRow(_:labelled:in:)` resolves by identifier first+and falls back to the label. Drive any new inline-picker row through that helper+(or the same fallback) rather than by identifier alone.+ **`accessibilityElement(children: .contain)` is not the fix.** It looks like one — name the container as a container and the children keep their own names — and it works only while the container holds **more than one** element. Hit again in
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 4c09537..4ad05ec 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -35,6 +35,7 @@ | [Character Ranking](#character-ranking) | 2026-08-30 | Done | T-2273. Orders a work's characters by prominence instead of name: each character's facts are bucketed by story position, scored `Σ 2^(-d/10) · log2(n+1)` with `d` the ordinal distance from the latest chapter, and ranked descending with name order as the tie-break. One derived order for the work page and the share sheet (overrules share-sheet-characters Q1); no schema change. | | [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed: the capsule passes the no-clipping assertion at `AccessibilityExtraExtraExtraLarge` (Q26) | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. | | [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. |+| [Works List Options](#works-list-options) | 2026-09-03 | Done — all 10 tasks implemented 2026-09-03 across three phases (pure logic, the list, fixture and journeys); `make test-quick` and `make test-ui` green (pre-existing M4Scale sim trio excepted) with no new warnings. The Mac toolbar menu's rendering remains the owner's manual check | Smolspec (T-2302). A four-way sort for the Works list (Newest first, Oldest first, A to Z, Z to A) and one-value filters by type, tag and site, from one toolbar menu on iPhone, iPad and Mac. The sort persists on the device; filters are view state with the search query's lifetime. Empty works keep their trailing section under the date sorts and join one section under the title sorts. App-layer only, on `WorkSnapshot`; amends `polish-and-export` Req 4.1's fixed-ordering clause. | --- @@ -592,3 +593,16 @@ T-2286 (2026-08-28). Split-view layouts for the iPad and a native Mac app plus M - [prerequisites.md](ipad-and-mac-layouts/prerequisites.md) - [verification-run.md](ipad-and-mac-layouts/verification-run.md) - [implementation.md](ipad-and-mac-layouts/implementation.md)++---++## Works List Options++**Created:** 2026-09-03 · **Status:** Done — all 10 tasks implemented 2026-09-03; the Mac toolbar menu's rendering remains the owner's manual check++Smolspec (T-2302). The Works list gains a sort choice and three filters from one toolbar menu beside New Work, on every platform the list appears on. The sort is one four-way picker (Q11) — Newest first, Oldest first, A to Z, Z to A — persisted in `@AppStorage` under one key (Q5), while filters are `@State` with the search query's lifetime and are cleared by the Recent truncation route (Q5). Filters are one value per dimension, ANDed with each other and with search (Q3); options come from the full snapshot and never narrow (Q12). Type options are keyed by `WorkTypeName.normalize` of the displayed name, with unresolved types under "Untyped" (Q7). Everything is applied in the app layer on `WorkSnapshot` (Q6), with the option vocabularies derived once per snapshot publication in `AppLibraryModel` (Q14); `AsterismCore`, `WorksSearchFilter` and the merge picker are untouched. Amends `polish-and-export` Req 4.1's "preserving the list's existing ordering" clause.++- [smolspec.md](works-list-options/smolspec.md)+- [tasks.md](works-list-options/tasks.md)+- [decision_log.md](works-list-options/decision_log.md)+- [implementation.md](works-list-options/implementation.md)
diff --git a/specs/polish-and-export/requirements.md b/specs/polish-and-export/requirements.mdindex a32e68e..32475c3 100644--- a/specs/polish-and-export/requirements.md+++ b/specs/polish-and-export/requirements.md@@ -66,7 +66,7 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e **Acceptance Criteria:** -1. <a name="4.1"></a>The Works tab SHALL provide a search field; WHEN the reader types, the list SHALL narrow to works whose display title contains the query, case-insensitively and diacritic-insensitively, updating as the query changes, preserving the list's existing ordering (including empty works sinking).+1. <a name="4.1"></a>The Works tab SHALL provide a search field; WHEN the reader types, the list SHALL narrow to works whose display title contains the query, case-insensitively and diacritic-insensitively, updating as the query changes, preserving the list's existing ordering (including empty works sinking). **Amended by `specs/works-list-options/`**: the search still never reorders, but "the list's existing ordering" is no longer fixed — the reader chooses it (Newest first, Oldest first, A to Z, Z to A), and empty works sink into their own section under the two date sorts only. 2. <a name="4.2"></a>WHILE a search is active, the Unattached-notes group SHALL be hidden; unattached entries do not participate in work-title search. 3. <a name="4.3"></a>WHEN a search yields no matches, the system SHALL show an empty-state message rather than a blank list.
diff --git a/specs/works-list-options/decision_log.md b/specs/works-list-options/decision_log.mdnew file mode 100644index 0000000..93f025f--- /dev/null+++ b/specs/works-list-options/decision_log.md@@ -0,0 +1,26 @@+# Decision Log: Works List Options++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-03 | Smolspec rather than full spec | Owner's call at scope assessment. The open questions (empty-works ordering, persistence, filter semantics, control form) are settled here as defaults rather than gathered as requirements. No schema, API or Core change; every choice reverts with the branch. |+| Q2 | 2026-09-03 | One toolbar `Menu`, not a `ConstellationSegmentedControl` capsule | The capsule precedent (`work-detail-reading-redesign` Decision 1, `stats-period-navigation` Q11) is for two or three fixed short labels. This control has a sort and three filters with open vocabularies; capsules above the list would cost the rows their space at accessibility sizes. Pickers are inline under section headers, not submenus: five submenus on a phone is five extra taps. |+| Q3 | 2026-09-03 | One value per filter dimension, AND across dimensions and with search | The simplest reading of "filter by type, tag, and site". Multi-select is a later addition if wanted; nothing in the pure filter shape prevents it. |+| Q4 | 2026-09-03 | Empty works keep their own trailing section under the date sorts, each section reversed for Oldest first; the title sorts have one section | "No latest entry" is a property of the date ordering, which is why the section exists (`polish-and-export` Req 4.1). A title has no such gap, and a second section under a title sort would break the alphabet in two. |+| Q5 | 2026-09-03 | Sort persists in `@AppStorage`; filters are per-visit `@State` | A sort of the whole library is a preference a reader sets once; a filter is a question they are asking now. The work page's chapter sort is deliberately per-visit (`work-detail-reading-redesign` Q3) because the useful chapter order changes with what the reader is doing on that page; the library's order does not. Per-visit filters also keep `recent-window-cap` Req 3.2's promise true without a new route. |+| Q6 | 2026-09-03 | Sort and filter are applied in the app layer on `WorkSnapshot`, not in `LibraryRepository.works()` | The repository's order stays the one "latest entry" order every other surface reads; a reversed or title order is presentation. `genreTags` is a `[String]` column and cannot be predicated on anyway. |+| Q7 | 2026-09-03 | Type options are keyed by `WorkTypeName.normalize` of the displayed name; unresolved types fall under "Untyped" | `WorkSnapshot.typeDisplay` already carries the directory-resolved name, so works on either side of a merge share an option without an app-layer directory fetch, and the directory's own identity is the normalised name (`WorkTypeName.swift:46`). An unresolved type draws no pill, exactly as an untyped work does, so "Untyped" is what the reader sees; it leaves the option when the row arrives. "Untyped" is an enum case, not a string, so a type literally named "Untyped" cannot collide with it. |+| Q8 | 2026-09-03 | A seeded UI-test launch removes the stored sort from `UserDefaults.standard` in `launchModel()` | The first persisted preference the UI suites can change; without a reset a test that picks A to Z leaks into every later test. A per-run defaults suite would need the run id plumbed into `AsterismApp` and would leave a plist per run behind; one `removeObject(forKey:)` where the seeded request is already resolved does the same job. |+| Q9 | 2026-09-03 | Filters hide the Unattached Notes group as search does | Unattached entries have no type or tag and no work-site membership, so no filter can describe them; an empty group under a filter reads as "your filter cleared them" (`polish-and-export` Q12). |+| Q10 | 2026-09-03 | Sort and filter are excluded from the Mac keyboard command set | `ipad-and-mac-layouts` Req 6.1 lists the commands; adding to it is its own change and the menu is reachable from the toolbar on every platform. |+| Q11 | 2026-09-03 | One four-way sort picker, not a key picker plus a direction picker | With key and direction stored separately, a reader on Z to A who switches to latest entry lands on Oldest first without asking for it. Four rows have no coupled state, one storage key, and one fewer control in the menu. |+| Q12 | 2026-09-03 | Option lists are not faceted | Options come from the full snapshot and never narrow as other dimensions are chosen, so a reader can pick a tag no work on the chosen site carries and land on the empty state. Simpler than faceting, and the empty state names the filters so the dead end explains itself. |+| Q13 | 2026-09-03 | Clear removes the filters only; a query-only miss keeps the existing search empty state | One meaning for Clear. The search field stays on screen and editable, so a reader left on the search empty state after Clear can see why. `SearchUITests` already asserts `works-search-empty` for the query-only case and keeps passing. |+| Q14 | 2026-09-03 | Filter options are derived once per snapshot in `AppLibraryModel` and passed in | `WorksView.body` runs per keystroke, and the file already records the rule against per-body derivations (`titlesByWorkID`). Three sorted vocabularies over every work belong beside `workTitlesByID`. |+| Q15 | 2026-09-03 | Active-filter pills sit in the first section's header, not above the list | Q2's own argument: chrome fixed above the list costs the rows their space at accessibility sizes. A header scrolls away with the content. |+| Q16 | 2026-09-03 | `WorksFilterPresentation` (pill labels, empty sentence, menu row identifiers) lives in `WorksListOptions.swift` beside the values it formats, as a `nonisolated` enum with its own test file | Implementation Approach bullet 1 puts the pure logic in that file; the first cut left it at the tail of `WorksView.swift` beside `WorksRowPresentation`, where a 700-line view hides it. Moved during the pre-push review. |+| Q17 | 2026-09-03 | `worksSection` spells two `Section`s over one shared row body rather than one `Section` with a conditional header | A `Section` built with a `header:` closure is a section with a header even when that header resolves to nothing, which changes the unfiltered list's spacing. An unfiltered list has to stay byte-for-byte the list it was. |+| Q18 | 2026-09-03 | Every active-filter pill carries the same identifier, `works-filter-pills`; the empty state's identifier sits on its title text | An identifier on the `FlowLayout` or on the whole `ContentUnavailableView` is pushed down onto every child and overwrote `works-filter-clear` on the Clear button — Req 7's one meaningful control was then addressable only by label. The cost is that a journey can assert the pill row exists but not read one pill's text; `WorksFilterPresentation.activeLabels` is unit-tested instead. |+| Q19 | 2026-09-03 | The seeded works-options fixture creates each work whole (capture, create, move, metadata) before the next capture | `works()` orders on the newest entry's `lastSharedAt`, quantized to a millisecond with a random-identifier tie-break; the writes between captures are what keep the next capture in a later millisecond, so the four sort orders the journey asserts are deterministic. Sequential seeding is load-bearing, not a missed concurrency. |+| Q20 | 2026-09-03 | A filter value the new snapshot no longer offers is pruned when the options change; the sort's `@AppStorage` keeps the explicit `WorksSort(storedValue:)` fallback rather than the `RawRepresentable` overload | Pruning: without it the picker holds a selection none of its rows carry and the only way out is Clear. The explicit fallback: Req 4's "unrecognised value reads as the default" is a stated, unit-tested contract of the value type, worth one small binding in the view. Both from the pre-push review. |
diff --git a/specs/works-list-options/implementation.md b/specs/works-list-options/implementation.mdnew file mode 100644index 0000000..f2735d4--- /dev/null+++ b/specs/works-list-options/implementation.md@@ -0,0 +1,291 @@+# Implementation: Works List Options++Branch `T-2302/works-list-options`, nine commits over `origin/main`, written+2026-09-03 during the pre-push review. The three explanations below are the+review's validation pass: each requirement had to be explainable at every level+or it was flagged in the Completeness Assessment at the end.++## Beginner Level++### What Changed / What This Does++The Works tab lists every work (a novel, a webtoon, a series of articles) the+reader has captured chapters of. Until now that list came in one fixed order,+newest chapter first, and the only way to shorten it was to type in the search+field.++This change adds one button beside "New Work" that opens a small menu. The+menu has four rows for the order (Newest first, Oldest first, A to Z, Z to A)+and three groups of rows for narrowing: pick one type ("novel", "webtoon"),+one tag ("mystery"), or one site ("alpha.test"). Each group starts with "Any",+which means "do not narrow on this".++When anything is narrowing the list, the button's icon fills in, small pills at+the top of the list say what is chosen, and a "Clear" pill removes all of them+at once. If the chosen combination matches nothing, the list is replaced by a+message that names what was chosen, with the same Clear control.++The chosen order is remembered on the device. The narrowing is not: it lasts as+long as the search text does and starts fresh next time.++### Why It Matters++A library of a few hundred works is hard to scan in one order. A reader who+wants to find the oldest thing they never finished, or every mystery on one+site, can now ask the list directly instead of scrolling or remembering+titles.++### Key Concepts++- **Sort** is the order of the rows. **Filter** is which rows are shown at all.+ The two are independent: filtering never changes order, sorting never hides+ anything.+- **Snapshot**: the app reads the whole library once into a plain value (a+ list of works with their titles, chapters, type, tags and sites) and every+ screen draws from that value. Sorting and filtering happen on that value in+ memory, not in the database.+- **Empty works**: a work the reader created but has not captured a chapter+ for. It has no "latest chapter" date, so under the date orders it sits in+ its own section at the bottom. Under the alphabetical orders it sits where+ its title falls, because a title has no such gap.+- **Persisted vs view state**: the sort is written to the device's settings+ store and read back next launch. The filter lives only in the screen and is+ gone when the screen is rebuilt.++---++## Intermediate Level++### Changes Overview++App layer only; `AsterismCore` is untouched.++- `Asterism/Asterism/ViewModels/WorksListOptions.swift` (new): `WorksSort`+ (four-case `String` enum, `apply(to:)`, `sectionsEmptyWorks`), `WorksFilter`+ (three optionals, `apply(to:)`, `pruned(to:)`), `WorksTypeSelection`+ (`.untyped` or `.named(normalisedName)`), `WorksFilterOptions` (the three+ vocabularies derived from a snapshot), `WorksFilterPresentation` (pill+ labels, empty-state sentence, menu row identifiers) and the storage key.+- `Asterism/Asterism/ViewModels/AppLibraryModel.swift`: builds+ `WorksFilterOptions` in the same block that builds `workTitlesByID`, once per+ snapshot publication; a new seeded UI-test fixture.+- `Asterism/Asterism/Layout/AppScreens.swift`: passes the options into+ `WorksView`.+- `Asterism/Asterism/Views/WorksView.swift`: `@AppStorage` sort, `@State`+ filter, the toolbar `Menu` with four inline pickers, the pill header, the+ filter empty state, and sectioning driven by the sort.+- `Asterism/Asterism/ContentView.swift`: a seeded launch removes the stored+ sort.+- `Asterism/Asterism/UITestLaunchSupport.swift`: the `seeded-works-options`+ scenario.+- `Asterism/Asterism/ViewModels/SearchFilters.swift` and+ `specs/polish-and-export/requirements.md`: wording only, pointing at this+ spec.+- Tests: `WorksListOptionsTests` (sort, filter, options, pruning),+ `WorksFilterPresentationTests`, `WorksListOptionsUITests` (the journey, a+ two-launch reset case, seeder reachability), one accessibility case in+ `AccessibilityJourneyUITests`, and two shared helpers in `UIJourneySupport`.++### Implementation Approach++The pipeline in `WorksView.body` is search, then filter, then sort, on+`snapshot.works`, once per body evaluation. The result is partitioned into+non-empty and empty works only when `sort.sectionsEmptyWorks` says so; under+the title sorts the empty partition is `[]` and one section draws.++`WorksSort.apply(to:)` treats the repository order as the source of truth.+Newest first returns the input. Oldest first partitions on `entries.isEmpty`,+reverses each half and concatenates, so the empty works stay trailing. The+title sorts use one comparator, `localizedStandardCompare` on `displayTitle`+with the lowercased id string as tie-break, which is the comparator+`LibraryRepository.workDestinations` uses. Z to A sorts by the inverted+comparator rather than reversing the result.++Type identity is `WorkTypeName.normalize` of the displayed name, so works on+either side of a type merge share an option without a directory fetch.+`WorksTypeSelection.selection(for:)` is the one place a `WorkTypeDisplay`+becomes a filter key; both the matcher and the option builder go through it.+`.none` and `.unresolved` both map to `.untyped`, because both draw no pill.++`WorksFilterOptions` is a function of the full snapshot, never of the filtered+one (options are not faceted). It is built in `AppLibraryModel` beside+`workTitlesByID` and passed in, because `WorksView.body` runs per keystroke of+the search field. A type option is dimmed only when every work under it wears+`.removed`. When the options change, `WorksView` prunes the filter of any value+the new options no longer offer.++The menu is one `Menu` holding four `Picker`s with `.pickerStyle(.inline)`, so+each picker's title renders as a section header. The three filter pickers are+one generic helper, `filterPicker`, which supplies the "Any" row, the+identifiers and the `.tag(Optional)` wiring. No `#if os` anywhere in it.++The pill row is the first section's `header:`, built as a `FlowLayout` of+`.constellationPill(.genreTag)` texts plus the Clear button. `worksSection`+spells two `Section`s (with and without the header) over one shared row body,+because a `Section` with a header closure keeps header layout even when the+closure yields nothing.++### Trade-offs++- **Sort in `body`, not per snapshot.** Simpler, and the spec accepts it at+ the library sizes the app has. The efficiency review measured a+ `localizedStandardCompare` sort of 3,000 titles at 11 ms on a Mac, so at the+ top of the plausible range the title sorts could be felt while typing. The+ fix shape (derive the title order per snapshot, filter per keystroke) is+ recorded in this file's expert section rather than built.+- **Explicit `WorksSort(storedValue:)` over the `@AppStorage` `RawRepresentable`+ overload** (Q20). Three members in the view instead of one, in exchange for+ the fallback being a stated, unit-tested contract of the value type.+- **One value per dimension** (Q3). Multi-select would need a set per+ dimension and a different pill row; nothing in the value shape prevents+ adding it later.+- **Filters as view state** (Q5). A filter is a question the reader is asking+ now; making it persist would also require a new route for+ `recent-window-cap` Req 3.2 to clear it.+- **The comparator is duplicated from Core** rather than published from it.+ The spec puts any `AsterismCore` change out of scope. The duplication is+ noted for a follow-up.++---++## Expert Level++### Technical Deep Dive++**Ordering invariants.** `titleAscending` is a strict total order (a tie on+`localizedStandardCompare` is broken by a unique lowercased UUID string), so+`sorted { titleAscending($1, $0) }` is exactly the reverse of+`sorted(by: titleAscending)`, tie-break included. Oldest first's "each section+reversed" means the id tie-break also reverses within a section; the spec+says so explicitly. The section boundary is `entries.isEmpty` in both the sort+and the view, which matches the repository's `(nil, nil)` branch because a+work's `entries.first?.lastSharedAt` is nil exactly when it has no entries.++**`localizedStandardCompare` ties.** Case variants are not ties: measured,+`"shonen" < "Shonen"` and `"manga" < "Manga"` deterministically (lowercase+first), so the tag and hostname vocabularies sort without a secondary key. Two+distinct type keys comparing `.orderedSame` would need `WorkTypeName.normalize`+to leave apart something Finder-style comparison folds; not observed. The one+constructed tie, a type literally spelled "Untyped" against the untyped+option, is broken by putting the untyped option last.++**Type-option first spelling.** The snapshot arrives in repository order, so+"first seen" is the spelling on the work with the newest entry. A merge that+re-spells a type changes the option's label on the next publication; the+option's identity (the normalised key) and its identifier+(`works-filter-type-<normalised>`) do not move.++**Pruning.** `.onChange(of: filterOptions)` runs on every publication that+changes any vocabulary. It drops only values the new options no longer carry,+so a filter survives unrelated publications untouched. A type selection is+pruned by key, not by spelling.++**Identifier push-down.** SwiftUI applies a container's+`accessibilityIdentifier` to every child element. Naming the `FlowLayout` or+the whole `ContentUnavailableView` overwrote `works-filter-clear` on the Clear+button (verified from an element dump), so each pill carries+`works-filter-pills` and the empty state's identifier sits on its title+`Label`. An identifier on a `Text` inside an inline `Picker` does not reliably+reach the generated menu item on iOS either; `UIJourneySupport.worksOptionRow`+resolves by identifier first and falls back to the visible label.++**Fixture determinism.** `works()` orders on the newest entry's+`lastSharedAt`, millisecond-quantized, with a random-identifier tie-break.+The seeder creates each work whole (capture, create, move, metadata) before+the next capture; the intervening writes are what keep the next capture in a+later millisecond. Concurrent seeding would make the journey's four asserted+orders flaky.++**Seeded reset.** `ContentView.launchModel()` removes the stored sort in its+seeded branch. It runs inside the `@State` initialiser expression on+`AsterismApp`, which evaluates once per creation of the app struct; a re-init+would already discard and rebuild the whole `AppLibraryModel`, so the reset+adds no new hazard.++### Architecture Impact++- `AppLibraryModel` now publishes a third snapshot-derived value+ (`worksSnapshot`, `workTitlesByID`, `worksFilterOptions`) in the same+ no-`await` block, so no extra observation invalidation. The quality review+ suggested bundling the three into one `WorksPresentation` value published as+ a unit, following `recentPresentation`; that is a reasonable follow-up and+ would take `WorksView.init` from thirteen defaulted parameters to eleven.+- `WorksSearchFilter.apply(to: WorksSnapshot)` no longer has a production+ caller; the view moved to the `[WorkSnapshot]` overload. It survives for its+ tests, and the spec forbids changing that type in this change.+- `WorksFilterPresentation` is `nonisolated`, matching the `Sendable` value+ types it formats, so tests need no actor annotation.+- The Mac gets the same `Menu` from the same source; `PlatformSeamTests` is+ untouched. Its rendering on the column toolbar is unverified until the+ owner's manual check.++### Potential Issues++- **Title sorts at thousands of works.** ~25 to 40 ms per keystroke on a phone+ at 3,000 works under A to Z or Z to A. Fix if felt: derive the title order+ once per snapshot beside `workTitlesByID` and filter per keystroke (sort and+ filter commute because both narrowings only remove rows). Not done, because+ it adds an 11 ms sort to each of the ~45 publications a hydration makes.+- **`WorkTypeName.normalize` per work per keystroke** while a type filter is+ on. ~10 ms at 3,000 works on a phone. Fix shape: carry a per-work selection+ map in `WorksFilterOptions`.+- **Dimmed row on AppKit.** `foregroundStyle` on a menu row is largely+ ignored by AppKit menus; accepted by the spec.+- **Filter lifetime promises untested by journey.** The filter surviving a+ tab switch and a push, and being cleared by the truncation route, rely on+ `@State` and `.id(worksResetToken)` and are not walked by a UI test. See+ the Completeness Assessment.++---++## Completeness Assessment++**Fully implemented and tested**++- Req 1 to 3: the four sorts, sectioning, and the default. Unit tests cover+ every order, a title tie, an empty section under Oldest first, and the+ journey asserts all four orders from launch.+- Req 4: `@AppStorage` under one key, unrecognised value reads as default+ (unit test), seeded launch discards the stored value (two-launch UI case).+- Req 5: the three filters, AND semantics, vocabularies from the full+ snapshot, merged-type collapse, Untyped covering `.unresolved`, the dimmed+ rule both ways, hostnames from any membership. Unit tests throughout; the+ journey exercises a site filter and a removed-type filter.+- Req 6: one toolbar `Menu` with inline pickers, icon filled only while a+ filter is active. Journey asserts the label in both states.+- Req 7: pills in the first section's header, Clear leaves the query alone,+ unattached group hidden. Journey asserts all three.+- Req 8: filter empty state naming filters and query; query-only miss keeps+ `works-search-empty`. Journey asserts both. Sentence composition is+ unit-tested including the blank-query case.+- Req 10: `WorksSearchFilter` matching and the merge picker untouched.+- Req 11: every new control carries an identifier and label; the XXXL+ accessibility case passes on iPhone.+- Superseded wording amended in `polish-and-export` Req 4.1 and the+ `WorksSearchFilter` doc comment.++**Implemented, partially proven**++- Req 9: filters survive tab switches and a push, and are cleared by the+ truncation route. Implemented by `@State` and the existing+ `.id(navigation.worksResetToken)`; no journey walks those three paths.+- Req 7's "pills on whichever section is first": the branch where only empty+ works remain (Untyped plus beta.test on the fixture isolates Quill Harbour)+ is implemented but not covered by a test.+- Persistence across ordinary launches cannot be observed by a UI test at+ all, because every seeded launch resets the sort by design; it rests on+ `@AppStorage`.++**Missing**++- Nothing from the requirements. The Mac toolbar menu's rendering is the+ owner's manual check per the spec.++**Follow-ups noted, not built**++- Publish the title comparator from `AsterismCore` and use it in+ `workDestinations` and `WorksSort` (Core change, out of scope here).+- Bundle the three snapshot-derived values into one published+ `WorksPresentation`.+- Generalise `seedWorksOptionsWork` so `seedCharactersFixture` shares it.+- Journeys for Req 9's three lifetime promises.
diff --git a/specs/works-list-options/smolspec.md b/specs/works-list-options/smolspec.mdnew file mode 100644index 0000000..8821aae--- /dev/null+++ b/specs/works-list-options/smolspec.md@@ -0,0 +1,42 @@+# Works List Options++## Overview++The Works list is always ordered by newest entry date, descending, with empty works sinking, and its only narrowing control is the title search. This change adds a sort choice (newest first, oldest first, A to Z, Z to A) and three single-value filters (type, tag, site) to that list, on every platform the list appears on. Transit ticket T-2302. It amends `polish-and-export` Req 4.1's "preserving the list's existing ordering" clause: the search still never reorders, but the reader now can.++## Requirements++- The system MUST offer one sort choice with four values: Newest first, Oldest first, A to Z, Z to A. Newest first is the repository's order, unchanged. Oldest first reverses each section of that order (the empty section included, so its `modifiedAt` order and the id tie-break reverse with it). A to Z orders display titles by `localizedStandardCompare`, ties broken by the lowercased id string ascending, as the repository breaks them; Z to A is that comparison reversed.+- Under Newest first and Oldest first the system MUST keep empty works in their own section after the non-empty works. Under A to Z and Z to A the system MUST list every work in one section.+- The default sort MUST be Newest first, so a reader who never opens the control sees today's list.+- The system MUST persist the sort choice across launches, under one stored value shared by the compact and wide trees on the same device (not synced between devices). An unrecognised stored value reads as the default. A seeded UI-test launch MUST discard the stored value so every test starts from the default.+- The system MUST let the reader filter the list by one work type, one tag and one site at a time, combined with AND across the three dimensions and with the search query. Each dimension offers "Any" plus the values present in the full works snapshot, before search and filters, ordered with `localizedStandardCompare`; options never narrow as other dimensions are chosen. Type options are identified by `WorkTypeName.normalize` of the displayed name and shown with the first spelling seen; an "Untyped" option, distinct from any named type, is offered when a work draws no type pill (`WorkTypeDisplay.Kind.none` or `.unresolved`) and matches exactly those works. A type option is dimmed as `WorkTypePresentation.menuRowStyle` dims a removed type only when every work under it wears `.removed`. Tag options are the stored tag strings. Site options are the membership hostnames, shown as hostnames; a site filter matches a work with a membership on that hostname.+- Sort and filter controls MUST live in one toolbar `Menu` beside the New Work button, on iPhone, iPad and Mac, with the pickers inline under section headers rather than as submenus. The menu's icon is `line.3.horizontal.decrease.circle`, switching to `line.3.horizontal.decrease.circle.fill` while any filter is active; the sort choice alone does not change it.+- WHILE any filter is active, the system MUST show the active values as pills in the header of the list's first section, scrolling with the content, with a Clear control that removes every filter and leaves the search query alone; and MUST hide the Unattached Notes group as `polish-and-export` Req 4.2 hides it under a query.+- WHEN a filter is active and nothing matches, with or without a query, the system MUST show a filter empty state that names the active filters (and the query when there is one) and offers the same Clear control. WHEN only a query is active and nothing matches, the existing `works-search-empty` state MUST show unchanged.+- Filters MUST NOT be stored. They are view state with the search query's lifetime: they survive tab switches and a push to a work, are cleared by the Recent truncation footer's route (`recent-window-cap` Req 3.2, via `worksResetToken`), and are lost with the view instance, as on an iPad size-class change.+- The merge picker's order and `WorksSearchFilter`'s matching MUST NOT change.+- Every new control MUST carry an accessibility identifier and label. At `UICTContentSizeCategoryAccessibilityXXXL` on iPhone the menu button and at least one work row MUST be hittable and the menu MUST open.++## Implementation Approach++- **Pure logic, new file `Asterism/Asterism/ViewModels/WorksListOptions.swift`** beside `WorksSearchFilter` (`Asterism/Asterism/ViewModels/SearchFilters.swift:74-94`, the pattern to copy): a four-case sort enum with a raw value for storage, `apply(to: [WorkSnapshot])` and a flag saying whether empty works are sectioned; a filter value (type selection as an enum of untyped or a normalised name, tag, hostname, each optional) with `isActive` and `apply(to:)`; and a `WorksFilterOptions` value derived from `[WorkSnapshot]`, carrying the three vocabularies and each type option's dimmed flag. Type matching reads `WorkSnapshot.typeDisplay` (`Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift:101-159`). The storage key `worksList.sort` lives in this file as a constant, following `RestoreStorageKey` in `Asterism/Asterism/Support/UITestMarker.swift:40`.+- **Options derived once per snapshot, not per body.** `AppLibraryModel` builds `WorksFilterOptions` where it builds `workTitlesByID` (`Asterism/Asterism/ViewModels/AppLibraryModel.swift:889`) and `AppScreens.works()` passes it in (`Asterism/Asterism/Layout/AppScreens.swift:95-129`), for the reason `WorksView` gives at lines 20-26. The sort itself runs in `body` on every evaluation, as the search filter already does; accepted at the library sizes the app has.+- **`Asterism/Asterism/Views/WorksView.swift`**: sort in `@AppStorage`, filter in `@State` next to `searchQuery` (line 72). `body` (line 104) applies search, then filter, then sort, and sections on the sort's flag instead of unconditionally. The toolbar (line 135) gains the `Menu`. The pill row uses `.constellationPill(.genreTag)` in a `FlowLayout` (`Asterism/Asterism/Views/TeachingComponents.swift:99`) as the first section's header; the filter empty state follows the `works-search-empty` branch (line 117) with its own identifier. The dimmed menu row is honoured on iOS; AppKit menus largely ignore it, and that is accepted.+- **UI-test reset of the stored sort**: `ContentView.launchModel()` (`Asterism/Asterism/ContentView.swift:119`) already resolves the seeded request; in that branch it removes `worksList.sort` from `UserDefaults.standard`. No `AsterismApp` change.+- **A seeded scenario with something to sort and filter**: a new `UITestFixtureKind` case and `ASTERISM_UI_TEST_SCENARIO` value, seeded in `AppLibraryModel` beside `seedComposedFixture` (line 1648): at least three works on two hostnames, with titles that order differently by date and by letter, one typed with an active type, one wearing a removed type, one tagged, one with no entries, plus one unattached entry.+- **Amend the superseded wording**: annotate `specs/polish-and-export/requirements.md` Req 4.1 in place with a pointer to this spec, and reword the "never reordering" doc comment on `WorksSearchFilter` (`SearchFilters.swift:69-73`) so it says the filter never reorders while the list's sort is the reader's.+- **Tests**: unit tests for the pure logic in `Asterism/AsterismTests/WorksListOptionsTests.swift`, shaped like `SearchFilterTests.swift`; one UI journey in `Asterism/AsterismUITests/SearchUITests.swift`'s style covering each sort, a filter, the pill row, the hidden unattached group, Clear and both empty states; the accessibility-size pass in `AccessibilityJourneyUITests` (the iPhone class, `AccessibilityJourneyUITests.swift:4`) so `make test-ui` runs it. `make test-quick`, then `make test-ui`. The Mac toolbar menu is compiled by `make test-quick` but its rendering is unverified until the owner's own Mac check; launching the Mac app is a device run.+- **Dependencies**: `WorksSnapshot`/`WorkSnapshot`, `WorkTypeName.normalize` (public in `AsterismCore`), `WorkTypePresentation`, `ConstellationKit` pills, `navigation.worksResetToken` (`AppScreens.swift:128`).+- **Out of scope**: keyboard shortcuts for sort or filter, multi-select within a dimension, faceted option lists, stored filters, tag or site management, any `AsterismCore` change, the merge picker, Recent's list.++## Risks and Assumptions++- Risk: a toolbar `Menu` with inline pickers renders differently on the Mac's column toolbar than on iOS. | Mitigation: `Menu` and `Picker` are cross-platform SwiftUI and the menu carries no `#if`, so `PlatformSeamTests` is untouched; if the Mac needs a seam it goes in `Asterism/Asterism/Support/PlatformModifiers.swift` like `listSearch`. Rendering is the owner's manual check.+- Risk: pills in a section header take several rows at the largest accessibility sizes. | Mitigation: they scroll with the list rather than sitting above it, and the accessibility pass asserts a work row stays hittable.+- Assumption: `WorkTypeDisplay.name` is the canonical name for a merged type, so keying options by its normalised form needs no directory fetch in the app layer.+- Assumption: genre tags are compared as stored (trimmed, deduplicated case-sensitively on write); two tags differing only in case are two options.+- Prerequisite: none. No schema, migration or archive change.++## Escalation Note+This change was scoped as a smolspec. If implementation reveals ambiguity only the user can resolve, an irreversible boundary (public API, persisted schema, auth path), or a contested architectural choice, stop and escalate to the full spec workflow rather than deciding it inline.
diff --git a/specs/works-list-options/tasks.md b/specs/works-list-options/tasks.mdnew file mode 100644index 0000000..9683476--- /dev/null+++ b/specs/works-list-options/tasks.md@@ -0,0 +1,76 @@+---+references:+ - specs/works-list-options/smolspec.md+ - specs/works-list-options/decision_log.md+---+# Works List Options++## Pure logic++- [x] 1. The four-way works sort orders a snapshot as specified and says whether empty works are sectioned <!-- id:y4khv6c -->+ - Per specs/works-list-options/smolspec.md (Requirements 1-3, Implementation Approach bullet 1).+ - A sort value in Asterism/Asterism/ViewModels/WorksListOptions.swift with a stored raw value: Newest first leaves the repository order alone; Oldest first reverses each section, empty section included; A to Z orders by localizedStandardCompare with the lowercased id string as tie-break; Z to A is that reversed.+ - The value reports whether empty works are sectioned (date sorts yes, title sorts no); an unrecognised raw value reads as Newest first.+ - Success: unit tests in Asterism/AsterismTests/WorksListOptionsTests.swift (shaped like SearchFilterTests.swift) cover every case, including a title tie and an empty section under Oldest first, and pass under make test-quick.++- [x] 2. The works filter narrows a snapshot by one type, one tag and one site, and its option vocabularies derive from a snapshot <!-- id:y4khv6a -->+ - Per specs/works-list-options/smolspec.md (Requirement 5, Implementation Approach bullet 1, decision_log Q3, Q7, Q12).+ - A filter value with optional type selection (an enum of untyped or a WorkTypeName.normalize'd name), tag and hostname; isActive; apply(to:) ANDs the three; untyped matches works whose typeDisplay.kind is .none or .unresolved; site matches any membership hostname.+ - A WorksFilterOptions value derived from [WorkSnapshot] carries the three vocabularies ordered by localizedStandardCompare, the first-seen spelling per type, an Untyped option only when such a work exists, and a dimmed flag set only when every work under a type wears .removed. It never narrows by other selections.+ - Success: unit tests cover merged-type collapse by normalised name, the dimmed rule both ways, unresolved under Untyped, multi-site matching and AND across dimensions, and pass under make test-quick. WorksSearchFilter and the merge picker are untouched.++## The list++- [x] 3. Filter options are derived once per snapshot publication and reach the works list from the model <!-- id:y4khv6b -->+ - Per specs/works-list-options/smolspec.md (Implementation Approach bullet 2, decision_log Q14).+ - AppLibraryModel builds WorksFilterOptions where it builds workTitlesByID (Asterism/Asterism/ViewModels/AppLibraryModel.swift:889) and AppScreens.works() (Asterism/Asterism/Layout/AppScreens.swift:95-129) passes it into WorksView as a plain input with a default, the way titlesByWorkID arrives.+ - Success: WorksView never derives the vocabularies in body; make test-quick builds both platforms and the existing unit tests pass.+ - Blocked-by: y4khv6a (The works filter narrows a snapshot by one type, one tag and one site, and its option vocabularies derive from a snapshot)++- [x] 4. The works list sorts and filters from one toolbar menu, and the sort survives relaunch <!-- id:y4khv6d -->+ - Per specs/works-list-options/smolspec.md (Requirements 1-6, Implementation Approach bullet 3, decision_log Q2, Q5, Q11).+ - In Asterism/Asterism/Views/WorksView.swift: the sort lives in @AppStorage under worksList.sort, the filter in @State beside searchQuery; body applies search, then filter, then sort, and sections on the sort's flag.+ - The toolbar gains a Menu beside the New Work button with inline pickers under section headers for sort, type, tag and site, each row with an accessibility identifier and label, removed-type rows dimmed with WorkTypePresentation.menuRowStyle; the icon is line.3.horizontal.decrease.circle, .fill while any filter is active.+ - Success: make test-quick green including the Mac build; the existing SearchUITests and WorksAndAssignmentUITests still pass; relaunching with A to Z chosen shows A to Z.+ - Blocked-by: y4khv6c (The four-way works sort orders a snapshot as specified and says whether empty works are sectioned), y4khv6b (Filter options are derived once per snapshot publication and reach the works list from the model)++- [x] 5. Active filters show as pills with Clear, hide the unattached group, and a no-match list shows the filter empty state <!-- id:y4khv6e -->+ - Per specs/works-list-options/smolspec.md (Requirements 7-9, decision_log Q9, Q13, Q15).+ - While any filter is active the first section's header carries the active values as .constellationPill(.genreTag) pills in a FlowLayout with a Clear control that removes every filter and leaves the query alone, and the Unattached Notes group is hidden.+ - A filter with no matches, with or without a query, shows a filter empty state naming the filters (and the query) with the same Clear, under its own identifier; a query-only miss keeps works-search-empty unchanged.+ - Filters are @State only, so the worksResetToken route clears them.+ - Success: make test-quick green; SearchUITests' works-search-empty assertion still passes.+ - Blocked-by: y4khv6d (The works list sorts and filters from one toolbar menu, and the sort survives relaunch)++- [x] 6. The superseded wording on works ordering points at this spec <!-- id:y4khv6f -->+ - Per specs/works-list-options/smolspec.md (Implementation Approach bullet 6).+ - Annotate specs/polish-and-export/requirements.md Req 4.1 in place with a pointer to specs/works-list-options/, and reword the doc comment on WorksSearchFilter (Asterism/Asterism/ViewModels/SearchFilters.swift:69-73) so it says the filter never reorders while the list's order is the reader's sort.+ - Success: no remaining text claims the Works list has one fixed order; the code still compiles unchanged.+ - Blocked-by: y4khv6d (The works list sorts and filters from one toolbar menu, and the sort survives relaunch)++## Fixture and journeys++- [x] 7. A seeded UI-test scenario has works worth sorting and filtering, and seeded launches start from the default sort <!-- id:y4khv6g -->+ - Per specs/works-list-options/smolspec.md (Requirement 4, Implementation Approach bullets 4-5, decision_log Q8).+ - A new UITestFixtureKind case and ASTERISM_UI_TEST_SCENARIO value (Asterism/Asterism/UITestLaunchSupport.swift), seeded in AppLibraryModel beside seedComposedFixture (line 1648): at least three works on two hostnames whose titles order differently by date and by letter, one with an active type, one wearing a removed type, one tagged, one with no entries, plus one unattached entry.+ - ContentView.launchModel() (Asterism/Asterism/ContentView.swift:119) removes worksList.sort from UserDefaults.standard in its seeded branch.+ - Success: launching with the scenario lists the seeded works under Newest first regardless of a previously stored sort; make test-quick green.+ - Blocked-by: y4khv6d (The works list sorts and filters from one toolbar menu, and the sort survives relaunch)++- [x] 8. A UI journey proves each sort, a filter, the pill row, the hidden unattached group, Clear and both empty states <!-- id:y4khv6h -->+ - Per specs/works-list-options/smolspec.md (Implementation Approach bullet 7).+ - One journey in Asterism/AsterismUITests/SearchUITests.swift's style on the new scenario: choose each of the four sorts and assert the row order; choose a site or type filter and assert the rows, the pills, the filled icon and the missing unattached header; pick a combination that matches nothing and assert the filter empty state; tap Clear and assert the list returns; type a query with no match and no filter and assert works-search-empty.+ - Success: the journey passes under make test-ui alongside the existing suites.+ - Blocked-by: y4khv6e (Active filters show as pills with Clear, hide the unattached group, and a no-match list shows the filter empty state), y4khv6g (A seeded UI-test scenario has works worth sorting and filtering, and seeded launches start from the default sort)++- [x] 9. The menu and the list stay usable at the largest accessibility text size <!-- id:y4khv6i -->+ - Per specs/works-list-options/smolspec.md (Requirement 11).+ - In AccessibilityJourneyUITests (the iPhone class, Asterism/AsterismUITests/AccessibilityJourneyUITests.swift:4), launch the new scenario at UICTContentSizeCategoryAccessibilityXXXL with a filter active and assert the menu button and at least one work row are hittable and the menu opens.+ - Success: passes under make test-ui.+ - Blocked-by: y4khv6e (Active filters show as pills with Clear, hide the unattached group, and a no-match list shows the filter empty state), y4khv6g (A seeded UI-test scenario has works worth sorting and filtering, and seeded launches start from the default sort)++- [x] 10. The whole change is green with no new warnings <!-- id:y4khv6j -->+ - Per specs/works-list-options/smolspec.md (Implementation Approach bullet 7) and docs/agent-notes/testing.md.+ - make test-quick and make test-ui pass; verify no new compiler warnings against an unwrapped build (run_silent hides them).+ - The Mac toolbar menu compiles but its rendering is the owner's manual check; say so in the final report rather than launching the Mac app.+ - Blocked-by: y4khv6f (The superseded wording on works ordering points at this spec), y4khv6h (A UI journey proves each sort, a filter, the pill row, the hidden unattached group, Clear and both empty states), y4khv6i (The menu and the list stay usable at the largest accessibility text size)
Compiles under make build-mac; rendering on the column toolbar is unverified. Launching the Mac app is a device run and the owner's call.
Survives a tab switch and a push, cleared by the truncation route. Implemented by @State and .id(worksResetToken); no journey walks it.
M4ScaleRecentPerformanceUITests fails on the simulator on main too; reproduced in isolation during phase 3 and documented in testing.md as not this branch's.