14 unpushed commits implementing the optional-chapter-sequence spec: a combined URL rule can declare its chapter sequence optional, so a separator-free URL derives the story's Work identity and chapter 1. Reviewed by four parallel agents (reuse, quality, efficiency, spec adherence); all fixes applied and verified on-device by the author beforehand.
URLRuleDefinition's arms are untouched; only the applicator changes behaviour, and only for rules that declare. An asymmetric hand-written Codable keeps undeclared archives byte-identical to pre-feature builds (pinned by a golden-document SHA-256 test).1 (Decision 7): on the motivating site every capture shares one title, so the sequence is the only thing that can settle a chapter — and deriving it gives chapter 1 its siblings' identity-key shape, collapsing #storybody-style re-share spellings.rule(in:) branches and reset with the template it was declared on; the toggle's enable gate and dispatch now share one derivation core so they can never disagree.www. re-share clause was never achievable (Q32, annotated), and the design's validation-test bullet described the rejected locator-keyed guard (reworded).Ready to push
Two major findings surfaced and both are fixed: a live-split gesture could carry a declared-optional presence onto an unbounded template, bypassing the Req 1.10 gate and failing at projection time (fixed in d3ec217 with a gesture-path regression test), and the phase-1 changelog entry had clobbered an existing bullet's headline (restored). All minor refactors from the review are applied. make test-core and make test-quick pass with no new compiler warnings; the feature was verified working on-device by the author, including with new stories.
4baa089 [feat]: template, validation, applicator and deriver (tasks 1-4) 773bc2d [doc]: changelog for phase 1 dec1a85 [doc]: Q29, overview status In Progress cc9ade4 [feat]: definition comparison and archive gates (tasks 5-6) 299574d [doc]: changelog for phase 2 9c35868 [doc]: Q30 (which diagnosis covers the skipped mixed pair) c5e5769 [feat]: teaching surface (tasks 7, 8, 9) 294e72e [bug]: derive the preview's Work-attachment change from identity, not names 7688040 [doc]: changelog for phase 3 afe0b36 [doc]: Q31 (unteachable-shape message clauses) cfff3e7 [feat]: tasks 10 and 11 — end-to-end integration tests bcd930c [doc]: changelog for phase 4 2d4ffb9 [doc]: Q32 (www. clause annotation), spec marked Done d3ec217 [bug]: pre-push review fixes Some story sites leave the chapter number out of the address for a story's first chapter — tthfanfic.org serves chapter 105 at /Story-28614-105/ but chapter 1 at /Story-28614/. Before this branch, Asterism could read the first form but not the second, so a saved chapter 1 didn't know which story it belonged to and sat in the inbox forever.
Now, when teaching a site's address pattern, you can flip a switch saying "the chapter part may be absent". With it on, an address without the chapter part is read as chapter 1 of that story — because on such a site, leaving the number off is exactly how the site says "first chapter".
Every chapter of a story lands in one place: chapter 1 sits with its siblings, gets a real chapter number, and leaves the inbox on its own. Nothing changes for sites where the switch stays off, and old backups keep working exactly as before.
Story-, then the story number, then -, then the chapter number".Four phases, each its own commit: Core parsing (4baa089: URLSequencePresence as a non-optional template property with asymmetric Codable, the validation guard, the applicator's zero-separator branch, derive(presence:) with no default); Comparison and archive (cc9ade4: DuplicateReconciler compares decoded definitions; archive byte-compat gates); Teaching surface (c5e5769/294e72e: the toggle with a validate-backed gate, editor-state reset semantics, messages, preview additions); End to end (cfff3e7: integration suites over a real seeded library). Plus review fixes (d3ec217).
The declaration is a template property, not a new rule form (Decision 1) — every existing switch over URLRuleDefinition compiles unchanged and exactly one site changes behaviour. The Codable asymmetry (decode-if-present ?? .required; encode only when .optional) is what keeps an undeclared archive byte-identical to a pre-feature build's output (Req 5.5). In the editor, presence is state rather than a one-shot template rewrite, because the live-split branch re-derives the template on every dispatch; every gesture that clears the retained template resets the declaration with it.
checksumMismatch — accepted (Decision 1); only archives that use the feature pay it./about/ becomes Work about (Decision 5)..workCollision plus Work merge covers it.The zero-separator branch sits after the shared literal-match and interior-bounds checks and before the count == 1 guard, so .required behaviour is preserved by construction; the branch carries its own blank-interior check (blankField(.work), Req 1.6) because the shared blank-field guards sit after the split and are unreachable on this path. The extraction now carries sequenceDerived: Bool — in-memory only, URLRuleExtraction has no Codable conformance — set exactly in that branch and threaded through ComposedDerivation into ComposedEntryProjection, so the preview never re-parses URLs to answer "was this sequence inferred?".
convergeURLRuleGroup decodes each side once per pass and compares via RuleDefinitionComparator.semanticallyEqual; a mixed readable/unreadable pair skips the byte copy in both directions (inequality is what triggers the copy, and GroupOrdering picks the representative with no readability preference), while two undecodable rows fall back to byte comparison. The skip is never silent: unreadableURLRule covers rows on distinct Site rows, LibraryValidator's rule-membership clause covers rows sharing one (Q30).
The editor's gate and dispatch share combinedTemplateCore(in:), which also enforces the invariant the review's major bug violated: a live-split adjustment producing a blank-affix template drops a standing .optional to .required and writes it back, so the toggle can never render disabled-while-on and dispatch can never publish a rule validate would refuse.
derive(presence:) with no default is a compile-time fence: any future call site is forced to answer (Req 2.10).www. re-shares do not collapse (Q32) — the hostname is embedded verbatim in the identity key and selects the Site; chapter 1 behaves identically to its siblings, which is the parity Req 3.3 actually promises.-1 collapses the spellings into one Entry — correct, but reached by inference (Decision 7's recorded consequence).sequenceIsDerived(for:) is now an O(n) scan per rendered row (≤ ~7 rows) — immaterial unless the preview row cap is ever removed.Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift
Why it matters. Req 5.5 rests entirely on this: an archive in which no rule declares optionality must be byte-identical to a pre-feature build's output, or old builds refuse it as corrupt. The key is decoded when present (else .required) and encoded only when .optional.
What to look at. URLTwoFieldTemplate Codable extension + the validate guard
Packages/AsterismCore/Sources/AsterismCore/URLIdentityParsing.swift
Why it matters. The single behavioural change of the feature. Under .optional, zero separators yields the whole interior as Work identity and sequence 1; .required behaviour is untouched by construction because the branch precedes the count==1 guard and follows the shared literal/bounds checks.
What to look at. URLTwoFieldTemplateApplicator.apply, zero-separator branch; sequenceDerived on URLRuleExtraction
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift
Why it matters. Byte comparison would treat the conditionally-encoded sequencePresence key (and any key-order difference) as a rule change, churning CloudKit records on every pass — and a naive fix would copy unreadable bytes over readable ones, because inequality is what triggers the copy.
What to look at. convergeURLRuleGroup: decoded comparison, mixed-pair skip, undecodable byte fallback
Asterism/Asterism/Views/ComposedURLEditorState.swift
Why it matters. The live-split branch re-derives the template on every dispatch, so a one-shot template rewrite would be clobbered by the next token tap. The review then found gate and dispatch computing templates independently — real drift, and a real bug (see the fix below).
What to look at. sequencePresence + setSequencePresence + combinedTemplateCore(in:); resets tied to clearRetainedTemplate()
Asterism/Asterism/Views/ComposedURLEditorState.swift
Why it matters. Declare optional on a bounded split, then re-split so both affixes end up blank: presence survived, dispatch published without validate, and the reader met invalidURLDefinition at projection time with the toggle rendered disabled-while-on — exactly the late failure Req 1.10 forbids.
What to look at. presence(_:boundedBy:) in combinedTemplateCore; regression test reSplitToUnboundedDropsDeclaration
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4Fixtures.swift
Why it matters. Req 5.5's byte-identity claim is asserted against a hand-written pre-feature JSON document whose SHA-256 is computed over the literal text — so the codec's re-encode must reproduce pre-feature bytes exactly or the test fails, catching any accidental new key or spelling drift.
What to look at. combinedRulePayload(presence:), preFeatureCombinedPayloadJSON, preFeatureCombinedDocument()
Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift
Why it matters. The requirement-level claims (key parity with siblings, chapter-settled, provenance, collision reporting, removal post-state) are asserted through projectCapture/commitCapture over a real store — not against hand-built values — including the exact split post-state the removal warning describes.
What to look at. URLOptionalSequenceDerivationTests + URLOptionalSequenceTeachingCommitTests
Decision 1: optionality is a tolerance in how one component's text decomposes, so it belongs to the template. A new URLRuleDefinition arm would put the same question in front of five switches; Bool? has three states for two meanings, and with semanticallyEqual being plain ==, writing false over stored nil would mint a phantom rule version and force a hostname-wide re-derivation.
Decision 7, reversing Q3 on archive evidence: every capture of the motivating story shares one byte-identical title and none has a chapter title, so the sequence alone settles chapters. Deriving 1 also gives chapter 1 the robust .identitySequence key its siblings have — 25 of 40 stored raw URLs carry #storybody, so a verbatim key would have made chapter 1 the one entry that duplicates on a differently-spelled share.
Decision 5: with no affixes the literal-match guard is vacuous and every separator-free value becomes a Work identity (/about/ → Work about). A locator-based bound is wrong in both directions. Q29 records the implemented semantics: blank (empty or whitespace-only) affixes don't bound, matching the codebase's existing blank conventions and failing closed.
Q28 (owner's call): the split pre-state it would repair has no instance in the library, cannot arise once the declaration is taught, and the chain that forms it ends in a visible .workCollision with Work merge as the repair. The pass was the most delicate new code in the spec, guarding a state with no instance.
Decision 4: restoring the prior grouping would be a general teaching-undo facility no other rule change offers. The reader is warned; the integration test reproduces the exact post-state the warning describes (chapter 1 left behind, siblings created into a fresh Work).
Q32: the hostname is embedded verbatim in the v2 identity key and selects the Site the rule belongs to, so a bare-host share matches no Site and applies no rule — for every chapter alike. The test pins that parity; hostname canonicalisation would be its own feature.
Q31: the design's informal condition isn't computable before a split exists. Implemented: a .combined candidate fires when its own template finds zero separators in the example component (so declaring optional clears it); a .work candidate fires when the chapter is unsourced and no split is derivable at all. The Story/28614 mis-split hazard is neutralised by the affix gate instead.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | ComposedURLEditorState — Req 1.10 gate | A declared-optional presence survived a live-split adjustment into a blank-affix template: toggleSplitToken doesn't reset presence and dispatch skips validate, so the reader met invalidURLDefinition at projection time with the toggle rendered disabled-while-on. | combinedTemplateCore drops presence to .required when the derived template's affixes are both blank and writes it back to the state; gesture-path regression test added (failed on unfixed code with the exact predicted symptom). |
| major | CHANGELOG.md | The phase-1 entry was written over the opening sentence of the existing url-locator-generalisation bullet, fusing two unrelated entries and destroying the latter's headline. | Split into two bullets; the overwritten headline sentence restored verbatim. |
| minor | ComposedTeachingViewModel — duplicated probe | separatorFreeEntryIDs re-parsed every basis entry per projection dispatch to detect separator-free captures, re-encoding the applicator's zero-separator semantics in view-model code (flagged independently by the quality and efficiency reviews). | sequenceDerived: Bool threaded from the applicator's zero-separator branch through ComposedDerivation into ComposedEntryProjection (verified URLRuleExtraction is never encoded first); the probe and the redundant derivedSequences set deleted. |
| minor | ComposedURLEditorState — gate/dispatch drift | combinedTemplate(in:) restated rule(in:)'s two .combined branches by hand and had already drifted (missing the urlLocator resolution guard), so the toggle's gate could answer for a rule dispatch would not emit. | One non-mutating throwing core (combinedTemplateCore) used by both; rule(in:) keeps its error mapping and retained-template caching at the call site. |
| minor | URLTwoFieldTemplate — rebuild helper duplicated | The rebuild-with-different-presence reconstruction was written twice (private helper in the editor state, inline in the view model probe). | declaring(_:) added on URLTwoFieldTemplate in Core; both call sites use it. |
| minor | ComposedURLEditorState — stale retained presence | setSequencePresence left retainedTemplate.sequencePresence shadowed by the state field, forcing re-stamping at every read and letting two states authoring identical rules compare unequal. | setSequencePresence re-stamps the retained template via declaring(_:), keeping the pair consistent on every path. |
| minor | ComposedTeachingViewModel — hand-rolled pipeline | The unteachable-shape message's .combined case hand-rolled the parse→select→apply pipeline URLRuleApplicator.apply already composes. | Replaced with URLRuleApplicator.apply after verifying the composed error surface lands in the same catch. |
| minor | specs/optional-chapter-sequence/design.md | The Testing Strategy validation bullet described the rejected locator-keyed guard's behaviour ('empty affixes but a literal-anchored locator is admitted'), contradicting Decision 5 and the shipped locator-blind guard. | Reworded: refused whatever its locator; either affix admits under any locator. |
| minor | Req 6.3 — entry-side clause | The requirement's entry half (entries citing a losing definition are re-derived or diagnosed) rested on the design's existing-behaviour argument with no note in the test files, unlike the 5.8/6.2 precedent. | Note added to the reconciler suite in the same style as the integration suite's 5.8/6.2 note. |
| nit | URLTwoFieldTemplateApplicator | derivedFirstChapterSequence was public with zero external consumers. | Dropped to internal after verifying nothing outside the package references it. |
| nit | canAuthorSplit / defaultSplitSelection | canAuthorSplit re-runs the deriver on defaultSplitSelection's result to distinguish acceptance from the everything-but-the-last fallback, coupling to the fallback's shape. | Skipped: tap-driven path, one component string; the coupling is documented and the shared-helper refactor wasn't worth touching a stable API for. |
| nit | ComposedTeachingViewModel — per-render parses | unteachableCombinedShapeMessage re-parses the immutable example URL per body render; the toggle gate runs full validate per render. | Skipped: both reviewers rated the paths tap-driven and bounded by one component string — cosmetic at this scale. |
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swiftindex 09a6e56..5ec1810 100644--- a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift@@ -122,22 +122,95 @@ public struct URLFieldSelector: Codable, Equatable, Hashable, Sendable { } } -public struct URLTwoFieldTemplate: Codable, Equatable, Hashable, Sendable {+/// Whether a combined rule's chapter sequence must be present in the selected+/// component, or may be absent (Reqs 1.1, 2.3). `.required` is the only+/// representation of "not declared optional" — a non-optional two-state+/// property cannot compare unequal to itself the way `Bool?` could+/// (Decision 1).+///+/// `Sendable` is declared explicitly, as every enum in `DomainEnums.swift`+/// does: a public non-frozen enum does not infer it, and+/// `URLTwoFieldTemplate`'s `Sendable` depends on it.+public enum URLSequencePresence: String, Codable, Equatable, Hashable, Sendable {+ case required+ case optional+}++/// The decomposition of one URL component into a Work identity and a chapter+/// sequence.+///+/// **`Codable` is hand-written**, because synthesis cannot express the+/// asymmetry `sequencePresence` needs: the key is decoded when present and+/// defaulted to `.required` when absent, and it is encoded **only** when the+/// value is `.optional`. That asymmetry is what keeps an archive in which no+/// rule declares optionality byte-identical to what a build without this+/// feature writes (Req 5.5), so it still imports there. Adding a property here+/// means adding it to both halves by hand.+public struct URLTwoFieldTemplate: Equatable, Hashable, Sendable { public let prefix: ExactScalarString public let separator: ExactScalarString public let suffix: ExactScalarString public let order: URLTemplateFieldOrder+ public let sequencePresence: URLSequencePresence public init( prefix: ExactScalarString, separator: ExactScalarString, suffix: ExactScalarString,- order: URLTemplateFieldOrder+ order: URLTemplateFieldOrder,+ sequencePresence: URLSequencePresence = .required ) { self.prefix = prefix self.separator = separator self.suffix = suffix self.order = order+ self.sequencePresence = sequencePresence+ }++ /// The same template carrying a different declaration. The members are `let`,+ /// so this rebuilds rather than mutates.+ ///+ /// The declaration is editor state that has to be stamped onto a template at+ /// several points (dispatch, the gate, the retained-template re-stamp), and a+ /// hand-rolled rebuild at each of them is one added property away from+ /// silently dropping it.+ public func declaring(_ presence: URLSequencePresence) -> URLTwoFieldTemplate {+ URLTwoFieldTemplate(+ prefix: prefix, separator: separator, suffix: suffix, order: order,+ sequencePresence: presence)+ }+}++extension URLTwoFieldTemplate: Codable {+ private enum CodingKeys: String, CodingKey {+ case prefix+ case separator+ case suffix+ case order+ case sequencePresence+ }++ public init(from decoder: any Decoder) throws {+ let container = try decoder.container(keyedBy: CodingKeys.self)+ prefix = try container.decode(ExactScalarString.self, forKey: .prefix)+ separator = try container.decode(ExactScalarString.self, forKey: .separator)+ suffix = try container.decode(ExactScalarString.self, forKey: .suffix)+ order = try container.decode(URLTemplateFieldOrder.self, forKey: .order)+ sequencePresence =+ try container.decodeIfPresent(URLSequencePresence.self, forKey: .sequencePresence)+ ?? .required+ }++ public func encode(to encoder: any Encoder) throws {+ var container = encoder.container(keyedBy: CodingKeys.self)+ try container.encode(prefix, forKey: .prefix)+ try container.encode(separator, forKey: .separator)+ try container.encode(suffix, forKey: .suffix)+ try container.encode(order, forKey: .order)+ // Only `.optional` is written. See the type's note: this is Req 5.5.+ if sequencePresence == .optional {+ try container.encode(sequencePresence, forKey: .sequencePresence)+ } } } @@ -185,6 +258,19 @@ public enum URLRuleDefinition: Codable, Equatable, Hashable, Sendable { guard !template.separator.isBlank else { throw URLIdentityError.invalidTemplate(reason: "separator must not be blank") }+ // Req 1.10 / Decision 5: the affixes are what bound a declared-optional+ // rule. With neither, the literal-match guard is vacuous and only the+ // blank-interior check remains, so every separator-free value in the+ // selected component becomes a Work identity — `/about/` becomes Work+ // `about`. The locator plays no part: it bounds where a value comes+ // from, not which values are accepted. `.required` templates are+ // unaffected, so no existing rule form becomes invalid.+ guard template.sequencePresence == .required+ || !(template.prefix.isBlank && template.suffix.isBlank) else {+ throw URLIdentityError.invalidTemplate(+ reason: "an optional chapter sequence needs a prefix or a suffix to bound it"+ )+ } case .sequence(let locator): try locator.validate(origin: origin, isCurrent: isCurrent) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityParsing.swift b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityParsing.swiftindex 7d88174..bfb94ff 100644--- a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityParsing.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityParsing.swift@@ -310,10 +310,22 @@ public enum URLTemplateField: String, Equatable, Sendable { public struct URLRuleExtraction: Equatable, Sendable { public let workIdentity: ExactScalarString public let chapterSequence: ExactScalarString?+ /// Whether the chapter sequence was **derived** from an absent chapter part+ /// under a declared-optional rule rather than read out of the URL (Reqs 1.8,+ /// 2.6). Only the applicator can know this, and only at the moment it takes+ /// the zero-separator branch; re-deriving it downstream means running a second+ /// probe over every capture and keeping two definitions of "separator-free" in+ /// step. In-memory only — this type is not `Codable` and nothing persists it.+ public let sequenceDerived: Bool - public init(workIdentity: ExactScalarString, chapterSequence: ExactScalarString?) {+ public init(+ workIdentity: ExactScalarString,+ chapterSequence: ExactScalarString?,+ sequenceDerived: Bool = false+ ) { self.workIdentity = workIdentity self.chapterSequence = chapterSequence+ self.sequenceDerived = sequenceDerived } } @@ -347,9 +359,19 @@ public enum URLTemplateSelectionError: Error, Equatable, Sendable, CustomStringC } public enum URLTwoFieldTemplateDeriver {+ /// Derives a combined template from two selections inside one component.+ ///+ /// `presence` carries **no default value** (Req 2.10): the derived rule must+ /// carry the reader's declaration rather than silently defaulting it, and the+ /// compiler asking every call site is the mechanism. The reproduce check+ /// below is unaffected by it — the deriver requires a non-blank span between+ /// the two selections, so the example component contains the separator by+ /// construction and the applicator's zero-separator branch is unreachable+ /// from here (Req 2.4). public static func derive( from component: ExactScalarString,- selection: URLTwoFieldSelection+ selection: URLTwoFieldSelection,+ presence: URLSequencePresence ) throws -> URLTwoFieldTemplate { let count = component.value.count try validate(selection.work, field: .work, characterCount: count)@@ -380,7 +402,8 @@ public enum URLTwoFieldTemplateDeriver { separator: ExactScalarString(separator), suffix: ExactScalarString( characterSubstring(component.value, range: second.upperBound..<count)),- order: workFirst ? .workThenSequence : .sequenceThenWork+ order: workFirst ? .workThenSequence : .sequenceThenWork,+ sequencePresence: presence ) do { let result = try URLTwoFieldTemplateApplicator.apply(template, to: component)@@ -416,6 +439,16 @@ public enum URLTwoFieldTemplateDeriver { } public enum URLTwoFieldTemplateApplicator {+ /// The chapter sequence a declared-optional rule derives when the separator+ /// and sequence are absent (Req 1.8, Decision 7). On such a rule the missing+ /// indicator is the site's own expression of its first chapter, so reading it+ /// as `1` interprets a convention the reader taught rather than inventing+ /// data — a rule that does not declare optionality derives nothing.+ ///+ /// Internal: nothing outside the package reads the constant, and the app+ /// reads the *result* off the projection instead. Core tests use `@testable`.+ static let derivedFirstChapterSequence = ExactScalarString("1")+ public static func apply( _ template: URLTwoFieldTemplate, to component: ExactScalarString@@ -445,6 +478,20 @@ public enum URLTwoFieldTemplateApplicator { starts.append(index) } }+ // The literal match and the interior bounds above precede this branch and+ // apply in both presence states (Req 1.7). The blank-field guards do not —+ // they sit after the split — so the zero-separator path carries its own+ // blank-interior check (Req 1.6).+ if starts.isEmpty, template.sequencePresence == .optional {+ // Reqs 1.1, 1.8, 1.9: the whole interior is the Work identity whatever the+ // field order — with no separator there is nothing to order — and the+ // absent sequence is derived rather than left nil.+ let interior = ExactScalarString(scalarString(source[interiorStart..<interiorEnd]))+ guard !interior.isBlank else { throw URLRuleApplicationError.blankField(field: .work) }+ return URLRuleExtraction(+ workIdentity: interior, chapterSequence: Self.derivedFirstChapterSequence,+ sequenceDerived: true)+ } guard starts.count == 1, let separatorStart = starts.first else { throw URLRuleApplicationError.ambiguousSeparator(count: starts.count) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex a2255ec..582eea0 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -331,6 +331,28 @@ enum DuplicateReconciler { return (rewritten, version.converged) } + /// The surviving definition is the representative row's, compared as a+ /// **definition** rather than as bytes (Q21).+ ///+ /// `definitionData` is written by a bare `JSONEncoder()`, and a keyed+ /// container models keys rather than an ordered sequence — so neither call+ /// order nor synthesis promises a stable byte order across Foundation+ /// versions, and `URLTwoFieldTemplate` writes its `sequencePresence` key only+ /// when the sequence is declared optional. Two rows carrying one rule under+ /// two byte layouts are one definition; comparing bytes would copy them onto+ /// each other on every pass and churn a CloudKit record for a semantic no-op.+ ///+ /// **A mixed readable/unreadable pair is skipped outright** (Q26). Inequality+ /// is what *triggers* the copy here, and `GroupOrdering` picks the+ /// representative with no readability preference: copying down would+ /// overwrite readable bytes with unreadable ones, and copying up would+ /// overwrite the broken row and mask the standing `unreadableURLRule`+ /// diagnosis that names it. Nothing is written and the diagnosis stands —+ /// `unreadableURLRule` where the group's rows sit on different Site rows,+ /// `LibraryValidator`'s rule-membership clause where they share one, since an+ /// unreadable row makes the group non-converged either way. Re-teaching the+ /// site repairs it. Two rows that both fail to decode have no semantics to+ /// compare, so they compare by bytes as before. private static func convergeURLRuleGroup( _ rows: [URLRulePattern] ) -> (rewritten: Int, rewrittenVersion: Int?) {@@ -338,11 +360,20 @@ enum DuplicateReconciler { guard let representative = ordered.first else { return (0, nil) } var rewritten = 0+ let representativeDefinition = try? representative.definition for row in ordered where row !== representative {- if row.definitionData != representative.definitionData {- row.definitionData = representative.definitionData- rewritten += 1+ switch (try? row.definition, representativeDefinition) {+ case let (definition?, survivor?):+ guard !RuleDefinitionComparator.semanticallyEqual(definition, survivor) else {+ continue+ }+ case (nil, nil):+ guard row.definitionData != representative.definitionData else { continue }+ default:+ continue }+ row.definitionData = representative.definitionData+ rewritten += 1 } let version = alignVersions( ordered, representative: representative,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedDeriver.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedDeriver.swiftindex f986360..59d2d63 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedDeriver.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedDeriver.swift@@ -81,6 +81,10 @@ public struct ComposedDerivation: Equatable, Sendable { /// success). Present as settlement evidence even when it is not a key /// component (Q24). public let chapterSequence: ExactScalarString?+ /// Whether that sequence was derived from an absent chapter part under a+ /// declared-optional rule rather than read out of the URL (Reqs 1.8, 2.6).+ /// Carried from the applicator, which is the only place that knows.+ public let sequenceDerived: Bool public let identity: ComposedIdentityKey /// The title parse failure, when the name could not be derived. public let titleFailure: PatternApplicationError?@@ -110,7 +114,7 @@ public enum ComposedDeriver { ) -> ComposedDerivation { let (workName, chapterTitle, workNameSource, titleFailure) = deriveTitle( captureTitle: captureTitle, titleRule: titleRule)- let (workIdentity, chapterSequence, urlFailure) = deriveURL(+ let (workIdentity, chapterSequence, sequenceDerived, urlFailure) = deriveURL( rawURL: rawURL, urlRule: urlRule) let identity = composeIdentity( hostname: hostname, rawURL: rawURL, workName: workName,@@ -122,6 +126,7 @@ public enum ComposedDeriver { chapterTitle: chapterTitle, workIdentity: workIdentity, chapterSequence: chapterSequence,+ sequenceDerived: sequenceDerived, identity: identity, titleFailure: titleFailure, urlFailure: urlFailure@@ -160,27 +165,33 @@ public enum ComposedDeriver { private static func deriveURL( rawURL: String, urlRule: ComposedURLRule?- ) -> (workIdentity: ExactScalarString?, chapterSequence: ExactScalarString?, failure: URLRuleApplicationError?) {- guard let urlRule else { return (nil, nil, nil) }+ ) -> (+ workIdentity: ExactScalarString?, chapterSequence: ExactScalarString?,+ sequenceDerived: Bool, failure: URLRuleApplicationError?+ ) {+ guard let urlRule else { return (nil, nil, false, nil) } let scalarURL = ExactScalarString(rawURL) switch urlRule.definition { case .work, .workAndSequence, .combined: do { let extraction = try URLRuleApplicator.apply(urlRule.definition, to: scalarURL)- return (extraction.workIdentity, extraction.chapterSequence, nil)+ return (+ extraction.workIdentity, extraction.chapterSequence,+ extraction.sequenceDerived, nil+ ) } catch let error as URLRuleApplicationError {- return (nil, nil, error)+ return (nil, nil, false, error) } catch {- return (nil, nil, .invalidRule(reason: "\(error)"))+ return (nil, nil, false, .invalidRule(reason: "\(error)")) } case .sequence: do { let sequence = try URLRuleApplicator.applySequence(urlRule.definition, to: scalarURL)- return (nil, sequence, nil)+ return (nil, sequence, false, nil) } catch let error as URLRuleApplicationError {- return (nil, nil, error)+ return (nil, nil, false, error) } catch {- return (nil, nil, .invalidRule(reason: "\(error)"))+ return (nil, nil, false, .invalidRule(reason: "\(error)")) } } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex 337633f..3d95be3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -269,6 +269,22 @@ public struct ComposedEntryProjection: Equatable, Sendable { public let workNameSource: ComposedWorkNameSource public let projectedChapterTitle: String? public let projectedChapterSequence: String?+ /// The Work identity the URL rule extracted for this Entry, or nil when the+ /// rule supplies none or the extraction failed (Q20).+ ///+ /// No other field carries it: `projectedWorkID` names a *Work row*, and the+ /// preview Req 2.6 demands has to show the identity a separator-free capture+ /// derives before any Work exists to hold it. Defaulted in the initializer so+ /// fixtures that predate it still compile, exactly as `previousIdentityKey`+ /// is.+ public let derivedWorkIdentity: String?+ /// Whether `projectedChapterSequence` was **derived** from an absent chapter+ /// part under a declared-optional rule rather than read out of the URL (Reqs+ /// 1.8, 2.6). The applicator is the only place that knows, so the fact is+ /// carried out from there rather than re-derived by a second probe over+ /// every capture — two definitions of "separator-free" would have to be kept+ /// in step. Defaulted alongside `derivedWorkIdentity` for the same reason.+ public let sequenceDerived: Bool public let projectedIdentityBasis: EntryIdentityBasis public let projectedKeyVersion: Int public let projectedIdentityKey: String@@ -293,10 +309,13 @@ public struct ComposedEntryProjection: Equatable, Sendable { projectedIdentityKey: String, assignment: ComposedAssignmentProjection, projectedWorkID: UUID?, chapterSettled: Bool, actionableAfter: Bool, titleFailure: PatternApplicationError?, urlFailure: URLRuleApplicationError?,- previousIdentityKey: String? = nil, previousKeyVersion: Int = 1+ previousIdentityKey: String? = nil, previousKeyVersion: Int = 1,+ derivedWorkIdentity: String? = nil, sequenceDerived: Bool = false ) { self.previousIdentityKey = previousIdentityKey self.previousKeyVersion = previousKeyVersion+ self.derivedWorkIdentity = derivedWorkIdentity+ self.sequenceDerived = sequenceDerived self.entryID = entryID self.previousWorkID = previousWorkID self.previousChapterTitle = previousChapterTitle@@ -666,7 +685,9 @@ public enum ComposedTeachingProjectionPlanner { actionableAfter: actionableAfter, titleFailure: derivation.titleFailure, urlFailure: derivation.urlFailure, previousIdentityKey: entry.previousIdentityKey,- previousKeyVersion: entry.previousKeyVersion)+ previousKeyVersion: entry.previousKeyVersion,+ derivedWorkIdentity: derivation.workIdentity?.value,+ sequenceDerived: derivation.sequenceDerived) } // MARK: - Helpers
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RuleDefinitionComparator.swift b/Packages/AsterismCore/Sources/AsterismCore/RuleDefinitionComparator.swiftindex 0cf4d88..a7db034 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RuleDefinitionComparator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RuleDefinitionComparator.swift@@ -40,10 +40,14 @@ public enum RuleDefinitionComparator { _ lhs: URLRuleDefinition, _ rhs: URLRuleDefinition ) -> Bool {- // URL rule definitions are built entirely on `ExactScalarString`, so their- // structural equality is already exact by Unicode scalars and carries no- // order-independent redundancy. Distinct forms over the same locator- // (e.g. `.work` vs `.sequence`) are distinct cases and never equate.+ // URL rule definitions carry their identity-bearing text as+ // `ExactScalarString`, so that text compares exactly by Unicode scalars,+ // and nothing here carries order-independent redundancy. The one+ // non-string member is a combined template's `URLSequencePresence`, a+ // two-state enum with exactly one representation of each state, so plain+ // `==` is correct for it too — that is the whole reason Decision 1+ // rejected `Bool?`. Distinct forms over the same locator (e.g. `.work`+ // vs `.sequence`) are distinct cases and never equate. lhs == rhs }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftindex 33cda40..e6ad0d2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -580,11 +580,13 @@ public enum GroupOrdering { } } - /// The URL-rule counterpart. `URLRuleDefinition` is built entirely on- /// `ExactScalarString` and carries no order-independent redundancy, so the- /// canonical encoding *is* the identity — but its comparison is not, and- /// `Set<String>` equated two scalar-distinct locators the rest of the app- /// treats as two rules. Same encoding, exact-scalar compare.+ /// The URL-rule counterpart. `URLRuleDefinition` carries its identity-bearing+ /// text as `ExactScalarString` and its one non-string member — a combined+ /// template's `URLSequencePresence` — has a single representation per state,+ /// and it carries no order-independent redundancy, so the canonical encoding+ /// *is* the identity. Its comparison is not, though: `Set<String>` equated two+ /// scalar-distinct locators the rest of the app treats as two rules. Same+ /// encoding, exact-scalar compare. public static func isConvergedGroup(_ rows: [URLRulePattern]) -> Bool { guard rows.count > 1 else { return true } guard rows.count(where: \.isCurrent) <= 1 else { return false }
diff --git a/Asterism/Asterism/Views/ComposedURLEditorState.swift b/Asterism/Asterism/Views/ComposedURLEditorState.swiftindex 27c049b..50381b0 100644--- a/Asterism/Asterism/Views/ComposedURLEditorState.swift+++ b/Asterism/Asterism/Views/ComposedURLEditorState.swift@@ -47,6 +47,18 @@ extension ComposedTeachingPresentation { /// the minimum that needs — one stored template, cleared by the gestures /// that supersede it. public private(set) var retainedTemplate: URLTwoFieldTemplate?+ /// The reader's declaration that the chapter part may be absent (Req+ /// 2.1), held as editor state rather than written into a template once.+ ///+ /// A one-shot template rewrite cannot work: `rule(in:)` re-derives the+ /// template from a live split on **every** dispatch, so a swapped+ /// template would be clobbered by the next token tap. Both `.combined`+ /// branches of `rule(in:)` therefore read this value instead.+ ///+ /// `.required` is the default and the only representation of "not+ /// declared optional" (Req 2.3), so an untouched toggle authors exactly+ /// the rule a build without this feature would (Req 2.2).+ public private(set) var sequencePresence: URLSequencePresence = .required public init() {} @@ -57,6 +69,10 @@ extension ComposedTeachingPresentation { public mutating func seed( from definition: URLRuleDefinition?, in components: RawURLLexicalComponents? ) {+ // Presence follows the retained template exactly: it is set from a+ // stored `.combined` template below and reset everywhere else, so a+ // stale declaration never outlives the template it was made on.+ sequencePresence = .required guard let definition, let components else { work = nil sequence = nil@@ -91,6 +107,9 @@ extension ComposedTeachingPresentation { sequence = nil split = nil retainedTemplate = template+ // Req 2.8: reopening a taught rule shows the declaration still+ // in force.+ sequencePresence = template.sequencePresence } } @@ -102,16 +121,38 @@ extension ComposedTeachingPresentation { // Retained only when the *same* component is re-selected, and // compared by index rather than by text, since a path can repeat // a value and the template belongs to a component.- if work != selection { retainedTemplate = nil }+ if work != selection { clearRetainedTemplate() } work = selection case .sequence: // A separate sequence component supersedes a within-component // split, so the stored template no longer describes the rule.- retainedTemplate = nil+ clearRetainedTemplate() sequence = selection } } + /// The declaration is a property of the template it was made on, so the+ /// two are dropped together (design "Teaching UI": reset semantics follow+ /// the retained template's exactly).+ private mutating func clearRetainedTemplate() {+ retainedTemplate = nil+ sequencePresence = .required+ }++ /// The reader's declaration that the chapter part may be absent (Req+ /// 2.1). Routed through the ordinary dispatch path — `rule(in:)`+ /// republishes `.combined` from either branch, so the toggle cannot+ /// downgrade the rule to `.work` (Q23).+ ///+ /// The retained template is re-stamped here as well as read at dispatch:+ /// the stored template and this field are two representations of one+ /// declaration, and leaving them to disagree is how a stale template+ /// reaches a caller that reads it directly.+ public mutating func setSequencePresence(_ presence: URLSequencePresence) {+ sequencePresence = presence+ retainedTemplate = retainedTemplate.map { $0.declaring(presence) }+ }+ /// "Split this component" — opens the split editor on a default span. public mutating func beginSplit(of componentText: String) { split = ComposedTeachingPresentation.defaultSplitSelection(for: componentText)@@ -138,7 +179,7 @@ extension ComposedTeachingPresentation { /// split, so the retained template goes with it. public mutating func useWholeComponent() { split = nil- retainedTemplate = nil+ clearRetainedTemplate() } /// "Clear URL selection".@@ -146,7 +187,7 @@ extension ComposedTeachingPresentation { work = nil sequence = nil split = nil- retainedTemplate = nil+ clearRetainedTemplate() } // MARK: - The authored definition@@ -173,14 +214,18 @@ extension ComposedTeachingPresentation { // A live split supersedes anything retained, and becomes what is // retained if the reader then re-anchors the same component.- if let split {- guard let text = Self.componentText(for: workSelection, in: components) else {- return URLRuleOutcome(definition: nil)- }+ if split != nil { do {- let template = try URLTwoFieldTemplateDeriver.derive(- from: ExactScalarString(text), selection: split)+ guard let template = try combinedTemplateCore(in: components) else {+ return URLRuleOutcome(definition: nil)+ } retainedTemplate = template+ // Req 1.10: the core drops a declaration the newly derived+ // template's shape cannot carry. The toggle reads this+ // field, so it has to follow what was actually published —+ // otherwise the control reads on while the rule says+ // `.required`, and the gate and dispatch disagree.+ sequencePresence = template.sequencePresence return URLRuleOutcome( definition: .combined(locator: workLocator, template: template)) } catch let error as URLTemplateSelectionError {@@ -208,16 +253,112 @@ extension ComposedTeachingPresentation { // The stored template survives the re-anchor (Req 3.7). Without this // the only gesture that applies the corrected locator is also the one // that drops the template.- if let retainedTemplate {+ //+ // The declaration rides on the retained template rather than+ // replacing it, so reopening a taught site and toggling needs no+ // split re-authoring (Q23).+ if retainedTemplate != nil, let template = try? combinedTemplateCore(in: components) {+ // The same write-back as the live-split branch: the stored+ // template and the field the toggle reads both follow what was+ // published, so no caller can read a declaration the emitted+ // rule does not carry.+ retainedTemplate = template+ sequencePresence = template.sequencePresence return URLRuleOutcome(- definition: .combined(locator: workLocator, template: retainedTemplate))+ definition: .combined(locator: workLocator, template: template)) } return URLRuleOutcome(definition: .work(locator: workLocator)) } + // MARK: - The declaration's gate (Req 1.10, Decision 5)++ /// Whether the reader may declare the chapter sequence optional right+ /// now: false when no combined rule is in force, and false where+ /// `validate` would refuse the declared rule — with neither a prefix nor+ /// a suffix the literal-match guard is vacuous and every separator-free+ /// value in the component becomes a Work identity (Decision 5; blank+ /// means empty or whitespace-only, Q29).+ ///+ /// The View is render-and-dispatch only, so the gate lives here.+ /// `dispatchRuleDefinition` does not call `validate`, and the disabled+ /// control is what keeps the reader from meeting+ /// `invalidURLDefinition` at projection time instead.+ public func canDeclareSequenceOptional(in components: RawURLLexicalComponents?) -> Bool {+ guard let components, let workSelection = work,+ let locator = ComposedTeachingPresentation.urlLocator(+ for: workSelection, in: components),+ let template = combinedTemplate(in: components)+ else { return false }+ let declared = URLRuleDefinition.combined(+ locator: locator, template: template.declaring(.optional))+ return (try? declared.validate(origin: .readerTaught, isCurrent: true)) != nil+ }++ /// The template `rule(in:)` would emit for the current selections, or nil+ /// when they author something other than a combined rule. Non-mutating,+ /// unlike `rule(in:)`, so the View can ask the gate while rendering.+ public func combinedTemplate(in components: RawURLLexicalComponents?) -> URLTwoFieldTemplate? {+ guard let components else { return nil }+ return try? combinedTemplateCore(in: components)+ }+ // MARK: - Helpers + /// The one derivation of the combined template the current state+ /// authors: a live split re-derived from the selected component, or the+ /// retained template re-stamped with the current declaration. Nil when+ /// the selections author some other rule form.+ ///+ /// `rule(in:)` and the gate both route through this, so the control the+ /// reader is offered and the rule dispatch publishes can never be+ /// derived under different guards — which is what let a locator that+ /// does not resolve pass the gate while dispatch refused it.+ /// Non-mutating; `rule(in:)` does the retaining and the write-back at+ /// the call site.+ private func combinedTemplateCore(+ in components: RawURLLexicalComponents+ ) throws -> URLTwoFieldTemplate? {+ guard let workSelection = work,+ ComposedTeachingPresentation.urlLocator(for: workSelection, in: components) != nil+ else { return nil }+ if let split {+ guard let text = Self.componentText(for: workSelection, in: components) else {+ return nil+ }+ // The reader's declaration is editor state, stamped on the+ // template here rather than written into it once: this branch+ // re-derives on every dispatch, so a one-shot rewrite would be+ // clobbered by the next token tap. The parameter has no default,+ // so nothing is silently assumed (Req 2.10).+ let derived = try URLTwoFieldTemplateDeriver.derive(+ from: ExactScalarString(text), selection: split, presence: sequencePresence)+ return derived.declaring(Self.presence(sequencePresence, boundedBy: derived))+ }+ // A separate sequence component authors a two-locator rule instead.+ guard sequence == nil, let retainedTemplate else { return nil }+ return retainedTemplate.declaring(Self.presence(sequencePresence, boundedBy: retainedTemplate))+ }++ /// The declaration a template's *shape* can carry (Req 1.10, Decision 5).+ ///+ /// A declaration is made on one template and dies with it. A live-split+ /// adjustment can turn a bounded template into one with neither a prefix+ /// nor a suffix — moving the Work span to the start of the component and+ /// the sequence span to its end — and `toggleSplitToken` has no reason to+ /// know about the declaration. Left standing, the declaration would be+ /// stamped onto the blank-affix template and dispatched without+ /// `validate`, so the reader would meet `invalidURLDefinition` at+ /// projection time with the toggle rendered disabled-while-on. Blank is+ /// empty or whitespace-only, as `validate` reads it (Q29).+ private static func presence(+ _ presence: URLSequencePresence, boundedBy template: URLTwoFieldTemplate+ ) -> URLSequencePresence {+ guard presence == .optional, template.prefix.isBlank, template.suffix.isBlank+ else { return presence }+ return .required+ }+ /// The text of a selected component, for the split editor. public static func componentText( for selection: URLComponentSelection, in components: RawURLLexicalComponents
diff --git a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swiftindex 890a5be..643f5bd 100644--- a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift+++ b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift@@ -266,6 +266,27 @@ public final class ComposedTeachingViewModel { var unresolved: [UnresolvedURLCapture] = [] var keyChanges: [IdentityKeyChange] = [] var rows: [ComposedEntryProjection] = []+ /// Captures whose selected component holds no separator under the+ /// candidate rule — the shape the declaration exists for.+ var separatorFree: Set<UUID> = []+ }++ /// The before/after Work for one preview row (Req 4.7). "After" is resolved+ /// the way the deferred reconciliation pass's step 1 would have: the+ /// assignment target for `.reuse`/`.claim`, the prospective Work for+ /// `.create`, and the previous Work for the rest. `projectedWorkID` is nil+ /// for "no projected change" and must not be read as "post-commit+ /// attachment".+ public nonisolated struct WorkAttachment: Equatable, Sendable {+ public let before: String?+ public let after: String?+ /// Whether the commit moves this capture to a different Work. Read off+ /// the assignment's Work identity, never off the two names: Req 4.8's+ /// split pre-state has one story in two Works that carry the *same*+ /// display name, and a name comparison would hide the move in exactly+ /// the case Req 4.7 exists for. A name neither side resolves has the+ /// same effect.+ public let changes: Bool } private var previewReport = PreviewReport()@@ -298,12 +319,65 @@ public final class ComposedTeachingViewModel { return "\(changed) identity key." } + /// Req 2.6: whether this capture's chapter sequence was derived from the+ /// absence of the chapter indicator rather than read out of its URL. The row+ /// says so, so the reader can see the inference (Decision 7).+ ///+ /// Read off the projection, which carries the applicator's own answer. The+ /// view model does not re-derive it: a second probe here and the extraction+ /// there are two definitions of the same fact, and only one of them is the+ /// one the commit acts on.+ public func sequenceIsDerived(for entryID: UUID) -> Bool {+ previewOutcome?.entries.first { $0.entryID == entryID }?.sequenceDerived ?? false+ }++ /// Every capture whose URL carries no chapter part under the candidate rule.+ /// At least one such row is guaranteed to be shown when the site has one,+ /// which is what makes the Work name a chapter-less title yields visible+ /// before the commit (Req 2.11).+ public var separatorFreeCaptures: Set<UUID> { previewReport.separatorFree }++ /// The before/after Work for one row (Req 4.7).+ public func workAttachment(for projection: ComposedEntryProjection) -> WorkAttachment {+ let before = projection.previousWorkID.flatMap { workName(for: $0) }+ let after: String?+ let changes: Bool+ switch projection.assignment {+ case .reuse(let workID), .claim(let workID):+ after = workName(for: workID)+ changes = workID != projection.previousWorkID+ case .create:+ after = previewOutcome?.prospectiveWorks+ .first { $0.entryIDs.contains(projection.entryID) }?.displayTitle.value+ // The entry attaches to a Work that does not exist yet, so it moves+ // whether it was previously unattached or attached elsewhere.+ changes = true+ case .protected, .ambiguous, .noChange:+ after = before+ changes = false+ }+ return WorkAttachment(before: before, after: after, changes: changes)+ }++ private func workName(for workID: UUID) -> String? {+ frozenBasis?.works.first { $0.id == workID }?.displayTitle+ }+ private func derivePreviewReport() -> PreviewReport { guard let entries = previewOutcome?.entries else { return PreviewReport() } let byID = Dictionary( uniqueKeysWithValues: (frozenBasis?.entries ?? []).map { ($0.id, $0) }) var report = PreviewReport() for projection in entries {+ // Zero separators in the selected component is exactly what a+ // `.required` rule rejects and an `.optional` one reads as the first+ // chapter, so the two arms are the resolved and the unresolved half+ // of one shape. Both come off the projection the commit will act on+ // rather than from a second probe run here.+ if projection.sequenceDerived+ || projection.urlFailure == URLRuleApplicationError.ambiguousSeparator(count: 0) {+ report.separatorFree.insert(projection.entryID)+ } if let failure = projection.urlFailure { let basis = byID[projection.entryID] report.unresolved.append(UnresolvedURLCapture(@@ -323,6 +397,9 @@ public final class ComposedTeachingViewModel { } var flagged = Set(report.unresolved.map(\.entryID)) flagged.formUnion(report.keyChanges.map(\.entryID))+ // Req 2.6/2.11: at least one separator-free capture must be visible, so+ // it is flagged past the cap exactly as unresolved and re-keyed rows are.+ flagged.formUnion(report.separatorFree) var rows = Array(entries.prefix(Self.previewRowCap)) let shown = Set(rows.map(\.entryID)) rows.append(@@ -331,6 +408,75 @@ public final class ComposedTeachingViewModel { return report } + // MARK: - Optional chapter sequence (Reqs 2.5, 2.9)++ /// Req 2.5 / Q10: a combined rule needs two selections with a separator+ /// between them, and the teach surface's only example is the capture in front+ /// of the reader. A capture whose URL carries no chapter part therefore+ /// cannot express the rule at all, and says so rather than failing silently+ /// or offering an unusable editor.+ public static let unteachableCombinedShapeNotice =+ "This rule form has to be taught from a capture whose URL includes the chapter part. Open a chapter of this story whose URL carries its number and teach the site from there."++ /// Req 2.9 / Decision 4: removal is another re-teach with its own+ /// consequences, not a rollback to the grouping that stood before.+ public static let sequencePresenceRemovalWarning =+ "Captures whose URLs have no chapter part will stop resolving a Work identity and will be separated from their Work. Removing this does not restore the grouping the site had before — it re-derives the site again."++ /// The unteachable-shape message, or nil. Evaluated from the **example URL+ /// and the current selection**, never from a stored rule: Q10's case is the+ /// initial teach of a site that has none.+ public var unteachableCombinedShapeMessage: String? {+ guard let components = try? RawURLRuleParser.parse(ExactScalarString(exampleRawURL))+ else { return nil }+ switch urlRuleDefinition {+ case .combined(let locator, let template):+ // The candidate template finds no chapter part in this capture's+ // URL. Applied as authored, so a reader who has already declared the+ // sequence optional is not told to go and find another capture.+ //+ // Through the applicator rather than a hand-rolled select-then-apply:+ // it composes parse, select, and apply in exactly that order, and a+ // selection failure lands in the same `catch` that returns nil here.+ do {+ _ = try URLRuleApplicator.apply(+ .combined(locator: locator, template: template),+ to: ExactScalarString(exampleRawURL))+ return nil+ } catch URLRuleApplicationError.ambiguousSeparator(let count) where count == 0 {+ return Self.unteachableCombinedShapeNotice+ } catch {+ return nil+ }+ case .work(let locator):+ // A Work-identity selection with the chapter unsourced: the reader+ // needs the chapter out of this component and no split can be+ // authored in it.+ guard chapterUnsourced,+ let component = try? URLRuleApplicator.select(locator, from: components),+ !ComposedTeachingPresentation.canAuthorSplit(in: component.value)+ else { return nil }+ return Self.unteachableCombinedShapeNotice+ case .workAndSequence, .sequence, nil:+ return nil+ }+ }++ /// The removal warning, or nil. Shown while the stored rule declares the+ /// sequence optional and the candidate does not — the moment before the+ /// reader commits the removal.+ public var sequencePresenceRemovalMessage: String? {+ guard Self.declaresOptionalSequence(frozenBasis?.currentURLRule?.definition),+ !Self.declaresOptionalSequence(urlRuleDefinition)+ else { return nil }+ return Self.sequencePresenceRemovalWarning+ }++ nonisolated static func declaresOptionalSequence(_ definition: URLRuleDefinition?) -> Bool {+ guard case .combined(_, let template) = definition else { return false }+ return template.sequencePresence == .optional+ }+ /// Whether the composed rule set currently sources no chapter at all. The URL /// details auto-expand and present themselves as the remedy in this state /// (Req 8.2 as amended by Decision 8); it mirrors the projection's own@@ -1025,8 +1171,12 @@ public final class ComposedTeachingViewModel { "The Work is identified by \(describe(locator))." case .workAndSequence(let work, let sequence): "The Work is identified by \(describe(work.locator)), and the chapter number comes from \(describe(sequence.locator))."- case .combined(let locator, _):- "The Work and the chapter number are split out of \(describe(locator))."+ case .combined(let locator, let template):+ // Req 2.7: the tolerance is stated in the same plain-language+ // register as every other rule form, not as a separate badge.+ template.sequencePresence == .optional+ ? "The Work and the chapter number are split out of \(describe(locator)); the chapter part may be absent."+ : "The Work and the chapter number are split out of \(describe(locator))." case .sequence(let locator): "The chapter number comes from \(describe(locator))." }
diff --git a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swiftindex ee2340d..d091858 100644--- a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift+++ b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift@@ -270,9 +270,53 @@ struct ComposedURLDetailsEditor: View { .buttonStyle(.plain) .accessibilityIdentifier("composed-url-split-button") }+ optionalSequenceToggle } } + /// Req 2.1: the explicit control by which the reader declares the chapter+ /// sequence optional. It sits beside the split controls, because that is+ /// where the rule it qualifies is authored, and it rides the ordinary+ /// dispatch path — `rule(in:)` republishes `.combined` from either branch, so+ /// the toggle cannot narrow the rule to `.work` (Q23).+ ///+ /// Disabled where `validate` would refuse the declared rule. That gate is+ /// load-bearing rather than cosmetic: `dispatchRuleDefinition` does not call+ /// `validate`, so without it the reader would meet+ /// `invalidURLDefinition` at projection time instead of a disabled control+ /// (Decision 5).+ @ViewBuilder+ private var optionalSequenceToggle: some View {+ if splitSelection != nil || state.retainedTemplate != nil {+ let permitted = state.canDeclareSequenceOptional(in: components)+ VStack(alignment: .leading, spacing: 4) {+ Toggle(isOn: sequencePresenceBinding) {+ VStack(alignment: .leading, spacing: 2) {+ Text("The chapter part may be absent").font(.footnote.weight(.semibold))+ Text("Some sites leave the chapter out of the first chapter's URL. Read that as chapter 1 instead of failing to match.")+ .font(.caption).foregroundStyle(.secondary)+ }+ }+ .disabled(!permitted)+ .accessibilityIdentifier("composed-url-optional-sequence-toggle")+ if !permitted {+ Text("This rule has no text before or after the split, so anything in this component would be read as a Work. Include the surrounding text in the rule to allow it.")+ .font(.caption).foregroundStyle(.secondary)+ .accessibilityIdentifier("composed-url-optional-sequence-blocked")+ }+ }+ }+ }++ private var sequencePresenceBinding: Binding<Bool> {+ Binding(+ get: { state.sequencePresence == .optional },+ set: { isOptional in+ state.setSequencePresence(isOptional ? .optional : .required)+ dispatchRuleDefinition()+ })+ }+ @ViewBuilder private func splitEditor(text: String, split: URLTwoFieldSelection) -> some View { VStack(alignment: .leading, spacing: 12) {
diff --git a/Asterism/Asterism/Views/ComposedTeachingView.swift b/Asterism/Asterism/Views/ComposedTeachingView.swiftindex 758d980..26cbd3d 100644--- a/Asterism/Asterism/Views/ComposedTeachingView.swift+++ b/Asterism/Asterism/Views/ComposedTeachingView.swift@@ -245,6 +245,20 @@ struct ComposedTeachingView: View { currentDefinition: model.urlRuleDefinition, storedSummary: model.storedURLRuleDescription, onRuleChange: { model.setURLRuleDefinition($0) })+ // Req 2.5 (Q10): this capture's URL cannot express a combined+ // rule at all, said rather than left to a silent failure.+ if let notice = model.unteachableCombinedShapeMessage {+ Label(notice, systemImage: "questionmark.circle")+ .font(.caption).foregroundStyle(AsterismColors.amberText)+ .accessibilityIdentifier("composed-url-unteachable-shape")+ }+ // Req 2.9 (Decision 4): removal is another re-teach, not a+ // rollback, and the reader is told before paying for it.+ if let warning = model.sequencePresenceRemovalMessage {+ Label(warning, systemImage: "exclamationmark.triangle")+ .font(.caption).foregroundStyle(AsterismColors.amberText)+ .accessibilityIdentifier("composed-url-presence-removal")+ } } .padding(.top, 4) } label: {@@ -361,9 +375,31 @@ struct ComposedTeachingView: View { if let chapter = projection.projectedChapterTitle { Text("· \(chapter)").font(.caption).foregroundStyle(AsterismColors.amberText).lineLimit(1) } else if let sequence = projection.projectedChapterSequence {- Text("· #\(sequence)").font(.caption).foregroundStyle(AsterismColors.amberText).lineLimit(1)+ // Req 2.6: a sequence the rule *derived* from the absent+ // chapter part must not read as one the URL stated, so the+ // reader can see the inference (Decision 7).+ Text(+ model.sequenceIsDerived(for: projection.entryID)+ ? "· #\(sequence) (derived)" : "· #\(sequence)"+ )+ .font(.caption).foregroundStyle(AsterismColors.amberText).lineLimit(1) } }+ // Req 2.6: the Work identity a separator-free capture derives, which+ // no existing field carries (Q20).+ if model.separatorFreeCaptures.contains(projection.entryID),+ let identity = projection.derivedWorkIdentity {+ Text("No chapter part in the URL — Work identity “\(identity)”, chapter derived")+ .font(.caption2).foregroundStyle(.secondary)+ .accessibilityIdentifier("composed-preview-derived-\(index)")+ }+ // Req 4.7: an attachment change is visible before the commit.+ let attachment = model.workAttachment(for: projection)+ if attachment.changes {+ Text("Work: \(attachment.before ?? "unattached") → \(attachment.after ?? "unattached")")+ .font(.caption2).foregroundStyle(.secondary).lineLimit(1)+ .accessibilityIdentifier("composed-preview-attachment-\(index)")+ } if projection.actionableAfter { Text("Still needs attention").font(.caption2).foregroundStyle(AsterismColors.amberText) }
diff --git a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift b/Asterism/Asterism/Views/ComposedTeachingPresentation.swiftindex 93364b6..54f1e00 100644--- a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift+++ b/Asterism/Asterism/Views/ComposedTeachingPresentation.swift@@ -74,8 +74,13 @@ public enum ComposedTeachingPresentation { let workStarts = [0] + separatorIndices.filter { $0 < boundary }.map { $0 + 1 } for workStart in workStarts where workStart < boundary { let candidate = URLTwoFieldSelection(work: workStart..<boundary, sequence: sequence)+ // `.required` because this probes whether a *split* is+ // authorable at all; the reader's declaration rides on the+ // template `rule(in:)` builds, and the reproduce check this+ // exercises is presence-blind either way (Req 2.4). if (try? URLTwoFieldTemplateDeriver.derive(- from: ExactScalarString(componentText), selection: candidate)) != nil {+ from: ExactScalarString(componentText), selection: candidate,+ presence: .required)) != nil { return candidate } }@@ -84,6 +89,20 @@ public enum ComposedTeachingPresentation { return URLTwoFieldSelection(work: 0..<(count - 1), sequence: (count - 1)..<count) } + /// Whether a within-component split can be authored on this component at+ /// all — that is, whether `defaultSplitSelection`'s candidate is one the+ /// deriver accepts rather than its everything-but-the-last fallback.+ ///+ /// Used for the unteachable-shape message (Req 2.5, Q10): a capture whose+ /// URL carries no chapter part cannot express a combined rule, and the+ /// reader is told so instead of being offered an editor that cannot work.+ public nonisolated static func canAuthorSplit(in componentText: String) -> Bool {+ let candidate = defaultSplitSelection(for: componentText)+ return (try? URLTwoFieldTemplateDeriver.derive(+ from: ExactScalarString(componentText), selection: candidate,+ presence: .required)) != nil+ }+ // MARK: - Two-granularity title chip selection (Req 3.3, 8.3, 8.6) /// A delimiter-split segment of the example title with its character range.
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftnew file mode 100644index 0000000..9747466--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -0,0 +1,1064 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// `URLSequencePresence` on `URLTwoFieldTemplate` (Reqs 2.2, 2.3, 5.5) — the+/// property, and the asymmetric `Codable` that keeps a library with no+/// declared-optional rule byte-identical to what a build without this feature+/// writes.+///+/// The asymmetry is the point: `.required` is the absence of the key, so the+/// tests assert **key absence in the JSON**, not merely that a re-decode is+/// equal. A symmetric encoding would round-trip identically and still break+/// Req 5.5.+@Suite("Optional chapter sequence — template")+struct URLOptionalSequenceTemplateTests {++ private static func encoded(_ value: some Encodable) throws -> String {+ let encoder = JSONEncoder()+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]+ return String(decoding: try encoder.encode(value), as: UTF8.self)+ }++ private static func template(+ prefix: String = "Story-",+ separator: String = "-",+ suffix: String = "",+ order: URLTemplateFieldOrder = .workThenSequence,+ presence: URLSequencePresence = .required+ ) -> URLTwoFieldTemplate {+ URLTwoFieldTemplate(+ prefix: ExactScalarString(prefix),+ separator: ExactScalarString(separator),+ suffix: ExactScalarString(suffix),+ order: order,+ sequencePresence: presence)+ }++ // MARK: - The property++ @Test("The memberwise default is .required, so no call site declares optionality by accident")+ func memberwiseDefaultIsRequired() {+ let template = URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"),+ separator: ExactScalarString("-"),+ suffix: ExactScalarString(""),+ order: .workThenSequence)+ #expect(template.sequencePresence == .required)+ }++ /// Req 2.3: one representation of "not declared optional". A two-state+ /// non-optional property cannot compare unequal to itself, which is what+ /// Decision 1 rejected `Bool?` for.+ @Test("Presence participates in equality and hashing without a third state")+ func presenceIsPartOfIdentity() {+ let required = Self.template(presence: .required)+ let optional = Self.template(presence: .optional)+ #expect(required != optional)+ #expect(required == Self.template(presence: .required))+ #expect(Set([required, optional, Self.template(presence: .required)]).count == 2)+ }++ /// `Sendable` is declared explicitly on the enum — a public non-frozen enum+ /// does not infer it, and `URLTwoFieldTemplate`'s own `Sendable` depends on+ /// it. This compiles only while both hold.+ @Test("The presence enum is Sendable, and the template stays Sendable through it")+ func presenceIsSendable() async {+ let presence: any Sendable = URLSequencePresence.optional+ let template: any Sendable = Self.template(presence: .optional)+ let carried = await Task { (presence, template) }.value+ #expect(carried.0 as? URLSequencePresence == .optional)+ #expect(carried.1 as? URLTwoFieldTemplate == Self.template(presence: .optional))+ }++ // MARK: - Encoding (Req 5.5)++ @Test("A .required template writes no sequencePresence key at all")+ func requiredEncodesNoKey() throws {+ let json = try Self.encoded(Self.template(presence: .required))+ #expect(!json.contains("sequencePresence"))+ #expect(+ json+ == #"{"order":"workThenSequence","prefix":"Story-","separator":"-","suffix":""}"#)+ }++ @Test("A .required combined rule encodes exactly the bytes a pre-feature build writes")+ func requiredRuleBytesUnchanged() throws {+ let definition = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.template(presence: .required))+ let json = try Self.encoded(definition)+ #expect(!json.contains("sequencePresence"))+ #expect(+ json == #"{"combined":{"locator":{"pathBracketed":{"left":{"start":{}},"#+ + #""right":{"unanchored":{}}}},"template":{"order":"workThenSequence","#+ + #""prefix":"Story-","separator":"-","suffix":""}}}"#)+ }++ @Test("An .optional template writes the key, and only then")+ func optionalEncodesTheKey() throws {+ let json = try Self.encoded(Self.template(presence: .optional))+ #expect(+ json+ == #"{"order":"workThenSequence","prefix":"Story-","separator":"-","sequencePresence":"optional","suffix":""}"#+ )+ }++ // MARK: - Decoding (Reqs 2.2, 5.3)++ @Test("A payload written before this feature decodes as .required")+ func missingKeyDecodesAsRequired() throws {+ let payload = #"{"order":"workThenSequence","prefix":"Story-","separator":"-","suffix":""}"#+ let decoded = try JSONDecoder().decode(URLTwoFieldTemplate.self, from: Data(payload.utf8))+ #expect(decoded == Self.template(presence: .required))+ #expect(decoded.sequencePresence == .required)+ }++ @Test("An .optional template round-trips, inside a rule definition as well")+ func optionalRoundTrips() throws {+ let template = Self.template(presence: .optional)+ let data = try JSONEncoder().encode(template)+ #expect(try JSONDecoder().decode(URLTwoFieldTemplate.self, from: data) == template)++ let definition = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored), template: template)+ let ruleData = try JSONEncoder().encode(definition)+ let decodedRule = try JSONDecoder().decode(URLRuleDefinition.self, from: ruleData)+ #expect(decodedRule == definition)+ guard case .combined(_, let decodedTemplate) = decodedRule else {+ Issue.record("expected a combined rule")+ return+ }+ #expect(decodedTemplate.sequencePresence == .optional)+ }++ @Test("An explicit \"required\" value decodes rather than throwing")+ func explicitRequiredValueDecodes() throws {+ let payload =+ #"{"order":"workThenSequence","prefix":"Story-","separator":"-","sequencePresence":"required","suffix":""}"#+ let decoded = try JSONDecoder().decode(URLTwoFieldTemplate.self, from: Data(payload.utf8))+ #expect(decoded.sequencePresence == .required)+ }++ @Test("An unknown presence value throws rather than degrading to a valid rule")+ func unknownPresenceThrows() {+ let payload =+ #"{"order":"workThenSequence","prefix":"Story-","separator":"-","sequencePresence":"sometimes","suffix":""}"#+ #expect(throws: (any Error).self) {+ try JSONDecoder().decode(URLTwoFieldTemplate.self, from: Data(payload.utf8))+ }+ }++ @Test("A missing required key still throws — decodeIfPresent applies to presence alone")+ func missingRequiredKeyStillThrows() {+ #expect(throws: (any Error).self) {+ try JSONDecoder().decode(+ URLTwoFieldTemplate.self,+ from: Data(#"{"order":"workThenSequence","prefix":"a","separator":"-"}"#.utf8))+ }+ }+}++/// The unbounded-selection guard (Req 1.10, Decision 5). `.optional` with+/// neither prefix nor suffix has a vacuous literal-match guard, so every+/// separator-free value in the selected component would become a Work+/// identity — on such a site `/about/` becomes Work `about`. The affixes are+/// the bound; the locator plays no part.+@Suite("Optional chapter sequence — validation")+struct URLOptionalSequenceValidationTests {++ private static func combined(+ prefix: String,+ suffix: String,+ presence: URLSequencePresence,+ locator: URLComponentLocator = .pathBracketed(left: .start, right: .unanchored)+ ) -> URLRuleDefinition {+ .combined(+ locator: locator,+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString(prefix),+ separator: ExactScalarString("-"),+ suffix: ExactScalarString(suffix),+ order: .workThenSequence,+ sequencePresence: presence))+ }++ @Test("An .optional template with neither prefix nor suffix is refused")+ func unboundedOptionalRefused() {+ #expect(throws: URLIdentityError.self) {+ try Self.combined(prefix: "", suffix: "", presence: .optional)+ .validate(origin: .readerTaught, isCurrent: true)+ }+ // A whitespace-only affix bounds nothing the domain treats as text, and+ // `isBlank` is how every other affix guard here reads emptiness.+ #expect(throws: URLIdentityError.self) {+ try Self.combined(prefix: " ", suffix: "", presence: .optional)+ .validate(origin: .readerTaught, isCurrent: true)+ }+ }++ /// The refusal is `invalidTemplate`, not `invalidRule` or `blankValue` — the+ /// teaching surface disables the toggle on this condition, so the error is+ /// reached only when something bypasses the control.+ @Test("The refusal is an invalidTemplate error")+ func refusalIsInvalidTemplate() {+ #expect(throws: URLIdentityError.self) {+ do {+ try Self.combined(prefix: "", suffix: "", presence: .optional)+ .validate(origin: .readerTaught, isCurrent: true)+ } catch let error as URLIdentityError {+ guard case .invalidTemplate = error else {+ Issue.record("expected .invalidTemplate, got \(error)")+ throw error+ }+ throw error+ }+ }+ }++ /// The locator plays no part (Decision 5): the same unaffixed template is+ /// refused under a literal-anchored locator, and admitted under the loosest+ /// locator as soon as it carries an affix.+ @Test(+ "The guard is keyed on affixes alone, not on locator looseness",+ arguments: [+ URLComponentLocator.pathBracketed(left: .start, right: .end),+ .pathBracketed(left: .literal(ExactScalarString("fiction")), right: .unanchored),+ .query(name: ExactScalarString("id")),+ ])+ func guardIgnoresLocator(locator: URLComponentLocator) throws {+ #expect(throws: URLIdentityError.self) {+ try Self.combined(prefix: "", suffix: "", presence: .optional, locator: locator)+ .validate(origin: .readerTaught, isCurrent: true)+ }+ try Self.combined(prefix: "Story-", suffix: "", presence: .optional, locator: locator)+ .validate(origin: .readerTaught, isCurrent: true)+ }++ @Test(+ "Either affix alone bounds the rule, so the motivating template is admitted",+ arguments: [("Story-", ""), ("", ".htm"), ("Story-", ".htm")])+ func oneAffixSuffices(prefix: String, suffix: String) throws {+ try Self.combined(prefix: prefix, suffix: suffix, presence: .optional)+ .validate(origin: .readerTaught, isCurrent: true)+ }++ /// The guard fires for `.optional` only. An unaffixed `.required` template is+ /// the shape several existing fixtures use, and stays legal.+ @Test("A .required template with empty affixes stays valid")+ func requiredWithEmptyAffixesStaysValid() throws {+ try Self.combined(prefix: "", suffix: "", presence: .required)+ .validate(origin: .readerTaught, isCurrent: true)+ try Self.combined(prefix: " ", suffix: "", presence: .required)+ .validate(origin: .readerTaught, isCurrent: true)+ }++ @Test("Pre-existing combined refusals are unchanged in both presence states")+ func existingRefusalsUnchanged() {+ for presence in [URLSequencePresence.required, .optional] {+ // A blank separator is still refused whatever the presence.+ #expect(throws: URLIdentityError.self) {+ try URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"),+ separator: ExactScalarString(" "),+ suffix: ExactScalarString(""),+ order: .workThenSequence,+ sequencePresence: presence)+ ).validate(origin: .readerTaught, isCurrent: true)+ }+ // A both-unanchored bracket is still refused whatever the presence.+ #expect(throws: URLIdentityError.self) {+ try Self.combined(+ prefix: "Story-", suffix: "", presence: presence,+ locator: .pathBracketed(left: .unanchored, right: .unanchored)+ ).validate(origin: .readerTaught, isCurrent: true)+ }+ }+ }+}++/// What the applicator does with a component (Reqs 1.1–1.9, Decision 7).+///+/// The outcome of applying a template, as a value the matrix can state.+enum URLTemplateOutcome: Equatable, Sendable {+ case extracted(work: String, sequence: String?)+ case refused(URLRuleApplicationError)++ static func of(+ _ template: URLTwoFieldTemplate,+ _ component: String+ ) -> URLTemplateOutcome {+ do {+ let result = try URLTwoFieldTemplateApplicator.apply(+ template, to: ExactScalarString(component))+ return .extracted(+ work: result.workIdentity.value, sequence: result.chapterSequence?.value)+ } catch let error as URLRuleApplicationError {+ return .refused(error)+ } catch {+ return .refused(.invalidRule(reason: String(describing: error)))+ }+ }+}++/// One component, and what each of the four (presence × field order) templates+/// makes of it. Stated per column rather than computed, so the table is a claim+/// about behaviour rather than a second implementation of it.+struct URLTemplateMatrixRow: Sendable {+ let component: String+ let requiredWorkFirst: URLTemplateOutcome+ let requiredSequenceFirst: URLTemplateOutcome+ let optionalWorkFirst: URLTemplateOutcome+ let optionalSequenceFirst: URLTemplateOutcome+}++@Suite("Optional chapter sequence — applicator")+struct URLOptionalSequenceApplicatorTests {++ static func template(+ prefix: String = "Story-",+ suffix: String = "",+ separator: String = "-",+ order: URLTemplateFieldOrder,+ presence: URLSequencePresence+ ) -> URLTwoFieldTemplate {+ URLTwoFieldTemplate(+ prefix: ExactScalarString(prefix),+ separator: ExactScalarString(separator),+ suffix: ExactScalarString(suffix),+ order: order,+ sequencePresence: presence)+ }++ // MARK: - The matrix++ /// Separator count (0, 1, 2, 3) × presence × field order × blank and+ /// non-blank interior × affix match and mismatch, over the motivating+ /// template (prefix `Story-`, separator `-`, no suffix). The motivating pair+ /// `Story-28614` / `Story-28614-105` are the first two rows, not the whole+ /// test.+ @Test(+ "The presence branch matrix",+ arguments: [+ // Zero separators, non-blank interior: the one cell presence changes+ // (Reqs 1.1, 1.3, 1.8, 1.9). Field order does not matter — the whole+ // interior is the Work identity either way.+ URLTemplateMatrixRow(+ component: "Story-28614",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 0)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 0)),+ optionalWorkFirst: .extracted(work: "28614", sequence: "1"),+ optionalSequenceFirst: .extracted(work: "28614", sequence: "1")),+ // Exactly one separator: presence changes nothing at all (Req 1.2).+ URLTemplateMatrixRow(+ component: "Story-28614-105",+ requiredWorkFirst: .extracted(work: "28614", sequence: "105"),+ requiredSequenceFirst: .extracted(work: "105", sequence: "28614"),+ optionalWorkFirst: .extracted(work: "28614", sequence: "105"),+ optionalSequenceFirst: .extracted(work: "105", sequence: "28614")),+ // Two and three separators stay refused in both states (Req 1.4).+ URLTemplateMatrixRow(+ component: "Story-28614-105-2",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 2)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 2)),+ optionalWorkFirst: .refused(.ambiguousSeparator(count: 2)),+ optionalSequenceFirst: .refused(.ambiguousSeparator(count: 2))),+ URLTemplateMatrixRow(+ component: "Story-a-b-c-d",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 3)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 3)),+ optionalWorkFirst: .refused(.ambiguousSeparator(count: 3)),+ optionalSequenceFirst: .refused(.ambiguousSeparator(count: 3))),+ // Zero separators, empty interior (Req 1.6): the zero-separator path+ // carries its own blank check and refuses with blankField(.work).+ URLTemplateMatrixRow(+ component: "Story-",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 0)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 0)),+ optionalWorkFirst: .refused(.blankField(field: .work)),+ optionalSequenceFirst: .refused(.blankField(field: .work))),+ // Zero separators, whitespace interior — blank is blank.+ URLTemplateMatrixRow(+ component: "Story- ",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 0)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 0)),+ optionalWorkFirst: .refused(.blankField(field: .work)),+ optionalSequenceFirst: .refused(.blankField(field: .work))),+ // A separator with a blank field after it stays refused in both states+ // (Req 1.5) — optionality means "no separator at all", not "empty after+ // the separator" (Q4).+ URLTemplateMatrixRow(+ component: "Story-28614-",+ requiredWorkFirst: .refused(.blankField(field: .sequence)),+ requiredSequenceFirst: .refused(.blankField(field: .work)),+ optionalWorkFirst: .refused(.blankField(field: .sequence)),+ optionalSequenceFirst: .refused(.blankField(field: .work))),+ URLTemplateMatrixRow(+ component: "Story--105",+ requiredWorkFirst: .refused(.blankField(field: .work)),+ requiredSequenceFirst: .refused(.blankField(field: .sequence)),+ optionalWorkFirst: .refused(.blankField(field: .work)),+ optionalSequenceFirst: .refused(.blankField(field: .sequence))),+ // Affix mismatch, with and without a separator (Req 1.7). The literal+ // match precedes the presence branch, so an unmatched component is+ // refused before optionality is consulted.+ URLTemplateMatrixRow(+ component: "Tale-28614-105",+ requiredWorkFirst: .refused(.literalMismatch),+ requiredSequenceFirst: .refused(.literalMismatch),+ optionalWorkFirst: .refused(.literalMismatch),+ optionalSequenceFirst: .refused(.literalMismatch)),+ URLTemplateMatrixRow(+ component: "Tale-28614",+ requiredWorkFirst: .refused(.literalMismatch),+ requiredSequenceFirst: .refused(.literalMismatch),+ optionalWorkFirst: .refused(.literalMismatch),+ optionalSequenceFirst: .refused(.literalMismatch)),+ URLTemplateMatrixRow(+ component: "Story",+ requiredWorkFirst: .refused(.literalMismatch),+ requiredSequenceFirst: .refused(.literalMismatch),+ optionalWorkFirst: .refused(.literalMismatch),+ optionalSequenceFirst: .refused(.literalMismatch)),+ ])+ func presenceMatrix(row: URLTemplateMatrixRow) {+ #expect(+ URLTemplateOutcome.of(+ Self.template(order: .workThenSequence, presence: .required), row.component)+ == row.requiredWorkFirst)+ #expect(+ URLTemplateOutcome.of(+ Self.template(order: .sequenceThenWork, presence: .required), row.component)+ == row.requiredSequenceFirst)+ #expect(+ URLTemplateOutcome.of(+ Self.template(order: .workThenSequence, presence: .optional), row.component)+ == row.optionalWorkFirst)+ #expect(+ URLTemplateOutcome.of(+ Self.template(order: .sequenceThenWork, presence: .optional), row.component)+ == row.optionalSequenceFirst)+ }++ /// The same matrix where the bound is a *suffix* rather than a prefix: under+ /// the default field order the optional part is trailing, but the suffix is+ /// still matched before the branch and excluded from the interior.+ @Test(+ "A suffix bounds the rule the same way a prefix does",+ arguments: [+ URLTemplateMatrixRow(+ component: "Story-28614.htm",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 0)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 0)),+ optionalWorkFirst: .extracted(work: "28614", sequence: "1"),+ optionalSequenceFirst: .extracted(work: "28614", sequence: "1")),+ URLTemplateMatrixRow(+ component: "Story-28614-105.htm",+ requiredWorkFirst: .extracted(work: "28614", sequence: "105"),+ requiredSequenceFirst: .extracted(work: "105", sequence: "28614"),+ optionalWorkFirst: .extracted(work: "28614", sequence: "105"),+ optionalSequenceFirst: .extracted(work: "105", sequence: "28614")),+ URLTemplateMatrixRow(+ component: "Story-.htm",+ requiredWorkFirst: .refused(.ambiguousSeparator(count: 0)),+ requiredSequenceFirst: .refused(.ambiguousSeparator(count: 0)),+ optionalWorkFirst: .refused(.blankField(field: .work)),+ optionalSequenceFirst: .refused(.blankField(field: .work))),+ URLTemplateMatrixRow(+ component: "Story-28614.html",+ requiredWorkFirst: .refused(.literalMismatch),+ requiredSequenceFirst: .refused(.literalMismatch),+ optionalWorkFirst: .refused(.literalMismatch),+ optionalSequenceFirst: .refused(.literalMismatch)),+ ])+ func suffixMatrix(row: URLTemplateMatrixRow) {+ for (order, expected) in [+ (URLTemplateFieldOrder.workThenSequence, row.requiredWorkFirst),+ (.sequenceThenWork, row.requiredSequenceFirst),+ ] {+ #expect(+ URLTemplateOutcome.of(+ Self.template(suffix: ".htm", order: order, presence: .required), row.component)+ == expected)+ }+ for (order, expected) in [+ (URLTemplateFieldOrder.workThenSequence, row.optionalWorkFirst),+ (.sequenceThenWork, row.optionalSequenceFirst),+ ] {+ #expect(+ URLTemplateOutcome.of(+ Self.template(suffix: ".htm", order: order, presence: .optional), row.component)+ == expected)+ }+ }++ /// A multi-scalar separator: the zero-separator branch is reached by counting+ /// occurrences of the whole separator, not of its first scalar.+ @Test("A multi-scalar separator still yields the whole interior when absent")+ func multiScalarSeparator() {+ let optional = Self.template(+ separator: "-chapter-", order: .workThenSequence, presence: .optional)+ #expect(+ URLTemplateOutcome.of(optional, "Story-28614-105")+ == .extracted(work: "28614-105", sequence: "1"))+ #expect(+ URLTemplateOutcome.of(optional, "Story-28614-chapter-105")+ == .extracted(work: "28614", sequence: "105"))+ }++ // MARK: - Invariants++ /// Every component the generated table produces, with a template whose affixes+ /// bound it. Ten interiors × three affix spellings, so each invariant runs over+ /// thirty components per field order.+ static let componentTable: [String] = {+ let interiors = [+ "28614", "28614-105", "28614-105-2", "a-b-c-d", "", " ", "-105", "28614-", "-", "--",+ ]+ return interiors.map { "Story-" + $0 } + interiors.map { "Tale-" + $0 } + interiors+ }()++ /// Req 1.2: presence never changes the outcome when the component holds+ /// exactly one separator. Stated over the whole table rather than over the+ /// one-separator rows, by asserting equality wherever `.required` extracted —+ /// which is exactly the one-separator, both-fields-non-blank case.+ @Test(+ "Presence never changes an outcome the required form already resolved",+ arguments: [URLTemplateFieldOrder.workThenSequence, .sequenceThenWork])+ func presenceNeverChangesAResolvedOutcome(order: URLTemplateFieldOrder) {+ let required = Self.template(order: order, presence: .required)+ let optional = Self.template(order: order, presence: .optional)+ for component in Self.componentTable {+ let requiredOutcome = URLTemplateOutcome.of(required, component)+ guard case .extracted = requiredOutcome else { continue }+ #expect(+ URLTemplateOutcome.of(optional, component) == requiredOutcome,+ "presence changed a resolved outcome for \(component)")+ }+ }++ /// Reqs 1.4–1.7: every rejection under `.required` is also a rejection under+ /// `.optional`, with the same error, **except** the zero-separator one — which+ /// is the single cell this feature moves, and which `.optional` either resolves+ /// or refuses as a blank Work.+ @Test(+ "Every required rejection is an optional rejection, except zero-separator",+ arguments: [URLTemplateFieldOrder.workThenSequence, .sequenceThenWork])+ func rejectionsAreCarriedOverExceptZeroSeparator(order: URLTemplateFieldOrder) {+ let required = Self.template(order: order, presence: .required)+ let optional = Self.template(order: order, presence: .optional)+ for component in Self.componentTable {+ guard case .refused(let error) = URLTemplateOutcome.of(required, component) else { continue }+ let optionalOutcome = URLTemplateOutcome.of(optional, component)+ if error == .ambiguousSeparator(count: 0) {+ switch optionalOutcome {+ case .extracted(let work, let sequence):+ #expect(!work.isEmpty && sequence == "1", "zero-separator outcome for \(component)")+ case .refused(let optionalError):+ #expect(+ optionalError == .blankField(field: .work),+ "zero-separator refusal for \(component) was \(optionalError)")+ }+ } else {+ #expect(+ optionalOutcome == .refused(error),+ "optional changed a non-zero-separator refusal for \(component)")+ }+ }+ }++ /// Decision 7: the derived sequence is `1`, and it is the *whole* interior that+ /// becomes the Work identity — including interior text that looks like an+ /// affix or contains the separator's scalars in a non-separating position.+ @Test("The derived sequence completes the extraction tuple through the whole rule")+ func derivedSequenceThroughTheWholeRule() throws {+ let rule = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.template(order: .workThenSequence, presence: .optional))+ let extracted = try URLRuleApplicator.apply(+ rule,+ to: ExactScalarString(+ "https://www.tthfanfic.org/Story-28614/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))+ #expect(extracted.workIdentity == ExactScalarString("28614"))+ #expect(extracted.chapterSequence == ExactScalarString("1"))+ // Req 2.6: the extraction states that the sequence was derived, so no+ // caller has to re-run the zero-separator probe to find out.+ #expect(extracted.sequenceDerived)++ let sibling = try URLRuleApplicator.apply(+ rule,+ to: ExactScalarString(+ "https://www.tthfanfic.org/Story-28614-105/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))+ #expect(sibling.workIdentity == ExactScalarString("28614"))+ #expect(sibling.chapterSequence == ExactScalarString("105"))+ #expect(!sibling.sequenceDerived, "this one read its sequence out of the URL")+ }++ /// A rule that does not declare optionality derives nothing (Req 1.3): the+ /// chapter-1 URL still fails through the whole rule, exactly as today.+ @Test("A required rule still rejects the chapter-1 URL through the whole rule")+ func requiredRuleStillRejectsChapterOne() {+ let rule = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.template(order: .workThenSequence, presence: .required))+ #expect(throws: URLRuleApplicationError.ambiguousSeparator(count: 0)) {+ try URLRuleApplicator.apply(+ rule,+ to: ExactScalarString(+ "https://www.tthfanfic.org/Story-28614/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))+ }+ }+}++/// The deriver stamps the reader's declaration on the template it builds+/// (Reqs 2.4, 2.10). `presence:` carries no default value — defaulting it is+/// exactly the silent defaulting Req 2.10 forbids, and the compiler enforcing+/// an answer at every call site is the point.+@Suite("Optional chapter sequence — deriver")+struct URLOptionalSequenceDeriverTests {++ private static func characterRange(of needle: String, in value: String) -> Range<Int> {+ let range = value.range(of: needle)!+ return value.distance(from: value.startIndex, to: range.lowerBound)+ ..< value.distance(from: value.startIndex, to: range.upperBound)+ }++ @Test(+ "The derived template carries the presence it was given",+ arguments: [URLSequencePresence.required, .optional])+ func derivedTemplateCarriesPresence(presence: URLSequencePresence) throws {+ let component = ExactScalarString("Story-28614-105")+ let selection = URLTwoFieldSelection(+ work: Self.characterRange(of: "28614", in: component.value),+ sequence: Self.characterRange(of: "105", in: component.value))+ let template = try URLTwoFieldTemplateDeriver.derive(+ from: component, selection: selection, presence: presence)++ #expect(template.sequencePresence == presence)+ // Q9: the prefix absorbs `Story-`, so the derived Work identity is `28614`.+ #expect(template.prefix == ExactScalarString("Story-"))+ #expect(template.separator == ExactScalarString("-"))+ #expect(template.suffix == ExactScalarString(""))+ #expect(template.order == .workThenSequence)+ }++ /// Req 2.4: the reproduce check runs against an example component that, by+ /// construction, contains the separator — the deriver requires a non-blank+ /// span *between* the two selections — so the zero-separator branch is+ /// unreachable from it and presence cannot change the verdict.+ @Test(+ "The reproduce check behaves identically in both presence states",+ arguments: [+ // Accepted: the motivating split, a reversed field order, and a+ // multi-scalar separator over non-ASCII text.+ ("Story-28614-105", 6..<11, 12..<15),+ ("105-Story-28614", 10..<15, 0..<3),+ ("📚work-42-chapter-7", 6..<8, 17..<18),+ // Refused by the reproduce check: taking `Story-28614` as the Work leaves+ // two separators in the interior, which is why `defaultSplitSelection`+ // walks past this candidate to the next (Decision 5).+ ("Story-28614-105", 0..<11, 12..<15),+ // Refused for structural reasons, before the reproduce check.+ ("abc-def", 0..<4, 3..<7),+ ("abc-def", 0..<99, 4..<7),+ (" -value", 0..<2, 3..<8),+ ])+ func reproduceCheckIsPresenceBlind(+ component: String, work: Range<Int>, sequence: Range<Int>+ ) {+ let value = ExactScalarString(component)+ let selection = URLTwoFieldSelection(work: work, sequence: sequence)+ let required = Result {+ try URLTwoFieldTemplateDeriver.derive(+ from: value, selection: selection, presence: .required)+ }+ let optional = Result {+ try URLTwoFieldTemplateDeriver.derive(+ from: value, selection: selection, presence: .optional)+ }++ switch (required, optional) {+ case (.success(let requiredTemplate), .success(let optionalTemplate)):+ #expect(requiredTemplate.sequencePresence == .required)+ #expect(optionalTemplate.sequencePresence == .optional)+ // Identical in every other respect.+ #expect(+ URLTwoFieldTemplate(+ prefix: optionalTemplate.prefix,+ separator: optionalTemplate.separator,+ suffix: optionalTemplate.suffix,+ order: optionalTemplate.order) == requiredTemplate)+ case (.failure(let requiredError), .failure(let optionalError)):+ #expect(+ requiredError as? URLTemplateSelectionError+ == optionalError as? URLTemplateSelectionError)+ default:+ Issue.record("presence changed the deriver's verdict for \(component)")+ }+ }++ /// The motivating pair end to end: the template taught from chapter 105 is the+ /// template that resolves chapter 1, and only because the declaration rides on+ /// it.+ @Test("A template taught from a chapter-bearing URL resolves the chapter-less one")+ func taughtTemplateResolvesTheChapterLessForm() throws {+ let component = ExactScalarString("Story-28614-105")+ let selection = URLTwoFieldSelection(+ work: Self.characterRange(of: "28614", in: component.value),+ sequence: Self.characterRange(of: "105", in: component.value))++ let optional = try URLTwoFieldTemplateDeriver.derive(+ from: component, selection: selection, presence: .optional)+ let resolved = try URLTwoFieldTemplateApplicator.apply(+ optional, to: ExactScalarString("Story-28614"))+ #expect(resolved.workIdentity == ExactScalarString("28614"))+ #expect(resolved.chapterSequence == ExactScalarString("1"))++ let required = try URLTwoFieldTemplateDeriver.derive(+ from: component, selection: selection, presence: .required)+ #expect(throws: URLRuleApplicationError.ambiguousSeparator(count: 0)) {+ try URLTwoFieldTemplateApplicator.apply(required, to: ExactScalarString("Story-28614"))+ }+ }+}++/// `DuplicateReconciler.convergeURLRuleGroup` compares **definitions**, not the+/// stored bytes (Q21, Q26, Req 6.3).+///+/// A keyed container models keys rather than an ordered sequence, so neither+/// call order nor synthesis promises a stable byte order across Foundation+/// versions — and this feature's asymmetric `Codable` writes a key only+/// sometimes, which is exactly the kind of difference a byte compare+/// misreads. Inequality is what *triggers* the copy here, so the comparison is+/// the safe place to fix it.+///+/// **What is deliberately not asserted here.** Req 6.3's entry-side clause — the+/// entries citing the losing definition are re-derived or produce a recorded+/// diagnosis — is not exercised in this suite. The design's pattern-extension+/// audit places that clause on the existing replay-diagnosis path+/// (`DuplicateReconciler.swift:334-355`): an entry holding a stored extraction+/// the surviving definition cannot reproduce records the standing per-hostname+/// diagnosis, which a re-teach clears. This feature adds no code path to it, and+/// `ReteachDiagnosisComparisonTests` already pins it.+@Suite("Optional chapter sequence — duplicate reconciliation")+struct URLOptionalSequenceReconcilerTests {++ /// The rule the tthfanfic re-teach produces, in the two JSON key orders a+ /// keyed container may legally write it in. Both decode to one definition.+ private static let compactBytes = Data(+ (#"{"combined":{"locator":{"pathBracketed":{"left":{"start":{}},"#+ + #""right":{"unanchored":{}}}},"template":{"order":"workThenSequence","#+ + #""prefix":"Story-","separator":"-","suffix":""}}}"#).utf8)++ private static let reorderedBytes = Data(+ (#"{"combined":{"template":{"suffix":"","separator":"-","prefix":"Story-","#+ + #""order":"workThenSequence"},"locator":{"pathBracketed":{"#+ + #""right":{"unanchored":{}},"left":{"start":{}}}}}}"#).utf8)++ /// The same rule with the declaration on — a real definition difference, and+ /// the one this feature adds.+ private static let optionalBytes = Data(+ (#"{"combined":{"locator":{"pathBracketed":{"left":{"start":{}},"#+ + #""right":{"unanchored":{}}}},"template":{"order":"workThenSequence","#+ + #""prefix":"Story-","separator":"-","sequencePresence":"optional","#+ + #""suffix":""}}}"#).utf8)++ private static let unreadableBytes = Data(#"{"combined":{"locator":{"martian":{}}}}"#.utf8)+ private static let otherUnreadableBytes = Data(#"{"work":{"locator":{"martian":{}}}}"#.utf8)++ /// Two rows of one rule on one Site: the first is `isCurrent` and older, so it+ /// represents the group under `GroupOrdering`'s ordering, and the versions+ /// match so nothing but the definition can be rewritten.+ private static func seed(+ representative: Data, twin: Data+ ) throws -> (store: DuplicateStore, ruleID: UUID) {+ let store = try DuplicateStore()+ let site = store.addSite(mode: .taught)+ let ruleID = DuplicateStore.rankedID(1)+ let first = try store.addURLRule(+ id: ruleID, site: site, version: 1, current: true, createdAt: 0)+ let second = try store.addURLRule(+ id: ruleID, site: site, version: 1, current: false, createdAt: 10)+ first.definitionData = representative+ second.definitionData = twin+ try store.commit()+ return (store, ruleID)+ }++ private static func storedBytes(_ store: DuplicateStore) throws -> Set<Data> {+ try store.read { context in+ Set(try context.fetch(FetchDescriptor<URLRulePattern>()).map(\.definitionData))+ }+ }++ // MARK: - Q21: byte layout is not a difference++ @Test("Two rows differing only in JSON key order converge without a byte copy")+ func keyOrderIsNotADefinitionDifference() throws {+ let (store, _) = try Self.seed(+ representative: Self.compactBytes, twin: Self.reorderedBytes)++ let outcome = try store.reconcile()++ #expect(+ outcome.convergedRuleRows == 0,+ "a byte-layout difference was written as a definition change")+ #expect(try Self.storedBytes(store) == [Self.compactBytes, Self.reorderedBytes])+ }++ /// Req 6.3: rows whose definitions differ only in the declaration are two+ /// definitions, and the group still converges on the representative's.+ @Test("Rows differing only in the declaration are a real difference and converge")+ func declarationDifferenceConverges() throws {+ let (store, _) = try Self.seed(+ representative: Self.optionalBytes, twin: Self.compactBytes)++ let outcome = try store.reconcile()++ #expect(outcome.convergedRuleRows == 1)+ #expect(try Self.storedBytes(store) == [Self.optionalBytes])+ let presences = try store.read { context in+ try context.fetch(FetchDescriptor<URLRulePattern>()).map { rule -> URLSequencePresence? in+ guard case .combined(_, let template) = try rule.definition else { return nil }+ return template.sequencePresence+ }+ }+ #expect(presences == [.optional, .optional])+ }++ // MARK: - Q26: mixed readable/unreadable pairs++ /// The copy is skipped in **both** directions. `GroupOrdering` picks the+ /// representative with no readability preference, so copying down would+ /// overwrite readable bytes with unreadable ones, and copying up would mask+ /// the standing `unreadableURLRule` diagnosis that names the broken row.+ @Test(+ "A mixed readable/unreadable pair triggers no copy in either direction",+ arguments: [true, false])+ func mixedPairIsSkipped(representativeIsReadable: Bool) throws {+ let representative = representativeIsReadable ? Self.compactBytes : Self.unreadableBytes+ let twin = representativeIsReadable ? Self.unreadableBytes : Self.compactBytes+ let (store, _) = try Self.seed(representative: representative, twin: twin)++ let outcome = try store.reconcile()++ #expect(outcome.convergedRuleRows == 0)+ #expect(try Self.storedBytes(store) == [Self.compactBytes, Self.unreadableBytes])+ }++ // MARK: - Two undecodable rows compare by bytes++ @Test("Two rows that both fail to decode converge by bytes")+ func undecodableRowsCompareByBytes() throws {+ let (store, _) = try Self.seed(+ representative: Self.unreadableBytes, twin: Self.otherUnreadableBytes)++ let outcome = try store.reconcile()++ #expect(outcome.convergedRuleRows == 1)+ #expect(try Self.storedBytes(store) == [Self.unreadableBytes])+ }++ @Test("Two undecodable rows already holding one byte string write nothing")+ func identicalUndecodableRowsWriteNothing() throws {+ let (store, _) = try Self.seed(+ representative: Self.unreadableBytes, twin: Self.unreadableBytes)++ let outcome = try store.reconcile()++ #expect(outcome.convergedRuleRows == 0)+ #expect(try Self.storedBytes(store) == [Self.unreadableBytes])+ }+}++/// The archive gates (Requirement 5). Both presence states must ride through the+/// 4/4 codec, and the `.required` one must ride through it **invisibly**.+///+/// Reqs 5.1, 5.2 and 5.7 are structural rather than assertable here: this feature+/// adds no store schema version and no backup format version — the document still+/// declares 4/4, which `BackupV4CodecTests.roundTrip` pins — and mirroring carries+/// `URLRulePattern.definitionData` as opaque bytes, so the declaration travels+/// inside the definition like every other part of it. Req 5.6's refusal by a build+/// *without* the feature is Decision 1's recorded consequence and cannot be+/// observed from a build that has it; what is testable is its mechanism, and the+/// pre-feature decode below exercises exactly that re-encode-and-compare path.+@Suite("Optional chapter sequence — archive")+struct URLOptionalSequenceArchiveTests {++ private static func combinedRule(of payload: BackupV4Payload) throws -> URLTwoFieldTemplate? {+ guard case .combined(_, let template) = try #require(payload.urlRules.first).definition else {+ return nil+ }+ return template+ }++ /// Req 5.3: an archive exported before this feature carries no+ /// `sequencePresence` key. Decoding it must reproduce those exact bytes on the+ /// re-encode, or the codec refuses the whole archive with `checksumMismatch` —+ /// so this asserts the asymmetric `Codable` from the reading side.+ @Test("A pre-feature archive decodes, and its combined rule is still required")+ func preFeatureArchiveDecodes() throws {+ let document = BackupV4Fixtures.preFeatureCombinedDocument()+ #expect(!String(decoding: document, as: UTF8.self).contains("sequencePresence"))++ let decoded = try BackupV4Codec.decode(document)++ #expect(decoded.payload == BackupV4Fixtures.combinedRulePayload(presence: .required))+ #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .required)+ }++ /// Req 5.5: the direct claim, asserted on the JSON itself. An archive in which+ /// no rule declares an optional sequence encodes to the bytes a build without+ /// this feature would produce — which is what keeps it importable there.+ @Test("An archive with no declared-optional rule encodes the pre-feature bytes")+ func requiredArchiveIsByteIdenticalToPreFeature() throws {+ let encoded = try BackupV4Codec.encode(+ payload: BackupV4Fixtures.combinedRulePayload(presence: .required),+ metadata: BackupV4Metadata(+ appBuild: "pre-feature", exportedAt: BackupV4Fixtures.created))+ let json = String(decoding: encoded, as: UTF8.self)++ #expect(!json.contains("sequencePresence"))+ #expect(+ json.contains(BackupV4Fixtures.preFeatureCombinedPayloadJSON),+ "the exported payload is no longer the pre-feature payload")+ #expect(encoded == BackupV4Fixtures.preFeatureCombinedDocument())+ }++ /// Req 5.4: a declared-optional rule survives export and import unchanged. The+ /// import half is `materializeV4Payload`, so what is asserted is the stored+ /// `URLRulePattern` a reader would end up with, not merely a decoded value.+ @Test("A declared-optional rule round-trips through export and import")+ func optionalRuleRoundTripsThroughTheArchive() throws {+ let payload = BackupV4Fixtures.combinedRulePayload(presence: .optional)+ let encoded = try BackupV4Codec.encode(+ payload: payload,+ metadata: BackupV4Metadata(appBuild: "with-feature", exportedAt: BackupV4Fixtures.created))+ #expect(String(decoding: encoded, as: UTF8.self).contains(#""sequencePresence":"optional""#))++ let decoded = try BackupV4Codec.decode(encoded)+ #expect(decoded.payload == payload)+ #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional)++ let schema = Schema(versionedSchema: AsterismSchemaV5.self)+ let container = try ModelContainer(+ for: schema,+ configurations: [+ ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)+ ])+ let context = ModelContext(container)+ try LibraryRepository.materializeV4Payload(decoded.payload, into: context)++ let rule = try #require(try context.fetch(FetchDescriptor<URLRulePattern>()).first)+ guard case .combined(_, let template) = try rule.definition else {+ Issue.record("the imported rule is no longer a combined rule")+ return+ }+ #expect(template.sequencePresence == .optional)+ #expect(template.prefix == ExactScalarString("Story-"))+ withExtendedLifetime(container) {}+ }+}++/// Q20: `ComposedEntryProjection` carries the per-entry derived Work identity.+///+/// No existing field does. `projectedWorkID` names a Work *row*, and the preview+/// Req 2.6 demands must show the identity a separator-free capture derives+/// before any Work exists to hold it.+@Suite("Optional chapter sequence — projection")+struct URLOptionalSequenceProjectionTests {++ private static let hostname = "www.tthfanfic.org"+ private static let storyTitle = "TtH • Story • The Secret Return of Alex Mack"+ private static let chapterOne = UUID(uuidString: "00000000-0000-0000-0000-0000000000C1")!+ private static let chapterTwo = UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!++ private static func url(_ component: String) -> String {+ "https://www.tthfanfic.org/\(component)/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"+ }++ private static func entry(_ id: UUID, component: String, at seconds: TimeInterval)+ -> ComposedEntryBasis+ {+ ComposedEntryBasis(+ id: id, captureTitle: storyTitle, rawURLString: url(component), hostname: hostname,+ firstCapturedAt: Date(timeIntervalSince1970: seconds), chapterTitle: nil,+ chapterTitleProvenance: .none, workID: nil, workAssignmentProvenance: .none,+ intentionallyUnattached: false, previousIdentityKey: url(component),+ previousKeyVersion: 1)+ }++ private static func rule(_ presence: URLSequencePresence) -> URLRuleDefinition {+ .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence, sequencePresence: presence))+ }++ private static func project(_ presence: URLSequencePresence) throws -> ComposedTeachingOutcome {+ let basis = ComposedTeachingBasis(+ siteMode: .untaught, hostname: hostname,+ entries: [+ entry(chapterOne, component: "Story-30975", at: 1),+ entry(chapterTwo, component: "Story-30975-2", at: 2),+ ],+ works: [], currentTitleRule: nil, currentURLRule: nil)+ return try ComposedTeachingProjectionPlanner.project(+ basis: basis,+ request: ComposedTeachingRequest(+ titleDefinition: .wholeTitle, urlDefinition: rule(presence),+ acknowledgeUnsettled: true))+ }++ @Test("The derived Work identity rides on every resolved projection")+ func derivedIdentityIsCarried() throws {+ let outcome = try Self.project(.optional)+ let one = try #require(outcome.entries.first { $0.entryID == Self.chapterOne })+ let two = try #require(outcome.entries.first { $0.entryID == Self.chapterTwo })+ #expect(one.derivedWorkIdentity == "30975")+ #expect(two.derivedWorkIdentity == "30975")+ // Req 1.8: the separator-free capture's sequence is derived, not read.+ #expect(one.projectedChapterSequence == "1")+ #expect(two.projectedChapterSequence == "2")+ // Req 2.6: and the projection says which it was, carried out of the+ // applicator rather than re-derived by whoever renders the row.+ #expect(one.sequenceDerived)+ #expect(!two.sequenceDerived)+ // Req 3.3: one identity-sequence key shape across the story.+ #expect(one.projectedKeyVersion == two.projectedKeyVersion)+ #expect(one.projectedIdentityBasis == .urlRule)+ // Req 3.7: a derived sequence settles the chapter with no chapter title.+ #expect(one.projectedChapterTitle == nil)+ #expect(one.chapterSettled)+ }++ @Test("A capture the rule cannot resolve carries no derived identity")+ func unresolvedCaptureCarriesNoIdentity() throws {+ let outcome = try Self.project(.required)+ let one = try #require(outcome.entries.first { $0.entryID == Self.chapterOne })+ #expect(one.derivedWorkIdentity == nil)+ #expect(one.urlFailure != nil)+ // And the sibling is unaffected by the other row's failure.+ let two = try #require(outcome.entries.first { $0.entryID == Self.chapterTwo })+ #expect(two.derivedWorkIdentity == "30975")+ }++ /// The defaulted parameter is what keeps existing fixtures compiling — the+ /// `previousIdentityKey` precedent.+ @Test("The initializer defaults the field, so fixtures that predate it still build")+ func fieldIsDefaulted() {+ let projection = ComposedEntryProjection(+ entryID: UUID(), previousWorkID: nil, previousChapterTitle: nil, workName: "Work",+ workNameSource: .parsed, projectedChapterTitle: nil, projectedChapterSequence: nil,+ projectedIdentityBasis: .conservative, projectedKeyVersion: 1,+ projectedIdentityKey: "key", assignment: .noChange, projectedWorkID: nil,+ chapterSettled: true, actionableAfter: false, titleFailure: nil, urlFailure: nil)+ #expect(projection.derivedWorkIdentity == nil)+ #expect(!projection.sequenceDerived)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swiftnew file mode 100644index 0000000..4c4d70a--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift@@ -0,0 +1,685 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// `optional-chapter-sequence` tasks 10 and 11: the declaration's behaviour over+/// a real library, driven through the contract paths the app itself uses.+///+/// **Seeding discipline.** Every fixture here goes capture → teach → capture,+/// never seed-everything-then-teach. `LibraryRepository.capture(_:)` applies no+/// rules at all (the sibling spec's Q20), and teaching a fully-seeded site+/// batches Works differently from the way a library actually arrives at the+/// state — it manufactures a phantom Work collision that has nothing to do with+/// the declaration.+///+/// **URLs.** Every `ArchiveCorpus` tthfanfic URL carries a separator, so the+/// chapter-1 shape is written by these tests rather than found in the fixture —+/// which mirrors the real library, where no chapter-1 capture exists either+/// (requirements, "Current Behaviour").+///+/// **What is deliberately not asserted here.** Reqs 5.8 and 6.2 are existing+/// replay-diagnosis behaviour and this feature adds nothing to it: a build that+/// decodes the definition without the presence key replays it against entries+/// carrying a stored extraction it cannot reproduce and records the standing+/// per-hostname diagnosis, which a re-teach clears. No new code path, no new+/// state, and the mixed-fleet apparatus is cut outright (Q25) — so there is+/// nothing here that `ReteachDiagnosisComparisonTests` does not already pin.++// MARK: - Fixture++private enum OptionalSequenceFixtureError: Error, CustomStringConvertible {+ case teachRefused(String)+ case captureRefused(String)++ var description: String {+ switch self {+ case .teachRefused(let reason): "the teach did not commit: \(reason)"+ case .captureRefused(let reason): "a capture did not commit: \(reason)"+ }+ }+}++/// A real library over a temporary directory, reached by the ordinary routes.+private struct OptionalSequenceLibrary {+ let hostname: String+ let directory: URL+ let container: ModelContainer+ let repository: LibraryRepository++ init(hostname: String = "www.tthfanfic.org") throws {+ self.hostname = hostname+ directory = FileManager.default.temporaryDirectory+ .appending(path: "AsterismOptionalSequenceCore-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ let configuration = LibraryConfiguration(rootDirectory: directory)+ try FileManager.default.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true)+ container = try LibraryRepository.openContainer(at: configuration.storeURL)+ repository = LibraryRepository.makeRepository(+ configuration, container, .m4, OptionalSequenceClock(), ModelContextSaveStrategy())+ }++ func cleanup() { try? FileManager.default.removeItem(at: directory) }++ // MARK: Capture++ /// The contract capture path. `capture(_:)` applies no rules, so a capture+ /// made through it can never show what a taught rule derives.+ @discardableResult+ func capture(+ _ capture: ArchiveCorpus.Capture, note: String = ""+ ) async throws -> (id: UUID, outcome: CaptureOutcome) {+ let contract = try await project(capture, note: note)+ let result = try await repository.commitCapture(contract)+ guard case .committed(let snapshot) = result else {+ throw OptionalSequenceFixtureError.captureRefused(String(describing: result))+ }+ return (snapshot.id, contract.outcome)+ }++ func project(_ capture: ArchiveCorpus.Capture, note: String = "") async throws -> CaptureContract {+ try await repository.projectCapture(+ hostname: hostname, captureTitle: capture.title, captureTitleSource: .host,+ rawURLString: capture.url, canonicalURLString: nil, note: note, rating: nil)+ }++ // MARK: Teach++ func teachRequest(+ _ url: URLRuleDefinition?, title: PatternDefinition = .wholeTitle+ ) -> ComposedTeachingRequest {+ ComposedTeachingRequest(+ titleDefinition: title, urlDefinition: url, acknowledgeUnsettled: true)+ }++ func projectTeach(+ _ url: URLRuleDefinition?, title: PatternDefinition = .wholeTitle+ ) async throws -> ComposedTeachingContract {+ try await repository.projectComposedTeaching(+ hostname: hostname, request: teachRequest(url, title: title))+ }++ @discardableResult+ func teach(+ _ url: URLRuleDefinition?, title: PatternDefinition = .wholeTitle+ ) async throws -> ComposedTeachingOutcome {+ let contract = try await projectTeach(url, title: title)+ let result = try await repository.commitComposedTeaching(contract)+ guard case .committed = result else {+ throw OptionalSequenceFixtureError.teachRefused(String(describing: result))+ }+ return contract.outcome+ }++ // MARK: Reads++ func context() -> ModelContext { ModelContext(container) }++ func entries() throws -> [Entry] {+ try context().fetch(FetchDescriptor<Entry>()).sorted { $0.firstCapturedAt < $1.firstCapturedAt }+ }++ func entry(_ id: UUID) throws -> Entry {+ guard let entry = try context().fetch(FetchDescriptor<Entry>()).first(where: { $0.id == id })+ else { throw OptionalSequenceFixtureError.captureRefused("no Entry \(id)") }+ return entry+ }++ func works() throws -> [Work] { try context().fetch(FetchDescriptor<Work>()) }++ func site() throws -> Site {+ try LibraryRepository.fetchSites(hostname: hostname, context: context())[0]+ }++ /// The site's URL rules, newest version first.+ func urlRules() throws -> [URLRulePattern] {+ (try site().urlRules ?? []).sorted { $0.version > $1.version }+ }++ func currentURLRule() throws -> URLRulePattern {+ guard let current = try urlRules().first(where: \.isCurrent) else {+ throw OptionalSequenceFixtureError.teachRefused("no current URL rule")+ }+ return current+ }++ /// Mutate rows directly. Used only to author the reader-authored state the+ /// rules must not touch (manual provenance, intentional unattachment) and+ /// the synthetic split pre-state, neither of which any ordinary route+ /// produces.+ func mutate(_ body: (ModelContext) throws -> Void) throws {+ let context = self.context()+ try body(context)+ try context.save()+ }+}++private final class OptionalSequenceClock: RepositoryClock, @unchecked Sendable {+ private let lock = NSLock()+ private var value = Date(timeIntervalSince1970: 1_800_000_000)+ func now() -> Date {+ lock.withLock {+ value = value.addingTimeInterval(1)+ return MillisecondInstant.quantize(value)+ }+ }+}++// MARK: - Shared rule shapes++private enum TthRules {+ static let hostname = "www.tthfanfic.org"++ /// The locator `url-locator-generalisation` produces for this site (Q22).+ static let locator = URLComponentLocator.pathBracketed(left: .start, right: .unanchored)++ static func template(_ presence: URLSequencePresence) -> URLTwoFieldTemplate {+ URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: presence)+ }++ static func rule(_ presence: URLSequencePresence) -> URLRuleDefinition {+ .combined(locator: locator, template: template(presence))+ }++ /// The story's own first chapter, served without the chapter indicator.+ static let chapterOneURL =+ "https://www.tthfanfic.org/Story-30975/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"++ static var chapterOne: ArchiveCorpus.Capture {+ ArchiveCorpus.Capture(title: ArchiveCorpus.tthStoryTitle, url: chapterOneURL)+ }++ /// Chapters 2–6 of the corpus story. Chapter 1's *separated* spelling is+ /// left out deliberately: under the declaration `Story-30975` and+ /// `Story-30975-1` derive one identity key, which Decision 7 records as+ /// correct and which would make these fixtures about re-share instead.+ static var siblings: [ArchiveCorpus.Capture] { Array(ArchiveCorpus.tthCaptures[1...5]) }++ static func identityKey(work: String, sequence: String) throws -> String {+ EntryIdentityKeyV2Codec.encode(try URLDerivedEntryIdentity(+ hostname: ExactScalarString(hostname),+ workIdentity: ExactScalarString(work),+ chapterSequence: ExactScalarString(sequence)))+ }+}++// MARK: - Task 10: derivation, identity keys, re-share++@Suite("Optional chapter sequence — composed derivation", .serialized)+struct URLOptionalSequenceDerivationTests {++ /// Brings a library to the state the declaration is taught into: one Work+ /// holding chapters 2–6, taught with the declaration already in force, so a+ /// later chapter-1 capture exercises the *capture* path rather than the+ /// re-derivation one.+ private static func declaredLibrary() async throws -> (OptionalSequenceLibrary, [UUID]) {+ let library = try OptionalSequenceLibrary()+ var ids: [UUID] = []+ ids.append(try await library.capture(TthRules.siblings[0]).id)+ try await library.teach(TthRules.rule(.optional))+ for capture in TthRules.siblings.dropFirst() {+ ids.append(try await library.capture(capture).id)+ }+ return (library, ids)+ }++ /// Reqs 1.8, 3.1, 3.3, 3.6, 3.7 in one pass over one capture — they are+ /// claims about a single Entry and splitting them would re-seed the library+ /// five times for nothing.+ @Test("A separator-free capture lands in the story's Work with its siblings' key shape")+ func separatorFreeCaptureResolves() async throws {+ let (library, siblingIDs) = try await Self.declaredLibrary()+ defer { library.cleanup() }++ let (chapterOneID, outcome) = try await library.capture(TthRules.chapterOne)++ // Req 1.8 / Decision 7: the absent indicator is read as chapter 1.+ #expect(outcome.composedChapterSequence == "1")+ // Req 3.7: no chapter title exists anywhere on this site, so the derived+ // sequence is the only thing that can settle the chapter — and it does.+ #expect(outcome.actionable == false)++ let entry = try library.entry(chapterOneID)+ #expect(entry.urlWorkIdentity == "30975")+ #expect(entry.chapterSequence == "1")+ #expect(entry.chapterTitle == nil)++ // Req 3.3: the same key *shape* the siblings receive, not merely a key.+ let sibling = try library.entry(siblingIDs[0])+ #expect(entry.identityKeyVersion == sibling.identityKeyVersion)+ #expect(entry.identityBasis == sibling.identityBasis)+ #expect(entry.entryIdentityKey == (try TthRules.identityKey(work: "30975", sequence: "1")))+ let decoded = try EntryIdentityKeyV2Codec.decode(entry.entryIdentityKey)+ let decodedSibling = try EntryIdentityKeyV2Codec.decode(sibling.entryIdentityKey)+ #expect(decoded.hostname == decodedSibling.hostname)+ #expect(decoded.workIdentity == decodedSibling.workIdentity)+ #expect(decoded.chapterSequence != decodedSibling.chapterSequence)++ // Req 3.1: attached to the Work carrying that identity, and only it.+ #expect(try library.works().count == 1)+ #expect(entry.work?.id == sibling.work?.id)+ #expect(entry.work?.urlIdentity == "30975")++ // Req 3.6: per-field provenance names the rule and version that derived+ // the identity, on both the extraction and the assignment it drove.+ let rule = try library.currentURLRule()+ #expect(entry.urlWorkRuleID == rule.id)+ #expect(entry.urlWorkRuleVersion == rule.version)+ #expect(entry.workAssignmentProvenance == .urlRule)+ #expect(entry.workURLRuleID == rule.id)+ #expect(entry.workURLRuleVersion == rule.version)+ #expect(entry.workURLAssignmentKind == .identity)+ }++ /// Req 3.8: nothing about an unseen identity is special-cased. The capture+ /// creates a Work keyed by the derived identity, exactly as a first capture+ /// of a separator-bearing URL would.+ @Test("An unseen derived identity behaves as any other first capture")+ func unseenIdentityCreatesItsWork() async throws {+ let (library, _) = try await Self.declaredLibrary()+ defer { library.cleanup() }++ let other = ArchiveCorpus.Capture(+ title: "A Different Story",+ url: "https://www.tthfanfic.org/Story-40404/Another+Author+A+Different+Story.htm")+ let (id, outcome) = try await library.capture(other)++ #expect(outcome.composedAssignment == .create(key: .urlIdentity(ExactScalarString("40404"))))+ #expect(outcome.composedChapterSequence == "1")+ #expect(outcome.actionable == false)++ let entry = try library.entry(id)+ #expect(entry.urlWorkIdentity == "40404")+ #expect(entry.work?.urlIdentity == "40404")+ #expect(entry.work?.displayTitle == "A Different Story")+ #expect(try library.works().count == 2)+ }++ /// Req 3.1's other half, stated as the gate it is: attachment needs a+ /// resolved Work *name*, and a chapter-bearing title rule resolves none from+ /// a chapter-less title. Req 3.2 then leaves the Entry unattached and+ /// actionable rather than naming a Work from the URL identity.+ @Test("The title rule gates attachment: a name attaches, no name leaves it actionable")+ func titleRuleGatesAttachment() async throws {+ let library = try OptionalSequenceLibrary()+ defer { library.cleanup() }++ // A chapter-bearing segment title rule: Work first, chapter after.+ let segment = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])+ let chaptered = ArchiveCorpus.Capture(+ title: "Alex Mack - Chapter 2",+ url: "https://www.tthfanfic.org/Story-30975-2/DianeCastle+Alex+Mack.htm")+ try await library.capture(chaptered)+ try await library.teach(TthRules.rule(.optional), title: segment)++ // Req 3.1: the rule resolves a name, so the derived identity attaches.+ let named = ArchiveCorpus.Capture(+ title: "Alex Mack - Chapter 3",+ url: "https://www.tthfanfic.org/Story-30975-3/DianeCastle+Alex+Mack.htm")+ let (namedID, namedOutcome) = try await library.capture(named)+ #expect(namedOutcome.actionable == false)+ #expect(try library.entry(namedID).work != nil)++ // Req 3.2: the chapter-less title yields no Work name — `.segment`+ // rejects an empty chapter remainder — so the Entry stays unattached and+ // actionable even though the URL derived a Work identity and a sequence.+ let unnamed = ArchiveCorpus.Capture(title: "Alex Mack", url: TthRules.chapterOneURL)+ let (unnamedID, unnamedOutcome) = try await library.capture(unnamed)+ #expect(unnamedOutcome.projectedWorkTitle == nil)+ #expect(unnamedOutcome.composedAssignment == .noChange)+ #expect(unnamedOutcome.actionable == true)++ let entry = try library.entry(unnamedID)+ #expect(entry.work == nil)+ #expect(entry.urlWorkIdentity == "30975")+ #expect(entry.chapterSequence == "1")+ // No Work was invented from the URL identity.+ #expect(try library.works().allSatisfy { $0.urlIdentity != nil })+ #expect(try library.works().count == 1)+ }++ /// Req 3.5. This is the gap Decision 7 closes: while chapter 1 kept a+ /// verbatim raw-URL key it was the one entry of the story that duplicated on+ /// a differently-spelled share, and 25 of the real library's 40 tth URLs+ /// carry `#storybody`.+ @Test(+ "A re-share spelled differently in the same component edits the existing Entry",+ arguments: [+ "#storybody",+ "?utm_source=share",+ "/",+ ])+ func reShareOfADifferentSpellingEditsTheEntry(suffix: String) async throws {+ let (library, _) = try await Self.declaredLibrary()+ defer { library.cleanup() }+ let (chapterOneID, _) = try await library.capture(TthRules.chapterOne)+ let before = try library.entries().count++ let respelled = TthRules.chapterOneURL + suffix+ let disposition = try await library.repository.captureLookup(+ rawURL: respelled, captureTitle: ArchiveCorpus.tthStoryTitle)+ guard case .edit(let basis) = disposition else {+ Issue.record("\(respelled) resolved as \(disposition), not an edit")+ return+ }+ #expect(basis.entryID == chapterOneID)++ let outcome = try await library.repository.commitReShareUpdate(+ basis: basis, note: "re-shared", rating: nil)+ #expect(outcome == .committed)+ #expect(try library.entries().count == before)+ #expect(try library.entry(chapterOneID).note == "re-shared")+ }++ /// The `www.` half of Req 3.5, and the one spelling difference the+ /// identity-sequence key cannot absorb — because the hostname is part of+ /// both the key and the Site the rule belongs to, so a bare-host share+ /// reaches no rule at all. This is not something the declaration changes:+ /// chapter 1 and its separator-bearing siblings behave identically, which is+ /// exactly the parity Req 3.3 promises. Pinned so the parity is a stated+ /// contract rather than an accident.+ @Test("A bare-host re-share is a new capture for chapter 1 and its siblings alike")+ func bareHostReShareBehavesTheSameForEveryChapter() async throws {+ let (library, _) = try await Self.declaredLibrary()+ defer { library.cleanup() }+ try await library.capture(TthRules.chapterOne)++ for url in [TthRules.chapterOneURL, TthRules.siblings[0].url] {+ let bare = url.replacingOccurrences(of: "https://www.", with: "https://")+ let disposition = try await library.repository.captureLookup(+ rawURL: bare, captureTitle: ArchiveCorpus.tthStoryTitle)+ guard case .new(let basis) = disposition else {+ Issue.record("\(bare) resolved as \(disposition)")+ return+ }+ #expect(basis.hostname == "tthfanfic.org")+ }+ }+}++// MARK: - Task 11: the teaching commit++@Suite("Optional chapter sequence — teaching commit", .serialized)+struct URLOptionalSequenceTeachingCommitTests {++ /// The realistic pre-state, reached the way the library reached it: the site+ /// was taught a combined rule with no declaration, chapters 2–6 were+ /// captured under it, and a chapter-1 capture then arrived that the rule+ /// cannot extract from. It sits in the story's Work by title claim, with no+ /// URL identity of its own and a verbatim raw-URL key.+ private static func realisticPreState() async throws -> (+ library: OptionalSequenceLibrary, siblingIDs: [UUID], chapterOneID: UUID+ ) {+ let library = try OptionalSequenceLibrary()+ var ids: [UUID] = []+ ids.append(try await library.capture(TthRules.siblings[0]).id)+ try await library.teach(TthRules.rule(.required))+ for capture in TthRules.siblings.dropFirst() {+ ids.append(try await library.capture(capture).id)+ }+ let chapterOneID = try await library.capture(TthRules.chapterOne).id+ return (library, ids, chapterOneID)+ }++ /// Reqs 4.1, 4.2, 4.5, 4.7 and 6.1, plus amended Req 3.4's reporting.+ @Test("Teaching the declaration re-derives the site onto one Work and reports the re-key")+ func teachingTheDeclarationRepairsTheSite() async throws {+ let (library, siblingIDs, chapterOneID) = try await Self.realisticPreState()+ defer { library.cleanup() }++ let priorRule = try library.currentURLRule()+ let priorMaxVersion = try library.urlRules().map(\.version).max() ?? 0+ let before = try library.entry(chapterOneID)+ #expect(before.urlWorkIdentity == nil)+ #expect(before.identityKeyVersion == 1)+ #expect(before.entryIdentityKey == TthRules.chapterOneURL)+ #expect(before.work != nil, "the pre-state groups chapter 1 by title claim")+ #expect(try library.works().count == 1)++ // Req 4.7 / amended Req 3.4: the preview carries the derived identity,+ // the derived sequence and the key change *before* anything is written.+ let outcome = try await library.projectTeach(TthRules.rule(.optional)).outcome+ let row = try #require(outcome.entries.first { $0.entryID == chapterOneID })+ #expect(row.derivedWorkIdentity == "30975")+ #expect(row.projectedChapterSequence == "1")+ #expect(row.previousIdentityKey == TthRules.chapterOneURL)+ #expect(row.previousKeyVersion == 1)+ #expect(row.projectedIdentityKey == (try TthRules.identityKey(work: "30975", sequence: "1")))+ #expect(row.projectedKeyVersion == 2)+ #expect(row.chapterSettled)+ #expect(row.actionableAfter == false)+ #expect(row.assignment == .reuse(workID: try #require(before.work?.id)))+ // Req 3.4's other half: nothing the declaration did not newly resolve+ // re-keys. The siblings extracted before and extract identically now.+ for id in siblingIDs {+ let sibling = try #require(outcome.entries.first { $0.entryID == id })+ #expect(sibling.projectedIdentityKey == sibling.previousIdentityKey)+ #expect(sibling.projectedKeyVersion == sibling.previousKeyVersion)+ }+ #expect(try library.entry(chapterOneID).entryIdentityKey == TthRules.chapterOneURL,+ "the preview writes nothing")++ try await library.teach(TthRules.rule(.optional))++ // Req 4.2 / 4.5: chapter 1 now carries the identity and sits in the one+ // Work that holds it — no second Work for the same identity.+ let works = try library.works()+ #expect(works.count == 1)+ #expect(works[0].urlIdentity == "30975")+ let after = try library.entry(chapterOneID)+ #expect(after.urlWorkIdentity == "30975")+ #expect(after.chapterSequence == "1")+ #expect(after.identityKeyVersion == 2)+ #expect(after.work?.id == works[0].id)+ for entry in try library.entries() {+ #expect(entry.work?.id == works[0].id, "\(entry.rawURLString) is not in the story's Work")+ }++ // Req 4.2's provenance half: the chapter-1 entry cites the rule.+ let rule = try library.currentURLRule()+ #expect(after.urlWorkRuleID == rule.id)+ #expect(after.urlWorkRuleVersion == rule.version)++ // Req 6.1: the declaration is a property of the one current rule. It+ // mints the next version, and no second current rule appears.+ #expect(rule.version == priorMaxVersion + 1)+ #expect(rule.id != priorRule.id)+ #expect(try library.urlRules().filter(\.isCurrent).count == 1)+ #expect(try library.currentURLRule().definition == TthRules.rule(.optional))+ }++ /// The durability half of the same requirement: after the repair, a further+ /// separator-free capture of the story resolves to that one Work rather than+ /// finding two Works carrying the identity and going ambiguous.+ ///+ /// Asserted at the projection, not the commit: committing a second spelling+ /// of one chapter would mint a duplicate Entry, and collapsing pre-existing+ /// duplicates is an explicit non-goal. The shipped route for a second+ /// spelling is `captureLookup`, which the re-share tests pin.+ @Test("A later chapter-1-shaped capture of the same story attaches, not ambiguously")+ func laterSeparatorFreeCaptureAttaches() async throws {+ let (library, _, _) = try await Self.realisticPreState()+ defer { library.cleanup() }+ try await library.teach(TthRules.rule(.optional))++ let workID = try #require(try library.works().first?.id)+ let respelled = ArchiveCorpus.Capture(+ title: ArchiveCorpus.tthStoryTitle, url: TthRules.chapterOneURL + "#storybody")+ let outcome = try await library.project(respelled).outcome++ #expect(outcome.composedAssignment == .reuse(workID: workID))+ #expect(outcome.composedChapterSequence == "1")+ #expect(outcome.actionable == false)+ }++ /// Reqs 4.1 and 4.6. The re-derivation is hostname-wide, so what protects+ /// reader-authored state has to be the derivation itself, not the scope.+ @Test("Protected entries and manually set fields survive the re-derivation")+ func protectedStateIsUntouched() async throws {+ let (library, siblingIDs, chapterOneID) = try await Self.realisticPreState()+ defer { library.cleanup() }++ let unattachedID = siblingIDs[1]+ let manualChapterID = siblingIDs[2]+ let manualAssignmentID = siblingIDs[3]+ let manualWorkID = try #require(try library.entry(manualAssignmentID).work?.id)+ try library.mutate { context in+ let entries = try context.fetch(FetchDescriptor<Entry>())+ // Intentional unattachment on a taught Site is manual assignment+ // with no Work — the tuple table admits no other spelling of it.+ let unattached = try #require(entries.first { $0.id == unattachedID })+ unattached.work = nil+ unattached.intentionallyUnattached = true+ unattached.workAssignmentProvenance = .manual+ unattached.workPatternID = nil+ unattached.workPatternVersion = nil+ unattached.workURLRuleID = nil+ unattached.workURLRuleVersion = nil+ unattached.workURLAssignmentKindRaw = nil++ let manualChapter = try #require(entries.first { $0.id == manualChapterID })+ manualChapter.chapterTitle = "The one I named myself"+ manualChapter.chapterTitleProvenance = .manual+ manualChapter.chapterPatternID = nil+ manualChapter.chapterPatternVersion = nil++ let manualAssignment = try #require(entries.first { $0.id == manualAssignmentID })+ manualAssignment.workAssignmentProvenance = .manual+ manualAssignment.workPatternID = nil+ manualAssignment.workPatternVersion = nil+ manualAssignment.workURLRuleID = nil+ manualAssignment.workURLRuleVersion = nil+ manualAssignment.workURLAssignmentKindRaw = nil+ }++ try await library.teach(TthRules.rule(.optional))++ let unattached = try library.entry(unattachedID)+ #expect(unattached.intentionallyUnattached)+ #expect(unattached.work == nil, "an intentionally unattached Entry was reattached")++ let manualChapter = try library.entry(manualChapterID)+ #expect(manualChapter.chapterTitle == "The one I named myself")+ #expect(manualChapter.chapterTitleProvenance == .manual)++ let manualAssignment = try library.entry(manualAssignmentID)+ #expect(manualAssignment.workAssignmentProvenance == .manual)+ #expect(manualAssignment.work?.id == manualWorkID)+ #expect(manualAssignment.workURLRuleID == nil, "the rule overwrote a manual assignment")++ // The declaration still did its work for everything it was allowed to+ // touch, so this is a protection test rather than a no-op test.+ #expect(try library.entry(chapterOneID).urlWorkIdentity == "30975")+ }++ /// Reqs 4.3 and 4.8 under Q28: the reconciliation pass is deferred, so the+ /// contract is that the split is *reported* and left where the assignments+ /// put it — never silently reunited, never silently cleared.+ ///+ /// The pre-state is synthetic because it does not exist in the library and+ /// cannot arise on a site once the declaration is taught: it needs a+ /// combined rule taught without the declaration, a chapter-1 capture whose+ /// title lands it in a Work of its own, and the declaration taught last.+ @Test("The split pre-state is reported as a collision and the commit leaves it in place")+ func splitPreStateIsReportedNotRepaired() async throws {+ let library = try OptionalSequenceLibrary()+ defer { library.cleanup() }++ try await library.capture(TthRules.siblings[0])+ try await library.teach(TthRules.rule(.required))+ for capture in TthRules.siblings.dropFirst() { try await library.capture(capture) }++ // The split: chapter 1 arrives under a title the site had changed, so+ // the title claim misses and it is created into a Work of its own with+ // no URL identity — the rule cannot extract from its URL.+ let chapterOneID = try await library.capture(ArchiveCorpus.Capture(+ title: "The Secret Return of Alex Mack (Complete)", url: TthRules.chapterOneURL)).id+ let siblingEntry = try #require(+ try library.entries().first { $0.rawURLString == TthRules.siblings[0].url })+ let storyWorkID = try #require(siblingEntry.work?.id)+ let strandedWorkID = try #require(try library.entry(chapterOneID).work?.id)+ #expect(storyWorkID != strandedWorkID)++ let outcome = try await library.projectTeach(TthRules.rule(.optional)).outcome+ let collisions = outcome.issues.compactMap { issue -> (ExactScalarString, [UUID])? in+ guard case .workCollision(let identity, let workIDs) = issue else { return nil }+ return (identity, workIDs)+ }+ #expect(collisions.count == 1)+ #expect(collisions.first?.0 == ExactScalarString("30975"))+ #expect(collisions.first?.1.sorted { $0.uuidString < $1.uuidString }+ == [storyWorkID, strandedWorkID].sorted { $0.uuidString < $1.uuidString })++ try await library.teach(TthRules.rule(.optional))++ // Neither Work was deleted nor merged. Work merge is the repair, and it+ // is the reader's to run.+ let works = try library.works()+ #expect(Set(works.map(\.id)) == [storyWorkID, strandedWorkID])++ // The entries sit where the assignments put them — no reunification and+ // no clearing beyond what the projection said.+ let projected = try #require(outcome.entries.first { $0.entryID == chapterOneID })+ #expect(projected.assignment == .reuse(workID: storyWorkID))+ #expect(try library.entry(chapterOneID).work?.id == storyWorkID)++ // And the Work the commit emptied keeps the identity its own disposition+ // set — Decision 3's mechanism, left standing by Q28. It is the reason+ // the collision has to be reported rather than shrugged off.+ let stranded = try #require(works.first { $0.id == strandedWorkID })+ #expect(stranded.entryValues.isEmpty)+ #expect(stranded.urlIdentity == "30975")+ }++ /// Decision 4: removal is another re-teach, not a rollback. The documented+ /// post-state is that chapter-1 captures stop resolving a Work identity —+ /// which is what the reader is warned about (Req 2.9) — and emphatically not+ /// a restoration of whatever grouping stood before the declaration.+ @Test("Removing the declaration re-derives rather than restoring the prior grouping")+ func removalIsNotARollback() async throws {+ let (library, _, chapterOneID) = try await Self.realisticPreState()+ defer { library.cleanup() }+ try await library.teach(TthRules.rule(.optional))+ #expect(try library.entry(chapterOneID).urlWorkIdentity == "30975")+ let declaredWorkID = try #require(try library.entry(chapterOneID).work?.id)++ try await library.teach(TthRules.rule(.required))++ // The URL-derived identity is gone and the key falls back to the+ // verbatim raw URL — the state the declaration was taught to fix.+ let after = try library.entry(chapterOneID)+ #expect(after.urlWorkIdentity == nil)+ #expect(after.chapterSequence == nil)+ #expect(after.identityKeyVersion == 1)+ #expect(after.entryIdentityKey == TthRules.chapterOneURL)+ #expect(after.urlWorkRuleID == nil)++ // And the site is not back where it started: the Work that held every+ // capture has had its identity cleared by the failed extraction, which+ // is the re-derivation's outcome and not the pre-declaration state.+ let declaredWork = try #require(try library.works().first { $0.id == declaredWorkID })+ #expect(declaredWork.urlIdentity == nil)+ #expect(declaredWork.urlIdentityState == .none)++ // The documented post-state, and the reason the warning exists: chapter+ // 1 is left behind in the Work whose identity the failed extraction just+ // cleared, while the siblings that still extract are created into a+ // fresh Work keyed by the identity. The story is split — which is not+ // the grouping that stood before the declaration was taught, and not any+ // grouping the reader chose.+ #expect(after.work?.id == declaredWorkID)+ let siblingWorkIDs = Set(try library.entries()+ .filter { $0.id != chapterOneID }+ .compactMap { $0.work?.id })+ #expect(siblingWorkIDs.count == 1)+ #expect(!siblingWorkIDs.contains(declaredWorkID))+ #expect(try library.works().count == 2)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4Fixtures.swiftindex 5a2c572..d4f8ffa 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4Fixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4Fixtures.swift@@ -1,3 +1,4 @@+import CryptoKit import Foundation @testable import AsterismCore@@ -162,6 +163,84 @@ enum BackupV4Fixtures { titlePatterns: [pattern], urlRules: [rule]) } + // MARK: - Combined rule, both presence states (Reqs 5.3–5.5)++ /// A taught Site whose current rule is the tthfanfic-shaped combined rule,+ /// with the chapter sequence declared optional or not.+ ///+ /// Fixed UUIDs and a fixed date, so the encoded bytes are stable and can be+ /// asserted on directly — which a fixture generated by a teaching commit+ /// cannot be (it mints random UUIDs and wall-clock timestamps).+ static func combinedRulePayload(presence: URLSequencePresence) -> BackupV4Payload {+ let patternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff3")!+ let ruleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff4")!++ let pattern = BackupV4TitlePattern(+ id: patternID, version: 1, isActive: true, createdAt: created,+ definition: .wholeTitle, trimPrefix: nil, trimSuffix: nil,+ siteHostname: combinedRuleHost)++ let rule = BackupV4URLRule(+ id: ruleID, version: 1, isCurrent: true, createdAt: created,+ origin: .readerTaught,+ definition: .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"),+ separator: ExactScalarString("-"),+ suffix: ExactScalarString(""),+ order: .workThenSequence,+ sequencePresence: presence)),+ siteHostname: combinedRuleHost)++ let site = BackupV4Site(+ hostname: combinedRuleHost, displayName: "Combined", mode: .taught,+ patternIDs: [patternID], urlRuleIDs: [ruleID], junkSuffixRule: nil)++ return BackupV4Payload(+ entries: [], works: [], sites: [site],+ titlePatterns: [pattern], urlRules: [rule])+ }++ static let combinedRuleHost = "combined.example"++ /// The payload bytes a build **without** this feature writes for+ /// `combinedRulePayload(presence: .required)`: the same records, hand-written+ /// in the codec's canonical `.sortedKeys` layout, and carrying no+ /// `sequencePresence` key anywhere. Req 5.3's pre-feature archive is exactly+ /// this text.+ static let preFeatureCombinedPayloadJSON =+ #"{"entries":[],"sites":[{"displayName":"Combined","hostname":"combined.example","#+ + #""mode":"taught","patternIDs":["FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF3"],"#+ + #""urlRuleIDs":["FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF4"]}],"titlePatterns":"#+ + #"[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"wholeTitle":{}},"#+ + #""id":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF3","isActive":true,"#+ + #""siteHostname":"combined.example","version":1}],"urlRules":"#+ + #"[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"combined":"#+ + #"{"locator":{"pathBracketed":{"left":{"start":{}},"right":{"unanchored":{}}}},"#+ + #""template":{"order":"workThenSequence","prefix":"Story-","separator":"-","#+ + #""suffix":""}}},"id":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF4","isCurrent":true,"#+ + #""origin":"readerTaught","siteHostname":"combined.example","version":1}],"works":[]}"#++ /// `preFeatureCombinedPayloadJSON` wrapped in a 4/4 envelope, with the+ /// checksum taken over that literal text.+ ///+ /// The checksum is what makes the fixture a test rather than a restatement:+ /// `BackupV4Codec.decode` re-encodes the payload it decoded and compares a+ /// SHA-256 (`BackupV4Codec.swift:86-97`), so a build that dropped the+ /// pre-feature spelling — or added a key of its own — fails with+ /// `checksumMismatch` (Decision 1).+ static func preFeatureCombinedDocument(appBuild: String = "pre-feature") -> Data {+ let payload = preFeatureCombinedPayloadJSON+ let checksum = SHA256.hash(data: Data(payload.utf8))+ .map { String(format: "%02x", $0) }.joined()+ return Data(+ (#"{"appBuild":"\#(appBuild)","backupFormatVersion":4,"capabilityGate":"m4","#+ + #""checksum":"\#(checksum)","databaseSchemaVersion":4,"entryCount":0,"#+ + #""exportedAt":"1970-01-12T13:46:40.000Z","payload":\#(payload),"#+ + #""workCount":0}"#).utf8)+ }+ // MARK: - Two current URL rules (illegal) static func twoCurrentRulePayload() -> BackupV4Payload {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityParsingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityParsingTests.swiftindex 3d96ed0..c2e402a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityParsingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityParsingTests.swift@@ -258,7 +258,8 @@ struct ExactURLRuleApplicationTests { work: characterRange(of: "42", in: component.value), sequence: characterRange(of: "7", in: component.value) )- let template = try URLTwoFieldTemplateDeriver.derive(from: component, selection: selection)+ let template = try URLTwoFieldTemplateDeriver.derive(+ from: component, selection: selection, presence: .required) #expect(template.prefix == ExactScalarString("📚work-")) #expect(template.separator == ExactScalarString("-chapter-"))@@ -303,19 +304,22 @@ struct ExactURLRuleApplicationTests { #expect(throws: URLTemplateSelectionError.overlappingSelections) { try URLTwoFieldTemplateDeriver.derive( from: value,- selection: URLTwoFieldSelection(work: 0..<4, sequence: 3..<7)+ selection: URLTwoFieldSelection(work: 0..<4, sequence: 3..<7),+ presence: .required ) } #expect(throws: URLTemplateSelectionError.selectionOutOfBounds(field: .work)) { try URLTwoFieldTemplateDeriver.derive( from: value,- selection: URLTwoFieldSelection(work: 0..<99, sequence: 4..<7)+ selection: URLTwoFieldSelection(work: 0..<99, sequence: 4..<7),+ presence: .required ) } #expect(throws: URLTemplateSelectionError.blankSelection(field: .work)) { try URLTwoFieldTemplateDeriver.derive( from: ExactScalarString(" -value"),- selection: URLTwoFieldSelection(work: 0..<2, sequence: 3..<8)+ selection: URLTwoFieldSelection(work: 0..<2, sequence: 3..<8),+ presence: .required ) } }
diff --git a/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swift b/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swiftnew file mode 100644index 0000000..6ca52b7--- /dev/null+++ b/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swift@@ -0,0 +1,463 @@+import AsterismCore+import Foundation+import Testing++@testable import Asterism++/// `optional-chapter-sequence` task 8's two messages, and task 9's preview+/// additions, over the mock provider.+///+/// Both messages are properties of the teaching view model rather than of the+/// editor view, so they are asserted here; the toggle's own gesture path is+/// pinned in `ComposedURLEditorStateTests` and `OptionalSequenceThroughEditorTests`.+@Suite("Optional chapter sequence — teaching messages")+@MainActor+struct OptionalSequenceTeachingMessagesTests {++ private static let hostname = "www.tthfanfic.org"+ private static let storyTitle = "TtH • Story • The Secret Return of Alex Mack"++ private static func url(_ component: String) -> String {+ "https://www.tthfanfic.org/\(component)/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"+ }++ private static func combined(_ presence: URLSequencePresence) -> URLRuleDefinition {+ .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: presence))+ }++ private static func makeSUT(+ exampleURL: String, storedURLRule: URLRuleDefinition? = nil,+ titleDefinition: PatternDefinition = .wholeTitle,+ entries: [ComposedEntryBasis] = [], projections: [ComposedEntryProjection] = [],+ works: [ComposedWorkBasis] = [], prospectiveWorks: [ProspectiveWorkIntent] = []+ ) -> ComposedTeachingViewModel {+ let mock = MockLibraryProvider()+ let basis = ComposedTeachingBasis(+ siteMode: .taught, hostname: hostname, entries: entries, works: works,+ currentTitleRule: ComposedTitleRuleBasis(+ id: UUID(), version: 1, definition: titleDefinition,+ trimPrefix: nil, trimSuffix: nil),+ currentURLRule: storedURLRule.map {+ ComposedURLRuleBasis(id: UUID(), version: 1, origin: .readerTaught, definition: $0)+ })+ mock.projectComposedTeachingResult = .success(+ ComposedTeachingContract(+ basis: basis,+ request: ComposedTeachingRequest(titleDefinition: titleDefinition),+ outcome: ComposedTeachingOutcome(+ titleVersion: .unchanged(1), urlVersion: .available(2), entries: projections,+ works: [], issues: [], prospectiveWorks: prospectiveWorks,+ requiresUnsettledAcknowledgment: false)))+ mock.commitComposedTeachingResult = .success(+ .committed(+ titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ return ComposedTeachingViewModel(+ entry: TestFixtures.makeEntry(+ captureTitle: storyTitle, hostname: hostname, rawURLString: exampleURL),+ library: mock, capabilities: .m4, entryContext: .urlFocused, onMutation: {})+ }++ // MARK: - Req 2.5 / Q10: the unteachable shape++ /// The motivating case: the reader opens teach mode from the chapter-1+ /// capture, whose URL carries no chapter part, and the candidate rule cannot+ /// find one in it.+ @Test("A capture with no chapter part in its URL cannot teach this rule form")+ func unteachableShapeIsStated() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975"), storedURLRule: Self.combined(.required))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))++ let message = try #require(vm.unteachableCombinedShapeMessage)+ #expect(message == ComposedTeachingViewModel.unteachableCombinedShapeNotice)+ #expect(message.lowercased().contains("chapter part"))+ }++ /// Once the declaration is in force the capture is no longer unteachable, so+ /// the reader is not sent away to find another one.+ @Test("Declaring the sequence optional clears the unteachable-shape message")+ func declarationClearsTheMessage() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975"), storedURLRule: Self.combined(.required))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.unteachableCombinedShapeMessage != nil)++ vm.setURLRuleDefinition(Self.combined(.optional))+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.unteachableCombinedShapeMessage == nil)+ }++ @Test("A capture whose URL carries the chapter part is silent")+ func teachableShapeIsSilent() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975-105"), storedURLRule: Self.combined(.required))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.unteachableCombinedShapeMessage == nil)+ }++ /// Q10: the condition is the example URL and the **current selection**, not a+ /// stored rule — the case it exists for is the initial teach of a site that+ /// has none. So clearing the selection clears the message even though the+ /// stored rule is untouched.+ @Test("The condition follows the selection, not the stored rule")+ func conditionFollowsTheSelection() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975"), storedURLRule: Self.combined(.required))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.unteachableCombinedShapeMessage != nil)++ vm.setURLRuleDefinition(nil)+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.unteachableCombinedShapeMessage == nil)+ #expect(vm.storedURLRuleDescription != nil, "the stored rule is untouched")+ }++ /// The initial teach of a site with no rule at all: the reader taps the+ /// identity component and the chapter has nowhere to come from.+ @Test("An unsplittable component with the chapter unsourced says so")+ func unsplittableComponentOnAnUntaughtSite() async throws {+ let vm = Self.makeSUT(exampleURL: "https://example.test/28614/read")+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ vm.setURLRuleDefinition(+ .work(locator: .pathBracketed(left: .start, right: .unanchored)))+ try await Task.sleep(for: .milliseconds(50))++ #expect(vm.chapterUnsourced)+ #expect(vm.unteachableCombinedShapeMessage != nil)+ }++ @Test("A component that can still be split is not called unteachable")+ func splittableComponentIsSilent() async throws {+ let vm = Self.makeSUT(exampleURL: Self.url("Story-30975-105"))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ vm.setURLRuleDefinition(+ .work(locator: .pathBracketed(left: .start, right: .unanchored)))+ try await Task.sleep(for: .milliseconds(50))++ #expect(vm.chapterUnsourced)+ #expect(vm.unteachableCombinedShapeMessage == nil)+ }++ // MARK: - Req 2.9 / Decision 4: the removal warning++ @Test("Removing the declaration warns what removal costs, and calls it a re-teach")+ func removalIsWarnedAbout() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975-105"), storedURLRule: Self.combined(.optional))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ // Nothing has been removed yet.+ #expect(vm.sequencePresenceRemovalMessage == nil)++ vm.setURLRuleDefinition(Self.combined(.required))+ try await Task.sleep(for: .milliseconds(50))++ let warning = try #require(vm.sequencePresenceRemovalMessage)+ #expect(warning == ComposedTeachingViewModel.sequencePresenceRemovalWarning)+ // Decision 4: the two facts the reader must have before paying for it.+ #expect(warning.lowercased().contains("separated from their work"))+ #expect(warning.lowercased().contains("does not restore"))+ }++ @Test("A site that never declared the sequence optional is never warned")+ func noWarningWithoutADeclaration() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975-105"), storedURLRule: Self.combined(.required))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.sequencePresenceRemovalMessage == nil)++ vm.setURLRuleDefinition(+ .work(locator: .pathBracketed(left: .start, right: .unanchored)))+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.sequencePresenceRemovalMessage == nil)+ }++ /// Removal is not only "toggle off": narrowing the rule to `.work` drops the+ /// declaration with the template, and costs the same thing.+ @Test("Dropping the combined rule altogether is also a removal")+ func narrowingTheRuleIsARemoval() async throws {+ let vm = Self.makeSUT(+ exampleURL: Self.url("Story-30975-105"), storedURLRule: Self.combined(.optional))+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ vm.setURLRuleDefinition(+ .work(locator: .pathBracketed(left: .start, right: .unanchored)))+ try await Task.sleep(for: .milliseconds(50))+ #expect(vm.sequencePresenceRemovalMessage != nil)+ }+}++/// Task 9's preview additions (Reqs 2.6, 2.11, 4.7).+@Suite("Optional chapter sequence — preview")+@MainActor+struct OptionalSequencePreviewTests {++ private static let hostname = "www.tthfanfic.org"+ private static let storyTitle = "TtH • Story • The Secret Return of Alex Mack"+ private static let workID = UUID()+ private static let otherWorkID = UUID()+ /// Stable across the suite so a prospective-Work intent can name a row.+ private static let entryIDs: [UUID] = (0..<8).map { _ in UUID() }++ private static func url(_ component: String) -> String {+ "https://www.tthfanfic.org/\(component)/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"+ }++ private static func optionalRule() -> URLRuleDefinition {+ .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: .optional))+ }++ private static func key(_ sequence: String) -> String { "v2|30975|\(sequence)" }++ private static func basisEntry(+ _ id: UUID, component: String, at seconds: TimeInterval, workID: UUID?,+ previousKey: String+ ) -> ComposedEntryBasis {+ ComposedEntryBasis(+ id: id, captureTitle: storyTitle, rawURLString: url(component), hostname: hostname,+ firstCapturedAt: Date(timeIntervalSince1970: seconds), chapterTitle: nil,+ chapterTitleProvenance: .none, workID: workID, workAssignmentProvenance: .none,+ intentionallyUnattached: false, previousIdentityKey: previousKey,+ previousKeyVersion: 2)+ }++ /// `sequenceDerived` is supplied here because the projection carries it out+ /// of the applicator — the view model reads the fact rather than re-deriving+ /// it from the basis URLs.+ private static func projection(+ _ id: UUID, sequence: String, previousWorkID: UUID?, previousKey: String,+ assignment: ComposedAssignmentProjection, sequenceDerived: Bool = false+ ) -> ComposedEntryProjection {+ let projectedWorkID: UUID?+ switch assignment {+ case .reuse(let workID), .claim(let workID): projectedWorkID = workID+ default: projectedWorkID = nil+ }+ return ComposedEntryProjection(+ entryID: id, previousWorkID: previousWorkID, previousChapterTitle: nil,+ workName: "The Secret Return of Alex Mack", workNameSource: .parsed,+ projectedChapterTitle: nil, projectedChapterSequence: sequence,+ projectedIdentityBasis: .urlRule, projectedKeyVersion: 2,+ projectedIdentityKey: key(sequence), assignment: assignment,+ projectedWorkID: projectedWorkID, chapterSettled: true, actionableAfter: false,+ titleFailure: nil, urlFailure: nil, previousIdentityKey: previousKey,+ previousKeyVersion: 2, derivedWorkIdentity: "30975",+ sequenceDerived: sequenceDerived)+ }++ /// Eight chapters, the separator-free one **last** — past the six-row cap.+ /// Every key already matches its projection, so nothing here is flagged as a+ /// key change: the separator-free row's visibility must come from its own+ /// flag and nothing else.+ private static func makeSUT(+ separatorFreeAssignment: ComposedAssignmentProjection = .reuse(workID: workID),+ separatorFreePreviousWorkID: UUID? = workID,+ prospectiveWorks: [ProspectiveWorkIntent] = [],+ otherWorkTitle: String = "A stray Work"+ ) -> ComposedTeachingViewModel {+ let ids = entryIDs+ var entries: [ComposedEntryBasis] = []+ var projections: [ComposedEntryProjection] = []+ for index in 0..<7 {+ let sequence = String(index + 2)+ entries.append(+ basisEntry(+ ids[index], component: "Story-30975-\(sequence)",+ at: TimeInterval(index), workID: workID, previousKey: key(sequence)))+ projections.append(+ projection(+ ids[index], sequence: sequence, previousWorkID: workID,+ previousKey: key(sequence), assignment: .reuse(workID: workID)))+ }+ entries.append(+ basisEntry(+ ids[7], component: "Story-30975", at: 7, workID: separatorFreePreviousWorkID,+ previousKey: key("1")))+ projections.append(+ projection(+ ids[7], sequence: "1", previousWorkID: separatorFreePreviousWorkID,+ previousKey: key("1"), assignment: separatorFreeAssignment,+ sequenceDerived: true))++ let mock = MockLibraryProvider()+ let basis = ComposedTeachingBasis(+ siteMode: .taught, hostname: hostname, entries: entries,+ works: [+ ComposedWorkBasis(+ id: workID, displayTitle: "The Secret Return of Alex Mack",+ lastParsedTitle: nil, titleProvenance: .parsed, identity: .none),+ ComposedWorkBasis(+ id: otherWorkID, displayTitle: otherWorkTitle, lastParsedTitle: nil,+ titleProvenance: .parsed, identity: .none),+ ],+ currentTitleRule: ComposedTitleRuleBasis(+ id: UUID(), version: 1, definition: .wholeTitle, trimPrefix: nil, trimSuffix: nil),+ currentURLRule: ComposedURLRuleBasis(+ id: UUID(), version: 1, origin: .readerTaught, definition: optionalRule()))+ mock.projectComposedTeachingResult = .success(+ ComposedTeachingContract(+ basis: basis,+ request: ComposedTeachingRequest(titleDefinition: .wholeTitle),+ outcome: ComposedTeachingOutcome(+ titleVersion: .unchanged(1), urlVersion: .unchanged(1), entries: projections,+ works: [], issues: [], prospectiveWorks: prospectiveWorks,+ requiresUnsettledAcknowledgment: false)))+ mock.commitComposedTeachingResult = .success(+ .committed(+ titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ return ComposedTeachingViewModel(+ entry: TestFixtures.makeEntry(+ captureTitle: storyTitle, hostname: hostname,+ rawURLString: url("Story-30975-2")),+ library: mock, capabilities: .m4, entryContext: .urlFocused, onMutation: {})+ }++ /// Req 2.6/2.11: the six-row cap must not hide the one row that shows what+ /// the declaration does — including the Work name a chapter-less title+ /// yields, which is what makes a wrong positional anchor visible.+ @Test("A separator-free capture is shown past the six-row cap")+ func separatorFreeRowIsFlaggedPastTheCap() async throws {+ let vm = Self.makeSUT()+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))++ #expect(vm.identityKeyChanges.isEmpty, "the fixture must not flag by key change")+ #expect(vm.unresolvedURLCaptures.isEmpty, "nor by an unresolved capture")+ #expect(vm.separatorFreeCaptures == [Self.entryIDs[7]])+ #expect(vm.previewRows.map(\.entryID).contains(Self.entryIDs[7]))+ // Six ordinary rows plus the one flagged row.+ #expect(vm.previewRows.count == 7)+ let row = try #require(vm.previewRows.first { $0.entryID == Self.entryIDs[7] })+ // Req 2.6/2.11: the derived identity and the Work name a chapter-less+ // title yields are both on the row the reader can now see.+ #expect(row.derivedWorkIdentity == "30975")+ #expect(row.workName == "The Secret Return of Alex Mack")+ }++ @Test("The derived sequence is marked as derived, not as one the URL stated")+ func derivedSequenceIsDistinguished() async throws {+ let vm = Self.makeSUT()+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))++ #expect(vm.sequenceIsDerived(for: Self.entryIDs[7]))+ for index in 0..<7 {+ #expect(+ !vm.sequenceIsDerived(for: Self.entryIDs[index]),+ "row \(index) reads its sequence out of the URL")+ }+ }++ /// Req 4.7 and the design's step-1 rule: `projectedWorkID` is nil for "no+ /// projected change" and must not be read as the post-commit attachment.+ @Test("Before and after Work are resolved per the step-1 rule")+ func beforeAndAfterWork() async throws {+ // A move: the capture sits in a stray Work and the rule reunites it.+ let moving = Self.makeSUT(+ separatorFreeAssignment: .reuse(workID: Self.workID),+ separatorFreePreviousWorkID: Self.otherWorkID)+ await moving.load()+ try await Task.sleep(for: .milliseconds(50))+ let movedRow = try #require(moving.previewRows.first { $0.entryID == Self.entryIDs[7] })+ let moved = moving.workAttachment(for: movedRow)+ #expect(moved.before == "A stray Work")+ #expect(moved.after == "The Secret Return of Alex Mack")+ #expect(moved.changes)+ // A row that does not move reports no change.+ let stayingRow = try #require(moving.previewRows.first { $0.entryID == Self.entryIDs[0] })+ #expect(!moving.workAttachment(for: stayingRow).changes)+ }++ /// Req 4.8's split pre-state: the story already sits in two Works that carry+ /// the same display name, and the rule reunites the capture into one of them.+ /// Comparing names would report no change in exactly the case Req 4.7 exists+ /// for, so the change has to be read off the Work identity.+ @Test("A move between two same-named Works is still reported as a change")+ func moveBetweenSameNamedWorksIsAChange() async throws {+ let vm = Self.makeSUT(+ separatorFreeAssignment: .reuse(workID: Self.workID),+ separatorFreePreviousWorkID: Self.otherWorkID,+ otherWorkTitle: "The Secret Return of Alex Mack")+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ let row = try #require(vm.previewRows.first { $0.entryID == Self.entryIDs[7] })+ let attachment = vm.workAttachment(for: row)+ #expect(attachment.before == "The Secret Return of Alex Mack")+ #expect(attachment.after == "The Secret Return of Alex Mack")+ #expect(attachment.changes)+ }++ /// The same defect through the other door: neither Work resolves a name, so+ /// both sides are nil. The move is still a move.+ @Test("A move between Works with no resolvable name is still reported as a change")+ func moveBetweenUnnamedWorksIsAChange() async throws {+ let vm = Self.makeSUT(+ separatorFreeAssignment: .reuse(workID: UUID()),+ separatorFreePreviousWorkID: UUID())+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ let row = try #require(vm.previewRows.first { $0.entryID == Self.entryIDs[7] })+ let attachment = vm.workAttachment(for: row)+ #expect(attachment.before == nil)+ #expect(attachment.after == nil)+ #expect(attachment.changes)+ }++ /// `.create`: no Work row exists yet, so "after" is the prospective Work the+ /// batch plan names — `projectedWorkID` is nil here and reading it directly+ /// would report the entry as landing nowhere.+ @Test("A created Work is named from the prospective plan, not from projectedWorkID")+ func createdWorkIsNamedFromTheProspectivePlan() async throws {+ let vm = Self.makeSUT(+ separatorFreeAssignment: .create(key: .urlIdentity(ExactScalarString("30975"))),+ separatorFreePreviousWorkID: nil,+ prospectiveWorks: [+ ProspectiveWorkIntent(+ key: .urlIdentity(ExactScalarString("30975")),+ entryIDs: [Self.entryIDs[7]],+ displayTitle: ExactScalarString("The Secret Return of Alex Mack"),+ lastParsedTitle: ExactScalarString("The Secret Return of Alex Mack"))+ ])+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ let row = try #require(vm.previewRows.first { $0.entryID == Self.entryIDs[7] })+ #expect(row.projectedWorkID == nil)+ let attachment = vm.workAttachment(for: row)+ #expect(attachment.before == nil)+ #expect(attachment.after == "The Secret Return of Alex Mack")+ #expect(attachment.changes)+ }++ /// `.ambiguous` and `.noChange` keep the entry where it is: the design's rule+ /// for the arms `projectedWorkID` leaves nil.+ @Test("An unresolved assignment leaves the entry in the Work it is in")+ func unresolvedAssignmentKeepsTheWork() async throws {+ let vm = Self.makeSUT(+ separatorFreeAssignment: .ambiguous(workIDs: [Self.workID, Self.otherWorkID]),+ separatorFreePreviousWorkID: Self.otherWorkID)+ await vm.load()+ try await Task.sleep(for: .milliseconds(50))+ let row = try #require(vm.previewRows.first { $0.entryID == Self.entryIDs[7] })+ let attachment = vm.workAttachment(for: row)+ #expect(attachment.before == "A stray Work")+ #expect(attachment.after == "A stray Work")+ #expect(!attachment.changes)+ }+}
diff --git a/Asterism/AsterismTests/ComposedURLEditorStateTests.swift b/Asterism/AsterismTests/ComposedURLEditorStateTests.swiftindex 4e8be88..85b7e71 100644--- a/Asterism/AsterismTests/ComposedURLEditorStateTests.swift+++ b/Asterism/AsterismTests/ComposedURLEditorStateTests.swift@@ -271,4 +271,302 @@ struct ComposedURLEditorStateTests { from: .work(locator: .pathBracketed(left: .start, right: .unanchored)), in: parsed) #expect(state.retainedTemplate == nil) }++ // MARK: - Sequence presence as editor state (Reqs 1.10, 2.1, 2.2, 2.3, 2.8)++ /// `optional-chapter-sequence` task 7. Presence is editor state rather than a+ /// one-shot template rewrite: the live-split branch of `rule(in:)` re-derives+ /// the template on **every** dispatch, so a swapped template would be+ /// clobbered by the next token tap.++ private static let tthOptionalTemplate = URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence, sequencePresence: .optional)++ @Test("Presence defaults to required — the historical form, authored explicitly")+ func presenceDefaultsToRequired() throws {+ let parsed = try Self.components(Self.tthURL)+ var state = State()+ #expect(state.sequencePresence == .required)+ state.select(.path(0))+ state.beginSplit(of: "Story-30975-1")+ #expect(+ state.rule(in: parsed).definition+ == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthTemplate))+ }++ @Test("Seeding takes the presence from a stored combined template (Req 2.8)")+ func seedTakesPresenceFromStoredTemplate() throws {+ let parsed = try Self.components(Self.tthURL)+ var state = State()+ state.seed(+ from: .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate),+ in: parsed)+ #expect(state.sequencePresence == .optional)+ #expect(state.retainedTemplate == Self.tthOptionalTemplate)+ }++ @Test("Seeding from every other arm resets presence to required")+ func seedResetsPresenceForOtherArms() throws {+ let parsed = try Self.components(Self.tthURL)+ let arms: [URLRuleDefinition?] = [+ nil,+ .work(locator: .pathBracketed(left: .start, right: .unanchored)),+ .sequence(locator: .pathBracketed(left: .start, right: .unanchored)),+ .workAndSequence(+ work: URLFieldSelector(+ locator: .pathBracketed(left: .start, right: .unanchored)),+ sequence: URLFieldSelector(+ locator: .pathBracketed(+ left: Self.literal("Story-30975-1"), right: .end))),+ .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthTemplate),+ ]+ for arm in arms {+ var state = State()+ state.seed(+ from: .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate),+ in: parsed)+ #expect(state.sequencePresence == .optional)+ state.seed(from: arm, in: parsed)+ #expect(+ state.sequencePresence == .required,+ "\(String(describing: arm)) must not keep a stale declaration")+ }+ }++ /// A stale declaration must not survive the template it was declared on, so+ /// presence resets wherever the retained template does.+ @Test("Every retained-template-clearing gesture resets presence")+ func clearingGesturesResetPresence() throws {+ let parsed = try Self.components("https://example.test/Story-1-2/Story-1-2/tail")+ let seeded = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate)++ // A different component, compared by index — the two here share text.+ var byComponent = State()+ byComponent.seed(from: seeded, in: parsed)+ byComponent.select(.path(1))+ #expect(byComponent.sequencePresence == .required)+ #expect(byComponent.retainedTemplate == nil)++ var byWholeComponent = State()+ byWholeComponent.seed(from: seeded, in: parsed)+ byWholeComponent.useWholeComponent()+ #expect(byWholeComponent.sequencePresence == .required)++ var bySequence = State()+ bySequence.seed(from: seeded, in: parsed)+ bySequence.activeSlot = .sequence+ bySequence.select(.path(2))+ #expect(bySequence.sequencePresence == .required)++ var byClear = State()+ byClear.seed(from: seeded, in: parsed)+ byClear.clear()+ #expect(byClear.sequencePresence == .required)+ }++ @Test("Re-selecting the same component keeps the declaration with the template")+ func sameComponentKeepsPresence() throws {+ let parsed = try Self.components(Self.tthURL)+ var state = State()+ state.seed(+ from: .combined(+ locator: .pathBracketed(+ left: .start,+ right: .literal(+ ExactScalarString("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))),+ template: Self.tthOptionalTemplate),+ in: parsed)+ state.select(.path(0))+ #expect(state.sequencePresence == .optional)+ #expect(+ state.rule(in: parsed).definition+ == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate))+ }++ /// The retained-template branch. Reopening a taught site and toggling is the+ /// whole gesture: no split re-authoring is needed (Q23).+ @Test("The retained-template branch emits the state's presence")+ func retainedBranchCarriesPresence() throws {+ let parsed = try Self.components(Self.tthURL)+ var state = State()+ state.seed(from: Self.tthStoredDefinition, in: parsed)+ state.setSequencePresence(.optional)+ // The locator is rebuilt from the selection, as every dispatch does; only+ // the template is retained.+ #expect(+ state.rule(in: parsed).definition+ == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate))+ // And back off again: `.required` has one representation, so the template+ // is byte-for-byte the stored one (Req 2.3).+ #expect(+ state.rule(in: parsed).definition+ != .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthTemplate))+ state.setSequencePresence(.required)+ #expect(+ state.rule(in: parsed).definition+ == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthTemplate))+ }++ /// The live-split branch re-derives on every dispatch, which is exactly why a+ /// one-shot template rewrite cannot work.+ @Test("The live-split branch survives a token tap with the declaration intact")+ func liveSplitBranchCarriesPresence() throws {+ let parsed = try Self.components(Self.tthURL)+ let text = "Story-30975-1"+ var state = State()+ state.select(.path(0))+ state.beginSplit(of: text)+ state.setSequencePresence(.optional)+ let first = state.rule(in: parsed).definition+ #expect(first == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: Self.tthOptionalTemplate))++ // A token tap re-derives the template; the declaration must not be lost.+ let tokens = ComposedTeachingPresentation.tokenRanges(in: text)+ state.toggleSplitToken(at: tokens.count - 1, tokens: tokens)+ state.toggleSplitToken(at: tokens.count - 1, tokens: tokens)+ guard case .combined(_, let template)? = state.rule(in: parsed).definition else {+ Issue.record("expected a combined rule after the token taps")+ return+ }+ #expect(template.sequencePresence == .optional)+ }++ /// The toggle must never narrow the rule to `.work` — that is the downgrade+ /// which moved 40 captures off their version-2 keys.+ @Test("No path from the toggle publishes a work-only rule")+ func toggleNeverPublishesWorkOnly() throws {+ let parsed = try Self.components(Self.tthURL)+ for presence in [URLSequencePresence.optional, .required, .optional] {+ var state = State()+ state.seed(from: Self.tthStoredDefinition, in: parsed)+ state.setSequencePresence(presence)+ guard case .combined? = state.rule(in: parsed).definition else {+ Issue.record("presence \(presence) published a non-combined rule")+ return+ }+ }+ }++ // MARK: - The gate (Req 1.10's teaching-surface half, Decision 5/Q29)++ @Test("The gate refuses a template with neither prefix nor suffix")+ func gateRefusesUnboundedTemplate() throws {+ // `/28614/28614-105/`: splitting the second component authors an+ // unaffixed template, which `validate` refuses under `.optional`.+ let parsed = try Self.components("https://example.test/28614-105/tail")+ var state = State()+ state.select(.path(0))+ state.beginSplit(of: "28614-105")+ _ = state.rule(in: parsed)+ #expect(state.retainedTemplate?.prefix.value == "")+ #expect(state.retainedTemplate?.suffix.value == "")+ #expect(!state.canDeclareSequenceOptional(in: parsed))+ }++ /// Q29: a whitespace-only affix is a near-vacuous bound, and `validate`+ /// already uses blank semantics for the separator.+ @Test("A whitespace-only affix is no bound either")+ func gateRefusesWhitespaceAffixes() throws {+ let parsed = try Self.components("https://example.test/a")+ var state = State()+ state.seed(+ from: .combined(+ locator: .pathBracketed(left: .start, right: .end),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString(" "), separator: ExactScalarString("-"),+ suffix: ExactScalarString(" "), order: .workThenSequence)),+ in: parsed)+ #expect(!state.canDeclareSequenceOptional(in: parsed))+ }++ /// The gate is checked once, at the moment of the tap; the split can be+ /// re-adjusted afterwards. Moving the Work span to the start of the component+ /// and leaving the sequence span at its end produces a template with neither+ /// a prefix nor a suffix — the exact shape Req 1.10 refuses — and+ /// `toggleSplitToken` has no reason to know a declaration is standing. Before+ /// this, the declaration was stamped onto the unbounded template and+ /// dispatched without `validate`, so the reader met `invalidURLDefinition` at+ /// projection time with the toggle rendered disabled-while-on.+ @Test("Re-splitting into an unbounded template drops the declaration with it")+ func reSplitToUnboundedDropsDeclaration() throws {+ let parsed = try Self.components("https://example.test/Story-28614-105/tail")+ let text = "Story-28614-105"+ var state = State()+ state.select(.path(0))+ state.beginSplit(of: text)++ // The derived template is bounded (`Story-`), so the gate permits the+ // declaration and the reader makes it.+ #expect(state.canDeclareSequenceOptional(in: parsed))+ state.setSequencePresence(.optional)+ guard case .combined(_, let declared)? = state.rule(in: parsed).definition else {+ Issue.record("the bounded split must publish a combined rule")+ return+ }+ #expect(declared.prefix.value == "Story-")+ #expect(declared.sequencePresence == .optional)++ // The reader then re-splits: Work becomes `Story`, the sequence stays+ // `105`, and the affixes vanish into the separator `-28614-`.+ let tokens = ComposedTeachingPresentation.tokenRanges(in: text)+ state.toggleSplitToken(at: 0, tokens: tokens)+ state.toggleSplitToken(at: 1, tokens: tokens)+ let outcome = state.rule(in: parsed)+ guard case .combined(let locator, let template)? = outcome.definition else {+ Issue.record("expected a combined rule, got \(String(describing: outcome.definition))")+ return+ }+ #expect(template.prefix.value == "")+ #expect(template.suffix.value == "")+ #expect(template.separator.value == "-28614-")+ // The declaration died with the template shape it was declared on…+ #expect(template.sequencePresence == .required)+ // …in the state itself, so the toggle reads off rather than+ // disabled-while-on.+ #expect(state.sequencePresence == .required)+ #expect(!state.canDeclareSequenceOptional(in: parsed))+ // And what dispatch publishes is what `validate` accepts — the gate and+ // the commit path cannot disagree.+ try URLRuleDefinition.combined(locator: locator, template: template)+ .validate(origin: .readerTaught, isCurrent: true)+ }++ @Test("The gate admits the motivating template, and nothing else opens it")+ func gateAdmitsBoundedTemplate() throws {+ let parsed = try Self.components(Self.tthURL)+ var state = State()+ state.seed(from: Self.tthStoredDefinition, in: parsed)+ #expect(state.canDeclareSequenceOptional(in: parsed))++ // No combined rule in force: nothing to declare optional.+ state.useWholeComponent()+ #expect(!state.canDeclareSequenceOptional(in: parsed))++ var empty = State()+ #expect(!empty.canDeclareSequenceOptional(in: parsed))+ empty.activeSlot = .sequence+ empty.select(.path(1))+ #expect(!empty.canDeclareSequenceOptional(in: parsed))+ } }
diff --git a/Asterism/AsterismTests/OptionalSequenceThroughEditorTests.swift b/Asterism/AsterismTests/OptionalSequenceThroughEditorTests.swiftnew file mode 100644index 0000000..d7fa2c6--- /dev/null+++ b/Asterism/AsterismTests/OptionalSequenceThroughEditorTests.swift@@ -0,0 +1,291 @@+import AsterismCore+import Foundation+import SwiftData+import Testing++@testable import Asterism++/// `optional-chapter-sequence` tasks 7 and 9, driven through the editor's own+/// gesture path over a real V4 library.+///+/// Driving the gestures is the point, as it was for `url-locator-generalisation`:+/// a test that hands the repository a hand-built `.combined(… .optional)` passes+/// while the shipped toggle publishes something else — and the failure mode the+/// spec fears is precisely a toggle that downgrades the rule to `.work` and+/// re-keys the whole site.+@Suite("Optional chapter sequence through the editor", .serialized)+@MainActor+struct OptionalSequenceThroughEditorTests {++ private typealias EditorState = ComposedTeachingPresentation.URLEditorState++ private static let hostname = "www.tthfanfic.org"++ /// The rule the site holds after `url-locator-generalisation`'s re-anchor:+ /// the unanchored right side, with the `Story-`/`-` template retained (Q22).+ private static let storedTemplate = URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence)++ private static let stored = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored), template: storedTemplate)++ /// The site's first chapter, served without the chapter indicator. Every URL+ /// in `ArchiveCorpus` carries a separator, so this one is added by the test —+ /// no such capture exists in the real library either (requirements, "Current+ /// Behaviour").+ private static let chapterOneURL =+ "https://www.tthfanfic.org/Story-30975/DianeCastle+The+Secret+Return+of+Alex+Mack.htm"++ /// Chapters 2–6. Chapter 1's *separated* spelling is deliberately left out:+ /// under the declaration `Story-30975` and `Story-30975-1` derive one identity+ /// key, which Decision 7 records as correct and which would make this fixture+ /// about re-share instead of about the declaration.+ private static var siblingCaptures: [ArchiveCorpus.Capture] {+ Array(ArchiveCorpus.tthCaptures[1...5])+ }++ @Test("Toggling the declaration republishes .combined and resolves chapter 1")+ func toggleResolvesTheSeparatorFreeCapture() async throws {+ var fixture = try PresenceFixture()+ defer { fixture.cleanup() }+ try await fixture.seedAndTeach(Self.stored, captures: Self.siblingCaptures)+ let chapterOneID = try await fixture.capture(+ ArchiveCorpus.Capture(title: ArchiveCorpus.tthStoryTitle, url: Self.chapterOneURL)).id++ // The pre-state: chapter 1 sits in the story's Work by title, with no URL+ // identity of its own and a conservative key.+ let before = try fixture.identityKeys()+ #expect(try fixture.entriesWithoutURLIdentity() == [Self.chapterOneURL])+ #expect(before[chapterOneID]?.version == 1)+ #expect(before[chapterOneID]?.key == Self.chapterOneURL)+ #expect(try fixture.workIDs().count == 1)++ // The reader reopens the site from one of the siblings.+ let model = try await fixture.viewModel(for: fixture.entryIDs[0])+ await model.load()+ try await Self.waitForPreview(model)+ let seeded = try #require(model.urlRuleDefinition)+ #expect(seeded == Self.stored)++ // The gesture: seed, then flip the toggle. No split re-authoring (Q23).+ let components = try RawURLRuleParser.parse(+ ExactScalarString(Self.siblingCaptures[0].url))+ var editor = EditorState()+ editor.seed(from: seeded, in: components)+ #expect(editor.sequencePresence == .required)+ #expect(editor.canDeclareSequenceOptional(in: components))+ editor.setSequencePresence(.optional)+ let authored = try #require(editor.rule(in: components).definition)+ #expect(+ authored+ == .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: .optional)))++ model.setURLRuleDefinition(authored)+ try await Self.waitForPreview(model)+ let preview = try #require(model.previewOutcome)++ // Req 2.6: the separator-free capture resolves, with the sequence derived+ // rather than read, and its Work identity shown.+ #expect(model.unresolvedURLCaptures.isEmpty, "\(model.unresolvedURLCaptures)")+ let chapterOneRow = try #require(preview.entries.first { $0.entryID == chapterOneID })+ #expect(chapterOneRow.derivedWorkIdentity == "30975")+ #expect(chapterOneRow.projectedChapterSequence == "1")+ #expect(model.sequenceIsDerived(for: chapterOneID))+ #expect(!model.sequenceIsDerived(for: fixture.entryIDs[0]))+ #expect(model.previewRows.contains { $0.entryID == chapterOneID })++ // Amended Req 3.4: the one capture the declaration newly resolves moves+ // from its conservative key to the identity-sequence shape, and the move+ // is reported through the existing notice before the commit.+ #expect(model.identityKeyChanges.map(\.entryID) == [chapterOneID])+ #expect(model.identityKeyChanges.first?.fromVersion == 1)+ #expect(model.identityKeyChanges.first?.toVersion == 2)+ #expect(model.identityKeyChangeNotice != nil)+ #expect(try fixture.identityKeys() == before, "nothing has moved yet")++ if model.requiresUnsettledAcknowledgment {+ await model.acknowledgeAndConfirm()+ } else {+ await model.confirm()+ }+ #expect(model.state == .committed, "\(model.state)")++ // Reqs 3.1, 3.3: one Work, every capture in it, chapter 1 keyed like its+ // siblings and no longer identity-less.+ #expect(try fixture.entriesWithoutURLIdentity().isEmpty)+ let after = try fixture.identityKeys()+ #expect(after[chapterOneID]?.version == 2)+ for id in fixture.entryIDs where id != chapterOneID {+ #expect(after[id] == before[id], "a sibling's key changed")+ }+ #expect(try fixture.workIDs().count == 1)+ }++ /// Req 2.8, through the surface rather than the state alone: the declaration+ /// is still shown in force when the site is reopened.+ @Test("Reopening a declared site seeds the declaration back into the editor")+ func reopeningShowsTheDeclarationInForce() async throws {+ var fixture = try PresenceFixture()+ defer { fixture.cleanup() }+ let declared = URLRuleDefinition.combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: .optional))+ try await fixture.seedAndTeach(declared, captures: Self.siblingCaptures)++ let model = try await fixture.viewModel(for: fixture.entryIDs[0])+ await model.load()+ try await Self.waitForPreview(model)+ let seeded = try #require(model.urlRuleDefinition)+ #expect(seeded == declared)++ let components = try RawURLRuleParser.parse(+ ExactScalarString(Self.siblingCaptures[0].url))+ var editor = EditorState()+ editor.seed(from: seeded, in: components)+ #expect(editor.sequencePresence == .optional)+ // And re-publishing without touching the toggle is a no-op, not a new+ // rule version (Req 2.2's "indistinguishable" half).+ #expect(editor.rule(in: components).definition == declared)+ }++ // MARK: - Helpers++ private static func waitForPreview(+ _ model: ComposedTeachingViewModel, sourceLocation: SourceLocation = #_sourceLocation+ ) async throws {+ for _ in 0..<1_000 {+ if model.state == .previewReady, model.previewGeneration == model.generation { return }+ if model.state == .error {+ Issue.record(+ "preview failed — \(model.errorMessage ?? "no message")",+ sourceLocation: sourceLocation)+ return+ }+ try await Task.sleep(for: .milliseconds(10))+ }+ Issue.record("preview never became ready (\(model.state))", sourceLocation: sourceLocation)+ }+}++// MARK: - Fixture++private enum PresenceFixtureError: Error, CustomStringConvertible {+ case teachRefused(String)+ case captureRefused(String)++ var description: String {+ switch self {+ case .teachRefused(let reason): "the rule did not commit: \(reason)"+ case .captureRefused(let reason): "a seeded capture did not commit: \(reason)"+ }+ }+}++/// A real V4 library over a temporary directory, brought to the realistic+/// pre-state by the ordinary routes — capture, teach, go on capturing. Modelled+/// on `URLRepairThroughEditorTests`' `RepairFixture`, and for the same reason:+/// seeding every capture *before* the teach batches Works differently from the+/// way the library actually got here and manufactures a phantom collision.+@MainActor+private struct PresenceFixture {+ struct KeyState: Equatable {+ let key: String+ let version: Int+ }++ let hostname = "www.tthfanfic.org"+ let directory: URL+ let container: ModelContainer+ let repository: LibraryRepository+ private(set) var entryIDs: [UUID] = []++ init() throws {+ directory = FileManager.default.temporaryDirectory+ .appending(path: "AsterismOptionalSequence-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ let configuration = LibraryConfiguration(rootDirectory: directory)+ try FileManager.default.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true)+ container = try LibraryRepository.openContainer(at: configuration.storeURL)+ repository = LibraryRepository.makeRepository(+ configuration, container, .m4, FixedPresenceClock(), ModelContextSaveStrategy())+ }++ func cleanup() { try? FileManager.default.removeItem(at: directory) }++ mutating func seedAndTeach(+ _ definition: URLRuleDefinition, captures: [ArchiveCorpus.Capture]+ ) async throws {+ var ids: [UUID] = []+ ids.append(try await capture(captures[0]).id)++ let request = ComposedTeachingRequest(+ titleDefinition: .wholeTitle, urlDefinition: definition, acknowledgeUnsettled: true)+ let contract = try await repository.projectComposedTeaching(+ hostname: hostname, request: request)+ let outcome = try await repository.commitComposedTeaching(contract)+ guard case .committed = outcome else {+ throw PresenceFixtureError.teachRefused(String(describing: outcome))+ }++ for capture in captures.dropFirst() {+ ids.append(try await self.capture(capture).id)+ }+ entryIDs = ids+ }++ /// The contract capture path, not `capture(_:)` — the convenience applies no+ /// rules (sibling Q20).+ @discardableResult+ func capture(_ capture: ArchiveCorpus.Capture) async throws -> EntrySnapshot {+ let contract = try await repository.projectCapture(+ hostname: hostname, captureTitle: capture.title, captureTitleSource: .host,+ rawURLString: capture.url, canonicalURLString: nil, note: "", rating: nil)+ let outcome = try await repository.commitCapture(contract)+ guard case .committed(let snapshot) = outcome else {+ throw PresenceFixtureError.captureRefused(String(describing: outcome))+ }+ return snapshot+ }++ func viewModel(for entryID: UUID) async throws -> ComposedTeachingViewModel {+ ComposedTeachingViewModel(+ entry: try await repository.entry(id: entryID), library: repository,+ capabilities: .m4, entryContext: .urlFocused)+ }++ func identityKeys() throws -> [UUID: KeyState] {+ let context = ModelContext(container)+ return Dictionary(+ uniqueKeysWithValues: try context.fetch(FetchDescriptor<Entry>()).map {+ ($0.id, KeyState(key: $0.entryIdentityKey, version: $0.identityKeyVersion))+ })+ }++ func entriesWithoutURLIdentity() throws -> [String] {+ let context = ModelContext(container)+ return try context.fetch(FetchDescriptor<Entry>())+ .filter { $0.urlWorkIdentity == nil }+ .map(\.rawURLString)+ }++ func workIDs() throws -> [UUID] {+ let context = ModelContext(container)+ return try context.fetch(FetchDescriptor<Work>()).map(\.id)+ }+}++private final class FixedPresenceClock: RepositoryClock, @unchecked Sendable {+ private let value = Date(timeIntervalSince1970: 1_800_000_000)+ func now() -> Date { MillisecondInstant.quantize(value) }+}
diff --git a/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift b/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swiftindex 2838635..6f08a2e 100644--- a/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift+++ b/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift@@ -113,6 +113,33 @@ struct ComposedURLRuleDescriptionTests { #expect(combined.contains(phrase)) #expect(combined.lowercased().contains("chapter")) }++ /// Req 2.7: the tolerance is stated in the same plain-language summary that+ /// describes every other rule form, not in a badge beside it.+ @Test("A declared-optional combined rule states the tolerance in the same register")+ func optionalCombinedRuleStatesTheTolerance() {+ let locator = URLComponentLocator.pathBracketed(left: .start, right: .unanchored)+ func combined(_ presence: URLSequencePresence) -> String {+ ComposedTeachingViewModel.describe(+ .combined(+ locator: locator,+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+ suffix: ExactScalarString(""), order: .workThenSequence,+ sequencePresence: presence)))+ }+ let required = combined(.required)+ let optional = combined(.optional)++ // Req 2.2: an undeclared rule reads exactly as it did before the feature.+ #expect(required == "The Work and the chapter number are split out of \(Self.describe(locator)).")+ #expect(!required.contains("absent"))++ // The declaration is one added clause on the same sentence.+ #expect(optional.hasPrefix(String(required.dropLast())))+ #expect(optional.contains("the chapter part may be absent"))+ #expect(optional.hasSuffix("."))+ } } /// The two slots the description occupies (Req 2.2): one beside the candidate,
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftindex 94b4d57..0013a10 100644--- a/Asterism/AsterismTests/Helpers/TestFixtures.swift+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -17,16 +17,20 @@ enum TestFixtures { modifiedAt: Date = fixedDate, workID: UUID? = nil, chapterTitle: String? = nil,- chapterSequence: String? = nil+ chapterSequence: String? = nil,+ /// The example URL a teaching surface authors against. Defaults to the+ /// hostname's placeholder page, which is all most fixtures need.+ rawURLString: String? = nil ) -> EntrySnapshot {- EntrySnapshot(+ let rawURL = rawURLString ?? "https://\(hostname)/page"+ return EntrySnapshot( id: id, captureTitle: captureTitle, captureTitleSource: .host,- rawURLString: "https://\(hostname)/page",+ rawURLString: rawURL, canonicalURLString: nil, hostname: hostname,- entryIdentityKey: "https://\(hostname)/page",+ entryIdentityKey: rawURL, identityKeyVersion: 1, chapterTitle: chapterTitle, chapterTitleProvenance: try! FieldProvenance(kind: .none),
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 162e51f..d714ab6 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -36,6 +36,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- The optional-chapter-sequence feature is complete and pinned end to end by integration tests over a real seeded library (End to end phase, `specs/optional-chapter-sequence/`). A separator-free capture under a declared-optional rule derives the story's Work identity and chapter 1, receives the same identity-key shape as its siblings — so a re-share differing only by fragment, trailing slash, or query noise edits the existing Entry instead of duplicating it — settles its chapter with no chapter title, and cites the rule and version as the identity's source. Teaching the declaration over the realistic pre-state re-derives the site onto one Work, reports the chapter-1 re-key before commit while every sibling's key stays byte-identical, and a later chapter-1-shaped capture attaches cleanly; protected entries and manually set fields survive; the synthetic split pre-state is reported as a Work collision and left for Work merge rather than silently reunited; and removing the declaration re-derives to the documented split post-state, confirming the removal warning describes exactly what happens. One spec correction fell out: a bare-host (`www.`-less) re-share was never collapsible — the hostname is part of the identity key and selects the Site — and chapter 1 behaves identically to its siblings there, which is the parity that actually matters (Q32).+- Teaching a combined URL rule now offers the declaration itself (Teaching surface phase, `specs/optional-chapter-sequence/`). The rule details gain a toggle stating that the chapter part may be absent, enabled whenever a combined rule is in force and disabled where the rule would be unbounded — with neither a prefix nor a suffix, every separator-free value in the component would become a Work identity, so the control refuses rather than letting the commit fail later. The declaration is editor state: it survives re-splitting the same component, seeds from the stored rule on reopen so a taught declaration shows as still in force, and resets with the template it was declared on for every gesture that discards that template. The plain-language rule summary states the tolerance ("; the chapter part may be absent"); a capture whose URL cannot express this rule form says so instead of failing silently; and removing the declaration warns that captures without the chapter part will stop resolving a Work identity and that removal re-derives the site rather than restoring the old grouping. The commit preview now shows, for a capture whose URL has no chapter part, the Work identity it derives and its sequence marked as derived rather than read from the URL; at least one such capture is always shown past the six-row cap; and a capture whose Work attachment would change shows the move — detected by Work identity, so a move between two same-named Works still reports.+- Duplicate rows of one URL rule are now reconciled by comparing what the rule *means* rather than the exact bytes it was stored as (Comparison and archive phase, `specs/optional-chapter-sequence/`). Two rows carrying one rule under different JSON key layouts — or differing only in a key the encoder writes conditionally — no longer copy bytes onto each other on every pass, which was a churned CloudKit record for a semantic no-op. A pair where one row's stored rule cannot be read is left alone entirely rather than overwritten in either direction, so a readable rule is never replaced by unreadable bytes and the standing diagnosis naming the broken row stays visible; re-teaching the site repairs it. Archive compatibility for the optional-sequence declaration is now pinned by tests: a backup written before the feature imports unchanged, a backup in which no rule declares optionality is byte-identical to what a pre-feature build writes, and a declared-optional rule survives export and re-import.+- A combined URL rule's template can now carry a declared-optional chapter sequence at the parsing layer (Template and applicator phase, `specs/optional-chapter-sequence/`). A rule so declared reads a separator-free component as the whole interior for the Work identity with the sequence derived as `1` — the site's own spelling of its first chapter (Decision 7) — while a rule without the declaration behaves exactly as before, rejection included. The declaration must be bounded by a prefix or suffix, or the rule is refused outright: unbounded, every separator-free value in the component would become a Work identity. Nothing reader-facing changes yet — the teaching toggle, preview, and grouping work are later phases — and an archive in which no rule declares optionality stays byte-identical to what a pre-feature build writes, so it still imports there. - The pinned-site repair is regression-tested through the editor itself (Verify the repair phase, `specs/url-locator-generalisation/`). Each pinned site is rebuilt to its real shape — the same hosts, capture counts, and pinned rules — and repaired in tests by the reader's actual gestures: seed from the stored rule, tap the component chip, commit what the editor authors. The tests pin that each repaired site resolves every capture, that not one identity key changes (including `www.tthfanfic.org`'s 40 version-2 keys, whose taught template survives the re-anchor), that the repair versions the rule rather than being dismissed as a no-op, and that re-teaching `m.fanfiction.net` work-only reports its one capture's key change before anything is committed. A Core-level test that hand-builds the corrected rule would have passed while the editor still dropped the template — driving the gesture path is the point. - A taught URL rule can now leave a side open, so one rule works for every story on a site (The unanchored side phase, `specs/url-locator-generalisation/`). Until now a path rule had to name both neighbours of the component it selects, and on most sites the right-hand neighbour is the story's own slug or filename — so every rule taught this way silently matched only the story it was taught from. A side can now be unanchored: "the component after `series`, whatever follows it." Teaching uses this by default — the right side is left open unless the selected component is genuinely last, where the more precise "at the end of the path" is kept — and a rule with *both* sides open is refused outright, since it would appear to work and then not. While teaching, the editor now describes the candidate rule in plain language naming its actual anchors, so a rule pinned to one story's filename is visible before you save it, and the stored rule's description stays on screen during re-teaching for comparison. The commit preview names each capture the new rule does not resolve — individually, not as a count, and no longer capped at six rows — and warns before commit when a rule that drops a chapter sequence will change captures' identity keys. Re-anchoring a component that splits work and chapter out of one piece of the URL no longer silently discards the split: the repair gesture keeps the taught template, so fixing a pinned site does not re-key its captures. Existing rules are untouched — every locator taught before this behaves exactly as it did, byte-for-byte, in the store, in archives, and in sync. - The record matches the code, and a test keeps it that way (Final phase, `specs/retire-migration-chain/`). The migration performance suite is retired with the pass it measured, and `make test-performance-m4` sheds its dominant cost — a fresh run completes in ~20 minutes and exits green, which also refuted the standing "knowingly red" description of the target: that note had been stale since before this feature, and the docs now say what a run actually produces. Every identifier that carried a schema version it did not describe is renamed — the validator, the certification types, the container openers, the bootstrap file itself, and the marker accessors, which now say what they are (`readinessMarker`, `historicalMarker`) instead of which numbered file they resolve — and the conventions test that froze the on-disk paths in phase 1 now also enforces the naming rule, the one-opener-per-role shape, the frozen persisted strings, and that no spec document presents removed code as current. The `relational-references` spec's migration requirement is formally withdrawn except its two surviving clauses, with the superseding decision recorded rather than history rewritten. Everything closes with the full verification bar: both test suites green, the pre-change graph baseline matching, and the schema surface stating exactly one version per name. The one step that remains is the owner's: installing the result on the phone.
diff --git a/specs/optional-chapter-sequence/decision_log.md b/specs/optional-chapter-sequence/decision_log.mdindex ec594cf..97c1aad 100644--- a/specs/optional-chapter-sequence/decision_log.md+++ b/specs/optional-chapter-sequence/decision_log.md@@ -32,6 +32,10 @@ | Q26 | 2026-08-09 | `DuplicateReconciler`'s decoded-definition comparison (Q21) skips the byte copy for mixed readable/unreadable pairs; two undecodable rows compare by bytes | Definitions can now fail to decode (`url-locator-generalisation`'s throwing accessor), and in `convergeURLRuleGroup` *inequality triggers the copy* — so a naive unequal-compare on a mixed pair would copy the representative's bytes over the other row, and `GroupOrdering` picks the representative with no readability preference: that can overwrite readable bytes with unreadable ones. When either side fails to decode, no copy happens; the standing `unreadableURLRule` diagnosis names the row and re-teaching repairs it | | Q27 | 2026-08-09 | Reference drift from the two landed features is annotated, not rewritten, in accepted entries | Decision 1's "five exhaustive switches" enumeration predates PR #16/#17: `ComposedTeachingViewModel.swift:909` no longer switches over `URLRuleDefinition`, and `describe(_:)` and `URLEditorState.seed` are new switches — the design's pattern-extension audit is the current source of truth for consumer sites. Line references inside accepted decision entries are historical; the design carries current ones | | Q28 | 2026-08-09 | Requirement 4 is slimmed: the `reconcileWorkDispositions` pass is deferred, Decision 3 superseded | Owner's call on review: no chapter-1 capture exists (the archive proves it), the split pre-state cannot arise on a site once the declaration is taught, and the chain that forms it (teach without declaration → capture chapter 1 → intervening re-derivation → declare) is guarded by a visible signal — the planner already emits `.workCollision` for the split pre-state — with Work merge as the repair. The pass was the most delicate new code in the spec, guarding a state with no instance. 4.1/4.2/4.5/4.6/4.7 stay as existing-behaviour assertions; 4.3 reports-and-merges; 4.4 withdrawn; 4.8 keeps the report-don't-silently-leave contract. Q12's synthetic split fixture shrinks to one collision-path test; Q15, Q16 and Q19 apply only if the pass is ever built |+| Q29 | 2026-08-10 | The Decision 5 guard treats a *blank* affix (empty or whitespace-only) as no bound, not just an empty one | Req [1.10](requirements.md#110) says "neither a prefix nor a suffix"; the implementation refuses `.optional` when both affixes are `isBlank`. A whitespace-only affix is a near-vacuous literal bound, and `validate` already uses blank semantics for the separator ("separator must not be blank"), so failing closed on whitespace matches the codebase's convention |+| Q30 | 2026-08-10 | Q26's skipped mixed pair is covered by *a* standing diagnosis, not always `unreadableURLRule` | Implementation found the design's claim imprecise: `unreadableURLRule` names the row where the group's rows sit on different Site rows, but where they share one Site row the covering diagnosis is `LibraryValidator`'s rule-membership clause. Both quarantine the hostname and re-teaching repairs either, so the contract Q26 rests on — the skip is never silent — holds unchanged |+| Q31 | 2026-08-10 | The unteachable-shape message (Req [2.5](requirements.md#25)) fires on two computable clauses, not the design's informal condition | The design's "no candidate separator outside the Work span" is not computable before a split exists. Implemented: a `.combined` candidate fires when its own template applied to the example component yields zero separators (so declaring the sequence optional clears it); a `.work` candidate fires when the chapter is unsourced and no split is derivable in the component at all. Consequence: on an initial teach from `Story-28614` no message shows, because the `Story`/`28614` mis-split is derivable — that hazard is neutralised by the affix gate instead (Decision 5: empty affixes disable the optional toggle), and a `.required` mis-split failing on other chapters is today's behaviour, not this feature's |+| Q32 | 2026-08-10 | Req [3.5](requirements.md#35)'s `www.` clause was never achievable and is annotated, not implemented | The hostname is embedded verbatim in the v2 identity key *and* selects the Site the rule belongs to, so a bare-host share matches no Site, applies no rule, and resolves `.new` — for chapter 1 and its separator-bearing siblings alike. The parity Req [3.3](requirements.md#33) promises holds and is what the test pins (`bareHostReShareBehavesTheSameForEveryChapter`); fragment, trailing-slash and query-noise robustness are real and tested. Making `www.` collapse would mean hostname canonicalisation across keys, Sites and lookup — a separate feature. Decision 2/Decision 7's "robust to … a `www.`" wording inherits the same correction | ---
diff --git a/specs/optional-chapter-sequence/design.md b/specs/optional-chapter-sequence/design.mdindex 2310f48..0154ddd 100644--- a/specs/optional-chapter-sequence/design.md+++ b/specs/optional-chapter-sequence/design.md@@ -428,9 +428,10 @@ is the export sort key so record order varies run to run. **Deriver** — the derived template carries the presence it was given (Req [2.10](requirements.md#210)); the reproduce check behaves identically in both states. -**Validation** — an unbounded `.optional` template is refused; a single-component-site with empty affixes but a literal-anchored locator is admitted; no existing-fixture or historical rule form becomes invalid.+**Validation** — an unbounded `.optional` template is refused whatever its+locator (the guard is locator-blind, Decision 5); a template carrying either+affix is admitted under any locator; no existing fixture or historical rule form+becomes invalid. **Composed derivation** — a separator-free capture under `.optional` yields the Work identity, sequence `1`, an `.identitySequence` key of the same shape its siblings get, and
diff --git a/specs/optional-chapter-sequence/requirements.md b/specs/optional-chapter-sequence/requirements.mdindex 506718e..8b06af9 100644--- a/specs/optional-chapter-sequence/requirements.md+++ b/specs/optional-chapter-sequence/requirements.md@@ -151,7 +151,7 @@ surface must show which Work name a chapter-less title yields before commit. 2. <a name="3.2"></a>WHERE the title rule resolves no Work name, WHEN a capture's URL yields a Work identity and no sequence, THEN the system SHALL leave the Entry unattached and actionable rather than creating a Work named from the URL identity 3. <a name="3.3"></a>WHEN a capture yields a Work identity and a sequence derived per [1.8](#18), THEN the system SHALL assign the Entry the same identity-sequence key shape its siblings receive, so every capture of one story shares one key shape 4. <a name="3.4"></a>No Entry's identity key SHALL change on any path this feature adds or modifies, EXCEPT a capture the declaration newly resolves — one whose URL previously yielded no URL-derived identity and now derives a Work identity and sequence moves from its conservative key to the identity-sequence shape (Decision 7, Q24) — and WHEN any such key change would occur, THEN it SHALL be reported in the commit preview before the reader commits -5. <a name="3.5"></a>WHEN a URL that yields a Work identity and a derived sequence is shared again, THEN the system SHALL edit the existing Entry under the existing re-share semantics rather than create a second Entry, including when the two shares differ only in fragment, `www.` prefix, trailing slash or query noise +5. <a name="3.5"></a>WHEN a URL that yields a Work identity and a derived sequence is shared again, THEN the system SHALL edit the existing Entry under the existing re-share semantics rather than create a second Entry, including when the two shares differ only in fragment, `www.` prefix, trailing slash or query noise *(the `www.` clause is annotated per Q32: the hostname is embedded verbatim in the identity key and selects the Site, so a bare-host share matches no Site under any rule — chapter 1 and its siblings behave identically, which is the parity [3.3](#33) promises; fragment, trailing-slash and query-noise robustness hold as written)* 6. <a name="3.6"></a>WHEN a Work identity is derived from a rule declaring an optional sequence, THEN the Entry's per-field provenance SHALL cite that URL rule and version as the source of the Work identity 7. <a name="3.7"></a>WHEN a capture's sequence is derived per [1.8](#18) and its title rule supplies no chapter title, THEN the Entry SHALL be treated as chapter-settled and SHALL NOT remain in the inbox on that account 8. <a name="3.8"></a>WHEN no Work carries the derived Work identity yet, THEN the system SHALL behave as it does for any other first capture of an unseen Work identity
diff --git a/specs/optional-chapter-sequence/tasks.md b/specs/optional-chapter-sequence/tasks.mdindex 11af434..78fa747 100644--- a/specs/optional-chapter-sequence/tasks.md+++ b/specs/optional-chapter-sequence/tasks.md@@ -8,56 +8,56 @@ references: ## Template and applicator -- [ ] 1. URLSequencePresence and the template's asymmetric Codable <!-- id:t583gma -->+- [x] 1. URLSequencePresence and the template's asymmetric Codable <!-- id:t583gma --> - Stream: 1 - Requirements: [2.2](requirements.md#2.2), [2.3](requirements.md#2.3)- - [ ] 1.1. Write failing tests for the presence property and its encoding+ - [x] 1.1. Write failing tests for the presence property and its encoding - Round-trips .optional; a payload lacking the key decodes as .required; encoding .required emits NO sequencePresence key — assert key absence in the JSON, not just re-decode equality - Sendable declared explicitly on the enum; URLTwoFieldTemplate's Sendable depends on it- - [ ] 1.2. Implement the enum, property, and custom Codable to pass+ - [x] 1.2. Implement the enum, property, and custom Codable to pass - URLIdentityTypes.swift; memberwise default .required; encode(to:) writes the key only when .optional — the asymmetry is what keeps Req 5.5's bytes identical - RuleDefinitionComparator/GroupOrdering comment corrections ride along (comment-only) -- [ ] 2. Validation guard for an unbounded optional template <!-- id:t583gmb -->+- [x] 2. Validation guard for an unbounded optional template <!-- id:t583gmb --> - Blocked-by: t583gma (URLSequencePresence and the template's asymmetric Codable) - Stream: 1 - Requirements: [1.10](requirements.md#1.10)- - [ ] 2.1. Write failing tests for the affix-keyed refusal+ - [x] 2.1. Write failing tests for the affix-keyed refusal - .optional with neither prefix nor suffix refused (URLIdentityError.invalidTemplate); the Story- prefixed motivating template admitted; every existing fixture form stays valid- - [ ] 2.2. Implement the validate guard to pass+ - [x] 2.2. Implement the validate guard to pass - validate at URLIdentityTypes.swift:168; the guard fires for .optional only — .required templates with empty affixes stay legal -- [ ] 3. Applicator presence branch and the derived sequence <!-- id:t583gmc -->+- [x] 3. Applicator presence branch and the derived sequence <!-- id:t583gmc --> - Blocked-by: t583gma (URLSequencePresence and the template's asymmetric Codable) - Stream: 1 - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9)- - [ ] 3.1. Write the failing applicator matrix and invariant tests+ - [x] 3.1. Write the failing applicator matrix and invariant tests - Matrix: separator count (0,1,2,3) x presence x field order x blank/non-blank interior x affix match/mismatch; the motivating pair Story-28614 / Story-28614-105 as rows - Invariants: presence never changes a one-separator outcome (Req 1.2); every .required rejection is also an .optional rejection except zero-separator (Reqs 1.4-1.7)- - [ ] 3.2. Implement the zero-separator branch to pass+ - [x] 3.2. Implement the zero-separator branch to pass - URLTwoFieldTemplateApplicator.apply; the zero-separator path carries its own blank-interior check rejecting blankField(.work) (Req 1.6); the derived sequence is 1 (Decision 7) -- [ ] 4. Deriver takes presence as a required parameter <!-- id:t583gmd -->+- [x] 4. Deriver takes presence as a required parameter <!-- id:t583gmd --> - Blocked-by: t583gma (URLSequencePresence and the template's asymmetric Codable) - Stream: 1 - Requirements: [2.4](requirements.md#2.4), [2.10](requirements.md#2.10)- - [ ] 4.1. Write failing tests for presence stamping and the unchanged reproduce check+ - [x] 4.1. Write failing tests for presence stamping and the unchanged reproduce check - The derived template carries the presence it was given; the reproduce check behaves identically in both states — the example URL contains the separator by definition (Req 2.4)- - [ ] 4.2. Implement the parameter and update every call site explicitly+ - [x] 4.2. Implement the parameter and update every call site explicitly - URLTwoFieldTemplateDeriver.derive(presence:) with NO default value (Req 2.10); update every call site, tests included, explicitly ## Comparison and archive -- [ ] 5. DuplicateReconciler compares definitions, not bytes <!-- id:t583gme -->+- [x] 5. DuplicateReconciler compares definitions, not bytes <!-- id:t583gme --> - Blocked-by: t583gma (URLSequencePresence and the template's asymmetric Codable) - Stream: 1 - Requirements: [6.3](requirements.md#6.3)- - [ ] 5.1. Write failing tests for canonical comparison and the mixed-pair skip+ - [x] 5.1. Write failing tests for canonical comparison and the mixed-pair skip - Two rows differing only in key order converge without a byte copy; a mixed readable/unreadable pair triggers NO copy in either direction (Q26 — inequality is what triggers the copy today); two undecodable rows compare by bytes- - [ ] 5.2. Implement the comparison change to pass+ - [x] 5.2. Implement the comparison change to pass - DuplicateReconciler.swift:342-345; canonical .sortedKeys re-encoding as GroupOrdering.canonicalDefinition does, or decoded comparison; the standing unreadableURLRule diagnosis covers skipped rows -- [ ] 6. Archive gates: byte-compatibility of both presence states <!-- id:t583gmf -->+- [x] 6. Archive gates: byte-compatibility of both presence states <!-- id:t583gmf --> - Test-only, extending BackupV4Fixtures (unanchoredRulePayload is the precedent) - A pre-feature payload with no sequencePresence key decodes without checksumMismatch (Req 5.3 — exercises the re-encode at BackupV4Codec.swift:86-97); a .required combined rule encodes with no sequencePresence key, asserted on the JSON (Req 5.5); a declared-optional rule round-trips (Req 5.4) - Reqs 5.1/5.2/5.7 are structural (no new versions; definitionData mirrors as bytes); Req 5.6's older-build refusal is Decision 1's recorded consequence, untestable here@@ -67,43 +67,43 @@ references: ## Teaching surface -- [ ] 7. Editor state carries sequence presence <!-- id:t583gmg -->+- [x] 7. Editor state carries sequence presence <!-- id:t583gmg --> - Blocked-by: t583gma (URLSequencePresence and the template's asymmetric Codable), t583gmd (Deriver takes presence as a required parameter) - Stream: 1 - Requirements: [1.10](requirements.md#1.10), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.8](requirements.md#2.8)- - [ ] 7.1. Write failing tests for the presence state, resets, and both rule(in:) branches+ - [x] 7.1. Write failing tests for the presence state, resets, and both rule(in:) branches - sequencePresence defaults .required; seed sets it from a stored .combined template and resets for every other arm; every retained-template-clearing gesture resets it (component change by index, useWholeComponent, sequence selection, clear) - rule(in:) emits the state presence from BOTH branches; the gate exposes disabled-where-validate-would-refuse (Req 1.10 teaching-surface half) - Drive the gesture path per the RepairFixture pattern (URLRepairThroughEditorTests) where a real store is in play; assert no path from the toggle publishes .work- - [ ] 7.2. Implement setSequencePresence and the branch plumbing to pass+ - [x] 7.2. Implement setSequencePresence and the branch plumbing to pass - ComposedURLEditorState.swift, nonisolated like the type - A one-shot template rewrite is wrong: the live-split branch re-derives on every dispatch and would clobber it (design Teaching UI) -- [ ] 8. Toggle rendering, rule description, and the two messages <!-- id:t583gmh -->+- [x] 8. Toggle rendering, rule description, and the two messages <!-- id:t583gmh --> - Blocked-by: t583gmg (Editor state carries sequence presence) - Stream: 1 - Requirements: [1.10](requirements.md#1.10), [2.5](requirements.md#2.5), [2.7](requirements.md#2.7), [2.9](requirements.md#2.9)- - [ ] 8.1. Write failing tests for the description wording and message conditions+ - [x] 8.1. Write failing tests for the description wording and message conditions - describe(.combined) with .optional states the tolerance in the same register (Req 2.7) - Unteachable-shape message evaluated from the example URL and current selection, not a stored rule (Q10 — and the Story/28614 mis-split is authorable, so the message matters) - Removal warning wording per Decision 4- - [ ] 8.2. Implement the toggle, describe arm, and messages to pass+ - [x] 8.2. Implement the toggle, describe arm, and messages to pass - Toggle in ComposedURLDetailsEditor beside the split controls, bound to the editor state, disabled per the gate; messages in ComposedTeachingViewModel -- [ ] 9. Projection's derived identity and the preview additions <!-- id:t583gmi -->+- [x] 9. Projection's derived identity and the preview additions <!-- id:t583gmi --> - Blocked-by: t583gmc (Applicator presence branch and the derived sequence) - Stream: 1 - Requirements: [2.6](requirements.md#2.6), [2.11](requirements.md#2.11), [3.4](requirements.md#3.4), [4.7](requirements.md#4.7)- - [ ] 9.1. Write failing tests for the projection field and preview rows+ - [x] 9.1. Write failing tests for the projection field and preview rows - ComposedEntryProjection gains the derived Work identity (Q20; defaulted parameter so existing fixtures compile — the previousIdentityKey precedent) - Preview: derived sequence visually marked as derived; a separator-free row flagged past the six-row cap exactly as unresolved/re-keyed rows are; before/after Work resolved per the step-1 rule (projectedWorkID is nil for no-projected-change)- - [ ] 9.2. Implement the field and derivePreviewReport additions to pass+ - [x] 9.2. Implement the field and derivePreviewReport additions to pass - Field populated in the projection planner; preview additions in derivePreviewReport and the row view - The chapter-1 conservative-to-identitySequence re-key must surface through the existing identityKeyChanges notice (amended Req 3.4) ## End to end -- [ ] 10. Derivation, identity keys, and re-share integration tests <!-- id:t583gmj -->+- [x] 10. Derivation, identity keys, and re-share integration tests <!-- id:t583gmj --> - Core integration tests, no new production code expected - Seed capture-teach-capture through projectCapture/commitCapture (sibling Q20 — capture(_:) applies no rules) - Separator-free capture under .optional: Work identity + sequence 1 + .identitySequence key shape identical to siblings + chapter-settled with no chapter title (Reqs 3.3/3.7, 1.8); provenance cites rule and version (3.6); unseen identity behaves as any first capture (3.8); title-rule gate (3.1/3.2)@@ -112,7 +112,7 @@ references: - Stream: 1 - Requirements: [1.8](requirements.md#1.8), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8) -- [ ] 11. Teaching-commit integration tests over the realistic and split pre-states <!-- id:t583gmk -->+- [x] 11. Teaching-commit integration tests over the realistic and split pre-states <!-- id:t583gmk --> - Realistic pre-state: one Work holds the story including identity-less chapter-1 captures; after the declaration teach: all attached, rule cited, exactly one current rule (Req 6.1), and a subsequent chapter-1-shaped capture attaches rather than resolving ambiguous - Split pre-state (synthetic, one test): the planner .workCollision appears in the preview and the commit neither silently reunites nor clears (Q28, Reqs 4.3/4.8) - Removal path (Decision 4, Req 2.9 behaviour half): turning the declaration off re-derives to the documented post-state — chapter-1 captures lose identity and detach — not a restoration of the prior grouping
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 3bd155f..b5906cf 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -12,7 +12,7 @@ | [Configuration Identity](#configuration-identity) | 2026-07-29 | Done | Single-sources the App Group and CloudKit container identifiers: one identity token per configuration, everything derived, divergence fails the build or the lint. Deletes `LibraryEnvironment`. Prerequisite for the CloudKit Mirroring Development flip (T-1982). | | [Duplicate Reconciliation](#duplicate-reconciliation) | 2026-08-01 | Done — all 23 tasks complete 2026-08-03. **One budget breached, recorded and host-only.** Req 10.1's 2 s settling pass measures 7.264–7.365 s after the pre-push review fixes, down from 8.861–9.080 s once Decision 29 chunked the deletion saves — the transaction count turned out to be ~18% of the pass, not the whole of it, so Decision 27 stands with the remaining cost unattributed (T-2093). `reconcile-noop-coherent`, which Decision 28 recorded at 0.286–0.298 s, is back to 1.82–2.00 ms now that Decision 30 gates the full tier; T-2092 is answered and the known issue removed. Req 10.2's two named baselines both improved, and `recentPresentation` is back to its pre-M4c band. Nothing measured on device | Phase 3 of the M4 split: duplicate Entry/Work/rule sets resolve silently where nothing reader-authored is at stake (collapse for distinct UUIDs, convergence for identity groups — rows sharing a UUID are never split), divergent sets reach a reader resolution sheet or Merge, and export projects groups instead of refusing on them. | | [Polish & Export](#polish--export) | 2026-08-03 | Done | M5, the final v1 milestone: markdown export, search, the §6 Work-detail shape, the Sites settings screen (including the articles exit), work deletion, and the Constellation visual pass. |-| [Optional Chapter Sequence](#optional-chapter-sequence) | 2026-08-06 | Planned — spec revised against the landed siblings, tasks defined, ready to implement | Lets a combined URL rule declare its chapter sequence optional, so `tthfanfic.org`'s `/Story-28614/` and `/Story-28614-105/` resolve to one Work identity. A declared-optional rule reads the missing indicator as chapter 1 (Decision 7), which also gives that capture its siblings' identity-key shape. The reconciliation pass originally in scope is deferred (Q28) — the planner's existing collision issue plus Work merge covers the split pre-state, which has no instance. |+| [Optional Chapter Sequence](#optional-chapter-sequence) | 2026-08-06 | Done — all 11 tasks complete 2026-08-10; `make test-core` and `make test-quick` pass. Req 3.5's `www.` clause annotated as never-achievable (Q32) | Lets a combined URL rule declare its chapter sequence optional, so `tthfanfic.org`'s `/Story-28614/` and `/Story-28614-105/` resolve to one Work identity. A declared-optional rule reads the missing indicator as chapter 1 (Decision 7), which also gives that capture its siblings' identity-key shape. The reconciliation pass originally in scope is deferred (Q28) — the planner's existing collision issue plus Work merge covers the split pre-state, which has no instance. | | [Retire Migration Chain](#retire-migration-chain) | 2026-08-06 | Done — all 25 tasks complete 2026-08-09 and the device confirmation passed the same day (`Personal` installed over the real library, opened intact). `make test-core` (1,195 tests) and `make test-quick` pass, the Req 2.15 graph baseline matches, and one `make test-performance-m4` run exits 0 in ~20 minutes (verification-run.md) | Removes the unreachable V3→V4 and V4→V5 runtime migration machinery (T-2113, T-2114): bootstrap becomes a total function over the on-disk states that remain reachable, the schema surface states one version per name, and every store opener — app, extension, and tests — shares one schema declaration and one file layout. The live library's on-disk paths are frozen and pinned by test. | | [URL Locator Generalisation](#url-locator-generalisation) | 2026-08-08 | Done — re-teaching the four sites remains (prerequisites.md) | Adds an unanchored side to a path locator, so a taught rule stops pinning itself to the story it was taught from. Every rule in the library is pinned today: `tapas.io` resolves a URL identity on 1 capture of 69, `royalroad` on 9 of 15. Ships with the fix for `URLRulePattern.definition`, which fabricates a rule from bytes it cannot decode and can bake that fabrication into a backup (Decision 5). |
The hand-written Codable on URLTwoFieldTemplate must be updated by hand for any future property — synthesis is gone. The doc comment on the type says so, but there is no compile-time enforcement; a forgotten key would decode as a default silently. Worth remembering at the next template change.
ReparseViewModelTests / "Duplicate submission is suppressed" failed once under full-suite load during the phase-3 fix run (commitReparseCallCount 2 ≠ 1), then passed in isolation and on re-run. It touches nothing in this diff; may be worth hardening separately.
The on-device verification installed the Development configuration, which since cloudkit-mirroring syncs to the dev container — any other device signed into the same iCloud account with the dev app installed shares that library.