From d72a52cc10e579ac1254aa883a56296fbf8435ee Mon Sep 17 00:00:00 2001 From: David Date: Mon, 24 Aug 2026 01:53:52 +0000 Subject: [PATCH 01/19] docs(mocking): restore MVUX mocking & previews spec after workspace loss Restored state = end of discussion (lost commits 8d589d9 -> 292fb5f -> 2618def -> cd4c9ad), merged from local recovery notes + David's VS Code copies (his architecture.md was the most recent, post-2618def). - spec.md: 3 tiers, real VM + real Model with null-injected services, derived feeds survive via Model-feed cache swap anchor, external mocking generator (consumer test project), D1-D9 decision log, open question: context-wide scope / ambient MockingService.Enable() - architecture.md: seams, swap anchor, MVUX gen (analysis + dependency attributes + hidden typed hooks) vs Mocking gen (metadata-driven {Model}Mock/Create/SetModel), tier-1 authorable plain-CLR MessageEntry (not a DependencyObject, not observable) with first-class custom axes, natural feed evolution contract, XAML examples - implementation.md: package split, attribute shapes, hidden hooks, typed vocabulary, diagnostics FEED3201-3203/MOCK0001, P0 canaries (derived-swap gate, null-inject, identity matrix, context spike), test plan, docs plan - history.md: full version/decision chronology of the spec discussion --- .../013-mvux-mocking-previews/architecture.md | 236 ++++++++++++++++++ specs/013-mvux-mocking-previews/history.md | 81 ++++++ .../implementation.md | 192 ++++++++++++++ specs/013-mvux-mocking-previews/spec.md | 111 ++++++++ 4 files changed, 620 insertions(+) create mode 100644 specs/013-mvux-mocking-previews/architecture.md create mode 100644 specs/013-mvux-mocking-previews/history.md create mode 100644 specs/013-mvux-mocking-previews/implementation.md create mode 100644 specs/013-mvux-mocking-previews/spec.md diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md new file mode 100644 index 0000000000..bec0f457c6 --- /dev/null +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -0,0 +1,236 @@ +# 013 — Architecture + +Grounded in the current tree. File refs relative to repo root. (Restored after workspace loss — line references to be re-verified against a fresh clone.) + +## 0. Existing seams reused + +| Concern | Existing type / seam | Location | +| --- | --- | --- | +| State carrier bound by templates | `MessageEntry` (public), `IMessageEntry` | `Core/MessageEntry.cs`, `Core/IMessageEntry.cs` | +| One-message-then-complete feed | `ValueFeed` (internal) | `Sources/ValueFeed.cs` | +| Runtime feed substitution | `HotSwapFeed.Set(feed)` (internal) | `Operators/HotSwapFeed.cs` | +| **Feed identity cache (per property)** | `AttachedProperty.GetOrCreate(owner/delegate, factory)` | `Core/Feed.cs` (all factories), `Core/Internal/AttachedProperty*` | +| Per-state swap seam | `IHotSwapState.HotSwap` → `_hotSwap.Set` | `Core/Internal/StateImpl.cs:95` | +| Swap gate (precedent) | `EffectiveHotReload.HasFlag(State)` | `StateImpl.cs:74`, `Config/HotReloadSupport.cs` | +| HR model replacement (inspiration) | `HotPatch` → `__Reactive_CreateModelInstance` → `__Reactive_UpdateModel` → `__Reactive_BindableInitializeForUpdatedModel` | `Presentation/Bindings/BindableViewModelBase.HotReload.cs`, `ViewModelGenTool_3.cs:202` | +| VM ctor wraps real Model | `{Vm}(params) : this(new Model(params))` | `ViewModelGenTool_3.cs:128` | +| Visual state from axes | `FeedViewVisualStateSelector.GetVisualState` | `UI/View/FeedViewVisualStateSelector.cs:31` | + +## 1. The swap anchor — why derived feeds survive (D6) + +Every feed factory caches its instance via `AttachedProperty.GetOrCreate` keyed on the provider delegate (stable when lambdas capture only `this` — the MVUX norm). A derived feed `StepsCount => Steps.Select(...)` is itself a cached `SelectFeed(sourceFeed, selector)` **composed on the instance returned by `Steps`**. + +**Anchor:** under the mockable flag, the feed returned for a Model feed-property is wrapped in a `HotSwapFeed` **at this cache level** (stable identity preserved — the wrapper is what gets cached). Consequences: + +- `Model.Steps` returns the wrapper → the VM state subscribes to it → **swap propagates to the VM member**; +- `StepsCount`'s `SelectFeed` composes on the same wrapper → **swap propagates through business logic** (live: a re-swap re-emits through `Select`); +- no `dynamic`, no duck-typed re-init needed for feeds: **`SetModel` = a series of typed swaps** on hidden handles. (The HR `dynamic` path stays untouched, HR-only.) + +Identity risk (R6): lambdas capturing locals/params produce fresh delegate targets → unstable cache key. This pre-exists mocking (same constraint for state persistence); P0 canary + doc. + +## 2. Split of responsibilities + +### 2.1 MVUX generator (Model's assembly — analysis + attributes + hidden hooks) + +**a) Dependency analysis** (Roslyn, source available): +- per feed/command member: walk initializer/getter body; **lambda/anonymous/local-function bodies = deferred boundary**; eager remainder binding to a ctor param (or param-assigned field) → `ServiceDependent(param)`; reference to another feed member → `DerivedFrom(member)`; else `Independent`. +- **ctor instrumentation**: walk ctor bodies (incl. field/property initializers, primary-ctor captures used eagerly); any eager service dereference → the ctor is **unsafe under null-inject for that parameter**. + +**b) Emitted metadata attributes** (defined in core so they survive as metadata; also **hand-declarable** on the Model — explicit declarations override/merge with analysis): + +```csharp +[FeedDependency(nameof(RecipeModel.Steps), OnParameter = "svc")] // service-dependent input +[FeedDependency(nameof(RecipeModel.StepsCount), OnFeed = nameof(Steps))] // derived — never required in mocks +[CtorDependency("svc", Eager = true, Members = new[] { "..." })] // ctor NREs if svc is null +``` + +(Names to bikeshed; semantics fixed: *input vs derived vs independent*, plus *ctor-eager* flags.) + +**c) Hidden hooks** (`EditorBrowsable(Never)`, emitted only under the opt-in flag): +- on the **Model partial**: typed per-feed swap handles — `__Mock_Swap_Steps(IListFeed feed)` → `hotSwapWrapper.Set(feed)`; no strings, no reflection; +- on the **VM partial**: `__Mock_Initialize()` (dedicated; NOT `__Reactive_UpdateModel` — must not reassign `__reactiveModel`, rebind INPC, nor let `Model`'s `Unsafe.As` see a foreign type) + command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` (R2). + +### 2.2 Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the test/preview project) + +Reads the app assembly **metadata** (generated VM/Model types + the attributes above). No syntax trees needed → cross-assembly by construction. Emits **external, generic and strongly typed types/extensions** (partial injection impossible and not needed): + +```csharp +public record RecipeModelMock +{ + public static RecipeModelMock Empty { get; } = new() { Steps = MockListFeed.Empty() }; + public required IListFeed Steps { get; init; } // ServiceDependent input → required + public IFeed? StepsCount { get; init; } // Derived → optional override; null = real business logic + public IAsyncCommand? Save { get; init; } // command → optional; null = idle no-op +} +public static class RecipeViewModelMockExtensions +{ + public static RecipeViewModel Create(); // null-inject + SetModel(Empty) + public static RecipeViewModel Create(IListFeed steps); // required inputs as params + public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // typed swaps via hidden handles +} +``` + +- `Create()` constructs the **real VM** via `new {Vm}(default!, …)`; **compile-time guard**: if `[CtorDependency(Eager=true)]` names parameter `p`, `Create` **requires** a real/fake `p` argument (or the generator emits an error diagnostic if no safe overload is possible). +- `SetModel` may be called repeatedly (live transitions, G6); `with`-expressions on the record make variants cheap (`Empty with { Steps = … }`). +- `required init` on service-dependent inputs = compile-time completeness. **Derived members are optional overrides**: `null` (default) → the real derivation recomputes over the swapped inputs; non-null → that member's own wrapper is swapped too (the cache-level anchor wraps *every* feed property, derived included) — lets a test pin a derived value without caring about its inputs. +- **Tier 2 and tier 3 never accept `MessageEntry`, an untyped feed envelope, or any other tier-1 authoring abstraction. Their contracts remain `IFeed`, `IListFeed`, typed states and typed commands end to end.** + +## 3. Tier 1 — declared `MessageEntry` as `FeedView.Source` + +`FeedView.Source` accepts an **`IMessageEntry`** directly (core contract, unchanged). The authoring surface is a new **non-generic, authorable `MessageEntry` in Core**. It is a plain CLR object that XAML can instantiate, implementing `IMessageEntry`; it is **deliberately not a `DependencyObject`** and adds no UI property-system complexity to the message model: + +```csharp +// Uno.Extensions.Reactive — authorable entry (XAML-friendly, plain CLR) +public sealed class MessageEntry : IMessageEntry +{ + public object? Data { get; set; } // set → Some; explicitly null → None + public bool IsUndefined { get; set; } // true → Data axis Undefined (pre-first-emission) + public object? Error { get; set; } // Exception, or any value wrapped as exception (strings) + public bool IsProgress { get; set; } // true → transient message → Indeterminate + + // Extensibility — MVUX's strength is its open axis model; anything beyond + // the core axes goes through the axis collection: + public AxisValueCollection Axes { get; } // XAML content collection + public void Set(MessageAxis axis, object? value); // code path (typed axis instance) +} + +public sealed class AxisValue +{ + public string Axis { get; set; } // axis identifier for XAML, + // resolved against core + registered app axes + public object? Value { get; set; } +} +``` + +**Custom axes are first-class.** The core axes (`Data`/`Error`/`Progress`) are just convenience properties; any other axis — built-in non-core (selection, pagination) or app-defined `MessageAxis` — is expressible through `Axes`/`Set`, participates in the wrapper's **axis diff** like any core axis, and flows to `FeedViewState`/bindings exactly as it would from a real feed. Axis resolution from XAML uses the axis **identifier** (`MessageAxis.Identifier`); an unknown identifier is a diagnostic, not a silent drop. + +`Data` may be a POCO or another value authored by the application. This convenience object is confined to tier 1; it is not a mocking vocabulary and does not participate in generated Model mocks. + +### Coercion & evolution semantics + +`FeedView.OnSourceChanged`: +1. `ISignal` (any feed/state) → passthrough, unchanged. +2. `IMessageEntry` → the view lazily creates **one entry-driven wrapper feed** (`MessageEntryFeed`, internal) and keeps it for the lifetime of the subscription. +3. anything else → today's behavior (ignored). No heuristic. + +**Requirement — natural feed evolution.** When the `FeedView.Source` **instance changes** to another `IMessageEntry` (for example, a preview state picker updates its bound source), the new entry is **pushed into the existing wrapper**: the subscription is preserved and the message stream evolves entry-by-entry, each message's changes being the **axis diff against the previous entry**. The view must **not** transit through a spurious initial/loading state between two entries — the stream must look like the natural evolution of a real feed (`Loading → Some → Error → …`). Re-creating the wrapper is acceptable as an implementation detail **only if** no visible state reset occurs; the observable contract is the natural evolution. + +**`MessageEntry` itself is not observable.** Mutating `Data`, `Error`, `IsProgress` or `Axes` after the entry has been assigned does not push a message; assign a new entry to `FeedView.Source` to represent the next state. + +### XAML examples + +```xml +xmlns:mvux="using:Uno.Extensions.Reactive.UI" +xmlns:reactive="using:Uno.Extensions.Reactive" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +The `FeedView.Source` converter above is only an illustration of normal XAML composition. It is application-owned and is not an implementation deliverable of this proposal. + +## 4. Tier 3 — complete-model helpers + +Pure consumers of §2.2: named catalogs (`static RecipeViewModel BasicRecipe => Create(ListFeed.Value(...))`), selection posed via states (`vm.Selected.Set(1)`), gallery pickers over `MockFeedState`. Hand-written in the test/preview project, optionally scaffolded. + +## 5. End-to-end flow + +``` +Test/preview project refs Uno.Extensions.Reactive.Mocking + → its generator reads app metadata + [FeedDependency]/[CtorDependency] + → emits {Model}Mock (required inputs only) + Create/SetModel +Create(steps) + → new {Vm}(default!…) // real VM + real Model (ctor-eager params required as args) + → mockable flag ON → every Model feed-property cached as HotSwapFeed wrapper + → SetModel(Empty with { Steps = steps }) + → vm.Model.__Mock_Swap_Steps(steps) // typed, hidden + → StepsCount (SelectFeed over wrapper) recomputes ✔ business logic + → FeedView renders pinned states; later SetModel(...) re-swaps live +``` + +## 6. Context-wide scope and ambient activation — UNRESOLVED + +Raised in the last exchange before the workspace loss. VM scope may be accidental: the more general boundary may be the context owning States/subscriptions, believed to be `SourceContext` but **not yet source-verified**. + +Desired creation scope: + +```csharp +using (MockingService.Enable()) +{ + var model = new RecipeModel(...); +} +``` + +A plausible design is that `Enable()` establishes an ambient capture scope; any feed context created inside it is tagged/configured as mockable. Disposing the scope would stop capture for future contexts while already-created contexts remain mockable for their lifetime. **This is only a hypothesis, not an accepted decision.** + +The source review / spike must answer: + +- exact context type and context-creation call; +- whether context creation is eager during Model/VM construction or lazy at first subscription; +- whether an `AsyncLocal` scope is sufficient across async construction; +- nested scope semantics; +- concurrent tests/model construction; +- subscription lifetime after scope disposal; +- whether activation attaches a mock registry/provider to a context; +- interaction with the separate mockable configuration flag (D4). + +If the context is created lazily after the `using` block, the desired syntax cannot work without either eager context capture during construction or transferring an activation token onto the Model/context owner. + +## 7. Constraints + +- **Non-AOT/trim-safe by design** (D7): dev/test-time only; document that the Mocking package must never be referenced by a published app head. +- Tier 2/3 APIs remain generic and strongly typed; they do not depend on the non-generic tier-1 `MessageEntry`, source conversion, or an untyped feed abstraction. +- Tier 1 stays an isolated UI convenience. +- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, attribute names, hidden hook prefixes. +- MVUX output byte-identical when opt-in flag absent. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md new file mode 100644 index 0000000000..afdd3d4f22 --- /dev/null +++ b/specs/013-mvux-mocking-previews/history.md @@ -0,0 +1,81 @@ +# 013 — Historique des versions et décisions + +Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-architecture`, détruit avec la branche `dev/devid/spec-013-mvux-mocking` non poussée). Sources du merge : +- fichiers **recovery** locaux (reconstruits depuis les transcripts) — portaient la question ouverte « context scope » et la référence au commit `cd4c9ad` ; +- fichiers **VS Code de David** (joints le 23/08 18:22) — `spec.md`/`impl.md` = état v1 (`8d589d9`, non rechargés), `archi.md` = état le plus récent (v4, post-`2618def`) ; +- transcript Telegram complet de la discussion. + +--- + +## Phase 0 — Cadrage (sam. 22/08, soirée) + +**Contexte.** Objectif posé par David : helpers de mocking pour les previews UI (Hot Design) et le testing d'apps consommant des feeds (simuler les états des feeds, pas tester les feeds). Deux POCs existants : PR **#3148** (Nick, spec 009 — XAML only, enveloppe POCO/JSON coercée dans `FeedView.Source`) et PR **#3147** (Steve, spec 012 — vocabulaire `Mocks` + générateur `{Vm}Mocks`/`CreateMock`). Vision à 3 niveaux de David : (1) statique dans le XAML, (2) structures de mock par-feed d'un VM, (3) helpers « modèle complet ». + +**Discussion & décisions :** +- Mon premier retour (socle 3147 + markup extension + catalogue) recadré par David : partir de **SON design** — `MessageEntry` pour la couche 1 (pas d'enveloppe magique, JSON→dynamic) et le **SwapFeed du hot-reload** pour la couche 2 (contrôle total, système 100 % malléable ; la couche 3 ne devient que des helpers au-dessus). +- Faisabilité vérifiée dans le code : `MessageEntry`/`IMessageEntry` publics ; `MessageEntry.Empty` force l'axe Data → tue le canari « Undefined » (spec 012 §10.2) ; `HotSwapFeed`/`IHotSwapState`/`StateImpl` = seam existant (seul appelant : hot-reload) ; gate `HotReloadSupport.State` ; commandes non swap-backed (gap identifié). +- Construction du VM : ni « vrai VM via DI » ni « ctor sans modèle » → **vrai VM + vrai Model**, services **null-injectés**, prouvé sûr par **analyse de dépendances au codegen** (« option 2 » de David). Fondement : les feeds MVUX sont des arrow-getters lazy (service capturé en closure, touché à l'énumération seulement) ; cas bloquant = accès service **eager dans le ctor**. « On ne contrôle pas comment nos users utilisent notre archi » → l'analyse + diagnostics sont obligatoires. +- Cross-assembly : le gen ne voit que les métadonnées d'un Model d'une autre assembly → accepté à ce stade : *mocking == assembly du Model* (contrats `MyModel.Empty`/`Create()` dispo seulement là). +- **Draft 1** écrit dans le repo (jamais committé, écrasé par le pivot v1) : tier-1 = DTO `FeedMock` + markup `{mvux:Mock}` ; tier-2 = `{Vm}Mocks`/`CreateMock` générés dans l'assembly du Model ; D1–D4 ouvertes. + +## v1 — commit `8d589d9` (dim. 23/08 10:42) — le pivot « génération extérieure » + checkpoint + +**Discussion (23/08 matin) :** David réalise en review que le mocking doit être **consommable de l'extérieur** (projet de test qui référence l'app) → on ne peut pas injecter le code dans le VM/Model ; le gen MVUX ajoute des **hooks cachés** (sur le modèle de HR) et le gen de mocking prend le contrôle depuis l'extérieur. Son dump : `RecipeModelMock` record `required init` + `Empty`, `Create()`/`Create(steps)` (null-inject + `SetModel`), `SetModel` ≈ `__Reactive_UpdateModel`. Mes vérifications ont ajouté : +- `__Reactive_UpdateModel` inutilisable tel quel (réassigne `__reactiveModel`, `Unsafe.As` sur type étranger = UB) → **méthode dédiée cachée** (confirmé par David, pt 3). +- **Dérivés doivent survivre** (pt « c'est tout le concept ») → découverte de l'ancrage : les feeds sont cachés par `AttachedProperty.GetOrCreate` avec identité stable → **wrap `HotSwapFeed` au niveau du cache Model-feed** ; les dérivations composent sur le wrapper → le swap traverse la logique métier ; `SetModel` = swaps typés, **plus de `dynamic`**. +- **Attributs de dépendances** émis par l'analyse ET déclarables à la main (idée `[FeedShape(...)]` de David, renommée `[FeedDependency]`/`[CtorDependency]`) — nécessaires car le gen externe n'a pas les syntax trees. +- **Instrumentation des ctors** (idée David) : accès service direct dans le ctor → `Create` exige le service en paramètre. +- Non-AOT accepté (pt 4) : mocking = injection dynamique, dev/test only. +- D1–D7 loggées ; D3 (façade) et D4 (flag dédié) tranchées par David. + +**Checkpoint demandé par David** : branche `dev/devid/spec-013-mvux-mocking` depuis `main@32faf32`, commit `8d589d9` (3 volets, 293 lignes). + +## v2 — commit `292fb5f` (10:47) — review David + +- **Dérivés overridables** : `{Model}Mock.StepsCount` nullable comme `Save` — non défini → vraie dérivation sur les inputs swappés ; défini → remplacé (utile pour les tests). +- **Tier 1 sans `FeedMock`** : « je ne vois pas l'intérêt d'un FeedMock à cet endroit » → `FeedView.Source` prend un **`MessageEntry` authorable** non-générique (le concept du framework lui-même). +- **Exemples XAML exigés** et ajoutés (loading pinné, POCO inline, error/empty/undefined en resources, state picker). +- **Contrat d'évolution naturelle** : changer l'instance dans `Source` **pousse** l'entry dans le wrapper existant (diff d'axes vs entry précédente) — interdiction de repasser par un loading state ; recréer le wrapper toléré seulement sans reset visible. +- Purge des références « v1/v2 » dans les documents (« on est en train de l'écrire cette spec, y'a pas de version qui existe »). + +## v3 — commit `2618def` (11:08) — review David + +- **Axes custom first-class** : une force de MVUX = l'extensibilité par axes → collection `Axes` (`AxisValue`) + `Set(MessageAxis, value)` en code ; identifier XAML résolu contre axes core + enregistrés, inconnu → diagnostic ; les axes custom participent au diff du wrapper. +- **Exemple JsonConverter** demandé (JSON → target object, type via `ConverterParameter` car la DP est `object`) et ajouté. +- Mes deux déductions de l'époque — `MessageEntry` en `DependencyObject` dans `.UI` (pour binder dans `Data`) et push sur mutation de DP — **seront annulées en v4**. + +## v4 — révisions de David dans VS Code (commit `cd4c9ad`, perdu ; contenu = son `archi.md` joint) + +Réponses de David à ma question « OK avec ce découpage ? » — par édition directe de l'architecture : +- **`MessageEntry` reste un plain CLR object dans Core** — délibérément **PAS** un `DependencyObject` (aucune complexité property-system UI dans le message model). +- **L'entry n'est pas observable** : muter `Data`/`Error`/`IsProgress`/`Axes` après assignation ne pousse rien ; **remplacer l'instance** est l'unité de changement. +- Le converter JSON **n'est plus un livrable** : illustration **app-owned** attachée à `FeedView.Source`, doit retourner `IMessageEntry` ; la spec ne définit ni n'implémente de converter. +- Contrainte explicite : **tiers 2/3 strictement typés de bout en bout** — jamais de `MessageEntry`/enveloppe untyped dans leurs contrats ; le gen émet des types « external, generic and strongly typed ». +- `AxisValue.Axis` typé `string` (identifier XAML) ; l'instance `MessageAxis` typée passe par `Set(...)` en code. +- (Ses `spec.md`/`impl.md` joints = v1 `8d589d9`, non rechargés dans VS Code ; seule l'archi portait la v4 → les volets spec/impl restaurés remontent ces décisions.) + +## v5 — dernier échange avant la perte (non committé) — question OUVERTE + +- David : le scope **VM** de tier-2 est peut-être accidentel ; la vraie frontière serait le **contexte** qui possède states/subscriptions (**`SourceContext`**, à vérifier en source). +- Syntaxe visée : `using (MockingService.Enable()) { var model = new MyModel(...); }` +- Sémantique à trancher par **spike** (P0-e) : ambient `AsyncLocal` vs global, scopes imbriqués, concurrence, contexte eager vs lazy, survie des contextes après `Dispose`, interaction avec le flag mockable (D4). +- **Aucune réponse acceptée tant que non vérifiée en source et reviewée par David.** + +Puis : **perte du workspace ACO** (node détruit, branche non poussée — commits `8d589d9`, `292fb5f`, `2618def`, `cd4c9ad` perdus). Reconstruction → restauration dans ce dossier (spawn `ext-mvux-mock`, 23/08 soir). + +--- + +## Registre final des décisions + +| # | Décision | Version | +| --- | --- | --- | +| D1 | Tier-1 = `MessageEntry` authorable non-générique **dans Core**, plain CLR (pas DO), **non observable**, axes core en propriétés directes + axes custom via `Axes`/`Set` ; remplacement d'instance = push dans le wrapper existant (évolution naturelle, pas de loading flash) | v2→v4 | +| D2 | Commandes via seam `??` (pas de swap analog) | v1 | +| D3 | Façade (`SetModel`/setters générés) devant les hooks ; `HotSwapFeed`/handles non publics | v1 | +| D4 | Flag mockable dédié dans `FeedConfiguration` (découplé du hot reload) | v1 | +| D5 | Codegen de mocking **externe** (projet consommateur) ; gen MVUX = analyse + attributs + hooks cachés | v1 | +| D6 | Swap ancré au **cache Model-feed** → les dérivés survivent (non négociable) ; dérivés néanmoins **overridables** individuellement | v1+v2 | +| D7 | Non-AOT du path mocking accepté (dev/test only) | v1 | +| D8 | Converters = illustrations app-owned à `FeedView.Source` (retournent `IMessageEntry`) ; rien d'implémenté par la feature | v4 | +| D9 | Tiers 2/3 strictement typés ; l'objet tier-1 confiné au tier 1 | v4 | +| — | **OUVERT** : scope contexte + activation ambiante `MockingService.Enable()` (spike P0-e requis) | v5 | diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md new file mode 100644 index 0000000000..17d57ce49a --- /dev/null +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -0,0 +1,192 @@ +# 013 — Implementation + +Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fixed per `spec.md`. (Restored after workspace loss.) + +## 1. Packages & where things live + +| Piece | Package | Notes | +| --- | --- | --- | +| Dependency attributes | `Uno.Extensions.Reactive` (core) | must survive as metadata in the app assembly | +| Mockable flag + HotSwap wrap at feed cache | core | `FeedConfiguration.Mockable` (new), wired in `AttachedProperty`/factories | +| Authorable `MessageEntry` + `AxisValue` (plain CLR) + internal `MessageEntryFeed` | core | tier-1, AOT-safe, **not** a `DependencyObject` | +| `FeedView.Source` coercion bridge | `Uno.Extensions.Reactive.UI` | tier-1 | +| Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, opt-in only | +| Mock vocabulary (`MockFeed`/`MockListFeed`/`MockCommand`/`MockFeedState`) | **`Uno.Extensions.Reactive.Mocking`** (new) | referenced by test/preview projects only | +| Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.Extensions.Reactive.Mocking` (analyzer asset) | runs in consumer project, reads app metadata | + +## 2. Core (`Uno.Extensions.Reactive`) + +### 2.1 Dependency attributes (emitted by MVUX gen AND hand-declarable; explicit wins/merges) +```csharp +namespace Uno.Extensions.Reactive.Config; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public sealed class FeedDependencyAttribute : Attribute +{ + public FeedDependencyAttribute(string member) { Member = member; } + public string Member { get; } + public string? OnParameter { get; init; } // ctor param (service) feeding this member + public string? OnFeed { get; init; } // other feed member → derived +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public sealed class CtorDependencyAttribute : Attribute +{ + public CtorDependencyAttribute(string parameter) { Parameter = parameter; } + public string Parameter { get; } + public bool Eager { get; init; } // true → NRE under null-inject; Create must require it +} +``` +(David's `[FeedShape("Steps", ModelParameter=…)]` idea, renamed. Multiple per member allowed.) + +### 2.2 Mockable flag + swap anchor +- `FeedConfiguration.Mockable` (flag, distinct from `HotReload`). +- When ON: feed factories wrap the cached instance in `HotSwapFeed` (the wrapper IS the cached value → stable identity; derivations compose on the wrapper). Minimal wiring: wrap inside `AttachedProperty.GetOrCreate` call sites in `Core/Feed.cs` / `Core/ListFeed.cs` factories (one helper). + +### 2.3 Tier-1 core surfaces +- `Feed.Value` public factory (from #3148, additive). +- Authorable non-generic `MessageEntry : IMessageEntry` — **plain CLR object, not a `DependencyObject`, not observable**; settable `Data` / `IsUndefined` / `Error` / `IsProgress`; `Axes` (`AxisValueCollection` of `AxisValue { string Axis; object? Value }`) + `Set(MessageAxis, object?)` code path. +- Axis-identifier resolution against core + registered app axes; **unknown identifier → diagnostic**, never a silent drop. +- Internal `MessageEntryFeed` — entry-driven wrapper with `Push(IMessageEntry)`; each pushed entry emitted as the **axis diff** vs the previous one, **custom axes included**. + +## 3. MVUX generator changes (`Uno.Extensions.Reactive.Generator`) + +Opt-in: `[assembly: EnableFeedMocking]` (or MSBuild prop). When absent → byte-identical output. + +1. **Analysis pass** (per Model): classify members `ServiceDependent(param) | DerivedFrom(feed) | Independent`; lambda/anonymous/local-function bodies = deferred boundary. **Ctor instrumentation**: walk ctor bodies + field/property initializers + primary-ctor eager captures → mark `CtorDependency(Eager=true)` per offending parameter. Hand-declared attributes override/merge (author is the escape hatch). +2. **Emit attributes** (§2.1) on the generated Model partial. +3. **Hidden hooks** (`EditorBrowsable(Never)`): + - Model partial: `void __Mock_Swap_{Member}(IListFeed/IFeed feed)` per feed member → wrapper `.Set(feed)`; plus `bool __Mock_IsMockable` guard (flag on). Hooks retain concrete generic member types. + - VM partial: `static {Vm} __Mock_Create(object?[] ctorArgs)` → `new {Vm}(…)` null-inject path (dedicated — NOT `__Reactive_UpdateModel`); command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` + `__Mock_SetCommand(name, IAsyncCommand)`. +4. Diagnostics: `FEED3201` eager ctor access detected (info: `Create` will require the service), `FEED3202` unstable feed identity (capture pattern defeats caching), `FEED3203` explicit attribute contradicts analysis. + +## 4. Mocking package (`Uno.Extensions.Reactive.Mocking`) + +### 4.1 Runtime vocabulary (all generic and strongly typed) +```csharp +public static class MockFeed +{ + public static IFeed Undefined(); + public static IFeed Loading(); // transient → Indeterminate, IsExecuting stays true + public static IFeed Empty(); // Option.None + public static IFeed Value(T value); + public static IFeed Error(Exception error); + public static IFeed Refreshing(T staleValue); + public static IFeed Message(Action> configure); + public static IFeed Script(params (TimeSpan after, Action> step)[] steps); // from #3147 +} +public static class MockListFeed +{ + // Typed list equivalents: Undefined, Loading, Empty (None), EmptyList (Some(empty)), + // Value(params/list), Value(list, SelectionInfo), Error, Refreshing. +} +public static class MockCommand +{ + public static IAsyncCommand Idle(); + public static IAsyncCommand Disabled(); + public static IAsyncCommand Executing(); + public static IAsyncCommand Callback(Action onExecute, bool canExecute = true); +} +public enum MockFeedState { Undefined, Loading, Empty, Value, Error, Refreshing } +``` +Built over public `Feed.Create` + `MessageBuilder` (vocabulary from #3147). **These APIs never accept the non-generic tier-1 `MessageEntry` or untyped envelopes.** Never referenced by a published app head (non-AOT, dev/test only — NG2/D7). + +### 4.2 Generator (runs in the consumer/test project, metadata-driven) +For each Model/VM pair found in referenced assemblies with `__Mock_*` hooks + attributes: +```csharp +public record RecipeModelMock +{ + public static RecipeModelMock Empty { get; } // ServiceDependent → MockFeed/MockListFeed.Empty + public required IListFeed Steps { get; init; } // exactly the ServiceDependent set + public IFeed? StepsCount { get; init; } // Derived → optional override; null = real derivation + public IAsyncCommand? Save { get; init; } // optional; default idle no-op +} +public static class RecipeViewModelMocking +{ + public static RecipeViewModel Create(); // null-inject + SetModel(Empty) + public static RecipeViewModel Create(IListFeed steps); // per required input + public static RecipeViewModel Create(IRecipeService svc, IListFeed steps); // when CtorDependency(Eager) → service required + public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // typed __Mock_Swap_* calls +} +``` +Rules: +- Required properties/parameters = the **ServiceDependent** input set. +- **Derived members: optional overrides** — `null` (default) → real derivation recomputes over swapped inputs; set → that member's wrapper is swapped too. Independent members: untouched. +- Commands optional; default is an idle no-op. +- `SetModel` callable repeatedly → live transitions (`vm.SetModel(mock with { Steps = ... })`). +- Concrete generic types preserved throughout; **no tier-1 type or conversion path is emitted**. +- Diagnostic `MOCK0001` when a VM is reachable but its assembly lacks hooks (opt-in missing). + +## 5. UI (`Uno.Extensions.Reactive.UI`) — tier 1 +- `FeedView.OnSourceChanged`: typed branch `IMessageEntry` → lazily create ONE `MessageEntryFeed` wrapper kept across `Source` changes; a subsequent `IMessageEntry` instance is **pushed** into the wrapper (subscription preserved, no state reset — natural-evolution contract, architecture §3). No heuristic. +- **Mutations of an already-assigned entry are not observed** (plain CLR, not observable); a new instance is the unit of change. +- XAML element syntax (``, …) — examples in architecture §3. +- **No converter implementation.** A converter at `FeedView.Source` returning `IMessageEntry` appears in docs/samples as an application-owned illustration only (D8/NG6). + +## 6. Activation/context spike — required before freezing the runtime hook (UNRESOLVED) + +Inspect the real State/subscription context API (believed `SourceContext`) and prototype: + +```csharp +using (MockingService.Enable()) +{ + var model = new MyModel(...); +} +``` + +The spike must prove: +- which context owns States/subscriptions; +- whether the mocking scope can apply to any context, not merely a VM; +- exactly when the context captures the mockable registry/flag; +- lazy-context behavior (context created after the `using` block?); +- nested and concurrent scopes; +- async flow (`AsyncLocal` candidate); +- survival of created contexts after `Dispose`; +- deterministic restoration and test isolation; +- interaction with `FeedConfiguration.Mockable` (D4). + +Do not treat `AsyncLocal` or the disposal semantics as decided until the spike/source review is complete. + +## 7. Phasing + +- **P0 — de-risk canaries (blocking):** + a. `MessageEntry` wrapper → Undefined/None/Some/Error/Loading visual states (R3), and entry push → axis-diff evolution with no loading flash; + b. wrap-at-cache: swap `Steps` → `StepsCount` (`Select`) re-emits (D6 — THE gate); + c. null-inject construction on a lazy model; eager-ctor fixture NREs as predicted; + d. feed-identity stability matrix (capture patterns) → informs FEED3202; + e. context-wide `MockingService.Enable()` spike (§6). +- **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. +- **P2 — Core mockable flag + wrap + hidden hooks + attributes + analysis** (MVUX gen). +- **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetModel`). +- **P4 — Tier 3 catalogs + Hot Design checkpoint** (name freeze), docs. + +## 8. Test plan + +### Core +- Every typed `MockFeed`/`MockListFeed`/`MockCommand` state emits expected axes. +- Authorable entry maps to Data/Error/Progress/Undefined correctly; custom axes map and diff correctly. +- Consecutive entry instances produce correct core + custom axis diffs. +- Wrap identity (`AttachedProperty` returns the same wrapper); swap propagation through `Select`/`Where` and chained derived feeds; live re-swap. + +### Generators +- Classification fixtures (lazy/eager/derived/independent; ctor bodies, field/property initializers, primary-ctor captures). +- Attribute emission; explicit-attribute override/merge; FEED3201–3203. +- Byte-identical output when opt-in absent; hooks hidden (`EditorBrowsable`) and typed (concrete generics). +- Consumer generation against a compiled fixture assembly; required-input set = ServiceDependent set; eager-ctor → required service parameter; MOCK0001; **no tier-1/untyped surface in tier-2/3 output**. + +### Runtime / UI (Skia) +- Each pinned state renders; Loading keeps `IsExecuting`. +- Successive `Source` entries evolve without re-subscribe (no loading flash); **mutating an assigned entry does not emit** — assigning a replacement does. +- `SetModel` drives Loading → Value → Error live; derived member updates on-screen after an input swap (D6 end-to-end). +- Command states drive `Button.IsEnabled`; hot reload does not clobber a mocked VM/context. + +### Context activation (with §6 spike) +- Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. + +### Contract freeze +- Reflection-discovery test for `{Model}Mock`/`Empty`/`Create`/`SetModel`/attribute names (Hot Design contract). + +## 9. Docs +- `doc/Learn/Mvux/Testing.md`: typed vocabulary, `Create`/`SetModel`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. +- `doc/Learn/Mvux/FeedView.md`: tier-1 entry authoring + custom axes; converter shown only as an application-owned illustration at `FeedView.Source` (not a deliverable). +- `rules.md`: FEED3201–3203, MOCK0001. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md new file mode 100644 index 0000000000..a1e0f941e1 --- /dev/null +++ b/specs/013-mvux-mocking-previews/spec.md @@ -0,0 +1,111 @@ +# 013 — MVUX Mocking & Previews + +**Status:** Draft — restored after workspace loss (branch `dev/devid/spec-013-mvux-mocking`, last known commits `8d589d9` → `292fb5f` → `2618def` → `cd4c9ad`, all lost with the ACO node; see `history.md`) +**Area:** `Uno.Extensions.Reactive` (attributes + hooks), `Uno.Extensions.Reactive.UI` (tier-1 bridge), **new package `Uno.Extensions.Reactive.Mocking`** (typed vocabulary + facade + its own generator) +**Prior art (POCs):** #3148 / spec 009, #3147 / spec 012 +**Primary consumers:** app **test projects** (referencing the app), and Uno **Hot Design** *MVUX State Previews* + +--- + +## 1. Problem + +Driving a page or a `FeedView` into a **non-happy feed state** (*Loading forever*, *Error*, *Empty/None*, *Refreshing*, *Undefined*) requires faking the service the Model consumes. Expensive, and several states are **unreachable** via service fakes (indefinite loading, refreshing, undefined, per-feed independence). Two audiences, and **two distinct needs that must stay separate**: + +- **Previews (Hot Design):** declare a feed state with no view model, ideally in XAML — a small UI authoring convenience. +- **App testing:** pin each feed of a **real VM** to a chosen state — *testing the app that consumes feeds, not the feeds* — without standing up DI, through a **strongly typed mocking engine**. **Crucially: mocking must be consumable from the OUTSIDE (a test project that references the app), not injected into the app's own source.** + +## 2. Core principle — real VM, real Model, business logic survives + +We always instantiate the **real ViewModel wrapping the real Model** (null-injected services). Mocking replaces **inputs** (service-dependent feeds), never **logic**: a derived feed such as + +```csharp +public IFeed StepsCount => Steps.Select(steps => steps.Count); // business logic +``` + +**MUST keep computing over the mocked `Steps`.** That is the whole point of building a real VM+Model. Achieved by anchoring the swap at the **Model-feed level** (the feed-identity cache), so every composition (`Select`, `Where`, …) observes the swapped source. Live re-swap drives state transitions. + +## 3. The three tiers (layers of one system) + +1. **Tier 1 — Static/XAML, no VM:** `FeedView.Source` accepts a declared **`MessageEntry`** — a new authorable non-generic entry in **Core**, a **plain CLR object (deliberately NOT a `DependencyObject`)**, XAML element syntax; core axes as direct convenience properties and **custom axes first-class** via an axis collection (MVUX's open axis model). **Replacing** the `Source` entry instance pushes the new entry through the existing wrapper feed: the stream evolves like a real feed (no re-subscribe, no loading flash). The entry itself is **not observable** — a new instance is the unit of change. No heuristic envelope, no parallel DTO, no converter deliverable (an application-owned converter at `FeedView.Source` is illustration only). Tier 1 is an **isolated UI convenience** and never leaks into tiers 2/3. +2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.Extensions.Reactive.Mocking` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. +3. **Tier 3 — Complete-model ergonomics:** `{Model}Mock.Empty`, `Create()` overloads whose **required parameters are exactly the service-dependent feeds**; **derived members are optional overrides** (unset → the real business logic runs over the mocked inputs; set → replaced — useful for tests); hand-extensible named catalogs (`BasicRecipe`, `RecipeWithSelection`…) for one-line preview binding. Strongly typed, no tier-1 abstractions. + +## 4. Split of responsibilities + +- **MVUX generator (runs in the Model's assembly, on the partial Model):** + a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); + b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); + c. emits **hidden hooks** (HR-style, `EditorBrowsable(Never)`): per-feed typed swap handles on the Model partial + a dedicated mock-apply path on the VM (NOT `__Reactive_UpdateModel`, which reassigns `__reactiveModel`/INPC and is unsafe for this use). +- **Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). + +## 5. Goals / Non-goals + +**Goals** +- G1. Pin any service-dependent feed / list-feed / state / command of a real generated VM. +- G2. **Derived feeds recompute over mocked inputs** (business logic survives); derived members remain individually overridable for tests. +- G3. Mock generation happens **in the consumer project** (test/preview), against app metadata. +- G4. Compile-time completeness (`required init`) and compile-time surfacing of eager-ctor constraints. +- G5. Opt-in; byte-identical MVUX output when disabled. Additive only. +- G6. Live re-swap to drive transitions. +- G7. Tier-1 XAML state declaration with no VM, including custom axes. +- G8. Tiers 2/3 **strongly typed end to end**. + +**Non-goals** +- NG1. Behavioral/integration testing of services (this targets presentation state). +- NG2. **AOT/trim compliance of the mocking path.** Mocking is dynamic injection, dev/test-time only (JIT). Accepted and documented; never ships in a published app. +- NG3. Making arbitrary JSON graphs bindable on every platform (WinAppSDK dynamic-binding caveat). +- NG4. Command invocation recording/assertions (deferred). +- NG5. Making the tier-1 `MessageEntry` bindable or observable. +- NG6. Defining or implementing a JSON (or any) converter — converters are application-owned illustrations only. +- NG7. Reusing tier-1 untyped authoring objects or conversion helpers in tiers 2/3. + +## 6. Frozen contracts (Hot Design + test code discover by name) + +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, the dependency attributes, hidden hook naming, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. + +## 7. Risks + +- R1. **Eager ctor service access** → NRE at construction, before any swap. Mitigation: ctor instrumentation (§4a) → attribute → `Create(...)` **requires** those services as parameters (or a diagnostic if unconstructible). +- R2. Commands are not swap-backed states → `??` seam in `CommandFromMethod` emission. +- R3. Undefined change-detection canary (spec 012 §10.2) → tier-1 `MessageEntry` route avoids it; tier-2 vocab must prove it by test (P0). +- R4. Refresh axis internal → `Refreshing` visually faithful, not axis-faithful. Documented. +- R5. Scalar `IFeed` → plain generated property, invisible to `FeedView`. Documented. +- R6. Swap anchor identity: feed caching keys on stable delegate targets (lambdas capturing only `this`); exotic capture patterns may produce unstable identity → P0 canary + diagnostic. + +## 8. Resolved decisions (log) + +- D1 → tier-1 authoring = authorable non-generic `MessageEntry` **in Core** (the framework concept itself — no parallel DTO, plain XAML element syntax); **plain CLR object, deliberately not a `DependencyObject`**; **not observable** (replacing the instance is the unit of change); core axes as convenience properties, **custom axes** via `Axes` collection / `Set(MessageAxis, value)`; entry-instance replacement pushes through the existing wrapper (natural feed evolution, no loading flash). +- D2 → commands = `??` seam (no swap analog). +- D3 → **facade** (`SetModel` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public. +- D4 → dedicated **`FeedConfiguration` mockable flag** (decoupled from hot reload). +- D5 → mock codegen is **external** (consumer project); MVUX gen only analyzes + emits attributes & hidden hooks. +- D6 → swap anchored at **Model-feed cache level** so derivations survive (non-negotiable). +- D7 → AOT non-compliance of the mocking path accepted (dev/test only). +- D8 → converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source` and must return `IMessageEntry`; this feature defines and implements none. +- D9 → tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 and never appears in generated mock contracts. + +## 9. Open question — context scope & ambient activation (UNRESOLVED) + +Raised immediately before the workspace loss; **not** a resolved decision: + +- Tier 2 may not fundamentally be VM-scoped. The actual boundary may be the feed subscription/state **context** (believed to be `SourceContext`; exact type/API must be verified in source). +- Investigate whether typed swaps can apply to **any** such feed context, not only a generated VM. +- Desired call-site shape: + +```csharp +using (MockingService.Enable()) +{ + var model = new MyModel(...); +} +``` + +- Required semantics to decide: + - whether `Enable()` is ambient (`AsyncLocal`) or process-global; + - nested scopes and restoration order; + - concurrent model creation; + - whether the context is created eagerly or lazily after the scope; + - subscriptions created in the scope but living after `Dispose`; + - whether `Dispose` disables only future capture while already-created contexts remain mockable; + - how this ambient activation relates to the dedicated mockable configuration flag (D4). + +No answer is considered accepted until verified against source and reviewed by David. A spike is scheduled in P0 (see `implementation.md` §6). From 49c37d3fbcc85ddc47b48f4e42f5f921b6e6a17f Mon Sep 17 00:00:00 2001 From: David Date: Mon, 24 Aug 2026 09:39:40 +0000 Subject: [PATCH 02/19] =?UTF-8?q?docs(mocking):=20visual=20pass=20?= =?UTF-8?q?=E2=80=94=20mermaid=20diagrams=20for=20review=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec.md: audience/deliverable split, derived-feeds-survive flow, three-tier layering, generator responsibility split, end-to-end sequence, tier-1 evolution sequence; risks and decision log as tables; status header cleaned for team/CEO review (history stays in history.md) - architecture.md: swap-anchor mermaid in section 1, end-to-end ASCII flow replaced by a sequence diagram in section 5 --- .../013-mvux-mocking-previews/architecture.md | 49 +++- specs/013-mvux-mocking-previews/spec.md | 209 ++++++++++++++---- 2 files changed, 206 insertions(+), 52 deletions(-) diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index bec0f457c6..3521e4e036 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -26,6 +26,23 @@ Every feed factory caches its instance via `AttachedProperty.GetOrCreate` keyed - `StepsCount`'s `SelectFeed` composes on the same wrapper → **swap propagates through business logic** (live: a re-swap re-emits through `Select`); - no `dynamic`, no duck-typed re-init needed for feeds: **`SetModel` = a series of typed swaps** on hidden handles. (The HR `dynamic` path stays untouched, HR-only.) +```mermaid +flowchart TB + F["feed factory call + ListFeed.Async(...) in Model.Steps"] --> C{"AttachedProperty + feed identity cache"} + C -->|mockable flag ON| W["HotSwapFeed wrapper + (the wrapper IS the cached value)"] + C -->|flag OFF| RAW["raw feed — today's behavior, + byte-identical"] + W --> VMS["VM state subscription"] + W --> SEL["SelectFeed = StepsCount + (composes on the wrapper)"] + SWAP["__Mock_Swap_Steps(mockFeed)"] -->|"wrapper.Set(mockFeed)"| W + SEL --> UI2["FeedView"] + VMS --> UI1["FeedView"] +``` + Identity risk (R6): lambdas capturing locals/params produce fresh delegate targets → unstable cache key. This pre-exists mocking (same constraint for state persistence); P0 canary + doc. ## 2. Split of responsibilities @@ -186,17 +203,27 @@ Pure consumers of §2.2: named catalogs (`static RecipeViewModel BasicRecipe => ## 5. End-to-end flow -``` -Test/preview project refs Uno.Extensions.Reactive.Mocking - → its generator reads app metadata + [FeedDependency]/[CtorDependency] - → emits {Model}Mock (required inputs only) + Create/SetModel -Create(steps) - → new {Vm}(default!…) // real VM + real Model (ctor-eager params required as args) - → mockable flag ON → every Model feed-property cached as HotSwapFeed wrapper - → SetModel(Empty with { Steps = steps }) - → vm.Model.__Mock_Swap_Steps(steps) // typed, hidden - → StepsCount (SelectFeed over wrapper) recomputes ✔ business logic - → FeedView renders pinned states; later SetModel(...) re-swaps live +```mermaid +sequenceDiagram + participant T as Test / preview project + participant MG as Generated mocking code + participant VM as RecipeViewModel (real) + participant M as RecipeModel (real, null-injected) + participant W as HotSwapFeed wrappers + participant UI as FeedView + + Note over T,MG: build time — the Mocking generator reads app metadata
+ FeedDependency / CtorDependency attributes and emits
RecipeModelMock + Create(...) + SetModel + T->>MG: RecipeViewModel.Create(steps) + MG->>VM: new RecipeViewModel(default!, ...) + VM->>M: new RecipeModel(default!, ...) + Note over M,W: mockable flag ON — every Model feed property
is cached as a HotSwapFeed wrapper + MG->>M: SetModel → __Mock_Swap_Steps(steps) (typed, hidden) + M->>W: wrapper.Set(steps) + W-->>UI: Steps emits the mock values + W-->>UI: StepsCount recomputes through the real Select + T->>M: SetModel(...) again — Loading / Value / Error + M->>W: re-swap + W-->>UI: live transition, no re-subscribe ``` ## 6. Context-wide scope and ambient activation — UNRESOLVED diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index a1e0f941e1..2ee8360648 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -1,44 +1,180 @@ # 013 — MVUX Mocking & Previews -**Status:** Draft — restored after workspace loss (branch `dev/devid/spec-013-mvux-mocking`, last known commits `8d589d9` → `292fb5f` → `2618def` → `cd4c9ad`, all lost with the ACO node; see `history.md`) +**Status:** Draft — under review **Area:** `Uno.Extensions.Reactive` (attributes + hooks), `Uno.Extensions.Reactive.UI` (tier-1 bridge), **new package `Uno.Extensions.Reactive.Mocking`** (typed vocabulary + facade + its own generator) **Prior art (POCs):** #3148 / spec 009, #3147 / spec 012 **Primary consumers:** app **test projects** (referencing the app), and Uno **Hot Design** *MVUX State Previews* +**Decision history:** [history.md](history.md) --- ## 1. Problem -Driving a page or a `FeedView` into a **non-happy feed state** (*Loading forever*, *Error*, *Empty/None*, *Refreshing*, *Undefined*) requires faking the service the Model consumes. Expensive, and several states are **unreachable** via service fakes (indefinite loading, refreshing, undefined, per-feed independence). Two audiences, and **two distinct needs that must stay separate**: +Driving a page or a `FeedView` into a **non-happy feed state** (*Loading forever*, *Error*, *Empty/None*, *Refreshing*, *Undefined*) requires faking the service the Model consumes. Expensive — and several states are simply **unreachable** via service fakes: + +| Feed state | Reachable with a fake service? | +| --- | --- | +| Value / Error / Empty | Yes | +| **Loading, indefinitely** | Only with a never-completing task — leaks, hangs test runs | +| **Refreshing** (stale value + progress) | No | +| **Undefined** (pre-first-emission) | No | +| **Transient error over stale data** | No | +| **Per-feed, independently, on one VM** | No — a fake is per-service, not per-feed | + +Two audiences, and **two distinct needs that must stay separate**: + +```mermaid +flowchart LR + P1["Previews / Hot Design + declare a feed state in XAML, + no view model"] + P2["App testing + pin each feed of a real VM, + no DI graph, driven from a test project"] + T1["Tier 1 + UI authoring convenience + MessageEntry on FeedView.Source"] + T23["Tiers 2 and 3 + strongly typed mocking engine + package Uno.Extensions.Reactive.Mocking"] + P1 --> T1 + P2 --> T23 + T1 -. isolated - never leaks into tiers 2/3 .- T23 +``` -- **Previews (Hot Design):** declare a feed state with no view model, ideally in XAML — a small UI authoring convenience. -- **App testing:** pin each feed of a **real VM** to a chosen state — *testing the app that consumes feeds, not the feeds* — without standing up DI, through a **strongly typed mocking engine**. **Crucially: mocking must be consumable from the OUTSIDE (a test project that references the app), not injected into the app's own source.** +**Crucially: mocking must be consumable from the OUTSIDE** — a test project that references the app — not injected into the app's own source. ## 2. Core principle — real VM, real Model, business logic survives -We always instantiate the **real ViewModel wrapping the real Model** (null-injected services). Mocking replaces **inputs** (service-dependent feeds), never **logic**: a derived feed such as +We always instantiate the **real ViewModel wrapping the real Model** (null-injected services). Mocking replaces **inputs** (service-dependent feeds), never **logic**: ```csharp public IFeed StepsCount => Steps.Select(steps => steps.Count); // business logic ``` -**MUST keep computing over the mocked `Steps`.** That is the whole point of building a real VM+Model. Achieved by anchoring the swap at the **Model-feed level** (the feed-identity cache), so every composition (`Select`, `Where`, …) observes the swapped source. Live re-swap drives state transitions. +`StepsCount` **MUST keep computing over the mocked `Steps`** — that is the whole point of building a real VM+Model. Achieved by anchoring the swap at the **Model-feed level** (the feed-identity cache), so every composition (`Select`, `Where`, …) observes the swapped source. Live re-swap drives state transitions. + +```mermaid +flowchart LR + MOCK["MockListFeed.Value(steps) + applied via SetModel"] + subgraph MODEL["Real RecipeModel — services null-injected"] + W["Steps + stable HotSwapFeed wrapper + (feed identity cache)"] + BL["StepsCount = Steps.Select(...) + real business logic — recomputes"] + W --> BL + end + MOCK -->|typed swap| W + W --> UI1["VM state → FeedView"] + BL --> UI2["VM state → FeedView"] +``` ## 3. The three tiers (layers of one system) +```mermaid +flowchart TB + subgraph ENGINE["Strongly typed mocking engine — test/preview projects"] + T3["Tier 3 — complete-model ergonomics + RecipeModelMock.Empty / Create(...) / named catalogs"] + T2["Tier 2 — per-feed control on the real VM + RecipeModelMock record + SetModel = typed swaps"] + T3 --> T2 + end + subgraph CONV["UI authoring convenience — XAML, no VM"] + T1["Tier 1 — MessageEntry on FeedView.Source + natural feed evolution, custom axes"] + end + T2 --> PRIM["Shared core primitives (opt-in) + HotSwapFeed wrap at the feed cache · hidden hooks · dependency attributes"] + T1 --> FV["FeedView entry wrapper"] +``` + 1. **Tier 1 — Static/XAML, no VM:** `FeedView.Source` accepts a declared **`MessageEntry`** — a new authorable non-generic entry in **Core**, a **plain CLR object (deliberately NOT a `DependencyObject`)**, XAML element syntax; core axes as direct convenience properties and **custom axes first-class** via an axis collection (MVUX's open axis model). **Replacing** the `Source` entry instance pushes the new entry through the existing wrapper feed: the stream evolves like a real feed (no re-subscribe, no loading flash). The entry itself is **not observable** — a new instance is the unit of change. No heuristic envelope, no parallel DTO, no converter deliverable (an application-owned converter at `FeedView.Source` is illustration only). Tier 1 is an **isolated UI convenience** and never leaks into tiers 2/3. 2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.Extensions.Reactive.Mocking` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. 3. **Tier 3 — Complete-model ergonomics:** `{Model}Mock.Empty`, `Create()` overloads whose **required parameters are exactly the service-dependent feeds**; **derived members are optional overrides** (unset → the real business logic runs over the mocked inputs; set → replaced — useful for tests); hand-extensible named catalogs (`BasicRecipe`, `RecipeWithSelection`…) for one-line preview binding. Strongly typed, no tier-1 abstractions. ## 4. Split of responsibilities +```mermaid +flowchart TB + subgraph APP["App assembly — owns the Model source"] + M["partial Model"] --> GEN["MVUX generator"] + GEN --> AN["dependency analysis + + ctor instrumentation"] + AN --> ATTR["metadata attributes + FeedDependency / CtorDependency + (also hand-declarable — explicit wins)"] + GEN --> HOOKS["hidden typed hooks + __Mock_Swap_Steps(...) on the Model + dedicated mock ctor path on the VM"] + end + subgraph TEST["Test / preview project — references the app"] + MG["Mocking generator + (ships in Uno.Extensions.Reactive.Mocking)"] + OUT["RecipeModelMock record + Create(...) factories · SetModel facade"] + MG --> OUT + end + ATTR -->|read as compiled metadata| MG + OUT -->|calls at runtime| HOOKS +``` + - **MVUX generator (runs in the Model's assembly, on the partial Model):** a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); c. emits **hidden hooks** (HR-style, `EditorBrowsable(Never)`): per-feed typed swap handles on the Model partial + a dedicated mock-apply path on the VM (NOT `__Reactive_UpdateModel`, which reassigns `__reactiveModel`/INPC and is unsafe for this use). - **Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). -## 5. Goals / Non-goals +## 5. End-to-end — a test drives a page through its states + +```mermaid +sequenceDiagram + participant T as Test / preview code + participant G as Generated (Mocking pkg) + participant VM as Real VM + real Model + participant W as HotSwap wrappers + participant UI as FeedView + + T->>G: RecipeViewModel.Create(steps) + G->>VM: new RecipeViewModel(default!, ...) + Note over VM: mockable flag ON —
every Model feed property is
cached as a HotSwapFeed wrapper + G->>W: SetModel(Empty with Steps = steps) + W-->>VM: Steps swapped (typed hidden hook) + VM-->>UI: StepsCount recomputes through the real Select + UI-->>UI: renders pinned states + T->>W: SetModel(...) — Loading, Value, Error + W-->>UI: live transitions, no re-subscribe +``` + +## 6. Tier 1 at a glance + +```xml + + + + + +``` + +```mermaid +sequenceDiagram + participant X as XAML / state picker + participant FV as FeedView + participant WR as MessageEntryFeed (single wrapper) + + X->>FV: Source = entry 1 (IsProgress) + FV->>WR: create wrapper, push entry 1 + WR-->>FV: Loading state + X->>FV: Source = entry 2 (Data) + FV->>WR: push entry 2 (axis diff vs entry 1) + WR-->>FV: Value state — no re-subscribe, no loading flash +``` + +Full authoring surface (custom axes, XAML examples, evolution contract): [architecture.md §3](architecture.md). + +## 7. Goals / Non-goals **Goals** - G1. Pin any service-dependent feed / list-feed / state / command of a real generated VM. @@ -59,38 +195,38 @@ public IFeed StepsCount => Steps.Select(steps => steps.Count); // busines - NG6. Defining or implementing a JSON (or any) converter — converters are application-owned illustrations only. - NG7. Reusing tier-1 untyped authoring objects or conversion helpers in tiers 2/3. -## 6. Frozen contracts (Hot Design + test code discover by name) +## 8. Frozen contracts (Hot Design + test code discover by name) `{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, the dependency attributes, hidden hook naming, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. -## 7. Risks +## 9. Risks -- R1. **Eager ctor service access** → NRE at construction, before any swap. Mitigation: ctor instrumentation (§4a) → attribute → `Create(...)` **requires** those services as parameters (or a diagnostic if unconstructible). -- R2. Commands are not swap-backed states → `??` seam in `CommandFromMethod` emission. -- R3. Undefined change-detection canary (spec 012 §10.2) → tier-1 `MessageEntry` route avoids it; tier-2 vocab must prove it by test (P0). -- R4. Refresh axis internal → `Refreshing` visually faithful, not axis-faithful. Documented. -- R5. Scalar `IFeed` → plain generated property, invisible to `FeedView`. Documented. -- R6. Swap anchor identity: feed caching keys on stable delegate targets (lambdas capturing only `this`); exotic capture patterns may produce unstable identity → P0 canary + diagnostic. +| # | Risk | Mitigation | +| --- | --- | --- | +| R1 | **Eager ctor service access** → NRE at construction, before any swap | ctor instrumentation (§4a) → attribute → `Create(...)` **requires** those services as parameters; diagnostic if unconstructible | +| R2 | Commands are not swap-backed states | `??` seam in `CommandFromMethod` emission | +| R3 | Undefined change-detection canary (spec 012 §10.2) | tier-1 `MessageEntry` route avoids it; tier-2 vocab proven by test (P0) | +| R4 | Refresh axis is internal → `Refreshing` visually faithful, not axis-faithful | documented | +| R5 | Scalar `IFeed` → plain generated property, invisible to `FeedView` | documented | +| R6 | Swap-anchor identity: exotic lambda captures → unstable cache key | P0 canary matrix + diagnostic | -## 8. Resolved decisions (log) +## 10. Resolved decisions (log) -- D1 → tier-1 authoring = authorable non-generic `MessageEntry` **in Core** (the framework concept itself — no parallel DTO, plain XAML element syntax); **plain CLR object, deliberately not a `DependencyObject`**; **not observable** (replacing the instance is the unit of change); core axes as convenience properties, **custom axes** via `Axes` collection / `Set(MessageAxis, value)`; entry-instance replacement pushes through the existing wrapper (natural feed evolution, no loading flash). -- D2 → commands = `??` seam (no swap analog). -- D3 → **facade** (`SetModel` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public. -- D4 → dedicated **`FeedConfiguration` mockable flag** (decoupled from hot reload). -- D5 → mock codegen is **external** (consumer project); MVUX gen only analyzes + emits attributes & hidden hooks. -- D6 → swap anchored at **Model-feed cache level** so derivations survive (non-negotiable). -- D7 → AOT non-compliance of the mocking path accepted (dev/test only). -- D8 → converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source` and must return `IMessageEntry`; this feature defines and implements none. -- D9 → tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 and never appears in generated mock contracts. +| # | Decision | +| --- | --- | +| D1 | Tier-1 authoring = authorable non-generic `MessageEntry` **in Core**; **plain CLR, not a `DependencyObject`**; **not observable** (instance replacement is the unit of change); core axes as convenience properties, **custom axes** via `Axes` / `Set(MessageAxis, value)`; replacement pushes through the existing wrapper (natural feed evolution, no loading flash) | +| D2 | Commands = `??` seam (no swap analog) | +| D3 | **Facade** (`SetModel` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public | +| D4 | Dedicated **`FeedConfiguration` mockable flag** (decoupled from hot reload) | +| D5 | Mock codegen is **external** (consumer project); MVUX gen only analyzes + emits attributes & hidden hooks | +| D6 | Swap anchored at **Model-feed cache level** so derivations survive (non-negotiable) | +| D7 | AOT non-compliance of the mocking path accepted (dev/test only) | +| D8 | Converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source`, returning `IMessageEntry`; this feature defines and implements none | +| D9 | Tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 | -## 9. Open question — context scope & ambient activation (UNRESOLVED) +## 11. Open question — context scope & ambient activation (UNRESOLVED) -Raised immediately before the workspace loss; **not** a resolved decision: - -- Tier 2 may not fundamentally be VM-scoped. The actual boundary may be the feed subscription/state **context** (believed to be `SourceContext`; exact type/API must be verified in source). -- Investigate whether typed swaps can apply to **any** such feed context, not only a generated VM. -- Desired call-site shape: +Tier 2 may not fundamentally be VM-scoped: the actual boundary may be the feed subscription/state **context** (believed to be `SourceContext`; must be verified in source). Desired call-site shape: ```csharp using (MockingService.Enable()) @@ -99,13 +235,4 @@ using (MockingService.Enable()) } ``` -- Required semantics to decide: - - whether `Enable()` is ambient (`AsyncLocal`) or process-global; - - nested scopes and restoration order; - - concurrent model creation; - - whether the context is created eagerly or lazily after the scope; - - subscriptions created in the scope but living after `Dispose`; - - whether `Dispose` disables only future capture while already-created contexts remain mockable; - - how this ambient activation relates to the dedicated mockable configuration flag (D4). - -No answer is considered accepted until verified against source and reviewed by David. A spike is scheduled in P0 (see `implementation.md` §6). +Semantics to decide by spike (P0-e, see [implementation.md §6](implementation.md)): ambient (`AsyncLocal`) vs process-global; nested scopes; concurrency; eager vs lazy context creation; subscriptions outliving `Dispose`; interaction with the mockable flag (D4). **Nothing here is accepted until verified against source and reviewed.** From cc4a51033bc3267844330edeef5921157ba6ab29 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 24 Aug 2026 15:50:07 +0000 Subject: [PATCH 03/19] docs: update the MVUX mocking spec --- .../013-mvux-mocking-previews/architecture.md | 41 ++++---- specs/013-mvux-mocking-previews/history.md | 10 +- .../implementation.md | 49 ++++++---- specs/013-mvux-mocking-previews/spec.md | 96 +++++++++++++------ 4 files changed, 125 insertions(+), 71 deletions(-) diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 3521e4e036..12436e39c5 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -20,7 +20,7 @@ Grounded in the current tree. File refs relative to repo root. (Restored after w Every feed factory caches its instance via `AttachedProperty.GetOrCreate` keyed on the provider delegate (stable when lambdas capture only `this` — the MVUX norm). A derived feed `StepsCount => Steps.Select(...)` is itself a cached `SelectFeed(sourceFeed, selector)` **composed on the instance returned by `Steps`**. -**Anchor:** under the mockable flag, the feed returned for a Model feed-property is wrapped in a `HotSwapFeed` **at this cache level** (stable identity preserved — the wrapper is what gets cached). Consequences: +**Anchor:** inside an activation scope (§6 — the mockable flag it drives), the feed returned for a Model feed-property is wrapped in a `HotSwapFeed` **at this cache level** (stable identity preserved — the wrapper is what gets cached). Consequences: - `Model.Steps` returns the wrapper → the VM state subscribes to it → **swap propagates to the VM member**; - `StepsCount`'s `SelectFeed` composes on the same wrapper → **swap propagates through business logic** (live: a re-swap re-emits through `Select`); @@ -31,9 +31,9 @@ flowchart TB F["feed factory call ListFeed.Async(...) in Model.Steps"] --> C{"AttachedProperty feed identity cache"} - C -->|mockable flag ON| W["HotSwapFeed wrapper + C -->|inside activation scope| W["HotSwapFeed wrapper (the wrapper IS the cached value)"] - C -->|flag OFF| RAW["raw feed — today's behavior, + C -->|no scope — live app| RAW["raw feed — today's behavior, byte-identical"] W --> VMS["VM state subscription"] W --> SEL["SelectFeed = StepsCount @@ -226,38 +226,39 @@ sequenceDiagram W-->>UI: live transition, no re-subscribe ``` -## 6. Context-wide scope and ambient activation — UNRESOLVED +## 6. Scoped activation — decided shape, mechanism to spike -Raised in the last exchange before the workspace loss. VM scope may be accidental: the more general boundary may be the context owning States/subscriptions, believed to be `SourceContext` but **not yet source-verified**. - -Desired creation scope: +The activation API is **decided** (D10): mocking exists only inside an explicit scope. ```csharp using (MockingService.Enable()) { - var model = new RecipeModel(...); + var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); } ``` -A plausible design is that `Enable()` establishes an ambient capture scope; any feed context created inside it is tagged/configured as mockable. Disposing the scope would stop capture for future contexts while already-created contexts remain mockable for their lifetime. **This is only a hypothesis, not an accepted decision.** +Rationale — **the wrap is not free**. §1 wraps each Model feed in a `HotSwapFeed`; that is one indirection per feed on every subscription path. Acceptable in a test/preview run, not in a live app. So the wrap must be **opt-in per scope**, never a framework-wide default: outside a scope, `AttachedProperty.GetOrCreate` caches the raw feed exactly as today. + +Granularity is the caller's: a test assembly wanting mocking for its whole run opens the scope in **assembly init** and disposes it at cleanup; a single test wraps one `Create`. Same API. -The source review / spike must answer: +The scope — not the ViewModel — is the boundary: the context owning States/subscriptions (believed `SourceContext`, to be source-verified) is the natural carrier. Plausible design: `Enable()` establishes an ambient capture scope, any feed context created inside it is tagged mockable, and disposal stops tagging *future* contexts while already-created ones stay mockable for their own lifetime. -- exact context type and context-creation call; -- whether context creation is eager during Model/VM construction or lazy at first subscription; -- whether an `AsyncLocal` scope is sufficient across async construction; -- nested scope semantics; -- concurrent tests/model construction; -- subscription lifetime after scope disposal; -- whether activation attaches a mock registry/provider to a context; -- interaction with the separate mockable configuration flag (D4). +The spike must still answer: -If the context is created lazily after the `using` block, the desired syntax cannot work without either eager context capture during construction or transferring an activation token onto the Model/context owner. +- exact context type and context-creation call site; +- whether context creation is eager during Model/VM construction or lazy at first subscription — **if lazy after the `using` block, activation must be captured on the Model/context owner at construction time** (an activation token), since an `AsyncLocal` alone would already be gone; +- whether `AsyncLocal` is sufficient across async construction; +- nested scope semantics and deterministic restoration; +- concurrent tests / concurrent Model construction (no cross-test leakage); +- subscription and context lifetime after `Dispose`; +- whether activation attaches a mock registry/provider to the context; +- exactly how the scope drives `FeedConfiguration.Mockable` (D4) — the flag is the internal gate, not an app-author knob. ## 7. Constraints - **Non-AOT/trim-safe by design** (D7): dev/test-time only; document that the Mocking package must never be referenced by a published app head. - Tier 2/3 APIs remain generic and strongly typed; they do not depend on the non-generic tier-1 `MessageEntry`, source conversion, or an untyped feed abstraction. - Tier 1 stays an isolated UI convenience. -- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, attribute names, hidden hook prefixes. +- **No wrap outside an activation scope** (§6, D10): the per-feed `HotSwapFeed` indirection must never exist in a live app. +- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, attribute names, hidden hook prefixes. - MVUX output byte-identical when opt-in flag absent. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index afdd3d4f22..a4b413bf71 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -63,6 +63,14 @@ Réponses de David à ma question « OK avec ce découpage ? » — par édition Puis : **perte du workspace ACO** (node détruit, branche non poussée — commits `8d589d9`, `292fb5f`, `2618def`, `cd4c9ad` perdus). Reconstruction → restauration dans ce dossier (spawn `ext-mvux-mock`, 23/08 soir). +## v6 — décision de David (dim. 24/08) — activation scopée TRANCHÉE + +- **Le `using (MockingService.Enable())` est certain**, ce n'est plus une question ouverte : c'est **lui** qui active le mocking. +- Granularité au choix de l'appelant : une assembly de tests qui veut le mocking « at large » ouvre le scope dans son **assembly init** ; sinon un scope par test. +- **Motif : le `HotSwapFeed` a un coût.** Activation à la demande uniquement — *« on ne veut pas injecter ce feed dans TOUS les feeds d'une app live »*. Hors scope → aucun wrap, le feed brut est caché comme aujourd'hui. +- Reste au spike (P0-e) le **mécanisme seul** (contexte propriétaire, eager/lazy, `AsyncLocal` vs token porté, imbrication, concurrence, survie après `Dispose`, câblage vers le flag D4) — plus la forme de l'API. +- Répercuté dans les 3 volets : spec §13 + G9 + R7 + D10, archi §1/§6/§7, impl §1/§2.2/§6/§7/§8/§9. + --- ## Registre final des décisions @@ -78,4 +86,4 @@ Puis : **perte du workspace ACO** (node détruit, branche non poussée — commi | D7 | Non-AOT du path mocking accepté (dev/test only) | v1 | | D8 | Converters = illustrations app-owned à `FeedView.Source` (retournent `IMessageEntry`) ; rien d'implémenté par la feature | v4 | | D9 | Tiers 2/3 strictement typés ; l'objet tier-1 confiné au tier 1 | v4 | -| — | **OUVERT** : scope contexte + activation ambiante `MockingService.Enable()` (spike P0-e requis) | v5 | +| D10 | **Activation scopée** : `using (MockingService.Enable())` — jamais un switch app-wide ; assembly init possible pour couvrir tout un run. Hors scope → **aucun wrap** (le `HotSwapFeed` coûte, interdit dans une app live). Seul le mécanisme interne reste à établir par le spike P0-e | v6 | diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index 17d57ce49a..e2332ffa4d 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -13,6 +13,7 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi | Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, opt-in only | | Mock vocabulary (`MockFeed`/`MockListFeed`/`MockCommand`/`MockFeedState`) | **`Uno.Extensions.Reactive.Mocking`** (new) | referenced by test/preview projects only | | Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.Extensions.Reactive.Mocking` (analyzer asset) | runs in consumer project, reads app metadata | +| `MockingService.Enable()` activation scope | `Uno.Extensions.Reactive.Mocking` | frozen name; the only way to turn the wrap on (§6) | ## 2. Core (`Uno.Extensions.Reactive`) @@ -40,7 +41,7 @@ public sealed class CtorDependencyAttribute : Attribute (David's `[FeedShape("Steps", ModelParameter=…)]` idea, renamed. Multiple per member allowed.) ### 2.2 Mockable flag + swap anchor -- `FeedConfiguration.Mockable` (flag, distinct from `HotReload`). +- `FeedConfiguration.Mockable` (flag, distinct from `HotReload`) — **driven by the activation scope (§6), off by default**; no scope → no wrap, so a live app pays nothing (spec G9/R7). - When ON: feed factories wrap the cached instance in `HotSwapFeed` (the wrapper IS the cached value → stable identity; derivations compose on the wrapper). Minimal wiring: wrap inside `AttachedProperty.GetOrCreate` call sites in `Core/Feed.cs` / `Core/ListFeed.cs` factories (one helper). ### 2.3 Tier-1 core surfaces @@ -123,29 +124,35 @@ Rules: - XAML element syntax (``, …) — examples in architecture §3. - **No converter implementation.** A converter at `FeedView.Source` returning `IMessageEntry` appears in docs/samples as an application-owned illustration only (D8/NG6). -## 6. Activation/context spike — required before freezing the runtime hook (UNRESOLVED) - -Inspect the real State/subscription context API (believed `SourceContext`) and prototype: +## 6. Scoped activation — API decided (D10), mechanism to spike ```csharp -using (MockingService.Enable()) +namespace Uno.Extensions.Reactive.Mocking; + +public static class MockingService { - var model = new MyModel(...); + public static IDisposable Enable(); // frozen name; disposal ends the scope } ``` -The spike must prove: -- which context owns States/subscriptions; -- whether the mocking scope can apply to any context, not merely a VM; -- exactly when the context captures the mockable registry/flag; -- lazy-context behavior (context created after the `using` block?); -- nested and concurrent scopes; -- async flow (`AsyncLocal` candidate); -- survival of created contexts after `Dispose`; -- deterministic restoration and test isolation; -- interaction with `FeedConfiguration.Mockable` (D4). +```csharp +// whole test run +[AssemblyInitialize] public static void Init(TestContext _) => _scope = MockingService.Enable(); +[AssemblyCleanup] public static void Cleanup() => _scope.Dispose(); + +// or a single test +using (MockingService.Enable()) { var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); } +``` + +**Non-negotiable constraint:** no activation scope → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `FeedConfiguration.Mockable` (§2.2) is the internal gate the scope drives, not a switch app authors set. -Do not treat `AsyncLocal` or the disposal semantics as decided until the spike/source review is complete. +Spike (P0-e) — establish the *mechanism*, the API shape is fixed: +- which context owns States/subscriptions (believed `SourceContext`) and where it is created; +- eager (Model/VM construction) vs lazy (first subscription) context creation — **if lazy, the scope must be captured on the Model/context owner at construction**, an `AsyncLocal` alone being gone by then; +- ambient propagation across async construction (`AsyncLocal` candidate) vs explicit token; +- nested scopes, deterministic restoration, test isolation under concurrency; +- survival of contexts/subscriptions created inside a disposed scope (expected: mockable for their own lifetime); +- exact wiring from the scope to `FeedConfiguration.Mockable` (D4). ## 7. Phasing @@ -154,7 +161,7 @@ Do not treat `AsyncLocal` or the disposal semantics as decided until the spike/s b. wrap-at-cache: swap `Steps` → `StepsCount` (`Select`) re-emits (D6 — THE gate); c. null-inject construction on a lazy model; eager-ctor fixture NREs as predicted; d. feed-identity stability matrix (capture patterns) → informs FEED3202; - e. context-wide `MockingService.Enable()` spike (§6). + e. `MockingService.Enable()` scope spike (§6) — mechanism only, API shape decided; must prove **no wrap when no scope is open**. - **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. - **P2 — Core mockable flag + wrap + hidden hooks + attributes + analysis** (MVUX gen). - **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetModel`). @@ -180,13 +187,15 @@ Do not treat `AsyncLocal` or the disposal semantics as decided until the spike/s - `SetModel` drives Loading → Value → Error live; derived member updates on-screen after an input swap (D6 end-to-end). - Command states drive `Button.IsEnabled`; hot reload does not clobber a mocked VM/context. -### Context activation (with §6 spike) +### Scoped activation (with §6 spike) +- **No scope open → feeds are the raw instances** (no `HotSwapFeed` in the cache, no measurable overhead) — the G9 guard test. +- Assembly-init scope covers every test of the run; a per-test scope covers only its own. - Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. ### Contract freeze - Reflection-discovery test for `{Model}Mock`/`Empty`/`Create`/`SetModel`/attribute names (Hot Design contract). ## 9. Docs -- `doc/Learn/Mvux/Testing.md`: typed vocabulary, `Create`/`SetModel`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. +- `doc/Learn/Mvux/Testing.md`: `MockingService.Enable()` scope (assembly-init vs per-test, and why it is never app-wide), typed vocabulary, `Create`/`SetModel`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. - `doc/Learn/Mvux/FeedView.md`: tier-1 entry authoring + custom axes; converter shown only as an application-owned illustration at `FeedView.Source` (not a deliverable). - `rules.md`: FEED3201–3203, MOCK0001. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 2ee8360648..bb964e1849 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -21,27 +21,6 @@ Driving a page or a `FeedView` into a **non-happy feed state** (*Loading forever | **Transient error over stale data** | No | | **Per-feed, independently, on one VM** | No — a fake is per-service, not per-feed | -Two audiences, and **two distinct needs that must stay separate**: - -```mermaid -flowchart LR - P1["Previews / Hot Design - declare a feed state in XAML, - no view model"] - P2["App testing - pin each feed of a real VM, - no DI graph, driven from a test project"] - T1["Tier 1 - UI authoring convenience - MessageEntry on FeedView.Source"] - T23["Tiers 2 and 3 - strongly typed mocking engine - package Uno.Extensions.Reactive.Mocking"] - P1 --> T1 - P2 --> T23 - T1 -. isolated - never leaks into tiers 2/3 .- T23 -``` - **Crucially: mocking must be consumable from the OUTSIDE** — a test project that references the app — not injected into the app's own source. ## 2. Core principle — real VM, real Model, business logic survives @@ -174,7 +153,50 @@ sequenceDiagram Full authoring surface (custom axes, XAML examples, evolution contract): [architecture.md §3](architecture.md). -## 7. Goals / Non-goals +## 7. Tier 2 at a glance + +The exhaustive route: build the **whole feed set** of the mock record, apply it with `SetModel`. + +```csharp +// Test / preview project — no DI graph, no fake service +var vm = RecipeViewModel.Create(); // real VM + real Model, services null-injected + +vm.SetModel(new RecipeModelMock // required init → the compiler lists every input to fill +{ + Steps = MockListFeed.Loading(), // pinned Loading, forever + Tags = MockListFeed.Empty(), +}); + +vm.SetModel(RecipeModelMock.Empty with { Steps = MockListFeed.Error(timeout) }); // live re-swap +``` + +- Required members = exactly the **service-dependent** feeds (compile-time completeness, G4). +- Derived members (`StepsCount`) and commands (`Save`) are **optional**: left unset, the real logic runs over the mocked inputs; set, they are replaced. +- `IListFeed` / `IFeed` / `IAsyncCommand` throughout — never a `MessageEntry`. + +Generated surface, `Create` overload rules and diagnostics: [architecture.md §2.2](architecture.md), [implementation.md §4](implementation.md). + +## 8. Tier 3 at a glance + +The same engine, one call: `Create` takes **only the required feeds** — nothing else to fill in. + +```csharp +var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); // one required input → one argument +var loading = RecipeViewModel.Create(MockListFeed.Loading()); +var empty = RecipeViewModel.Create(); // = every input Empty + +// Named catalogs, hand-written in the test/preview project +public static RecipeViewModel BasicRecipe => RecipeViewModel.Create(MockListFeed.Value(AvocadoToast)); +``` + +```xml + + +``` + +No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a preview head can keep re-issuing `SetModel` to walk states live (G6). Catalogs and pickers: [architecture.md §4](architecture.md). + +## 9. Goals / Non-goals **Goals** - G1. Pin any service-dependent feed / list-feed / state / command of a real generated VM. @@ -185,6 +207,7 @@ Full authoring surface (custom axes, XAML examples, evolution contract): [archit - G6. Live re-swap to drive transitions. - G7. Tier-1 XAML state declaration with no VM, including custom axes. - G8. Tiers 2/3 **strongly typed end to end**. +- G9. **Zero cost on a live app**: the `HotSwapFeed` wrap is created only for feeds built inside an explicit activation scope (§13). No wrapper is ever injected into the feeds of a running application. **Non-goals** - NG1. Behavioral/integration testing of services (this targets presentation state). @@ -195,11 +218,11 @@ Full authoring surface (custom axes, XAML examples, evolution contract): [archit - NG6. Defining or implementing a JSON (or any) converter — converters are application-owned illustrations only. - NG7. Reusing tier-1 untyped authoring objects or conversion helpers in tiers 2/3. -## 8. Frozen contracts (Hot Design + test code discover by name) +## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, the dependency attributes, hidden hook naming, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, the dependency attributes, hidden hook naming, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. -## 9. Risks +## 11. Risks | # | Risk | Mitigation | | --- | --- | --- | @@ -209,8 +232,9 @@ Full authoring surface (custom axes, XAML examples, evolution contract): [archit | R4 | Refresh axis is internal → `Refreshing` visually faithful, not axis-faithful | documented | | R5 | Scalar `IFeed` → plain generated property, invisible to `FeedView` | documented | | R6 | Swap-anchor identity: exotic lambda captures → unstable cache key | P0 canary matrix + diagnostic | +| R7 | The wrap has a **runtime cost** (an indirection per feed) → unacceptable if activation were global/always-on | activation is **scoped** (§13, D10): no scope, no wrap; the Mocking package is never referenced by a published head (D7) | -## 10. Resolved decisions (log) +## 12. Resolved decisions (log) | # | Decision | | --- | --- | @@ -223,16 +247,28 @@ Full authoring surface (custom axes, XAML examples, evolution contract): [archit | D7 | AOT non-compliance of the mocking path accepted (dev/test only) | | D8 | Converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source`, returning `IMessageEntry`; this feature defines and implements none | | D9 | Tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 | +| D10 | Activation is an **explicit scope** — `using (MockingService.Enable())` — never an ambient app-wide switch. A test assembly may open it once at assembly init to cover its whole run. **Rationale: the wrap costs at runtime; it must exist only on demand, never in the feeds of a live app** (G9, R7). The scope's internal mechanism is the only part still to be established by the spike (§13) | -## 11. Open question — context scope & ambient activation (UNRESOLVED) +## 13. Scoped activation — `MockingService.Enable()` (DECIDED shape, mechanism to spike) -Tier 2 may not fundamentally be VM-scoped: the actual boundary may be the feed subscription/state **context** (believed to be `SourceContext`; must be verified in source). Desired call-site shape: +**Decided.** Mocking is turned on by an **explicit scope**, and only inside it: ```csharp using (MockingService.Enable()) { - var model = new MyModel(...); + var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); } ``` -Semantics to decide by spike (P0-e, see [implementation.md §6](implementation.md)): ambient (`AsyncLocal`) vs process-global; nested scopes; concurrency; eager vs lazy context creation; subscriptions outliving `Dispose`; interaction with the mockable flag (D4). **Nothing here is accepted until verified against source and reviewed.** +- **On demand only.** Wrapping every Model feed in a `HotSwapFeed` costs at runtime (one indirection per feed, per subscription path). That cost is acceptable in a test/preview run and **not** in a live app: outside an activation scope nothing is wrapped, and no published app head ever references the Mocking package (G9, R7, D7). +- **Whole-run activation is the caller's choice, not the default.** A test assembly that wants mocking at large opens the scope once in its **assembly init** (and disposes it at assembly cleanup); a single test opens it around one `Create`. Same API either way — never a global flag flipped inside the framework. +- The scope, not the ViewModel, is the boundary: tier 2's VM scope was accidental. The real boundary is the feed subscription/state **context** that owns states and subscriptions (believed `SourceContext`, to be confirmed in source). +- `FeedConfiguration.Mockable` (D4) stays the low-level gate the scope drives — it is not a knob for app authors. + +**Still to establish by spike** (P0-e, see [implementation.md §6](implementation.md)) — the *mechanism*, not the shape: + +- exact context type and where/when it is created (eager during Model/VM construction, or lazy at first subscription — if lazy after the `using` block, activation must be captured on the context owner at construction); +- ambient propagation: `AsyncLocal` vs explicit token threading, and whether it survives async construction; +- nested scopes and restoration; concurrent tests not leaking mockability into each other; +- lifetime of contexts and subscriptions created inside a scope once it is disposed (expected: they stay mockable for their own lifetime); +- exactly how the scope drives `FeedConfiguration.Mockable` (D4). From 51c791c1714578d3df49f58058d8c1ad0e725907 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 03:20:48 +0000 Subject: [PATCH 04/19] docs(mocking): move the mocking gate to the source context and make the swap fail hard Records the decision to drop the dedicated static flag in favour of a per-context bit, and to reuse the hot-reload reflection driver with strict failure instead of best effort. --- .../013-mvux-mocking-previews/architecture.md | 47 +++++++++---------- specs/013-mvux-mocking-previews/history.md | 13 ++++- .../implementation.md | 46 +++++++++--------- specs/013-mvux-mocking-previews/spec.md | 38 ++++++++------- 4 files changed, 79 insertions(+), 65 deletions(-) diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 12436e39c5..9ad85421e2 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -11,7 +11,8 @@ Grounded in the current tree. File refs relative to repo root. (Restored after w | Runtime feed substitution | `HotSwapFeed.Set(feed)` (internal) | `Operators/HotSwapFeed.cs` | | **Feed identity cache (per property)** | `AttachedProperty.GetOrCreate(owner/delegate, factory)` | `Core/Feed.cs` (all factories), `Core/Internal/AttachedProperty*` | | Per-state swap seam | `IHotSwapState.HotSwap` → `_hotSwap.Set` | `Core/Internal/StateImpl.cs:95` | -| Swap gate (precedent) | `EffectiveHotReload.HasFlag(State)` | `StateImpl.cs:74`, `Config/HotReloadSupport.cs` | +| Swap gate (**new, per-context**) | `SourceContext.IsMockingActive` read in `StateImpl` ctor **instead of** `EffectiveHotReload.HasFlag(State)` | `StateImpl.cs:74`, `Core/Internal/SourceContext.cs` | +| Reflection swap driver (reused) | iterate `IHotSwapState` members → `HotSwap` (mocking = **fail-hard**, no silent skip) | `BindableViewModelBase.HotReload.cs:457` | | HR model replacement (inspiration) | `HotPatch` → `__Reactive_CreateModelInstance` → `__Reactive_UpdateModel` → `__Reactive_BindableInitializeForUpdatedModel` | `Presentation/Bindings/BindableViewModelBase.HotReload.cs`, `ViewModelGenTool_3.cs:202` | | VM ctor wraps real Model | `{Vm}(params) : this(new Model(params))` | `ViewModelGenTool_3.cs:128` | | Visual state from axes | `FeedViewVisualStateSelector.GetVisualState` | `UI/View/FeedViewVisualStateSelector.cs:31` | @@ -20,25 +21,25 @@ Grounded in the current tree. File refs relative to repo root. (Restored after w Every feed factory caches its instance via `AttachedProperty.GetOrCreate` keyed on the provider delegate (stable when lambdas capture only `this` — the MVUX norm). A derived feed `StepsCount => Steps.Select(...)` is itself a cached `SelectFeed(sourceFeed, selector)` **composed on the instance returned by `Steps`**. -**Anchor:** inside an activation scope (§6 — the mockable flag it drives), the feed returned for a Model feed-property is wrapped in a `HotSwapFeed` **at this cache level** (stable identity preserved — the wrapper is what gets cached). Consequences: +**Anchor:** when the owning `SourceContext.IsMockingActive` is set (§6 — the per-context bit the scope drives), the feed returned for a Model feed-property is wrapped in a `HotSwapFeed` **at this cache level** (stable identity preserved — the wrapper is what gets cached). Consequences: - `Model.Steps` returns the wrapper → the VM state subscribes to it → **swap propagates to the VM member**; - `StepsCount`'s `SelectFeed` composes on the same wrapper → **swap propagates through business logic** (live: a re-swap re-emits through `Select`); -- no `dynamic`, no duck-typed re-init needed for feeds: **`SetModel` = a series of typed swaps** on hidden handles. (The HR `dynamic` path stays untouched, HR-only.) +- no `dynamic`, no duck-typed re-init needed for feeds: **`SetModel` = reflection over the context's `IHotSwapState` members**, calling `HotSwap` per mocked feed (D11), reusing the hot-reload driver but **fail-hard** — a member that cannot be swapped throws. No per-member generated handle. (The HR `dynamic` path stays untouched, HR-only.) ```mermaid flowchart TB F["feed factory call ListFeed.Async(...) in Model.Steps"] --> C{"AttachedProperty feed identity cache"} - C -->|inside activation scope| W["HotSwapFeed wrapper + C -->|context.IsMockingActive| W["HotSwapFeed wrapper (the wrapper IS the cached value)"] - C -->|no scope — live app| RAW["raw feed — today's behavior, + C -->|not mocking — live app| RAW["raw feed — today's behavior, byte-identical"] W --> VMS["VM state subscription"] W --> SEL["SelectFeed = StepsCount (composes on the wrapper)"] - SWAP["__Mock_Swap_Steps(mockFeed)"] -->|"wrapper.Set(mockFeed)"| W + SWAP["reflection: IHotSwapState.HotSwap(mockFeed)"] -->|"wrapper.Set(mockFeed)"| W SEL --> UI2["FeedView"] VMS --> UI1["FeedView"] ``` @@ -64,8 +65,8 @@ Identity risk (R6): lambdas capturing locals/params produce fresh delegate targe (Names to bikeshed; semantics fixed: *input vs derived vs independent*, plus *ctor-eager* flags.) **c) Hidden hooks** (`EditorBrowsable(Never)`, emitted only under the opt-in flag): -- on the **Model partial**: typed per-feed swap handles — `__Mock_Swap_Steps(IListFeed feed)` → `hotSwapWrapper.Set(feed)`; no strings, no reflection; -- on the **VM partial**: `__Mock_Initialize()` (dedicated; NOT `__Reactive_UpdateModel` — must not reassign `__reactiveModel`, rebind INPC, nor let `Model`'s `Unsafe.As` see a foreign type) + command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` (R2). +- on the **Model partial**: **nothing per-feed** — the swap is reflection over `IHotSwapState` members at runtime (D11), reusing the hot-reload driver, fail-hard. The generator emits no `__Mock_Swap_{Member}`; +- on the **VM partial**: `__Mock_Initialize()` (dedicated; NOT `__Reactive_UpdateModel` — must not reassign `__reactiveModel`, rebind INPC, nor let `Model`'s `Unsafe.As` see a foreign type) + command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` (R2). These are the only seams reflection cannot synthesize. ### 2.2 Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the test/preview project) @@ -89,7 +90,7 @@ public static class RecipeViewModelMockExtensions - `Create()` constructs the **real VM** via `new {Vm}(default!, …)`; **compile-time guard**: if `[CtorDependency(Eager=true)]` names parameter `p`, `Create` **requires** a real/fake `p` argument (or the generator emits an error diagnostic if no safe overload is possible). - `SetModel` may be called repeatedly (live transitions, G6); `with`-expressions on the record make variants cheap (`Empty with { Steps = … }`). -- `required init` on service-dependent inputs = compile-time completeness. **Derived members are optional overrides**: `null` (default) → the real derivation recomputes over the swapped inputs; non-null → that member's own wrapper is swapped too (the cache-level anchor wraps *every* feed property, derived included) — lets a test pin a derived value without caring about its inputs. +- `required init` on service-dependent inputs = compile-time completeness. **Derived members are optional overrides**: `null` (default) → the real derivation recomputes over the swapped inputs; non-null → that member's own wrapper is swapped too (the cache-level anchor wraps *every* feed property when the context is mockable, derived included) — lets a test pin a derived value without caring about its inputs. - **Tier 2 and tier 3 never accept `MessageEntry`, an untyped feed envelope, or any other tier-1 authoring abstraction. Their contracts remain `IFeed`, `IListFeed`, typed states and typed commands end to end.** ## 3. Tier 1 — declared `MessageEntry` as `FeedView.Source` @@ -216,8 +217,8 @@ sequenceDiagram T->>MG: RecipeViewModel.Create(steps) MG->>VM: new RecipeViewModel(default!, ...) VM->>M: new RecipeModel(default!, ...) - Note over M,W: mockable flag ON — every Model feed property
is cached as a HotSwapFeed wrapper - MG->>M: SetModel → __Mock_Swap_Steps(steps) (typed, hidden) + Note over M,W: context.IsMockingActive ON — every Model feed property
is cached as a HotSwapFeed wrapper + MG->>M: SetModel → reflection HotSwap over IHotSwapState members (fail-hard) M->>W: wrapper.Set(steps) W-->>UI: Steps emits the mock values W-->>UI: StepsCount recomputes through the real Select @@ -226,7 +227,7 @@ sequenceDiagram W-->>UI: live transition, no re-subscribe ``` -## 6. Scoped activation — decided shape, mechanism to spike +## 6. Scoped activation — decided shape AND mechanism (source-verified) The activation API is **decided** (D10): mocking exists only inside an explicit scope. @@ -241,24 +242,22 @@ Rationale — **the wrap is not free**. §1 wraps each Model feed in a `HotSwapF Granularity is the caller's: a test assembly wanting mocking for its whole run opens the scope in **assembly init** and disposes it at cleanup; a single test wraps one `Create`. Same API. -The scope — not the ViewModel — is the boundary: the context owning States/subscriptions (believed `SourceContext`, to be source-verified) is the natural carrier. Plausible design: `Enable()` establishes an ambient capture scope, any feed context created inside it is tagged mockable, and disposal stops tagging *future* contexts while already-created ones stay mockable for their own lifetime. +The scope — not the ViewModel — is the boundary: the context owning States/subscriptions is **`SourceContext`** (`Core/Internal/SourceContext.cs`, source-verified), the natural carrier. `Enable()` establishes an ambient capture scope (over the existing `AsyncLocal Current`), any feed context created inside it is tagged `IsMockingActive`, and disposal stops tagging *future* contexts while already-created ones stay mockable for their own lifetime. -The spike must still answer: +Resolved against the source: -- exact context type and context-creation call site; -- whether context creation is eager during Model/VM construction or lazy at first subscription — **if lazy after the `using` block, activation must be captured on the Model/context owner at construction time** (an activation token), since an `AsyncLocal` alone would already be gone; -- whether `AsyncLocal` is sufficient across async construction; -- nested scope semantics and deterministic restoration; -- concurrent tests / concurrent Model construction (no cross-test leakage); -- subscription and context lifetime after `Dispose`; -- whether activation attaches a mock registry/provider to the context; -- exactly how the scope drives `FeedConfiguration.Mockable` (D4) — the flag is the internal gate, not an app-author knob. +- **Carrier:** `SourceContext` gains `bool IsMockingActive`. It already exposes `AsyncLocal Current`, per-owner contexts (`GetOrCreate(owner)`), and an eager pre-seed seam (`PreConfigure(type, ctx)` / `Set(owner, ctx)`). +- **Eager vs lazy:** `Create(...)` pre-seeds a mockable context on the VM/Model owner (via the `PreConfigure`/`Set` seam) so a lazy first subscription **after** the `using` block still wraps — the bit lives on the context instance, not only on the ambient `AsyncLocal`. +- **Wrap gate:** `StateImpl` ctor reads `context.IsMockingActive` instead of `FeedConfiguration.EffectiveHotReload` (D12). +- **Nested / concurrent / lifetime:** the bit is per-context-instance → concurrent tests don't leak; contexts created inside a scope stay mockable for their own lifetime after `Dispose`. +- **No mock registry on the context needed:** swap is reflection over the context's `IHotSwapState` members (D11); overrides are applied by `SetModel` at swap time. ## 7. Constraints - **Non-AOT/trim-safe by design** (D7): dev/test-time only; document that the Mocking package must never be referenced by a published app head. - Tier 2/3 APIs remain generic and strongly typed; they do not depend on the non-generic tier-1 `MessageEntry`, source conversion, or an untyped feed abstraction. - Tier 1 stays an isolated UI convenience. -- **No wrap outside an activation scope** (§6, D10): the per-feed `HotSwapFeed` indirection must never exist in a live app. -- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, attribute names, hidden hook prefixes. +- **No wrap unless `SourceContext.IsMockingActive`** (§6, D10/D12): the per-feed `HotSwapFeed` indirection must never exist in a live app; a live-app context never has the bit set. +- **Swap is reflection over `IHotSwapState`, fail-hard** (D11): no per-member generated hook; an un-swappable mocked member throws. +- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, VM null-inject ctor/command seam. - MVUX output byte-identical when opt-in flag absent. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index a4b413bf71..2d18b3fb06 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -71,6 +71,15 @@ Puis : **perte du workspace ACO** (node détruit, branche non poussée — commi - Reste au spike (P0-e) le **mécanisme seul** (contexte propriétaire, eager/lazy, `AsyncLocal` vs token porté, imbrication, concurrence, survie après `Dispose`, câblage vers le flag D4) — plus la forme de l'API. - Répercuté dans les 3 volets : spec §13 + G9 + R7 + D10, archi §1/§6/§7, impl §1/§2.2/§6/§7/§8/§9. + +## v7 — décision de David (dim. 24/08, soir) — gate per-context + swap réflexif + +- **Question tranchée (« où vit le flag mockable ? »)** : investigation source demandée par David. + - Constat code : le hot reload wrappe dans `StateImpl.cs:74-77` (`EffectiveHotReload.HasFlag(State)` → `new HotSwapFeed`), gate = **static global** `FeedConfiguration.EffectiveHotReload` ; driver de swap **déjà réflexif** sur `IHotSwapState` (`BindableViewModelBase.HotReload.cs:457-467`). `SourceContext` porte déjà `AsyncLocal Current`, contextes par-owner (`GetOrCreate`), seam eager `PreConfigure`/`Set`, et un `IStateStore States` par contexte. + - **Décision David** : *« flag sur le SourceContext (`IsMockingActive`) + réflexion pour le swap avec fail-hard »*. Le static `FeedConfiguration.Mockable` (D4) est abandonné : c'est le **contexte** qui a besoin de l'info (D12). Pas d'`AsyncLocal` maison. Swap réflexif strict (D11). + - Point AOT (David) : un split 2-assemblies impose la réflexion de toute façon (générer `{Model}Mock` à côté du `Model` rendrait l'assembly mock creuse) → réflexion-core assumée, path dev/test-only non-AOT (D7/NG2). +- Le « spike P0-e » (mécanisme du scope) est **résolu**, plus un spike : il ride `SourceContext`. +- Répercuté : spec §13/§10/§5/§4 + D4(superseded)/D11/D12, archi §0/§1/§2.1/§5/§6/§7, impl §1/§2.2/§3/§6/§7/§8. --- ## Registre final des décisions @@ -80,10 +89,12 @@ Puis : **perte du workspace ACO** (node détruit, branche non poussée — commi | D1 | Tier-1 = `MessageEntry` authorable non-générique **dans Core**, plain CLR (pas DO), **non observable**, axes core en propriétés directes + axes custom via `Axes`/`Set` ; remplacement d'instance = push dans le wrapper existant (évolution naturelle, pas de loading flash) | v2→v4 | | D2 | Commandes via seam `??` (pas de swap analog) | v1 | | D3 | Façade (`SetModel`/setters générés) devant les hooks ; `HotSwapFeed`/handles non publics | v1 | -| D4 | Flag mockable dédié dans `FeedConfiguration` (découplé du hot reload) | v1 | +| D4 | ~~Flag mockable dédié dans `FeedConfiguration`~~ **remplacé v7** → gate per-context `SourceContext.IsMockingActive` (D12) | v1→v7 | | D5 | Codegen de mocking **externe** (projet consommateur) ; gen MVUX = analyse + attributs + hooks cachés | v1 | | D6 | Swap ancré au **cache Model-feed** → les dérivés survivent (non négociable) ; dérivés néanmoins **overridables** individuellement | v1+v2 | | D7 | Non-AOT du path mocking accepté (dev/test only) | v1 | | D8 | Converters = illustrations app-owned à `FeedView.Source` (retournent `IMessageEntry`) ; rien d'implémenté par la feature | v4 | | D9 | Tiers 2/3 strictement typés ; l'objet tier-1 confiné au tier 1 | v4 | | D10 | **Activation scopée** : `using (MockingService.Enable())` — jamais un switch app-wide ; assembly init possible pour couvrir tout un run. Hors scope → **aucun wrap** (le `HotSwapFeed` coûte, interdit dans une app live). Seul le mécanisme interne reste à établir par le spike P0-e | v6 | +| D11 | **Swap réflexif fail-hard** : réutilise le driver hot-reload (`BindableViewModelBase.HotReload`, itération `IHotSwapState`) ; le générateur MVUX **n'émet aucun `__Mock_Swap_{Member}`**, seulement métadonnées + seam ctor null-inject/commande. **Delta vs hot reload : un membre non-swappable throw** (mocking strict, pas best-effort) | v7 | +| D12 | **Gate mockable = bit per-contexte `SourceContext.IsMockingActive`**, lu dans le ctor de `StateImpl` **au lieu** du static global `EffectiveHotReload` → seuls les contextes sous scope wrappent, le reste paie zéro (G9/R7 par construction). Pas de static séparé, pas d'`AsyncLocal` maison (on réutilise `AsyncLocal Current`). **Réflexion-core assumée vs AOT-strict** : le split 2-assemblies impose la réflexion de toute façon ; path mocking dev/test-only non-AOT (NG2/D7) | v7 | diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index e2332ffa4d..0300dc0e6d 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -7,13 +7,14 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi | Piece | Package | Notes | | --- | --- | --- | | Dependency attributes | `Uno.Extensions.Reactive` (core) | must survive as metadata in the app assembly | -| Mockable flag + HotSwap wrap at feed cache | core | `FeedConfiguration.Mockable` (new), wired in `AttachedProperty`/factories | +| Mockable gate + HotSwap wrap at feed cache | core | **`SourceContext.IsMockingActive`** (new per-context bit, D12) read in `StateImpl` ctor; wrap wired at the `AttachedProperty`/factory cache | | Authorable `MessageEntry` + `AxisValue` (plain CLR) + internal `MessageEntryFeed` | core | tier-1, AOT-safe, **not** a `DependencyObject` | | `FeedView.Source` coercion bridge | `Uno.Extensions.Reactive.UI` | tier-1 | | Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, opt-in only | | Mock vocabulary (`MockFeed`/`MockListFeed`/`MockCommand`/`MockFeedState`) | **`Uno.Extensions.Reactive.Mocking`** (new) | referenced by test/preview projects only | | Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.Extensions.Reactive.Mocking` (analyzer asset) | runs in consumer project, reads app metadata | -| `MockingService.Enable()` activation scope | `Uno.Extensions.Reactive.Mocking` | frozen name; the only way to turn the wrap on (§6) | +| Reflection swap driver (reused, fail-hard) | core | reuse hot-reload's `IHotSwapState` iteration; **throw on un-swappable member** (D11) | +| `MockingService.Enable()` activation scope | `Uno.Extensions.Reactive.Mocking` | frozen name; sets `SourceContext.IsMockingActive` on the ambient/pre-seeded context (§6) | ## 2. Core (`Uno.Extensions.Reactive`) @@ -40,9 +41,10 @@ public sealed class CtorDependencyAttribute : Attribute ``` (David's `[FeedShape("Steps", ModelParameter=…)]` idea, renamed. Multiple per member allowed.) -### 2.2 Mockable flag + swap anchor -- `FeedConfiguration.Mockable` (flag, distinct from `HotReload`) — **driven by the activation scope (§6), off by default**; no scope → no wrap, so a live app pays nothing (spec G9/R7). -- When ON: feed factories wrap the cached instance in `HotSwapFeed` (the wrapper IS the cached value → stable identity; derivations compose on the wrapper). Minimal wiring: wrap inside `AttachedProperty.GetOrCreate` call sites in `Core/Feed.cs` / `Core/ListFeed.cs` factories (one helper). +### 2.2 Mockable gate + swap anchor +- **`SourceContext.IsMockingActive`** (per-context bit, D12 — distinct from `HotReload`, no global static, no bespoke `AsyncLocal`) — **set by the activation scope (§6), off by default**; context not mockable → no wrap, so a live app pays nothing (spec G9/R7). Read at wrap time in `StateImpl` ctor **instead of** `FeedConfiguration.EffectiveHotReload`. +- When the owning context is mockable: feed factories wrap the cached instance in `HotSwapFeed` (the wrapper IS the cached value → stable identity; derivations compose on the wrapper). Minimal wiring: wrap inside `AttachedProperty.GetOrCreate` call sites in `Core/Feed.cs` / `Core/ListFeed.cs` factories (one helper reading the context bit). +- **Swap = reflection over the context's `IHotSwapState` members** (D11), reusing the hot-reload driver (`BindableViewModelBase.HotReload`), **fail-hard**: a mocked member that cannot be swapped throws (no silent skip — the hot-reload delta). ### 2.3 Tier-1 core surfaces - `Feed.Value` public factory (from #3148, additive). @@ -56,8 +58,8 @@ Opt-in: `[assembly: EnableFeedMocking]` (or MSBuild prop). When absent → byte- 1. **Analysis pass** (per Model): classify members `ServiceDependent(param) | DerivedFrom(feed) | Independent`; lambda/anonymous/local-function bodies = deferred boundary. **Ctor instrumentation**: walk ctor bodies + field/property initializers + primary-ctor eager captures → mark `CtorDependency(Eager=true)` per offending parameter. Hand-declared attributes override/merge (author is the escape hatch). 2. **Emit attributes** (§2.1) on the generated Model partial. -3. **Hidden hooks** (`EditorBrowsable(Never)`): - - Model partial: `void __Mock_Swap_{Member}(IListFeed/IFeed feed)` per feed member → wrapper `.Set(feed)`; plus `bool __Mock_IsMockable` guard (flag on). Hooks retain concrete generic member types. +3. **Emitted seams** (`EditorBrowsable(Never)`) — only what reflection cannot synthesize: + - Model partial: **no per-feed `__Mock_Swap_{Member}`** — swap is reflection over `IHotSwapState` at runtime (D11). (The `HotSwapFeed` wrappers already expose the swap seam the reflection driver uses.) - VM partial: `static {Vm} __Mock_Create(object?[] ctorArgs)` → `new {Vm}(…)` null-inject path (dedicated — NOT `__Reactive_UpdateModel`); command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` + `__Mock_SetCommand(name, IAsyncCommand)`. 4. Diagnostics: `FEED3201` eager ctor access detected (info: `Create` will require the service), `FEED3202` unstable feed identity (capture pattern defeats caching), `FEED3203` explicit attribute contradicts analysis. @@ -107,7 +109,7 @@ public static class RecipeViewModelMocking public static RecipeViewModel Create(); // null-inject + SetModel(Empty) public static RecipeViewModel Create(IListFeed steps); // per required input public static RecipeViewModel Create(IRecipeService svc, IListFeed steps); // when CtorDependency(Eager) → service required - public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // typed __Mock_Swap_* calls + public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // reflection HotSwap over IHotSwapState (fail-hard) } ``` Rules: @@ -124,7 +126,7 @@ Rules: - XAML element syntax (``, …) — examples in architecture §3. - **No converter implementation.** A converter at `FeedView.Source` returning `IMessageEntry` appears in docs/samples as an application-owned illustration only (D8/NG6). -## 6. Scoped activation — API decided (D10), mechanism to spike +## 6. Scoped activation — API decided (D10), mechanism resolved (D12) ```csharp namespace Uno.Extensions.Reactive.Mocking; @@ -144,26 +146,25 @@ public static class MockingService using (MockingService.Enable()) { var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); } ``` -**Non-negotiable constraint:** no activation scope → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `FeedConfiguration.Mockable` (§2.2) is the internal gate the scope drives, not a switch app authors set. +**Non-negotiable constraint:** context not mockable → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `SourceContext.IsMockingActive` (§2.2, D12) is the internal per-context gate the scope drives, not a switch app authors set. -Spike (P0-e) — establish the *mechanism*, the API shape is fixed: -- which context owns States/subscriptions (believed `SourceContext`) and where it is created; -- eager (Model/VM construction) vs lazy (first subscription) context creation — **if lazy, the scope must be captured on the Model/context owner at construction**, an `AsyncLocal` alone being gone by then; -- ambient propagation across async construction (`AsyncLocal` candidate) vs explicit token; -- nested scopes, deterministic restoration, test isolation under concurrency; -- survival of contexts/subscriptions created inside a disposed scope (expected: mockable for their own lifetime); -- exact wiring from the scope to `FeedConfiguration.Mockable` (D4). +Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): +- **Owner context = `SourceContext`** — already owns `States`/subscriptions, already ambient via `AsyncLocal Current`, already per-owner via `GetOrCreate(owner)`, with an eager pre-seed seam `PreConfigure(type, ctx)` / `Set(owner, ctx)`. It gains `bool IsMockingActive`. +- **Eager vs lazy = solved by pre-seed**: `Create(...)` pre-seeds a mockable context on the VM/Model owner (`PreConfigure`/`Set`), so a lazy first subscription after the `using` block still wraps — the bit is on the context instance, not only on the ambient `AsyncLocal`. +- **Ambient propagation**: the existing `AsyncLocal Current` carries mockability across async construction; no bespoke `AsyncLocal`. +- **Nested / concurrency / lifetime**: per-context-instance bit → concurrent tests don't leak; contexts created inside a scope stay mockable for their own lifetime after `Dispose`. +- **Wiring**: `StateImpl` ctor reads `context.IsMockingActive` (replaces the `EffectiveHotReload` read); swap is reflection over `IHotSwapState` (D11). ## 7. Phasing - **P0 — de-risk canaries (blocking):** - a. `MessageEntry` wrapper → Undefined/None/Some/Error/Loading visual states (R3), and entry push → axis-diff evolution with no loading flash; - b. wrap-at-cache: swap `Steps` → `StepsCount` (`Select`) re-emits (D6 — THE gate); + a. (tier-1, on hold) `MessageEntry` wrapper visual states + push axis-diff — deferred with tier 1; + b. wrap-at-cache via `SourceContext.IsMockingActive`: swap `Steps` → `StepsCount` (`Select`) re-emits (D6/D12 — THE gate). The hot-reload path already proves derivation-survives-swap; this canary re-verifies it under the per-context gate; c. null-inject construction on a lazy model; eager-ctor fixture NREs as predicted; d. feed-identity stability matrix (capture patterns) → informs FEED3202; - e. `MockingService.Enable()` scope spike (§6) — mechanism only, API shape decided; must prove **no wrap when no scope is open**. + e. `MockingService.Enable()` → `IsMockingActive` on the pre-seeded context: prove **no wrap when the context is not mockable**, and reflection swap is **fail-hard** on an un-swappable member (D11). - **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. -- **P2 — Core mockable flag + wrap + hidden hooks + attributes + analysis** (MVUX gen). +- **P2 — Core: `SourceContext.IsMockingActive` + wrap gate in `StateImpl` + fail-hard reflection swap + attributes + analysis + VM null-inject/command seam** (MVUX gen). No per-feed swap hooks. - **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetModel`). - **P4 — Tier 3 catalogs + Hot Design checkpoint** (name freeze), docs. @@ -188,7 +189,8 @@ Spike (P0-e) — establish the *mechanism*, the API shape is fixed: - Command states drive `Button.IsEnabled`; hot reload does not clobber a mocked VM/context. ### Scoped activation (with §6 spike) -- **No scope open → feeds are the raw instances** (no `HotSwapFeed` in the cache, no measurable overhead) — the G9 guard test. +- **Context not mockable → feeds are the raw instances** (no `HotSwapFeed` in the cache, no measurable overhead) — the G9 guard test. +- **Fail-hard swap**: a mocked member with no `IHotSwapState` throws (D11), asserted. - Assembly-init scope covers every test of the run; a per-test scope covers only its own. - Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index bb964e1849..9a5d617034 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -85,9 +85,9 @@ flowchart TB AN --> ATTR["metadata attributes FeedDependency / CtorDependency (also hand-declarable — explicit wins)"] - GEN --> HOOKS["hidden typed hooks - __Mock_Swap_Steps(...) on the Model - dedicated mock ctor path on the VM"] + GEN --> HOOKS["emitted seams (no per-feed hook) + VM null-inject ctor + command ?? seam + swap = reflection over IHotSwapState (D11)"] end subgraph TEST["Test / preview project — references the app"] MG["Mocking generator @@ -103,7 +103,7 @@ flowchart TB - **MVUX generator (runs in the Model's assembly, on the partial Model):** a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); - c. emits **hidden hooks** (HR-style, `EditorBrowsable(Never)`): per-feed typed swap handles on the Model partial + a dedicated mock-apply path on the VM (NOT `__Reactive_UpdateModel`, which reassigns `__reactiveModel`/INPC and is unsafe for this use). + c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, under the opt-in): the VM null-inject construction path + the command `??` seam (R2). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). - **Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). ## 5. End-to-end — a test drives a page through its states @@ -118,9 +118,9 @@ sequenceDiagram T->>G: RecipeViewModel.Create(steps) G->>VM: new RecipeViewModel(default!, ...) - Note over VM: mockable flag ON —
every Model feed property is
cached as a HotSwapFeed wrapper + Note over VM: context.IsMockingActive ON —
every Model feed property is
cached as a HotSwapFeed wrapper G->>W: SetModel(Empty with Steps = steps) - W-->>VM: Steps swapped (typed hidden hook) + W-->>VM: Steps swapped (reflection over IHotSwapState, fail-hard) VM-->>UI: StepsCount recomputes through the real Select UI-->>UI: renders pinned states T->>W: SetModel(...) — Loading, Value, Error @@ -220,7 +220,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, the dependency attributes, hidden hook naming, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM null-inject ctor/command seam, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. ## 11. Risks @@ -241,15 +241,17 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe | D1 | Tier-1 authoring = authorable non-generic `MessageEntry` **in Core**; **plain CLR, not a `DependencyObject`**; **not observable** (instance replacement is the unit of change); core axes as convenience properties, **custom axes** via `Axes` / `Set(MessageAxis, value)`; replacement pushes through the existing wrapper (natural feed evolution, no loading flash) | | D2 | Commands = `??` seam (no swap analog) | | D3 | **Facade** (`SetModel` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public | -| D4 | Dedicated **`FeedConfiguration` mockable flag** (decoupled from hot reload) | +| D4 | ~~Dedicated `FeedConfiguration` mockable flag~~ **superseded (2026-08-24)**: the gate lives on **`SourceContext.IsMockingActive`** (per-context, set by `MockingService.Enable()` on the ambient context). No separate static, no bespoke `AsyncLocal`. See D11–D12 | | D5 | Mock codegen is **external** (consumer project); MVUX gen only analyzes + emits attributes & hidden hooks | | D6 | Swap anchored at **Model-feed cache level** so derivations survive (non-negotiable) | | D7 | AOT non-compliance of the mocking path accepted (dev/test only) | | D8 | Converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source`, returning `IMessageEntry`; this feature defines and implements none | | D9 | Tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 | -| D10 | Activation is an **explicit scope** — `using (MockingService.Enable())` — never an ambient app-wide switch. A test assembly may open it once at assembly init to cover its whole run. **Rationale: the wrap costs at runtime; it must exist only on demand, never in the feeds of a live app** (G9, R7). The scope's internal mechanism is the only part still to be established by the spike (§13) | +| D10 | Activation is an **explicit scope** — `using (MockingService.Enable())` — never an ambient app-wide switch. A test assembly may open it once at assembly init to cover its whole run. **Rationale: the wrap costs at runtime; it must exist only on demand, never in the feeds of a live app** (G9, R7). The scope's internal mechanism is now **resolved** — it rides `SourceContext` (§13) | +| D11 | **Swap is reflection-driven over the members, fail-hard** — reuse the existing hot-reload reflection path (`BindableViewModelBase.HotReload`, iterating `IHotSwapState`); the MVUX generator emits **no per-member `__Mock_Swap_{Member}` hooks**, only metadata attributes + the VM null-inject ctor/command seam. **Delta vs hot reload: a member that cannot be swapped throws — no silent skip** (hot reload is best-effort; mocking is strict) | +| D12 | **The mockable gate is a per-context bit on `SourceContext.IsMockingActive`**, read at wrap time in `StateImpl` ctor **instead of** the global `EffectiveHotReload` static — so only contexts created under an open scope wrap, every other context pays zero (G9/R7 by construction). **Reflection-core accepted over AOT-strict**: a 2-assembly split needs reflection anyway (generating the mock beside the Model would make the mocking assembly hollow); the mocking path stays dev/test-only, non-AOT (NG2/D7) | -## 13. Scoped activation — `MockingService.Enable()` (DECIDED shape, mechanism to spike) +## 13. Scoped activation — `MockingService.Enable()` (DECIDED shape AND mechanism) **Decided.** Mocking is turned on by an **explicit scope**, and only inside it: @@ -262,13 +264,13 @@ using (MockingService.Enable()) - **On demand only.** Wrapping every Model feed in a `HotSwapFeed` costs at runtime (one indirection per feed, per subscription path). That cost is acceptable in a test/preview run and **not** in a live app: outside an activation scope nothing is wrapped, and no published app head ever references the Mocking package (G9, R7, D7). - **Whole-run activation is the caller's choice, not the default.** A test assembly that wants mocking at large opens the scope once in its **assembly init** (and disposes it at assembly cleanup); a single test opens it around one `Create`. Same API either way — never a global flag flipped inside the framework. -- The scope, not the ViewModel, is the boundary: tier 2's VM scope was accidental. The real boundary is the feed subscription/state **context** that owns states and subscriptions (believed `SourceContext`, to be confirmed in source). -- `FeedConfiguration.Mockable` (D4) stays the low-level gate the scope drives — it is not a knob for app authors. +- The scope, not the ViewModel, is the boundary. The real boundary is the feed subscription/state **context** that owns states and subscriptions — **confirmed in source: `SourceContext`** (`Core/Internal/SourceContext.cs`), which already holds an `AsyncLocal Current` and per-owner contexts. `Enable()` tags the ambient/created contexts `IsMockingActive`; `StateImpl` reads that bit at wrap time. +- `SourceContext.IsMockingActive` (D12) is the low-level per-context gate the scope drives — it is not a knob for app authors, and there is no global static equivalent. -**Still to establish by spike** (P0-e, see [implementation.md §6](implementation.md)) — the *mechanism*, not the shape: +**Resolved mechanism** (source-verified, see [implementation.md §6](implementation.md)): -- exact context type and where/when it is created (eager during Model/VM construction, or lazy at first subscription — if lazy after the `using` block, activation must be captured on the context owner at construction); -- ambient propagation: `AsyncLocal` vs explicit token threading, and whether it survives async construction; -- nested scopes and restoration; concurrent tests not leaking mockability into each other; -- lifetime of contexts and subscriptions created inside a scope once it is disposed (expected: they stay mockable for their own lifetime); -- exactly how the scope drives `FeedConfiguration.Mockable` (D4). +- **Context type & carrier:** `SourceContext` (`Core/Internal/SourceContext.cs`) — already the owner of `States`/subscriptions, already ambient via `AsyncLocal Current`, already created per-owner (`GetOrCreate(owner)`) with an eager pre-seed seam (`PreConfigure(type, ctx)` / `Set(owner, ctx)`). It carries a new `bool IsMockingActive`. +- **Activation:** `MockingService.Enable()` opens a scope that marks the relevant `SourceContext`(s) `IsMockingActive` (ambient for async construction; eager pre-seed for the VM/Model context built by `Create(...)` so a lazy first subscription after the `using` block still wraps). +- **Wrap gate:** `StateImpl` ctor reads `context.IsMockingActive` **instead of** `FeedConfiguration.EffectiveHotReload` — no scope ⇒ no wrap (G9/R7 hold by construction, per-context not per-process). +- **Nested scopes / concurrency / lifetime:** inherited from `SourceContext` semantics — the bit lives on the context instance, so concurrent tests do not leak, and contexts created inside a scope stay mockable for their own lifetime after `Dispose`. +- **Swap:** reflection over the context's `IHotSwapState` members (D11), fail-hard. From 57c8d738d167ea54eb2d9921cfcdb526d4b68945 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 03:31:32 +0000 Subject: [PATCH 05/19] feat(mocking): gate the hot-swap wrap on the source context Adds a per-context bit inherited at creation and reads it where the wrap is decided, so only contexts created under an activation scope wrap their feeds and a live application pays nothing. --- .../Core/Given_MockingActivation.cs | 101 ++++++++++++++++++ .../Core/Internal/SourceContext.cs | 32 ++++++ .../Core/Internal/StateImpl.cs | 6 +- .../Utils/Disposables/Disposable.cs | 12 +++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs diff --git a/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs b/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs new file mode 100644 index 0000000000..f3d98e6fb4 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions.Reactive.Core; +using Uno.Extensions.Reactive.Operators; +using Uno.Extensions.Reactive.Testing; + +namespace Uno.Extensions.Reactive.Tests.Core; + +/// +/// Spec 013 — substrate canaries for the per-context mocking gate (D12) and reflection swap (D11). +/// +[TestClass] +public class Given_MockingActivation : FeedTests +{ + private static HotSwapFeed? GetHotSwap(StateImpl state) + => (HotSwapFeed?)typeof(StateImpl) + .GetField("_hotSwap", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(state); + + [TestMethod] + public void When_NoScope_Then_ContextNotMockable_And_NoWrap() + { + using var ctx = new FeedTestContext(); + + ctx.SourceContext.IsMockingActive.Should().BeFalse("no MockingService.Enable() scope was opened"); + + var state = new StateImpl(ctx.SourceContext, Option.Some("v")); + GetHotSwap(state).Should().BeNull("a live-app context must never inject a HotSwapFeed indirection (G9/R7)"); + } + + [TestMethod] + public void When_UnderScope_Then_ContextMockable_And_Wrapped() + { + FeedTestContext ctx; + using (SourceContext.EnableMocking()) + { + ctx = new FeedTestContext(); + } + + using (ctx) + { + ctx.SourceContext.IsMockingActive.Should().BeTrue("the context was created inside an EnableMocking() scope"); + + var state = new StateImpl(ctx.SourceContext, Option.Some("v")); + GetHotSwap(state).Should().NotBeNull("a mocking context wraps every state's source so it can be swapped"); + } + } + + [TestMethod] + public void When_ScopeDisposed_Then_AlreadyCreatedContextStaysMockable_ButNewOnesDont() + { + FeedTestContext inside; + using (SourceContext.EnableMocking()) + { + inside = new FeedTestContext(); + } + using var outside = new FeedTestContext(); + + inside.SourceContext.IsMockingActive.Should().BeTrue("contexts created inside a scope stay mockable for their own lifetime"); + outside.SourceContext.IsMockingActive.Should().BeFalse("after disposal, new contexts are no longer mockable"); + + inside.Dispose(); + } + + [TestMethod] + public async Task When_MockableStateSwapped_Then_ReEmits() + { + FeedTestContext ctxHolder; + using (SourceContext.EnableMocking()) + { + ctxHolder = new FeedTestContext(); + } + + using (ctxHolder) + { + ctxHolder.RestoreCurrent(); + + var original = Feed.Async(async ct => "original"); + var state = (StateImpl)ctxHolder.SourceContext.GetOrCreateState(original); + + // Sanity: a mocking context wraps the state source so it can be swapped. + GetHotSwap(state).Should().NotBeNull(); + + var (result, _) = state.Record(); + + await result.WaitForMessages(1); + result.Last().Current.Data.SomeOrDefault().Should().Be("original"); + + // Reflection swap (D11): the state exposes IHotSwapState, like hot reload. + ((IHotSwapState)state).HotSwap(Feed.Async(async ct => "mocked")); + + await result.WaitForMessages(2); + result.Last().Current.Data.SomeOrDefault().Should().Be("mocked", + "the swapped source must re-emit through the same cached wrapper"); + } + } +} diff --git a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs index 65f143316f..2aea658639 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs @@ -32,6 +32,12 @@ public sealed class SourceContext : IAsyncDisposable private static readonly SourceContext _none = new(); private static readonly AsyncLocal _current = new(); + + // Mocking (spec 013): ambient flag driving whether newly created contexts are mockable. + // Reuses the AsyncLocal ambient model already used for Current (no bespoke AsyncLocal in the mocking layer). + // A context inherits IsMockingActive at creation time (root <- ambient, child <- parent) so the bit + // survives a lazy first subscription even after the activation scope's `using` block has exited. + private static readonly AsyncLocal _isMockingAmbient = new(); private static readonly ConditionalWeakTable _contexts = new(); /// @@ -210,6 +216,7 @@ private SourceContext(RootOwner ownerInfo) RootId = (uint)Interlocked.Increment(ref _nextRootId); States = _localStates = new StateStore(this); RequestSource = _localRequests = new NoneRequestSource(); // Currently we do not support messages directly on the root, using None allows AsyncFeed to complete enumeration + IsMockingActive = _isMockingAmbient.Value; // spec 013: inherit ambient mocking activation } // Creates a sub context @@ -230,6 +237,7 @@ private SourceContext(SourceContext parent, ISourceContextOwner owner, IStateSto Owner = owner; States = states ?? parent.States; // Note: A child StateStore should forward request to its parent store! RequestSource = requests ?? parent.RequestSource; + IsMockingActive = parent.IsMockingActive; // spec 013: mocking activation flows down the context tree } /// @@ -268,6 +276,30 @@ private SourceContext(SourceContext parent, ISourceContextOwner owner, IStateSto /// internal IRequestSource RequestSource { get; } + /// + /// Gets a value indicating whether feeds/states created under this context must be wrapped so their source + /// can be swapped at runtime (mocking — spec 013). Off by default; a live app context never has it set, + /// so no indirection is ever injected into a running application (G9/R7). + /// + /// + /// This is the per-context gate that MockingService.Enable() drives, read at wrap time in + /// 's constructor instead of the global . + /// + internal bool IsMockingActive { get; } + + /// + /// Opens an ambient mocking-activation scope: every created while the returned + /// disposable is alive (and their descendants) is marked . Disposal stops marking + /// future contexts; already-created contexts stay mockable for their own lifetime. + /// + /// Backing mechanism for MockingService.Enable() (spec 013 §13, D12). + internal static IDisposable EnableMocking() + { + var previous = _isMockingAmbient.Value; + _isMockingAmbient.Value = true; + return Utils.Disposable.Create(() => _isMockingAmbient.Value = previous); + } + /// /// Sets the context as . /// diff --git a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs index 8c5b8d4720..2e22760478 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs @@ -71,7 +71,11 @@ public StateImpl( _mode = mode; _updatesKind = updatesKind; - if (FeedConfiguration.EffectiveHotReload.HasFlag(HotReloadSupport.State)) + // Wrap the source in a HotSwapFeed when either: + // - hot-reload is enabled globally (existing behavior), or + // - this context is a mocking context (spec 013, D12): the per-context gate, so only contexts + // created under a MockingService.Enable() scope wrap — a live app pays nothing (G9/R7). + if (FeedConfiguration.EffectiveHotReload.HasFlag(HotReloadSupport.State) || context.IsMockingActive) { // It's valid to use the HotSwap feed here, as we are caching it internally and the subscription is managed by the State itself on its own Context. feed = _hotSwap = new HotSwapFeed(feed); diff --git a/src/Uno.Extensions.Reactive/Utils/Disposables/Disposable.cs b/src/Uno.Extensions.Reactive/Utils/Disposables/Disposable.cs index 43f43b57fb..5c3dcebe33 100644 --- a/src/Uno.Extensions.Reactive/Utils/Disposables/Disposable.cs +++ b/src/Uno.Extensions.Reactive/Utils/Disposables/Disposable.cs @@ -7,9 +7,21 @@ internal static class Disposable { public static IDisposable Empty { get; } = new Null(); + public static IDisposable Create(Action onDispose) => new Anonymous(onDispose); + private class Null : IDisposable { /// public void Dispose() { } } + + private sealed class Anonymous : IDisposable + { + private Action? _onDispose; + + public Anonymous(Action onDispose) => _onDispose = onDispose; + + /// + public void Dispose() => System.Threading.Interlocked.Exchange(ref _onDispose, null)?.Invoke(); + } } From a5ed1d076cf4875e6796c56bac6273865bc22084 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 10:42:03 +0000 Subject: [PATCH 06/19] feat(mocking): emit the feed dependency metadata from the MVUX generator Classifies every feed member as service dependent, derived or independent, and instruments the constructors so an eagerly dereferenced service is reported. The metadata is what the consumer generator reads. --- .../Bindables/BindableGenerationContext.cs | 21 + .../Bindables/ViewModelGenTool_3.Mocking.cs | 377 ++++++++++++++++++ .../Bindables/ViewModelGenTool_3.cs | 5 +- .../Config/CtorDependencyAttribute.cs | 41 ++ .../Config/EnableFeedMockingAttribute.cs | 26 ++ .../Config/FeedDependencyAttribute.cs | 49 +++ 6 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs create mode 100644 src/Uno.Extensions.Reactive/Config/CtorDependencyAttribute.cs create mode 100644 src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs create mode 100644 src/Uno.Extensions.Reactive/Config/FeedDependencyAttribute.cs diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs index 6f3a6f1091..df534a7544 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs @@ -30,6 +30,11 @@ internal record BindableGenerationContext( [ContextType(typeof(ImplicitCommandsAttribute))] INamedTypeSymbol ImplicitCommandsAttribute, [ContextType(typeof(ImplicitFeedCommandParametersAttribute))] INamedTypeSymbol ImplicitCommandParametersAttribute, + // Mocking (spec 013) — optional: absent on compilations that predate the feature + [ContextType(typeof(EnableFeedMockingAttribute))] INamedTypeSymbol? EnableFeedMockingAttribute, + [ContextType(typeof(FeedDependencyAttribute))] INamedTypeSymbol? FeedDependencyAttribute, + [ContextType(typeof(CtorDependencyAttribute))] INamedTypeSymbol? CtorDependencyAttribute, + // Bindable attributes [ContextType(typeof(ReactiveBindableAttribute))] INamedTypeSymbol BindableAttribute, [ContextType(typeof(InputAttribute))] INamedTypeSymbol InputAttribute, @@ -61,6 +66,22 @@ public bool IsGenerationNotDisable(ISymbol symbol) ? attribute.value ?? true : null; + /// + /// Spec 013 — whether the current assembly opted-in to mocking metadata generation + /// via [assembly: EnableFeedMocking]. When false, MVUX output is byte-identical. + /// + public bool IsMockingEnabled() + { + if (EnableFeedMockingAttribute is null) + { + return false; + } + + return Context.Compilation.Assembly.GetAttributes().Any(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, EnableFeedMockingAttribute) + && (a.NamedArguments.FirstOrDefault(na => na.Key == "IsEnabled").Value.Value as bool? ?? true)); + } + public bool IsFeed(ITypeSymbol type) => type.GetAllInterfaces().Select(intf => intf.OriginalDefinition).Contains(Feed, SymbolEqualityComparer.Default); diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs new file mode 100644 index 0000000000..546b44eca4 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Uno.Extensions.Generators; + +namespace Uno.Extensions.Reactive.Generator; + +/// +/// Spec 013 — MVUX mocking metadata emission (D11/D12). +/// +/// Emits, on the model partial and only under [assembly: EnableFeedMocking]: +/// - [FeedDependency(member, OnParameter/OnFeed)] classifying every feed member as +/// service-dependent input / derived / independent (read by the external mocking generator); +/// - [CtorDependency(param, Eager=true)] for constructor parameters dereferenced eagerly, +/// so the generated Create(...) can require them (R1 — would NRE under null-inject). +/// +/// There are deliberately NO per-feed swap hooks (D11): the runtime swap is reflection over the +/// model's IHotSwapState<T> members, reusing the hot-reload driver, fail-hard. +/// +/// When the opt-in is absent, returns an empty string, so the +/// generated output is byte-identical (G5). +/// +internal partial class ViewModelGenTool_3 +{ + private enum FeedKind { ServiceDependent, Derived, Independent } + + private string GenerateMockingMetadata(INamedTypeSymbol model) + { + if (!_ctx.IsMockingEnabled()) + { + return string.Empty; // opt-out → byte-identical output (G5) + } + + var compilation = _ctx.Context.Compilation; + + // Feed members of the model (name set for derived-detection + iteration). + var feedMembers = model + .GetMembers() + .Where(m => m is IPropertySymbol or IFieldSymbol && !m.IsStatic && m.IsAccessible()) + .Where(m => IsFeedMember(m)) + .ToList(); + var feedMemberNames = new HashSet(feedMembers.Select(m => m.Name), StringComparer.Ordinal); + + // Constructor parameters (services) + a field/property -> parameter map (assignments in ctor bodies). + var ctorParamNames = new HashSet(StringComparer.Ordinal); + foreach (var ctor in AccessibleInstanceCtors(model)) + { + foreach (var p in ctor.Parameters) + { + ctorParamNames.Add(p.Name); + } + } + + var fieldToParam = BuildFieldToParamMap(model, ctorParamNames, compilation); + + var sb = new StringBuilder(); + + // 1) Feed classification. + foreach (var member in feedMembers) + { + var (kind, derivedFrom, services) = ClassifyFeedMember(member, feedMemberNames, ctorParamNames, fieldToParam, compilation, model); + + switch (kind) + { + case FeedKind.Derived: + foreach (var feed in derivedFrom) + { + sb.Append($"\r\n[{NS.Config}.FeedDependency(\"{member.Name}\", OnFeed = \"{feed}\")]"); + } + break; + + case FeedKind.ServiceDependent: + foreach (var svc in services) + { + sb.Append($"\r\n[{NS.Config}.FeedDependency(\"{member.Name}\", OnParameter = \"{svc}\")]"); + } + break; + + default: + sb.Append($"\r\n[{NS.Config}.FeedDependency(\"{member.Name}\")]"); + break; + } + } + + // 2) Ctor instrumentation — eager parameter dereference (R1). + var eager = FindEagerCtorParameters(model, ctorParamNames, compilation); + foreach (var kvp in eager.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + var members = kvp.Value.Count > 0 + ? $", Members = new[] {{ {string.Join(", ", kvp.Value.OrderBy(m => m, StringComparer.Ordinal).Select(m => $"\"{m}\""))} }}" + : string.Empty; + sb.Append($"\r\n[{NS.Config}.CtorDependency(\"{kvp.Key}\", Eager = true{members})]"); + } + + return sb.ToString(); + } + + private bool IsFeedMember(ISymbol member) + { + var type = member switch + { + IPropertySymbol p => p.Type, + IFieldSymbol f => f.Type, + _ => null, + }; + return type is not null && (_ctx.IsFeed(type) || _ctx.IsListFeed(type) || _ctx.IsFeedOfList(type)); + } + + private IEnumerable AccessibleInstanceCtors(INamedTypeSymbol model) + => model.Constructors.Where(c => !c.IsStatic && !c.IsCloneCtor(model) && c.DeclaredAccessibility is not Accessibility.Private); + + /// + /// Maps a field/property name to the constructor parameter it is assigned from (e.g. _svc = svc; + /// or a primary-constructor capture), so a feed body referencing that field is recognized as service-dependent. + /// + private Dictionary BuildFieldToParamMap(INamedTypeSymbol model, HashSet ctorParamNames, Compilation compilation) + { + var map = new Dictionary(StringComparer.Ordinal); + + foreach (var ctor in AccessibleInstanceCtors(model)) + { + foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + { + var node = syntaxRef.GetSyntax(); + var body = (SyntaxNode?)(node as ConstructorDeclarationSyntax)?.Body + ?? (node as ConstructorDeclarationSyntax)?.ExpressionBody?.Expression; + if (body is null) + { + continue; + } + + var semanticModel = compilation.GetSemanticModel(node.SyntaxTree); + foreach (var assignment in body.DescendantNodes().OfType()) + { + if (!assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) + { + continue; + } + + if (assignment.Right is not IdentifierNameSyntax rhs) + { + continue; + } + + if (semanticModel.GetSymbolInfo(rhs).Symbol is not IParameterSymbol param || !ctorParamNames.Contains(param.Name)) + { + continue; + } + + var lhsSymbol = semanticModel.GetSymbolInfo(assignment.Left).Symbol; + var targetName = lhsSymbol switch + { + IFieldSymbol field => field.Name, + IPropertySymbol prop => prop.Name, + _ => null, + }; + if (targetName is not null) + { + map[targetName] = param.Name; + } + } + } + } + + return map; + } + + private (FeedKind kind, List derivedFrom, List services) ClassifyFeedMember( + ISymbol member, + HashSet feedMemberNames, + HashSet ctorParamNames, + Dictionary fieldToParam, + Compilation compilation, + INamedTypeSymbol model) + { + var derivedFrom = new List(); + var services = new List(); + var seenDerived = new HashSet(StringComparer.Ordinal); + var seenServices = new HashSet(StringComparer.Ordinal); + + foreach (var body in GetMemberBodies(member, compilation, out var semanticModelByTree)) + { + var semanticModel = semanticModelByTree(body.SyntaxTree); + foreach (var id in body.DescendantNodesAndSelf().OfType()) + { + var symbol = semanticModel.GetSymbolInfo(id).Symbol; + if (symbol is null) + { + continue; + } + + // Another feed member of THIS model → derived. + if ((symbol is IPropertySymbol or IFieldSymbol) + && SymbolEqualityComparer.Default.Equals(symbol.ContainingType, model) + && !string.Equals(symbol.Name, member.Name, StringComparison.Ordinal) + && feedMemberNames.Contains(symbol.Name)) + { + if (seenDerived.Add(symbol.Name)) + { + derivedFrom.Add(symbol.Name); + } + continue; + } + + // A ctor parameter (primary-ctor capture), directly referenced → service. + if (symbol is IParameterSymbol p && ctorParamNames.Contains(p.Name)) + { + if (seenServices.Add(p.Name)) + { + services.Add(p.Name); + } + continue; + } + + // A field/property assigned from a ctor parameter → service. + var backingName = symbol switch + { + IFieldSymbol f => f.Name, + IPropertySymbol pr => pr.Name, + _ => null, + }; + if (backingName is not null + && SymbolEqualityComparer.Default.Equals(symbol.ContainingType, model) + && fieldToParam.TryGetValue(backingName, out var paramName)) + { + if (seenServices.Add(paramName)) + { + services.Add(paramName); + } + } + } + } + + if (derivedFrom.Count > 0) + { + return (FeedKind.Derived, derivedFrom, services); + } + if (services.Count > 0) + { + return (FeedKind.ServiceDependent, derivedFrom, services); + } + return (FeedKind.Independent, derivedFrom, services); + } + + /// + /// Returns the getter/initializer body syntax nodes of a feed member (property expression body, + /// getter body, or field initializer). + /// + private IEnumerable GetMemberBodies(ISymbol member, Compilation compilation, out Func semanticModelByTree) + { + var cache = new Dictionary(); + semanticModelByTree = tree => + { + if (!cache.TryGetValue(tree, out var sm)) + { + cache[tree] = sm = compilation.GetSemanticModel(tree); + } + return sm; + }; + + var bodies = new List(); + foreach (var syntaxRef in member.DeclaringSyntaxReferences) + { + switch (syntaxRef.GetSyntax()) + { + case PropertyDeclarationSyntax pds: + if (pds.ExpressionBody?.Expression is { } exprBody) + { + bodies.Add(exprBody); + } + else if (pds.AccessorList?.Accessors.FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)) is { } getter) + { + var gb = (SyntaxNode?)getter.ExpressionBody?.Expression ?? getter.Body; + if (gb is not null) + { + bodies.Add(gb); + } + } + break; + + case VariableDeclaratorSyntax vds when vds.Initializer?.Value is { } fieldInit: + bodies.Add(fieldInit); + break; + } + } + + return bodies; + } + + /// + /// Constructor instrumentation (R1): finds constructor parameters that are dereferenced eagerly + /// (member access / invocation receiver) in a ctor body or an instance field/property initializer, + /// excluding references nested in a lambda / anonymous method / local function (deferred boundary). + /// + private Dictionary> FindEagerCtorParameters(INamedTypeSymbol model, HashSet ctorParamNames, Compilation compilation) + { + var eager = new Dictionary>(StringComparer.Ordinal); + + void Mark(string param, string? member) + { + if (!eager.TryGetValue(param, out var set)) + { + eager[param] = set = new HashSet(StringComparer.Ordinal); + } + if (member is not null) + { + set.Add(member); + } + } + + foreach (var ctor in AccessibleInstanceCtors(model)) + { + foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + { + var node = syntaxRef.GetSyntax(); + var body = (SyntaxNode?)(node as ConstructorDeclarationSyntax)?.Body + ?? (node as ConstructorDeclarationSyntax)?.ExpressionBody?.Expression; + if (body is null) + { + continue; + } + + var semanticModel = compilation.GetSemanticModel(node.SyntaxTree); + InspectEager(body, semanticModel, ctorParamNames, Mark, enclosingMember: null); + } + } + + return eager; + } + + private void InspectEager(SyntaxNode body, SemanticModel semanticModel, HashSet ctorParamNames, Action mark, string? enclosingMember) + { + foreach (var access in body.DescendantNodesAndSelf()) + { + // The receiver of a member-access / element-access is an eager dereference. + ExpressionSyntax? receiver = access switch + { + MemberAccessExpressionSyntax mae => mae.Expression, + ElementAccessExpressionSyntax eae => eae.Expression, + _ => null, + }; + if (receiver is not IdentifierNameSyntax id) + { + continue; + } + + if (IsInsideDeferredBoundary(receiver, body)) + { + continue; // lambda/anonymous/local-function body → not eager at construction + } + + if (semanticModel.GetSymbolInfo(id).Symbol is IParameterSymbol param && ctorParamNames.Contains(param.Name)) + { + mark(param.Name, enclosingMember); + } + } + } + + private static bool IsInsideDeferredBoundary(SyntaxNode node, SyntaxNode stopAt) + { + for (var current = node.Parent; current is not null && current != stopAt; current = current.Parent) + { + if (current is SimpleLambdaExpressionSyntax + or ParenthesizedLambdaExpressionSyntax + or AnonymousMethodExpressionSyntax + or LocalFunctionStatementSyntax) + { + return true; + } + } + return false; + } +} diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs index 4525aaafce..3c55a452a7 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs @@ -14,7 +14,7 @@ namespace Uno.Extensions.Reactive.Generator; -internal class ViewModelGenTool_3 : ICodeGenTool +internal partial class ViewModelGenTool_3 : ICodeGenTool { private const string ViewModelSufix = "ViewModel"; @@ -261,9 +261,10 @@ private void __Reactive_OnModelPropertyChanged(object? sender, global::System.Co private string GeneratePartialModel(INamedTypeSymbol model) { var vm = GetViewModelFullName(model); + var mockingAttributes = GenerateMockingMetadata(model); return this.AsPartialOf( model, - attributes: $"[{NS.Bindings}.Model(typeof({vm}))]\r\n[global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdate]", + attributes: $"[{NS.Bindings}.Model(typeof({vm}))]\r\n[global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdate]{mockingAttributes}", bases: $"global::System.IAsyncDisposable, {NS.Core}.ISourceContextAware, {NS.Bindings}.IModel<{vm}>", code: $@" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] diff --git a/src/Uno.Extensions.Reactive/Config/CtorDependencyAttribute.cs b/src/Uno.Extensions.Reactive/Config/CtorDependencyAttribute.cs new file mode 100644 index 0000000000..058db1bb70 --- /dev/null +++ b/src/Uno.Extensions.Reactive/Config/CtorDependencyAttribute.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Reactive.Config; + +/// +/// Metadata describing an eager constructor dependency of a model (spec 013 — MVUX mocking). +/// Emitted by the MVUX generator (ctor instrumentation) and also hand-declarable. Survives as +/// assembly metadata so the external mocking generator can constrain the generated +/// Create(...) factory (a service accessed eagerly in the ctor would NRE under null-inject, +/// so Create must require a real/fake value for that parameter). +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public sealed class CtorDependencyAttribute : Attribute +{ + /// + /// Creates a new constructor dependency descriptor for the given parameter. + /// + /// The name of the constructor parameter. + public CtorDependencyAttribute(string parameter) + { + Parameter = parameter; + } + + /// + /// The constructor parameter this descriptor applies to. + /// + public string Parameter { get; } + + /// + /// when the parameter is dereferenced eagerly during construction + /// (constructor body, field/property initializer, or eager primary-ctor capture), so a + /// null-injected value would throw. The generated Create must then require this parameter. + /// + public bool Eager { get; init; } + + /// + /// The members whose eager access to the parameter triggered this descriptor (diagnostics/traceability). + /// + public string[] Members { get; init; } = Array.Empty(); +} diff --git a/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs b/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs new file mode 100644 index 0000000000..0f71d43fbd --- /dev/null +++ b/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Reactive.Config; + +/// +/// Opt-in for the MVUX mocking metadata generation (spec 013). When present on an assembly, the MVUX +/// generator emits the mocking seams (dependency attributes, the view-model null-inject construction +/// path and the command seam) required by the external mocking generator. When absent, MVUX output is +/// byte-identical to the non-mocking output (additive, zero-cost opt-out). +/// +[AttributeUsage(AttributeTargets.Assembly)] +public sealed class EnableFeedMockingAttribute : Attribute +{ + /// + /// Gets or sets a value indicating whether the mocking metadata generation is enabled. + /// + public bool IsEnabled { get; init; } = true; + + /// + /// Creates a new instance enabling mocking metadata generation. + /// + public EnableFeedMockingAttribute() + { + } +} diff --git a/src/Uno.Extensions.Reactive/Config/FeedDependencyAttribute.cs b/src/Uno.Extensions.Reactive/Config/FeedDependencyAttribute.cs new file mode 100644 index 0000000000..5b93bb481b --- /dev/null +++ b/src/Uno.Extensions.Reactive/Config/FeedDependencyAttribute.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Reactive.Config; + +/// +/// Metadata describing how a feed/list-feed member of a model is fed (spec 013 — MVUX mocking). +/// Emitted by the MVUX generator on the model partial, and also hand-declarable by the author +/// (explicit declarations win/merge with the analysis). Survives as assembly metadata so the +/// external mocking generator (in a test/preview project) can read it without syntax trees. +/// +/// +/// Classification semantics (exactly one intent per instance; multiple instances per member allowed): +/// +/// set → the member is a service-dependent input +/// (fed by the named constructor parameter). These are the required inputs of a generated mock. +/// set → the member is derived from another feed member +/// (never required in a mock; recomputes over the swapped inputs). +/// neither set → the member is independent. +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)] +public sealed class FeedDependencyAttribute : Attribute +{ + /// + /// Creates a new dependency descriptor for the given model member. + /// + /// The name of the feed/list-feed member this descriptor applies to. + public FeedDependencyAttribute(string member) + { + Member = member; + } + + /// + /// The feed/list-feed member this descriptor applies to. + /// + public string Member { get; } + + /// + /// When set, the constructor parameter (service) feeding this member — marks the member as a + /// service-dependent input. + /// + public string? OnParameter { get; init; } + + /// + /// When set, another feed member this member is derived from — marks the member as derived. + /// + public string? OnFeed { get; init; } +} From 007e88cf424db6dfdc71ee33ed7ebe2399ad0349 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 10:55:45 +0000 Subject: [PATCH 07/19] feat(mocking): add the view-model command seam Commands have no hot-swap state, so a dedicated seam reassigns the command property after construction. Construction itself needs no seam: the public constructors plus the ambient scope are enough. --- .../013-mvux-mocking-previews/architecture.md | 4 +- .../implementation.md | 4 +- specs/013-mvux-mocking-previews/spec.md | 9 +++-- .../Bindables/ViewModelGenTool_3.Mocking.cs | 40 +++++++++++++++++++ .../Bindables/ViewModelGenTool_3.cs | 2 + src/Uno.Extensions.Reactive/AssemblyInfo.cs | 1 + 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 9ad85421e2..e02adfb621 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -66,7 +66,7 @@ Identity risk (R6): lambdas capturing locals/params produce fresh delegate targe **c) Hidden hooks** (`EditorBrowsable(Never)`, emitted only under the opt-in flag): - on the **Model partial**: **nothing per-feed** — the swap is reflection over `IHotSwapState` members at runtime (D11), reusing the hot-reload driver, fail-hard. The generator emits no `__Mock_Swap_{Member}`; -- on the **VM partial**: `__Mock_Initialize()` (dedicated; NOT `__Reactive_UpdateModel` — must not reassign `__reactiveModel`, rebind INPC, nor let `Model`'s `Unsafe.As` see a foreign type) + command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` (R2). These are the only seams reflection cannot synthesize. +- on the **VM partial**: **no construction seam** — null-inject uses the existing public ctors (`new {Vm}(default!, …)`) under an ambient `MockingService.Enable()` scope (D12: the `SourceContext` built at construction is mockable, captured on the instance). The only emitted seam is `__Mock_SetCommand(string name, IAsyncCommand)` (public, `EditorBrowsable(Never)`, fail-hard) which reassigns a command property post-construction — commands have no `IHotSwapState` and are unreachable by the reflection swap (R2). ### 2.2 Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the test/preview project) @@ -259,5 +259,5 @@ Resolved against the source: - Tier 1 stays an isolated UI convenience. - **No wrap unless `SourceContext.IsMockingActive`** (§6, D10/D12): the per-feed `HotSwapFeed` indirection must never exist in a live app; a live-app context never has the bit set. - **Swap is reflection over `IHotSwapState`, fail-hard** (D11): no per-member generated hook; an un-swappable mocked member throws. -- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, VM null-inject ctor/command seam. +- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, the `__Mock_SetCommand` command seam. - MVUX output byte-identical when opt-in flag absent. diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index 0300dc0e6d..b5f69d58d1 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -60,7 +60,7 @@ Opt-in: `[assembly: EnableFeedMocking]` (or MSBuild prop). When absent → byte- 2. **Emit attributes** (§2.1) on the generated Model partial. 3. **Emitted seams** (`EditorBrowsable(Never)`) — only what reflection cannot synthesize: - Model partial: **no per-feed `__Mock_Swap_{Member}`** — swap is reflection over `IHotSwapState` at runtime (D11). (The `HotSwapFeed` wrappers already expose the swap seam the reflection driver uses.) - - VM partial: `static {Vm} __Mock_Create(object?[] ctorArgs)` → `new {Vm}(…)` null-inject path (dedicated — NOT `__Reactive_UpdateModel`); command seam `Save = __mockCommands?.Save ?? new AsyncCommand(...)` + `__Mock_SetCommand(name, IAsyncCommand)`. + - VM partial: **no dedicated construction seam** — null-inject construction reuses the existing public constructors (`new {Vm}(default!, …)`); under an ambient `MockingService.Enable()` scope the `SourceContext` created at construction is mockable (D12), and the bit is captured on the context instance so a lazy first subscription after the scope is disposed still wraps. Commands have no `IHotSwapState` and are unreachable by the reflection swap, so a **dedicated public `__Mock_SetCommand(string name, IAsyncCommand)`** seam (`EditorBrowsable(Never)`) reassigns the command property post-construction (R2). Fail-hard: an unknown command name throws (strict, like D11). 4. Diagnostics: `FEED3201` eager ctor access detected (info: `Create` will require the service), `FEED3202` unstable feed identity (capture pattern defeats caching), `FEED3203` explicit attribute contradicts analysis. ## 4. Mocking package (`Uno.Extensions.Reactive.Mocking`) @@ -164,7 +164,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): d. feed-identity stability matrix (capture patterns) → informs FEED3202; e. `MockingService.Enable()` → `IsMockingActive` on the pre-seeded context: prove **no wrap when the context is not mockable**, and reflection swap is **fail-hard** on an un-swappable member (D11). - **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. -- **P2 — Core: `SourceContext.IsMockingActive` + wrap gate in `StateImpl` + fail-hard reflection swap + attributes + analysis + VM null-inject/command seam** (MVUX gen). No per-feed swap hooks. +- **P2 — Core: `SourceContext.IsMockingActive` + wrap gate in `StateImpl` + fail-hard reflection swap + attributes + analysis + `__Mock_SetCommand` seam** (MVUX gen). No per-feed swap hooks; no `__Mock_Create` (public ctors + ambient scope). - **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetModel`). - **P4 — Tier 3 catalogs + Hot Design checkpoint** (name freeze), docs. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 9a5d617034..655df98700 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -86,7 +86,8 @@ flowchart TB FeedDependency / CtorDependency (also hand-declarable — explicit wins)"] GEN --> HOOKS["emitted seams (no per-feed hook) - VM null-inject ctor + command ?? seam + __Mock_SetCommand on the VM (commands only) + construction = public ctors + ambient scope swap = reflection over IHotSwapState (D11)"] end subgraph TEST["Test / preview project — references the app"] @@ -103,7 +104,7 @@ flowchart TB - **MVUX generator (runs in the Model's assembly, on the partial Model):** a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); - c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, under the opt-in): the VM null-inject construction path + the command `??` seam (R2). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). + c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, under the opt-in): the VM `__Mock_SetCommand` seam for commands (R2 — commands have no `IHotSwapState`). Construction needs no seam (public ctors + ambient scope, D12). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). - **Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). ## 5. End-to-end — a test drives a page through its states @@ -220,7 +221,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM null-inject ctor/command seam, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. ## 11. Risks @@ -248,7 +249,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe | D8 | Converters (JSON or other) are **application-owned illustrations** attached at `FeedView.Source`, returning `IMessageEntry`; this feature defines and implements none | | D9 | Tiers 2/3 are **strongly typed end to end**; the tier-1 authoring object is confined to tier 1 | | D10 | Activation is an **explicit scope** — `using (MockingService.Enable())` — never an ambient app-wide switch. A test assembly may open it once at assembly init to cover its whole run. **Rationale: the wrap costs at runtime; it must exist only on demand, never in the feeds of a live app** (G9, R7). The scope's internal mechanism is now **resolved** — it rides `SourceContext` (§13) | -| D11 | **Swap is reflection-driven over the members, fail-hard** — reuse the existing hot-reload reflection path (`BindableViewModelBase.HotReload`, iterating `IHotSwapState`); the MVUX generator emits **no per-member `__Mock_Swap_{Member}` hooks**, only metadata attributes + the VM null-inject ctor/command seam. **Delta vs hot reload: a member that cannot be swapped throws — no silent skip** (hot reload is best-effort; mocking is strict) | +| D11 | **Swap is reflection-driven over the members, fail-hard** — reuse the existing hot-reload reflection path (`BindableViewModelBase.HotReload`, iterating `IHotSwapState`); the MVUX generator emits **no per-member `__Mock_Swap_{Member}` hooks**, only metadata attributes + the VM `__Mock_SetCommand` command seam (construction uses public ctors + ambient scope). **Delta vs hot reload: a member that cannot be swapped throws — no silent skip** (hot reload is best-effort; mocking is strict) | | D12 | **The mockable gate is a per-context bit on `SourceContext.IsMockingActive`**, read at wrap time in `StateImpl` ctor **instead of** the global `EffectiveHotReload` static — so only contexts created under an open scope wrap, every other context pays zero (G9/R7 by construction). **Reflection-core accepted over AOT-strict**: a 2-assembly split needs reflection anyway (generating the mock beside the Model would make the mocking assembly hollow); the mocking path stays dev/test-only, non-AOT (NG2/D7) | ## 13. Scoped activation — `MockingService.Enable()` (DECIDED shape AND mechanism) diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs index 546b44eca4..de49ccad59 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs @@ -99,6 +99,46 @@ private string GenerateMockingMetadata(INamedTypeSymbol model) return sb.ToString(); } + /// + /// Emits the view-model mocking seam (spec 013, gated by opt-in). Commands have no + /// IHotSwapState<T> backing, so the reflection swap (D11) cannot reach them: a dedicated + /// public __Mock_SetCommand hook lets the external mocking generator override a command + /// after construction. Fail-hard: an unknown command name throws (strict mocking, like D11). + /// + /// Construction itself needs NO seam: the generated public constructors + the ambient + /// MockingService.Enable() scope (D12) already produce a mockable SourceContext + /// (the bit is captured on the context instance at creation, so a lazy first subscription after + /// the scope is disposed still wraps). + /// + private string GenerateVmMockingSeam(IEnumerable members) + { + if (!_ctx.IsMockingEnabled()) + { + return string.Empty; // opt-out → byte-identical output (G5) + } + + var commands = members.OfType().ToList(); + if (commands.Count == 0) + { + return string.Empty; + } + + var cases = commands + .Select(c => $"case \"{c.Name}\": {c.Name} = command; break;") + .JoinBy("\r\n"); + + return $@" + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public void __Mock_SetCommand(string name, {NS.Reactive}.IAsyncCommand command) + {{ + switch (name) + {{ + {cases} + default: throw new global::System.ArgumentException($""No mockable command '{{name}}' on this view model."", nameof(name)); + }} + }}"; + } + private bool IsFeedMember(ISymbol member) { var type = member switch diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs index 3c55a452a7..7dc7bef5fe 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.cs @@ -239,6 +239,8 @@ private void __Reactive_OnModelPropertyChanged(object? sender, global::System.Co public {(hasBaseType ? "new ":"")}{model.ToFullString()} {N.Model} => global::System.Runtime.CompilerServices.Unsafe.As<{model.ToFullString()}>(__reactiveModel!); {members.Select(member => member.GetDeclaration()).Align(5)} + + {GenerateVmMockingSeam(members).Align(5)} }}"); diff --git a/src/Uno.Extensions.Reactive/AssemblyInfo.cs b/src/Uno.Extensions.Reactive/AssemblyInfo.cs index 2e82b109b6..908bb1ff2e 100644 --- a/src/Uno.Extensions.Reactive/AssemblyInfo.cs +++ b/src/Uno.Extensions.Reactive/AssemblyInfo.cs @@ -8,3 +8,4 @@ [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.UI")] [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.WinUI")] [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.Messaging")] +[assembly: InternalsVisibleTo("Uno.Extensions.Reactive.Mocking")] From 5852d9befc14a578f1a7f8a9fb72b7013ce977b0 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 11:05:10 +0000 Subject: [PATCH 08/19] feat(mocking): add the mocking runtime Adds the activation scope, the swap engine and the typed feed vocabulary used by the generated mocks. --- Uno.Extensions.sln | 18 ++++++ .../MockFeed.cs | 20 +++++++ .../MockListFeed.cs | 34 +++++++++++ .../MockModel.cs | 58 +++++++++++++++++++ .../MockingService.cs | 25 ++++++++ .../Uno.Extensions.Reactive.Mocking.csproj | 8 +++ .../Uno.Extensions.Reactive.Tests.csproj | 1 + 7 files changed, 164 insertions(+) create mode 100644 src/Uno.Extensions.Reactive.Mocking/MockFeed.cs create mode 100644 src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs create mode 100644 src/Uno.Extensions.Reactive.Mocking/MockModel.cs create mode 100644 src/Uno.Extensions.Reactive.Mocking/MockingService.cs create mode 100644 src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj diff --git a/Uno.Extensions.sln b/Uno.Extensions.sln index 7a5d363caf..6f16cc641a 100644 --- a/Uno.Extensions.sln +++ b/Uno.Extensions.sln @@ -155,6 +155,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Storage.WinU Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive", "src\Uno.HotTesting.Reactive\Uno.HotTesting.Reactive.csproj", "{A1A08FE3-19D4-4EB2-B228-10187E2C4CDF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive.Tests", "src\Uno.HotTesting.Reactive.Tests\Uno.HotTesting.Reactive.Tests.csproj", "{2347B2F1-002C-4165-B523-D4D236A13CEC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Mocking", "src\Uno.Extensions.Reactive.Mocking\Uno.Extensions.Reactive.Mocking.csproj", "{C1D1F711-4271-4E79-AABD-AA316F11D8C3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Authentication.MSAL.Tests", "src\Uno.Extensions.Authentication.MSAL.Tests\Uno.Extensions.Authentication.MSAL.Tests.csproj", "{E5EA0457-2031-48A7-9C28-90B94A3861DF}" EndProject @@ -914,6 +915,22 @@ Global {E5EA0457-2031-48A7-9C28-90B94A3861DF}.Release|x64.Build.0 = Release|Any CPU {E5EA0457-2031-48A7-9C28-90B94A3861DF}.Release|x86.ActiveCfg = Release|Any CPU {E5EA0457-2031-48A7-9C28-90B94A3861DF}.Release|x86.Build.0 = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|arm64.ActiveCfg = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|arm64.Build.0 = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|x64.Build.0 = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Debug|x86.Build.0 = Debug|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|arm64.ActiveCfg = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|arm64.Build.0 = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x64.ActiveCfg = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x64.Build.0 = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x86.ActiveCfg = Release|Any CPU + {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -970,6 +987,7 @@ Global {A1A08FE3-19D4-4EB2-B228-10187E2C4CDF} = {6B956AB1-06A6-4BEC-9467-E7B593A89E34} {2347B2F1-002C-4165-B523-D4D236A13CEC} = {6B956AB1-06A6-4BEC-9467-E7B593A89E34} {E5EA0457-2031-48A7-9C28-90B94A3861DF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C1D1F711-4271-4E79-AABD-AA316F11D8C3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6E7B035D-9A64-4D95-89AA-9D4653F17C42} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs new file mode 100644 index 0000000000..363cc150a5 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs @@ -0,0 +1,20 @@ +using System; + +namespace Uno.Extensions.Reactive.Mocking; + +/// +/// Typed vocabulary to build pinned scalar feed states for mocking (spec 013 §4.1). Strongly typed +/// end to end; never accepts the tier-1 MessageEntry or any untyped envelope (D9/NG7). +/// +public static class MockFeed +{ + /// A feed pinned to a value (Some). + public static IFeed Value(T value) + where T : notnull + => Feed.Async(async _ => value); + + /// A feed pinned to an error. + public static IFeed Error(Exception error) + where T : notnull + => Feed.Async(async _ => throw error); +} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs new file mode 100644 index 0000000000..a653c69033 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Uno.Extensions.Reactive.Mocking; + +/// +/// Typed vocabulary to build pinned list-feed states for mocking (spec 013 §4.1). +/// +public static class MockListFeed +{ + /// A list-feed pinned to the given items (Some). + public static IListFeed Value(params T[] items) + where T : notnull + => ListFeed.Async(async _ => items.ToImmutableList()); + + /// A list-feed pinned to None (no value). + public static IListFeed Empty() + where T : notnull + => ListFeed.Async(async _ => Option>.None()); + + /// A list-feed pinned to Some(empty list). + public static IListFeed EmptyList() + where T : notnull + => ListFeed.Async(async _ => ImmutableList.Empty); + + /// A list-feed pinned to an error. + public static IListFeed Error(Exception error) + where T : notnull + { + AsyncFunc> provider = async _ => throw error; + return ListFeed.Async(provider); + } +} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockModel.cs b/src/Uno.Extensions.Reactive.Mocking/MockModel.cs new file mode 100644 index 0000000000..b7695959aa --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/MockModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Immutable; +using Uno.Extensions.Reactive.Core; + +namespace Uno.Extensions.Reactive.Mocking; + +/// +/// Runtime swap engine for MVUX mocking (spec 013, D11). Replaces the source of a model feed member at +/// its cache-level HotSwapFeed wrapper, reusing the hot-reload swap seam (IHotSwapState<T>). +/// The generated SetModel (tier 2/3) emits strongly-typed calls into these helpers. +/// +/// +/// Fail-hard (delta vs hot-reload's best-effort): if the member's feed is not wrapped — i.e. the model +/// was not constructed inside a scope — the swap throws instead of silently +/// doing nothing. +/// +public static class MockModel +{ + /// + /// Swaps the source of a scalar feed member. + /// + /// The model (or view-model) instance owning the feed. + /// The feed currently exposed by the member (the cached wrapper). + /// The mock feed to swap in. + public static void SwapFeed(object owner, IFeed current, IFeed replacement) + where T : notnull + { + var ctx = SourceContext.GetOrCreate(owner); + var state = ctx.GetOrCreateState(current); + if (state is not IHotSwapState hotSwap) + { + throw new InvalidOperationException( + $"The feed for the mocked member is not swappable (no HotSwapFeed wrapper). " + + $"Ensure the model was constructed inside a MockingService.Enable() scope. Value type: {typeof(T)}."); + } + + hotSwap.HotSwap(replacement); + } + + /// + /// Swaps the source of a list-feed member. + /// + public static void SwapListFeed(object owner, IListFeed current, IListFeed replacement) + where T : notnull + { + var ctx = SourceContext.GetOrCreate(owner); + var currentFeed = ListFeed.AsFeed(current); + var state = ctx.GetOrCreateState(currentFeed); + if (state is not IHotSwapState> hotSwap) + { + throw new InvalidOperationException( + $"The list-feed for the mocked member is not swappable (no HotSwapFeed wrapper). " + + $"Ensure the model was constructed inside a MockingService.Enable() scope. Item type: {typeof(T)}."); + } + + hotSwap.HotSwap(ListFeed.AsFeed(replacement)); + } +} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockingService.cs b/src/Uno.Extensions.Reactive.Mocking/MockingService.cs new file mode 100644 index 0000000000..6e441f1963 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/MockingService.cs @@ -0,0 +1,25 @@ +using System; +using Uno.Extensions.Reactive.Core; + +namespace Uno.Extensions.Reactive.Mocking; + +/// +/// Entry point that activates MVUX mocking (spec 013). Inside the returned scope, every +/// created (and its descendants) is mockable: its feeds are wrapped so their +/// source can be swapped at runtime. Outside any scope nothing is wrapped, so a live application pays +/// nothing (G9/R7). +/// +/// +/// Granularity is the caller's: open it once at assembly-init to cover a whole test run, or around a +/// single Create(...). The bit is captured on the context instance at construction, so a lazy first +/// subscription after the scope is disposed still wraps (D12). +/// +public static class MockingService +{ + /// + /// Opens a mocking-activation scope. Dispose it to stop marking future contexts as mockable; + /// contexts already created inside the scope stay mockable for their own lifetime. + /// + public static IDisposable Enable() + => SourceContext.EnableMocking(); +} diff --git a/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj b/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj new file mode 100644 index 0000000000..8df6c0930a --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj index 6150fdabb0..a067f9825f 100644 --- a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj +++ b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj @@ -26,6 +26,7 @@ + From f4f5aff79f24c48382367c3df4a451b6b6a8d327 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 11:22:40 +0000 Subject: [PATCH 09/19] feat(mocking): add the consumer generator and make the swap fail hard The consumer generator reads the app metadata and emits the mock record, the Create factories and the SetModel facade. StateImpl always implements IHotSwapState, so the previous type test never rejected a non-wrapped feed and the swap silently did nothing. Added CanHotSwap and made the engine throw when a mocked feed is not swappable. --- Uno.Extensions.sln | 37 +++ .../AnalyzerReleases.Shipped.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 + .../FeedsMockGenerator.cs | 254 ++++++++++++++++++ ...tensions.Reactive.Mocking.Generator.csproj | 30 +++ .../MockModel.cs | 4 +- .../RecipeModel.cs | 32 +++ ...xtensions.Reactive.Tests.MockingApp.csproj | 8 + .../Mocking/Given_GeneratedMock.cs | 68 +++++ .../Mocking/Given_MockingRuntime.cs | 76 ++++++ .../Uno.Extensions.Reactive.Tests.csproj | 2 + .../Core/Internal/IHotSwapState.cs | 7 + .../Core/Internal/StateImpl.cs | 2 + 13 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md create mode 100644 src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md create mode 100644 src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs create mode 100644 src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj create mode 100644 src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs create mode 100644 src/Uno.Extensions.Reactive.Tests.MockingApp/Uno.Extensions.Reactive.Tests.MockingApp.csproj create mode 100644 src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs create mode 100644 src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs diff --git a/Uno.Extensions.sln b/Uno.Extensions.sln index 6f16cc641a..d088ff0482 100644 --- a/Uno.Extensions.sln +++ b/Uno.Extensions.sln @@ -158,6 +158,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive.Tes Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Mocking", "src\Uno.Extensions.Reactive.Mocking\Uno.Extensions.Reactive.Mocking.csproj", "{C1D1F711-4271-4E79-AABD-AA316F11D8C3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Authentication.MSAL.Tests", "src\Uno.Extensions.Authentication.MSAL.Tests\Uno.Extensions.Authentication.MSAL.Tests.csproj", "{E5EA0457-2031-48A7-9C28-90B94A3861DF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Mocking.Generator", "src\Uno.Extensions.Reactive.Mocking.Generator\Uno.Extensions.Reactive.Mocking.Generator.csproj", "{62D73733-0DB2-407F-92A8-01FA1EA675EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Tests.MockingApp", "src\Uno.Extensions.Reactive.Tests.MockingApp\Uno.Extensions.Reactive.Tests.MockingApp.csproj", "{C16D5143-8353-400D-BE40-9CEEEB2A5404}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -931,6 +934,38 @@ Global {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x64.Build.0 = Release|Any CPU {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x86.ActiveCfg = Release|Any CPU {C1D1F711-4271-4E79-AABD-AA316F11D8C3}.Release|x86.Build.0 = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|arm64.ActiveCfg = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|arm64.Build.0 = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|x64.Build.0 = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Debug|x86.Build.0 = Debug|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|Any CPU.Build.0 = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|arm64.ActiveCfg = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|arm64.Build.0 = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|x64.ActiveCfg = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|x64.Build.0 = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|x86.ActiveCfg = Release|Any CPU + {62D73733-0DB2-407F-92A8-01FA1EA675EB}.Release|x86.Build.0 = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|arm64.ActiveCfg = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|arm64.Build.0 = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|x64.ActiveCfg = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|x64.Build.0 = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|x86.ActiveCfg = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Debug|x86.Build.0 = Debug|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|Any CPU.Build.0 = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|arm64.ActiveCfg = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|arm64.Build.0 = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x64.ActiveCfg = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x64.Build.0 = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x86.ActiveCfg = Release|Any CPU + {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -988,6 +1023,8 @@ Global {2347B2F1-002C-4165-B523-D4D236A13CEC} = {6B956AB1-06A6-4BEC-9467-E7B593A89E34} {E5EA0457-2031-48A7-9C28-90B94A3861DF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C1D1F711-4271-4E79-AABD-AA316F11D8C3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {62D73733-0DB2-407F-92A8-01FA1EA675EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C16D5143-8353-400D-BE40-9CEEEB2A5404} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6E7B035D-9A64-4D95-89AA-9D4653F17C42} diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md b/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..134180937d --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1 @@ +## Release 1.0 diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md b/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..34c8af7da0 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1 @@ +### New Rules diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs b/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs new file mode 100644 index 0000000000..7cb909059a --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; + +namespace Uno.Extensions.Reactive.Mocking.Generator; + +/// +/// Consumer-side generator (spec 013, tiers 2/3). Runs in a test/preview project, reads the app +/// metadata (models carrying FeedDependency/CtorDependency attributes + their generated +/// view-models) and emits, per model: +/// - record {Model}Mock — required service-dependent inputs, optional derived overrides; +/// - {Vm}.Create(...) — null-inject construction (under the ambient MockingService scope); +/// - SetModel(this {Vm}, {Model}Mock) — typed swaps via the MockModel reflection engine. +/// Strongly typed end to end (D9); no tier-1 surface. Commands and the zero-arg Create()/Empty +/// come in a later increment. +/// +[Generator] +public sealed class FeedsMockGenerator : ISourceGenerator +{ + private const string FeedDependencyAttribute = "Uno.Extensions.Reactive.Config.FeedDependencyAttribute"; + private const string CtorDependencyAttribute = "Uno.Extensions.Reactive.Config.CtorDependencyAttribute"; + private const string ModelAttribute = "Uno.Extensions.Reactive.Bindings.ModelAttribute"; + private const string FeedInterface = "Uno.Extensions.Reactive.IFeed`1"; + private const string ListFeedInterface = "Uno.Extensions.Reactive.IListFeed`1"; + + public void Initialize(GeneratorInitializationContext context) { } + + public void Execute(GeneratorExecutionContext context) + { + var compilation = context.Compilation; + var feedDepSymbol = compilation.GetTypeByMetadataName(FeedDependencyAttribute); + var ctorDepSymbol = compilation.GetTypeByMetadataName(CtorDependencyAttribute); + var modelAttrSymbol = compilation.GetTypeByMetadataName(ModelAttribute); + if (feedDepSymbol is null || modelAttrSymbol is null) + { + return; // Core not referenced → nothing to do. + } + + foreach (var model in EnumerateModels(compilation, feedDepSymbol)) + { + if (GenerateFor(model, feedDepSymbol, ctorDepSymbol, modelAttrSymbol) is { } generated) + { + context.AddSource($"{model.ToDisplayString().Replace('.', '_')}.Mock.g.cs", generated); + } + } + } + + private static IEnumerable EnumerateModels(Compilation compilation, INamedTypeSymbol feedDep) + { + bool HasFeedDep(INamedTypeSymbol t) + => t.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, feedDep)); + + IEnumerable Walk(INamespaceOrTypeSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + if (member is INamespaceSymbol childNs) + { + foreach (var t in Walk(childNs)) yield return t; + } + else if (member is INamedTypeSymbol type) + { + if (HasFeedDep(type)) yield return type; + foreach (var nested in type.GetTypeMembers()) + { + if (HasFeedDep(nested)) yield return nested; + } + } + } + } + + // Current compilation. + foreach (var t in Walk(compilation.Assembly.GlobalNamespace)) yield return t; + + // Referenced assemblies (the app). + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm) + { + foreach (var t in Walk(asm.GlobalNamespace)) yield return t; + } + } + } + + private sealed class FeedMember + { + public string Name = ""; + public string FeedTypeFullName = ""; // e.g. global::Uno...IListFeed + public string ItemOrValueFullName = ""; // T + public bool IsList; + public bool IsDerived; // OnFeed set → optional override + } + + private string? GenerateFor(INamedTypeSymbol model, INamedTypeSymbol feedDep, INamedTypeSymbol? ctorDep, INamedTypeSymbol modelAttr) + { + // Resolve the generated view-model via [Model(typeof(Vm))]. + var modelAttrData = model.GetAttributes().FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, modelAttr)); + if (modelAttrData?.ConstructorArguments is not { Length: 1 } args || args[0].Value is not INamedTypeSymbol vm) + { + return null; + } + + // Classify members from FeedDependency attributes. + var inputs = new List(); // OnParameter set + var derived = new List(); // OnFeed set + + foreach (var attr in model.GetAttributes().Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, feedDep))) + { + if (attr.ConstructorArguments is not { Length: 1 } ca || ca[0].Value is not string memberName) + { + continue; + } + + var onParameter = attr.NamedArguments.FirstOrDefault(n => n.Key == "OnParameter").Value.Value as string; + var onFeed = attr.NamedArguments.FirstOrDefault(n => n.Key == "OnFeed").Value.Value as string; + + if (onParameter is null && onFeed is null) + { + continue; // independent → not part of the mock + } + + if (model.GetMembers(memberName).FirstOrDefault() is not { } memberSymbol) + { + continue; + } + + var memberType = memberSymbol switch + { + IPropertySymbol p => p.Type, + IFieldSymbol f => f.Type, + _ => null, + }; + if (memberType is null || !TryGetFeed(memberType, out var isList, out var valueType)) + { + continue; + } + + var fm = new FeedMember + { + Name = memberName, + FeedTypeFullName = memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ItemOrValueFullName = valueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + IsList = isList, + IsDerived = onFeed is not null, + }; + + (onFeed is not null ? derived : inputs).Add(fm); + } + + if (inputs.Count == 0 && derived.Count == 0) + { + return null; + } + + var vmFull = vm.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var modelFull = model.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var mockName = $"{model.Name}Mock"; + var ns = model.ContainingNamespace.IsGlobalNamespace ? null : model.ContainingNamespace.ToDisplayString(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + if (ns is not null) + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + // The mock record. + sb.AppendLine($"public sealed record {mockName}"); + sb.AppendLine("{"); + foreach (var m in inputs) + { + sb.AppendLine($"\tpublic required {m.FeedTypeFullName} {m.Name} {{ get; init; }}"); + } + foreach (var m in derived) + { + sb.AppendLine($"\tpublic {m.FeedTypeFullName}? {m.Name} {{ get; init; }}"); + } + sb.AppendLine("}"); + sb.AppendLine(); + + // The factory + facade. + sb.AppendLine($"public static class {mockName}Extensions"); + sb.AppendLine("{"); + + // Create(inputs...) — required inputs as parameters. + var createParams = string.Join(", ", inputs.Select(m => $"{m.FeedTypeFullName} {Camel(m.Name)}")); + var mockInit = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); + sb.AppendLine($"\tpublic static {vmFull} Create({createParams})"); + sb.AppendLine($"\t\t=> Create(new {mockName} {{ {mockInit} }});"); + sb.AppendLine(); + + // Create(mock) — null-inject construction + SetModel. + sb.AppendLine($"\tpublic static {vmFull} Create({mockName} mock)"); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\tvar vm = new {vmFull}(default!);"); + sb.AppendLine("\t\tvm.SetModel(mock);"); + sb.AppendLine("\t\treturn vm;"); + sb.AppendLine("\t}"); + sb.AppendLine(); + + // SetModel — typed swaps via the reflection engine. + sb.AppendLine($"\tpublic static void SetModel(this {vmFull} vm, {mockName} mock)"); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\tvar model = vm.Model;"); + foreach (var m in inputs) + { + var swap = m.IsList ? "SwapListFeed" : "SwapFeed"; + sb.AppendLine($"\t\tglobal::Uno.Extensions.Reactive.Mocking.MockModel.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); + } + foreach (var m in derived) + { + var swap = m.IsList ? "SwapListFeed" : "SwapFeed"; + sb.AppendLine($"\t\tif (mock.{m.Name} is not null)"); + sb.AppendLine($"\t\t\tglobal::Uno.Extensions.Reactive.Mocking.MockModel.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); + } + sb.AppendLine("\t}"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static bool TryGetFeed(ITypeSymbol type, out bool isList, out ITypeSymbol? valueType) + { + isList = false; + valueType = null; + foreach (var intf in type.AllInterfaces.Concat(type is INamedTypeSymbol nt ? new[] { nt } : Array.Empty())) + { + var def = intf.OriginalDefinition.ToDisplayString(); + if (def == "Uno.Extensions.Reactive.IListFeed" || intf.OriginalDefinition.MetadataName == "IListFeed`1") + { + isList = true; + valueType = intf.TypeArguments.FirstOrDefault(); + return valueType is not null; + } + } + foreach (var intf in type.AllInterfaces.Concat(type is INamedTypeSymbol nt2 ? new[] { nt2 } : Array.Empty())) + { + if (intf.OriginalDefinition.MetadataName == "IFeed`1") + { + isList = false; + valueType = intf.TypeArguments.FirstOrDefault(); + return valueType is not null; + } + } + return false; + } + + private static string Camel(string name) + => string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name.Substring(1); +} diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj b/src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj new file mode 100644 index 0000000000..3708f9b226 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj @@ -0,0 +1,30 @@ + + + + netstandard2.0 + false + false + + + Uno.Extensions.Reactive.Mocking + + $(NoWarn);RS2008 + + + + + + + + + + + + + + + + + + + diff --git a/src/Uno.Extensions.Reactive.Mocking/MockModel.cs b/src/Uno.Extensions.Reactive.Mocking/MockModel.cs index b7695959aa..0725614fd9 100644 --- a/src/Uno.Extensions.Reactive.Mocking/MockModel.cs +++ b/src/Uno.Extensions.Reactive.Mocking/MockModel.cs @@ -27,7 +27,7 @@ public static void SwapFeed(object owner, IFeed current, IFeed replacem { var ctx = SourceContext.GetOrCreate(owner); var state = ctx.GetOrCreateState(current); - if (state is not IHotSwapState hotSwap) + if (state is not IHotSwapState hotSwap || !hotSwap.CanHotSwap) { throw new InvalidOperationException( $"The feed for the mocked member is not swappable (no HotSwapFeed wrapper). " @@ -46,7 +46,7 @@ public static void SwapListFeed(object owner, IListFeed current, IListFeed var ctx = SourceContext.GetOrCreate(owner); var currentFeed = ListFeed.AsFeed(current); var state = ctx.GetOrCreateState(currentFeed); - if (state is not IHotSwapState> hotSwap) + if (state is not IHotSwapState> hotSwap || !hotSwap.CanHotSwap) { throw new InvalidOperationException( $"The list-feed for the mocked member is not swappable (no HotSwapFeed wrapper). " diff --git a/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs new file mode 100644 index 0000000000..1e8a5c4a85 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Uno.Extensions.Reactive; +using Uno.Extensions.Reactive.Config; + +// Opt-in: the MVUX generator emits FeedDependency/CtorDependency + [Model] into this assembly's metadata, +// which the consumer mocking generator (in the Tests assembly) reads. +[assembly: EnableFeedMocking] + +namespace Uno.Extensions.Reactive.Tests.MockingApp; + +public interface IRecipeService +{ + Task> GetSteps(CancellationToken ct); +} + +public partial class RecipeModel +{ + private readonly IRecipeService _svc; + + public RecipeModel(IRecipeService svc) + { + _svc = svc; + } + + // service-dependent input (list) + public IListFeed Steps => ListFeed.Async(async ct => await _svc.GetSteps(ct)); + + // independent scalar input + public IFeed Title => Feed.Async(async ct => "Recipe"); +} diff --git a/src/Uno.Extensions.Reactive.Tests.MockingApp/Uno.Extensions.Reactive.Tests.MockingApp.csproj b/src/Uno.Extensions.Reactive.Tests.MockingApp/Uno.Extensions.Reactive.Tests.MockingApp.csproj new file mode 100644 index 0000000000..e408d9021a --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests.MockingApp/Uno.Extensions.Reactive.Tests.MockingApp.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs new file mode 100644 index 0000000000..d07d3c9657 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions.Reactive.Core; +using Uno.Extensions.Reactive.Mocking; +using Uno.Extensions.Reactive.Testing; +using Uno.Extensions.Reactive.Tests.MockingApp; + +namespace Uno.Extensions.Reactive.Tests.Mocking; + +/// +/// Spec 013 step B — end-to-end: the consumer generator's {Model}Mock / Create / SetModel drive a real +/// VM (real Model, null-injected service) through mocked feed states via the reflection swap engine. +/// Observation goes through the cached list-state (same state the bindable VM subscribes to), which the +/// swap targets — not a fresh subscription to the raw feed. +/// +[TestClass] +public class Given_GeneratedMock : FeedUITests +{ + private static async Task?> CurrentItems(SourceContext ctx, IListFeed feed) + { + var (result, _) = ctx.GetOrCreateListState(feed).Record(); + // Wait until a defined (Some) message is observed. + for (var i = 0; i < 50; i++) + { + if (result.Count > 0 && result.Last().Current.Data.IsSome(out var v)) + { + return (IImmutableList)v!; + } + await Task.Delay(20); + } + return result.Count > 0 && result.Last().Current.Data.IsSome(out var last) ? (IImmutableList)last! : null; + } + + [TestMethod] + public async Task When_CreateWithMock_Then_FeedEmitsMockedValues() + { + using (MockingService.Enable()) + { + var vm = RecipeModelMockExtensions.Create(MockListFeed.Value(1, 2, 3)); + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); + } + } + + [TestMethod] + public async Task When_SetModelReSwaps_Then_ReEmitsLive() + { + using (MockingService.Enable()) + { + var vm = RecipeModelMockExtensions.Create(MockListFeed.Value(1)); + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + + (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) + .Should().BeEquivalentTo(new[] { 1 }); + + vm.SetModel(new RecipeModelMock { Steps = MockListFeed.Value(7, 8) }); + + (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) + .Should().BeEquivalentTo(new[] { 7, 8 }); + } + } +} diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs new file mode 100644 index 0000000000..fbc55c3426 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions.Reactive.Core; +using Uno.Extensions.Reactive.Mocking; +using Uno.Extensions.Reactive.Testing; + +namespace Uno.Extensions.Reactive.Tests.Mocking; + +/// +/// Spec 013 step A — the mocking runtime (MockingService scope, MockFeed/MockListFeed vocabulary, +/// MockModel swap engine). +/// +[TestClass] +public class Given_MockingRuntime : FeedTests +{ + [TestMethod] + public async Task When_MockFeed_Value_Then_EmitsValue() + { + var (result, _) = MockFeed.Value(42).Record(); + await result.WaitForMessages(1); + result.Last().Current.Data.SomeOrDefault().Should().Be(42); + } + + [TestMethod] + public async Task When_MockListFeed_Value_Then_EmitsItems() + { + var (result, _) = MockListFeed.Value(1, 2, 3).Record(); + await result.WaitForMessages(1); + ((IImmutableList)result.Last().Current.Data.SomeOrDefault()!).Should().BeEquivalentTo(new[] { 1, 2, 3 }); + } + + [TestMethod] + public async Task When_MockableFeedSwapped_ViaEngine_Then_ReEmits() + { + FeedTestContext ctxHolder; + using (MockingService.Enable()) + { + ctxHolder = new FeedTestContext(); + } + + using (ctxHolder) + { + ctxHolder.RestoreCurrent(); + + var original = MockFeed.Value("original"); + var state = (StateImpl)ctxHolder.SourceContext.GetOrCreateState(original); + var (result, _) = state.Record(); + + await result.WaitForMessages(1); + result.Last().Current.Data.SomeOrDefault().Should().Be("original"); + + MockModel.SwapFeed(ctxHolder, original, MockFeed.Value("mocked")); + + await result.WaitForMessages(2); + result.Last().Current.Data.SomeOrDefault().Should().Be("mocked"); + } + } + + [TestMethod] + public void When_SwapFeed_OnNonMockableContext_Then_FailsHard() + { + using var ctx = new FeedTestContext(); + ctx.SourceContext.IsMockingActive.Should().BeFalse(); + + var original = MockFeed.Value("x"); + _ = ctx.SourceContext.GetOrCreateState(original); + + var act = () => MockModel.SwapFeed(ctx, original, MockFeed.Value("y")); + + act.Should().Throw("fail-hard: a non-mockable feed cannot be swapped (D11)"); + } +} diff --git a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj index a067f9825f..6b99b9d2dc 100644 --- a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj +++ b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj @@ -27,6 +27,8 @@ + + diff --git a/src/Uno.Extensions.Reactive/Core/Internal/IHotSwapState.cs b/src/Uno.Extensions.Reactive/Core/Internal/IHotSwapState.cs index 0c24cc9f8a..9c9a33473f 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/IHotSwapState.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/IHotSwapState.cs @@ -10,6 +10,13 @@ namespace Uno.Extensions.Reactive.Core; /// internal interface IHotSwapState : IState { + /// + /// Gets a value indicating whether this state is actually backed by a hot-swap wrapper + /// (i.e. it was created while wrapping was enabled — hot-reload or mocking). When false, + /// is a no-op; mocking uses this to fail-hard (spec 013, D11). + /// + bool CanHotSwap { get; } + /// /// Hot swap the source of this state. /// diff --git a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs index 2e22760478..a83389c9cd 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs @@ -96,6 +96,8 @@ public StateImpl( } } + bool IHotSwapState.CanHotSwap => _hotSwap is not null; + void IHotSwapState.HotSwap(IFeed? source) { if (source is IState) From 6510bbc6592620d299535e3268230849afbcc9b6 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 12:51:20 +0000 Subject: [PATCH 10/19] feat(mocking): complete the pinned feed vocabulary Adds Value, Empty, Undefined, Loading, Error and Refreshing for both scalar feeds and list feeds, built on pinned messages and strongly typed end to end. --- .../MockFeed.cs | 40 ++++++++++++++--- .../MockListFeed.cs | 44 ++++++++++++++----- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs index 363cc150a5..1305927399 100644 --- a/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs +++ b/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Uno.Extensions.Reactive.Mocking; @@ -9,12 +12,39 @@ namespace Uno.Extensions.Reactive.Mocking; public static class MockFeed { /// A feed pinned to a value (Some). - public static IFeed Value(T value) - where T : notnull - => Feed.Async(async _ => value); + public static IFeed Value(T value) where T : notnull + => Pinned(b => b.Data(value)); + + /// A feed pinned to None (no value). + public static IFeed Empty() where T : notnull + => Pinned(b => b.Data(Option.None())); + + /// A feed pinned to Undefined (pre-first-emission). + public static IFeed Undefined() where T : notnull + => Pinned(b => b); + + /// A feed pinned to a transient/indeterminate loading state (IsExecuting stays true). + public static IFeed Loading() where T : notnull + => Pinned(b => b.IsTransient(true)); /// A feed pinned to an error. - public static IFeed Error(Exception error) + public static IFeed Error(Exception error) where T : notnull + => Pinned(b => b.Error(error)); + + /// A feed pinned to a stale value with a transient progress (refreshing). + public static IFeed Refreshing(T staleValue) where T : notnull + => Pinned(b => b.Data(staleValue).IsTransient(true)); + + private static IFeed Pinned(Func, MessageBuilder> configure) where T : notnull - => Feed.Async(async _ => throw error); + { + Message message = configure(Message.Initial.With()); + return Feed.Create(_ => Yield(message)); + } + + private static async IAsyncEnumerable> Yield(Message message) + { + yield return message; + await Task.CompletedTask; + } } diff --git a/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs index a653c69033..987d94833e 100644 --- a/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs +++ b/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; namespace Uno.Extensions.Reactive.Mocking; @@ -10,25 +12,43 @@ namespace Uno.Extensions.Reactive.Mocking; public static class MockListFeed { /// A list-feed pinned to the given items (Some). - public static IListFeed Value(params T[] items) - where T : notnull - => ListFeed.Async(async _ => items.ToImmutableList()); + public static IListFeed Value(params T[] items) where T : notnull + => Pinned(b => b.Data((IImmutableList)items.ToImmutableList())); /// A list-feed pinned to None (no value). - public static IListFeed Empty() - where T : notnull - => ListFeed.Async(async _ => Option>.None()); + public static IListFeed Empty() where T : notnull + => Pinned(b => b.Data(Option>.None())); /// A list-feed pinned to Some(empty list). - public static IListFeed EmptyList() - where T : notnull - => ListFeed.Async(async _ => ImmutableList.Empty); + public static IListFeed EmptyList() where T : notnull + => Pinned(b => b.Data((IImmutableList)ImmutableList.Empty)); + + /// A list-feed pinned to Undefined (pre-first-emission). + public static IListFeed Undefined() where T : notnull + => Pinned(b => b); + + /// A list-feed pinned to a transient/indeterminate loading state. + public static IListFeed Loading() where T : notnull + => Pinned(b => b.IsTransient(true)); /// A list-feed pinned to an error. - public static IListFeed Error(Exception error) + public static IListFeed Error(Exception error) where T : notnull + => Pinned(b => b.Error(error)); + + /// A list-feed pinned to a stale value with a transient progress (refreshing). + public static IListFeed Refreshing(params T[] staleItems) where T : notnull + => Pinned(b => b.Data((IImmutableList)staleItems.ToImmutableList()).IsTransient(true)); + + private static IListFeed Pinned(Func>, MessageBuilder>> configure) where T : notnull { - AsyncFunc> provider = async _ => throw error; - return ListFeed.Async(provider); + Message> message = configure(Message>.Initial.With()); + return ListFeed.Create(_ => Yield(message)); + } + + private static async IAsyncEnumerable>> Yield(Message> message) + { + yield return message; + await Task.CompletedTask; } } From 90aea9044281d8450939f354db989a774c6d4012 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 12:55:44 +0000 Subject: [PATCH 11/19] feat(mocking): add Create(), Empty, command wiring and the command vocabulary The generated record exposes Empty with every input pinned to its empty state, Create() builds from it, and commands can be overridden through the view-model seam. --- .../FeedsMockGenerator.cs | 37 +++++++++++- .../MockCommand.cs | 56 +++++++++++++++++++ .../RecipeModel.cs | 5 ++ .../Mocking/Given_GeneratedMock.cs | 32 +++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/Uno.Extensions.Reactive.Mocking/MockCommand.cs diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs b/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs index 7cb909059a..c7e12b5dd3 100644 --- a/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs +++ b/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs @@ -149,7 +149,22 @@ private sealed class FeedMember (onFeed is not null ? derived : inputs).Add(fm); } - if (inputs.Count == 0 && derived.Count == 0) + // Commands: the generated VM exposes them as public IAsyncCommand properties, overridable via + // the __Mock_SetCommand seam (emitted by the MVUX generator under opt-in). + var commands = vm.GetMembers() + .OfType() + .Where(pr => !pr.IsStatic && pr.DeclaredAccessibility == Accessibility.Public + && pr.Type.ToDisplayString() == "Uno.Extensions.Reactive.IAsyncCommand") + .Select(pr => pr.Name) + .ToList(); + + var hasMockCommandSeam = vm.GetMembers("__Mock_SetCommand").Any(); + if (!hasMockCommandSeam) + { + commands.Clear(); // no seam → cannot override commands + } + + if (inputs.Count == 0 && derived.Count == 0 && commands.Count == 0) { return null; } @@ -179,6 +194,10 @@ private sealed class FeedMember { sb.AppendLine($"\tpublic {m.FeedTypeFullName}? {m.Name} {{ get; init; }}"); } + foreach (var c in commands) + { + sb.AppendLine($"\tpublic global::Uno.Extensions.Reactive.IAsyncCommand? {c} {{ get; init; }}"); + } sb.AppendLine("}"); sb.AppendLine(); @@ -186,6 +205,17 @@ private sealed class FeedMember sb.AppendLine($"public static class {mockName}Extensions"); sb.AppendLine("{"); + // Empty — every service-dependent input set to its type's Empty state. + var emptyInits = string.Join(", ", inputs.Select(m => m.IsList + ? $"{m.Name} = global::Uno.Extensions.Reactive.Mocking.MockListFeed.Empty<{m.ItemOrValueFullName}>()" + : $"{m.Name} = global::Uno.Extensions.Reactive.Mocking.MockFeed.Empty<{m.ItemOrValueFullName}>()")); + sb.AppendLine($"\tpublic static {mockName} Empty {{ get; }} = new() {{ {emptyInits} }};"); + sb.AppendLine(); + + // Create() — every input Empty. + sb.AppendLine($"\tpublic static {vmFull} Create() => Create(Empty);"); + sb.AppendLine(); + // Create(inputs...) — required inputs as parameters. var createParams = string.Join(", ", inputs.Select(m => $"{m.FeedTypeFullName} {Camel(m.Name)}")); var mockInit = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); @@ -217,6 +247,11 @@ private sealed class FeedMember sb.AppendLine($"\t\tif (mock.{m.Name} is not null)"); sb.AppendLine($"\t\t\tglobal::Uno.Extensions.Reactive.Mocking.MockModel.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); } + foreach (var c in commands) + { + sb.AppendLine($"\t\tif (mock.{c} is not null)"); + sb.AppendLine($"\t\t\tvm.__Mock_SetCommand(\"{c}\", mock.{c});"); + } sb.AppendLine("\t}"); sb.AppendLine("}"); diff --git a/src/Uno.Extensions.Reactive.Mocking/MockCommand.cs b/src/Uno.Extensions.Reactive.Mocking/MockCommand.cs new file mode 100644 index 0000000000..0004a66b7e --- /dev/null +++ b/src/Uno.Extensions.Reactive.Mocking/MockCommand.cs @@ -0,0 +1,56 @@ +using System; +using System.ComponentModel; + +namespace Uno.Extensions.Reactive.Mocking; + +/// +/// Typed vocabulary to build mocked commands (spec 013 §4.1). All produce a strongly-typed +/// suitable for a {Model}Mock command override. +/// +public static class MockCommand +{ + /// An idle, executable no-op command. + public static IAsyncCommand Idle() => new MockAsyncCommand(canExecute: true); + + /// A command that cannot be executed. + public static IAsyncCommand Disabled() => new MockAsyncCommand(canExecute: false); + + /// A command pinned to the executing state. + public static IAsyncCommand Executing() => new MockAsyncCommand(canExecute: true) { IsExecuting = true }; + + /// An executable command invoking . + public static IAsyncCommand Callback(Action onExecute, bool canExecute = true) + => new MockAsyncCommand(canExecute) { OnExecute = onExecute ?? throw new ArgumentNullException(nameof(onExecute)) }; + + private sealed class MockAsyncCommand : IAsyncCommand + { + private readonly bool _canExecute; + private bool _isExecuting; + + public MockAsyncCommand(bool canExecute) => _canExecute = canExecute; + + public Action? OnExecute { get; init; } + + public bool IsExecuting + { + get => _isExecuting; + init => _isExecuting = value; + } + + public event EventHandler? CanExecuteChanged; + public event EventHandler? IsExecutingChanged; + public event PropertyChangedEventHandler? PropertyChanged; + + public bool CanExecute(object? parameter) => _canExecute; + + public void Execute(object? parameter) => OnExecute?.Invoke(parameter); + + // Referenced to avoid unused-event warnings; mock commands do not raise them. + private void Touch() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + IsExecutingChanged?.Invoke(this, EventArgs.Empty); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExecuting))); + } + } +} diff --git a/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs index 1e8a5c4a85..5f0f4cf3e2 100644 --- a/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs +++ b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs @@ -29,4 +29,9 @@ public RecipeModel(IRecipeService svc) // independent scalar input public IFeed Title => Feed.Async(async ct => "Recipe"); + + // command → IAsyncCommand Save on the VM + __Mock_SetCommand seam (opt-in) + public async ValueTask Save(CancellationToken ct) + { + } } diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs index d07d3c9657..fc8cf64abc 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs @@ -65,4 +65,36 @@ public async Task When_SetModelReSwaps_Then_ReEmitsLive() .Should().BeEquivalentTo(new[] { 7, 8 }); } } + + [TestMethod] + public async Task When_CreateDefault_Then_InputsAreEmpty() + { + using (MockingService.Enable()) + { + var vm = RecipeModelMockExtensions.Create(); // Empty → Steps = None + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeNull("Empty pins the input to None"); + } + } + + [TestMethod] + public void When_CommandOverridden_Then_VmCommandInvokesMock() + { + using (MockingService.Enable()) + { + var executed = false; + var vm = RecipeModelMockExtensions.Create(new RecipeModelMock + { + Steps = MockListFeed.Value(1), + Save = MockCommand.Callback(_ => executed = true), + }); + + vm.Save.Should().NotBeNull(); + vm.Save.CanExecute(null).Should().BeTrue(); + vm.Save.Execute(null); + executed.Should().BeTrue("SetModel routed the mock command through __Mock_SetCommand"); + } + } } From 7cd16503a6928e4fd262fd5085890c48d99e4707 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 12:57:02 +0000 Subject: [PATCH 12/19] docs(mocking): record the tier 2 and 3 implementation status Logs what landed for the tier 2 and 3 implementation and what is left out of scope. --- specs/013-mvux-mocking-previews/history.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 2d18b3fb06..8d0be93902 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -80,6 +80,26 @@ Puis : **perte du workspace ACO** (node détruit, branche non poussée — commi - Point AOT (David) : un split 2-assemblies impose la réflexion de toute façon (générer `{Model}Mock` à côté du `Model` rendrait l'assembly mock creuse) → réflexion-core assumée, path dev/test-only non-AOT (D7/NG2). - Le « spike P0-e » (mécanisme du scope) est **résolu**, plus un spike : il ride `SourceContext`. - Répercuté : spec §13/§10/§5/§4 + D4(superseded)/D11/D12, archi §0/§1/§2.1/§5/§6/§7, impl §1/§2.2/§3/§6/§7/§8. + +## v8 — implémentation tier 2/3 (mar. 25/08) + +Landée sur `dev/devid/spec-013-mvux-mocking` (poussée staging PR #1), après la spec (`b029713cb`) : + +- **Substrat core** (`62af49aa0`) : `SourceContext.IsMockingActive` (bit per-contexte hérité, pas de static séparé, pas d'AsyncLocal maison), `EnableMocking()` scope ambient, gate wrap dans `StateImpl` ctor (`|| context.IsMockingActive`). Swap réflexif via `IHotSwapState`. +- **Fix fail-hard (D11)** (`4b68b7e93`) : `StateImpl` implémente TOUJOURS `IHotSwapState` → ajout `IHotSwapState.CanHotSwap` (`=> _hotSwap is not null`) ; `MockModel` throw sur `!CanHotSwap` (un test manquant, jamais exécuté au départ, cachait ce bug — corrigé). +- **Passe générateur MVUX** (`4d02e6553`) : classification `FeedDependency` (service-dependent `OnParameter` / derived `OnFeed` / independent nu) + instrumentation ctor `CtorDependency(Eager=true)` ; opt-in `[assembly: EnableFeedMocking]`, byte-identique si absent. Seam VM `__Mock_SetCommand` (commandes, R2 ; pas de `__Mock_Create` — ctors publics + scope ambient, D12). +- **2e assembly `Uno.Extensions.Reactive.Mocking`** (`4b68b7e93`, `b6caeee03`, `6384a4d48`) : `MockingService.Enable()`, `MockModel.SwapFeed/SwapListFeed` (fail-hard), vocab `MockFeed`/`MockListFeed` (Value/Empty/EmptyList/Undefined/Loading/Error/Refreshing), `MockCommand` (Idle/Disabled/Executing/Callback). +- **Générateur consumer `Uno.Extensions.Reactive.Mocking.Generator`** (`4b68b7e93`, `6384a4d48`) : lit les métadonnées (assemblies référencées + compilation courante) → émet `record {Model}Mock` (inputs required, derived + commandes optionnels), `Empty`, `Create()`/`Create(inputs)`/`Create(mock)` (null-inject), `SetModel` (swaps typés + `__Mock_SetCommand`). +- **App-fixture `Uno.Extensions.Reactive.Tests.MockingApp`** : modèle réel + opt-in, pour tester le vrai flux 2-assemblies (les générateurs ne se chaînent pas en une compilation). + +**Tests (réellement exécutés) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4 (Create+SetModel → VM réel → feed mické ; live re-swap ; `Create()` Empty→None ; override commande), Tests.Generator 80/80 (byte-identique préservé), Given_HotReload 8/8 (Core inchangé). + +**Reste (hors périmètre du cœur tier 2/3, à planifier avec David) :** +- `MockFeed.Message`/`Script` (dépendent du vocabulaire de #3147, non mergé). +- Diagnostics `FEED3201–3203` / `MOCK0001` (analyse en place, diagnostics non émis). +- Docs `doc/Learn/Mvux/Testing.md` + `FeedView.md` (§9), Tier 1 (on hold). +- Remontée github : outbox ABO → PR #3165 (après review David). + --- ## Registre final des décisions From fb88e5a2c22b057290a6692cbe928921f367a1a3 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 16:48:48 +0000 Subject: [PATCH 13/19] refactor(mocking): reuse the FeedMock vocabulary shipped by #3149 The mocked feed vocabulary already exists in Uno.HotTesting.Reactive, so the duplicate assembly is removed and everything lives there: FeedMock and ListFeedMock are reused, CommandMock is added, and the swap engine moves into MockingService as strongly typed helpers. The consumer generator becomes Uno.HotTesting.Reactive.Generator and emits raw string literals. The MVUX instrumentation is now emitted by default, with an assembly level opt-out. --- Uno.Extensions.sln | 20 +++ .../013-mvux-mocking-previews/architecture.md | 12 +- specs/013-mvux-mocking-previews/history.md | 13 ++ .../implementation.md | 30 ++-- specs/013-mvux-mocking-previews/spec.md | 30 ++-- .../Bindables/BindableGenerationContext.cs | 13 +- .../MockFeed.cs | 50 ------ .../MockListFeed.cs | 54 ------ .../MockModel.cs | 58 ------- .../MockingService.cs | 25 --- .../Uno.Extensions.Reactive.Mocking.csproj | 8 - .../RecipeModel.cs | 4 - .../Mocking/Given_GeneratedMock.cs | 12 +- .../Mocking/Given_MockingRuntime.cs | 14 +- .../Uno.Extensions.Reactive.Tests.csproj | 4 +- src/Uno.Extensions.Reactive/AssemblyInfo.cs | 2 +- .../Config/EnableFeedMockingAttribute.cs | 9 +- .../AnalyzerReleases.Shipped.md | 0 .../AnalyzerReleases.Unshipped.md | 0 .../FeedsMockGenerator.cs | 163 ++++++++---------- .../Uno.HotTesting.Reactive.Generator.csproj} | 4 +- .../CommandMock.cs} | 14 +- src/Uno.HotTesting.Reactive/MockingService.cs | 69 ++++++++ 23 files changed, 247 insertions(+), 361 deletions(-) delete mode 100644 src/Uno.Extensions.Reactive.Mocking/MockFeed.cs delete mode 100644 src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs delete mode 100644 src/Uno.Extensions.Reactive.Mocking/MockModel.cs delete mode 100644 src/Uno.Extensions.Reactive.Mocking/MockingService.cs delete mode 100644 src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj rename src/{Uno.Extensions.Reactive.Mocking.Generator => Uno.HotTesting.Reactive.Generator}/AnalyzerReleases.Shipped.md (100%) rename src/{Uno.Extensions.Reactive.Mocking.Generator => Uno.HotTesting.Reactive.Generator}/AnalyzerReleases.Unshipped.md (100%) rename src/{Uno.Extensions.Reactive.Mocking.Generator => Uno.HotTesting.Reactive.Generator}/FeedsMockGenerator.cs (56%) rename src/{Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj => Uno.HotTesting.Reactive.Generator/Uno.HotTesting.Reactive.Generator.csproj} (87%) rename src/{Uno.Extensions.Reactive.Mocking/MockCommand.cs => Uno.HotTesting.Reactive/CommandMock.cs} (85%) create mode 100644 src/Uno.HotTesting.Reactive/MockingService.cs diff --git a/Uno.Extensions.sln b/Uno.Extensions.sln index d088ff0482..fc72432dd7 100644 --- a/Uno.Extensions.sln +++ b/Uno.Extensions.sln @@ -152,6 +152,7 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Authentication.Tests", "src\Uno.Extensions.Authentication.Tests\Uno.Extensions.Authentication.Tests.csproj", "{7AD671A6-E401-4C16-803B-C9554673FD19}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Storage.WinUI.Tests", "src\Uno.Extensions.Storage.UI.Tests\Uno.Extensions.Storage.WinUI.Tests.csproj", "{26DA9186-8DD8-4623-A455-0F0B9DA2E79A}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive", "src\Uno.HotTesting.Reactive\Uno.HotTesting.Reactive.csproj", "{A1A08FE3-19D4-4EB2-B228-10187E2C4CDF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive.Tests", "src\Uno.HotTesting.Reactive.Tests\Uno.HotTesting.Reactive.Tests.csproj", "{2347B2F1-002C-4165-B523-D4D236A13CEC}" @@ -162,6 +163,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Moc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.Extensions.Reactive.Tests.MockingApp", "src\Uno.Extensions.Reactive.Tests.MockingApp\Uno.Extensions.Reactive.Tests.MockingApp.csproj", "{C16D5143-8353-400D-BE40-9CEEEB2A5404}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.HotTesting.Reactive.Generator", "src\Uno.HotTesting.Reactive.Generator\Uno.HotTesting.Reactive.Generator.csproj", "{94FC36D9-81B5-4347-8EBE-1DF99DB55981}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -966,6 +969,22 @@ Global {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x64.Build.0 = Release|Any CPU {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x86.ActiveCfg = Release|Any CPU {C16D5143-8353-400D-BE40-9CEEEB2A5404}.Release|x86.Build.0 = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|arm64.ActiveCfg = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|arm64.Build.0 = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|x64.ActiveCfg = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|x64.Build.0 = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|x86.ActiveCfg = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Debug|x86.Build.0 = Debug|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|Any CPU.Build.0 = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|arm64.ActiveCfg = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|arm64.Build.0 = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|x64.ActiveCfg = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|x64.Build.0 = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|x86.ActiveCfg = Release|Any CPU + {94FC36D9-81B5-4347-8EBE-1DF99DB55981}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1025,6 +1044,7 @@ Global {C1D1F711-4271-4E79-AABD-AA316F11D8C3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {62D73733-0DB2-407F-92A8-01FA1EA675EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C16D5143-8353-400D-BE40-9CEEEB2A5404} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {94FC36D9-81B5-4347-8EBE-1DF99DB55981} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6E7B035D-9A64-4D95-89AA-9D4653F17C42} diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index e02adfb621..15da95a478 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -64,18 +64,18 @@ Identity risk (R6): lambdas capturing locals/params produce fresh delegate targe (Names to bikeshed; semantics fixed: *input vs derived vs independent*, plus *ctor-eager* flags.) -**c) Hidden hooks** (`EditorBrowsable(Never)`, emitted only under the opt-in flag): +**c) Hidden hooks** (`EditorBrowsable(Never)`, emitted by default — opt-out via `EnableFeedMocking(IsEnabled = false)`): - on the **Model partial**: **nothing per-feed** — the swap is reflection over `IHotSwapState` members at runtime (D11), reusing the hot-reload driver, fail-hard. The generator emits no `__Mock_Swap_{Member}`; - on the **VM partial**: **no construction seam** — null-inject uses the existing public ctors (`new {Vm}(default!, …)`) under an ambient `MockingService.Enable()` scope (D12: the `SourceContext` built at construction is mockable, captured on the instance). The only emitted seam is `__Mock_SetCommand(string name, IAsyncCommand)` (public, `EditorBrowsable(Never)`, fail-hard) which reassigns a command property post-construction — commands have no `IHotSwapState` and are unreachable by the reflection swap (R2). -### 2.2 Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the test/preview project) +### 2.2 Mocking generator (ships in `Uno.HotTesting.Reactive`, runs in the test/preview project) Reads the app assembly **metadata** (generated VM/Model types + the attributes above). No syntax trees needed → cross-assembly by construction. Emits **external, generic and strongly typed types/extensions** (partial injection impossible and not needed): ```csharp public record RecipeModelMock { - public static RecipeModelMock Empty { get; } = new() { Steps = MockListFeed.Empty() }; + public static RecipeModelMock Empty { get; } = new() { Steps = ListFeedMock.Empty() }; public required IListFeed Steps { get; init; } // ServiceDependent input → required public IFeed? StepsCount { get; init; } // Derived → optional override; null = real business logic public IAsyncCommand? Save { get; init; } // command → optional; null = idle no-op @@ -200,7 +200,7 @@ The `FeedView.Source` converter above is only an illustration of normal XAML com ## 4. Tier 3 — complete-model helpers -Pure consumers of §2.2: named catalogs (`static RecipeViewModel BasicRecipe => Create(ListFeed.Value(...))`), selection posed via states (`vm.Selected.Set(1)`), gallery pickers over `MockFeedState`. Hand-written in the test/preview project, optionally scaffolded. +Pure consumers of §2.2: named catalogs (`static RecipeViewModel BasicRecipe => Create(ListFeed.Value(...))`), selection posed via states (`vm.Selected.Set(1)`), gallery pickers over `FeedMockState`. Hand-written in the test/preview project, optionally scaffolded. ## 5. End-to-end flow @@ -234,7 +234,7 @@ The activation API is **decided** (D10): mocking exists only inside an explicit ```csharp using (MockingService.Enable()) { - var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); + var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); } ``` @@ -260,4 +260,4 @@ Resolved against the source: - **No wrap unless `SourceContext.IsMockingActive`** (§6, D10/D12): the per-feed `HotSwapFeed` indirection must never exist in a live app; a live-app context never has the bit set. - **Swap is reflection over `IHotSwapState`, fail-hard** (D11): no per-member generated hook; an un-swappable mocked member throws. - Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, the `__Mock_SetCommand` command seam. -- MVUX output byte-identical when opt-in flag absent. +- MVUX output byte-identical only when explicitly opted out (`EnableFeedMocking(IsEnabled = false)`); instrumentation is emitted by default. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 8d0be93902..3b07fbd602 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -100,6 +100,19 @@ Landée sur `dev/devid/spec-013-mvux-mocking` (poussée staging PR #1), après l - Docs `doc/Learn/Mvux/Testing.md` + `FeedView.md` (§9), Tier 1 (on hold). - Remontée github : outbox ABO → PR #3165 (après review David). + +## v9 — réconciliation avec #3149 (FeedMock mergé) + review David (mar. 25/08) + +Rebase sur `main` (PR #3154 / issue #3149 mergée) : **le vocabulaire de feeds mockés existe déjà** dans une assembly dédiée `Uno.HotTesting.Reactive` (`FeedMock`/`ListFeedMock`, namespace + assembly `Uno.HotTesting.Reactive`, spec 009). Mon `Uno.Extensions.Reactive.Mocking` le dupliquait → **supprimé**. Décisions de naming/namespace suite à la review de David sur la staging PR #1 : + +- **Assembly unique `Uno.HotTesting.Reactive`** : tout le mocking runtime y vit (le `FeedMock`/`ListFeedMock` existants + les ajouts tier 2/3). Suppression de `Uno.Extensions.Reactive.Mocking`. +- **Naming `Mock`** (suffixe, cohérent avec `FeedMock`) : `MockFeed`→`FeedMock` (réutilisé), `MockListFeed`→`ListFeedMock` (réutilisé), `MockCommand`→**`CommandMock`** (ajouté). Surface publique de `FeedMock`/`ListFeedMock` verrouillée par `Given_PublicApi` (7 primitives : Empty/Error/Loading/Message/Refreshing/Undefined/Value) → réutilisée telle quelle (mon `EmptyList` retiré, `Empty`=None suffit). +- **Moteur de swap dans `MockingService`** (drop du type `MockModel`, jugé « mêlant ») : `MockingService.Enable()` + `MockingService.SwapFeed`/`SwapListFeed` (public `EditorBrowsable(Never)`, fail-hard). Le swap est **fortement typé généré** (pas de réflexion runtime) → **AOT-safe**, l'assembly garde `IsAotCompatible=true`. +- **Générateur consumer → `Uno.HotTesting.Reactive.Generator`** (analyzer/tool du package `Uno.HotTesting.Reactive`) ; émission en **raw string literals** (cohérence codegen) ; émet `FeedMock`/`ListFeedMock`/`CommandMock` + `MockingService.Swap` + `__Mock_SetCommand`. +- **Instrumentation MVUX émise par DÉFAUT** (plus opt-in) : les attributs `FeedDependency`/`CtorDependency` + le seam `__Mock_SetCommand` sont toujours émis ; c'est le **runtime** (`MockingService.Enable()`) qui décide l'activation. Opt-out possible : `[assembly: EnableFeedMocking(IsEnabled = false)]` → sortie MVUX byte-identique. Modèle on-par-défaut/opt-out comme les autres attributs MVUX. + +**Tests après refactor (verts) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, `Uno.HotTesting.Reactive.Tests` 22/22 (FeedMock existant non régressé). + --- ## Registre final des décisions diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index b5f69d58d1..db03bb2552 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -10,11 +10,11 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi | Mockable gate + HotSwap wrap at feed cache | core | **`SourceContext.IsMockingActive`** (new per-context bit, D12) read in `StateImpl` ctor; wrap wired at the `AttachedProperty`/factory cache | | Authorable `MessageEntry` + `AxisValue` (plain CLR) + internal `MessageEntryFeed` | core | tier-1, AOT-safe, **not** a `DependencyObject` | | `FeedView.Source` coercion bridge | `Uno.Extensions.Reactive.UI` | tier-1 | -| Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, opt-in only | -| Mock vocabulary (`MockFeed`/`MockListFeed`/`MockCommand`/`MockFeedState`) | **`Uno.Extensions.Reactive.Mocking`** (new) | referenced by test/preview projects only | -| Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.Extensions.Reactive.Mocking` (analyzer asset) | runs in consumer project, reads app metadata | +| Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, on by default (opt-out) | +| Mock vocabulary (`FeedMock`/`ListFeedMock`/`CommandMock`/`FeedMockState`) | **`Uno.HotTesting.Reactive`** (new) | referenced by test/preview projects only | +| Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.HotTesting.Reactive` (analyzer asset) | runs in consumer project, reads app metadata | | Reflection swap driver (reused, fail-hard) | core | reuse hot-reload's `IHotSwapState` iteration; **throw on un-swappable member** (D11) | -| `MockingService.Enable()` activation scope | `Uno.Extensions.Reactive.Mocking` | frozen name; sets `SourceContext.IsMockingActive` on the ambient/pre-seeded context (§6) | +| `MockingService.Enable()` activation scope | `Uno.HotTesting.Reactive` | frozen name; sets `SourceContext.IsMockingActive` on the ambient/pre-seeded context (§6) | ## 2. Core (`Uno.Extensions.Reactive`) @@ -54,7 +54,7 @@ public sealed class CtorDependencyAttribute : Attribute ## 3. MVUX generator changes (`Uno.Extensions.Reactive.Generator`) -Opt-in: `[assembly: EnableFeedMocking]` (or MSBuild prop). When absent → byte-identical output. +On by default (the runtime decides activation). Opt-out: `[assembly: EnableFeedMocking(IsEnabled = false)]` → byte-identical MVUX output. 1. **Analysis pass** (per Model): classify members `ServiceDependent(param) | DerivedFrom(feed) | Independent`; lambda/anonymous/local-function bodies = deferred boundary. **Ctor instrumentation**: walk ctor bodies + field/property initializers + primary-ctor eager captures → mark `CtorDependency(Eager=true)` per offending parameter. Hand-declared attributes override/merge (author is the escape hatch). 2. **Emit attributes** (§2.1) on the generated Model partial. @@ -63,11 +63,11 @@ Opt-in: `[assembly: EnableFeedMocking]` (or MSBuild prop). When absent → byte- - VM partial: **no dedicated construction seam** — null-inject construction reuses the existing public constructors (`new {Vm}(default!, …)`); under an ambient `MockingService.Enable()` scope the `SourceContext` created at construction is mockable (D12), and the bit is captured on the context instance so a lazy first subscription after the scope is disposed still wraps. Commands have no `IHotSwapState` and are unreachable by the reflection swap, so a **dedicated public `__Mock_SetCommand(string name, IAsyncCommand)`** seam (`EditorBrowsable(Never)`) reassigns the command property post-construction (R2). Fail-hard: an unknown command name throws (strict, like D11). 4. Diagnostics: `FEED3201` eager ctor access detected (info: `Create` will require the service), `FEED3202` unstable feed identity (capture pattern defeats caching), `FEED3203` explicit attribute contradicts analysis. -## 4. Mocking package (`Uno.Extensions.Reactive.Mocking`) +## 4. Mocking package (`Uno.HotTesting.Reactive`) ### 4.1 Runtime vocabulary (all generic and strongly typed) ```csharp -public static class MockFeed +public static class FeedMock { public static IFeed Undefined(); public static IFeed Loading(); // transient → Indeterminate, IsExecuting stays true @@ -78,19 +78,19 @@ public static class MockFeed public static IFeed Message(Action> configure); public static IFeed Script(params (TimeSpan after, Action> step)[] steps); // from #3147 } -public static class MockListFeed +public static class ListFeedMock { // Typed list equivalents: Undefined, Loading, Empty (None), EmptyList (Some(empty)), // Value(params/list), Value(list, SelectionInfo), Error, Refreshing. } -public static class MockCommand +public static class CommandMock { public static IAsyncCommand Idle(); public static IAsyncCommand Disabled(); public static IAsyncCommand Executing(); public static IAsyncCommand Callback(Action onExecute, bool canExecute = true); } -public enum MockFeedState { Undefined, Loading, Empty, Value, Error, Refreshing } +public enum FeedMockState { Undefined, Loading, Empty, Value, Error, Refreshing } ``` Built over public `Feed.Create` + `MessageBuilder` (vocabulary from #3147). **These APIs never accept the non-generic tier-1 `MessageEntry` or untyped envelopes.** Never referenced by a published app head (non-AOT, dev/test only — NG2/D7). @@ -99,7 +99,7 @@ For each Model/VM pair found in referenced assemblies with `__Mock_*` hooks + at ```csharp public record RecipeModelMock { - public static RecipeModelMock Empty { get; } // ServiceDependent → MockFeed/MockListFeed.Empty + public static RecipeModelMock Empty { get; } // ServiceDependent → FeedMock/ListFeedMock.Empty public required IListFeed Steps { get; init; } // exactly the ServiceDependent set public IFeed? StepsCount { get; init; } // Derived → optional override; null = real derivation public IAsyncCommand? Save { get; init; } // optional; default idle no-op @@ -129,7 +129,7 @@ Rules: ## 6. Scoped activation — API decided (D10), mechanism resolved (D12) ```csharp -namespace Uno.Extensions.Reactive.Mocking; +namespace Uno.HotTesting.Reactive; public static class MockingService { @@ -143,7 +143,7 @@ public static class MockingService [AssemblyCleanup] public static void Cleanup() => _scope.Dispose(); // or a single test -using (MockingService.Enable()) { var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); } +using (MockingService.Enable()) { var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); } ``` **Non-negotiable constraint:** context not mockable → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `SourceContext.IsMockingActive` (§2.2, D12) is the internal per-context gate the scope drives, not a switch app authors set. @@ -171,7 +171,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): ## 8. Test plan ### Core -- Every typed `MockFeed`/`MockListFeed`/`MockCommand` state emits expected axes. +- Every typed `FeedMock`/`ListFeedMock`/`CommandMock` state emits expected axes. - Authorable entry maps to Data/Error/Progress/Undefined correctly; custom axes map and diff correctly. - Consecutive entry instances produce correct core + custom axis diffs. - Wrap identity (`AttachedProperty` returns the same wrapper); swap propagation through `Select`/`Where` and chained derived feeds; live re-swap. @@ -179,7 +179,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): ### Generators - Classification fixtures (lazy/eager/derived/independent; ctor bodies, field/property initializers, primary-ctor captures). - Attribute emission; explicit-attribute override/merge; FEED3201–3203. -- Byte-identical output when opt-in absent; hooks hidden (`EditorBrowsable`) and typed (concrete generics). +- Byte-identical output when opted out; hooks hidden (`EditorBrowsable`) and typed (concrete generics). - Consumer generation against a compiled fixture assembly; required-input set = ServiceDependent set; eager-ctor → required service parameter; MOCK0001; **no tier-1/untyped surface in tier-2/3 output**. ### Runtime / UI (Skia) diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 655df98700..1df1f0dc2f 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -1,7 +1,7 @@ # 013 — MVUX Mocking & Previews **Status:** Draft — under review -**Area:** `Uno.Extensions.Reactive` (attributes + hooks), `Uno.Extensions.Reactive.UI` (tier-1 bridge), **new package `Uno.Extensions.Reactive.Mocking`** (typed vocabulary + facade + its own generator) +**Area:** `Uno.Extensions.Reactive` (attributes + hooks), `Uno.Extensions.Reactive.UI` (tier-1 bridge), **new package `Uno.HotTesting.Reactive`** (typed vocabulary + facade + its own generator) **Prior art (POCs):** #3148 / spec 009, #3147 / spec 012 **Primary consumers:** app **test projects** (referencing the app), and Uno **Hot Design** *MVUX State Previews* **Decision history:** [history.md](history.md) @@ -35,7 +35,7 @@ public IFeed StepsCount => Steps.Select(steps => steps.Count); // busines ```mermaid flowchart LR - MOCK["MockListFeed.Value(steps) + MOCK["ListFeedMock.Value(steps) applied via SetModel"] subgraph MODEL["Real RecipeModel — services null-injected"] W["Steps @@ -71,7 +71,7 @@ flowchart TB ``` 1. **Tier 1 — Static/XAML, no VM:** `FeedView.Source` accepts a declared **`MessageEntry`** — a new authorable non-generic entry in **Core**, a **plain CLR object (deliberately NOT a `DependencyObject`)**, XAML element syntax; core axes as direct convenience properties and **custom axes first-class** via an axis collection (MVUX's open axis model). **Replacing** the `Source` entry instance pushes the new entry through the existing wrapper feed: the stream evolves like a real feed (no re-subscribe, no loading flash). The entry itself is **not observable** — a new instance is the unit of change. No heuristic envelope, no parallel DTO, no converter deliverable (an application-owned converter at `FeedView.Source` is illustration only). Tier 1 is an **isolated UI convenience** and never leaks into tiers 2/3. -2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.Extensions.Reactive.Mocking` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. +2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.HotTesting.Reactive` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. 3. **Tier 3 — Complete-model ergonomics:** `{Model}Mock.Empty`, `Create()` overloads whose **required parameters are exactly the service-dependent feeds**; **derived members are optional overrides** (unset → the real business logic runs over the mocked inputs; set → replaced — useful for tests); hand-extensible named catalogs (`BasicRecipe`, `RecipeWithSelection`…) for one-line preview binding. Strongly typed, no tier-1 abstractions. ## 4. Split of responsibilities @@ -92,7 +92,7 @@ flowchart TB end subgraph TEST["Test / preview project — references the app"] MG["Mocking generator - (ships in Uno.Extensions.Reactive.Mocking)"] + (ships in Uno.HotTesting.Reactive)"] OUT["RecipeModelMock record Create(...) factories · SetModel facade"] MG --> OUT @@ -104,8 +104,8 @@ flowchart TB - **MVUX generator (runs in the Model's assembly, on the partial Model):** a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); - c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, under the opt-in): the VM `__Mock_SetCommand` seam for commands (R2 — commands have no `IHotSwapState`). Construction needs no seam (public ctors + ambient scope, D12). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). -- **Mocking generator (ships in `Uno.Extensions.Reactive.Mocking`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). + c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, on by default — opt-out): the VM `__Mock_SetCommand` seam for commands (R2 — commands have no `IHotSwapState`). Construction needs no seam (public ctors + ambient scope, D12). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). +- **Mocking generator (ships in `Uno.HotTesting.Reactive`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). ## 5. End-to-end — a test drives a page through its states @@ -164,11 +164,11 @@ var vm = RecipeViewModel.Create(); // real VM + real Model, services nu vm.SetModel(new RecipeModelMock // required init → the compiler lists every input to fill { - Steps = MockListFeed.Loading(), // pinned Loading, forever - Tags = MockListFeed.Empty(), + Steps = ListFeedMock.Loading(), // pinned Loading, forever + Tags = ListFeedMock.Empty(), }); -vm.SetModel(RecipeModelMock.Empty with { Steps = MockListFeed.Error(timeout) }); // live re-swap +vm.SetModel(RecipeModelMock.Empty with { Steps = ListFeedMock.Error(timeout) }); // live re-swap ``` - Required members = exactly the **service-dependent** feeds (compile-time completeness, G4). @@ -182,12 +182,12 @@ Generated surface, `Create` overload rules and diagnostics: [architecture.md §2 The same engine, one call: `Create` takes **only the required feeds** — nothing else to fill in. ```csharp -var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); // one required input → one argument -var loading = RecipeViewModel.Create(MockListFeed.Loading()); +var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); // one required input → one argument +var loading = RecipeViewModel.Create(ListFeedMock.Loading()); var empty = RecipeViewModel.Create(); // = every input Empty // Named catalogs, hand-written in the test/preview project -public static RecipeViewModel BasicRecipe => RecipeViewModel.Create(MockListFeed.Value(AvocadoToast)); +public static RecipeViewModel BasicRecipe => RecipeViewModel.Create(ListFeedMock.Value(AvocadoToast)); ``` ```xml @@ -204,7 +204,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe - G2. **Derived feeds recompute over mocked inputs** (business logic survives); derived members remain individually overridable for tests. - G3. Mock generation happens **in the consumer project** (test/preview), against app metadata. - G4. Compile-time completeness (`required init`) and compile-time surfacing of eager-ctor constraints. -- G5. Opt-in; byte-identical MVUX output when disabled. Additive only. +- G5. Instrumentation emitted **by default** (opt-out via `[assembly: EnableFeedMocking(IsEnabled = false)]`, restoring byte-identical MVUX output). Additive only; the runtime (`MockingService.Enable()`) decides activation. - G6. Live re-swap to drive transitions. - G7. Tier-1 XAML state declaration with no VM, including custom axes. - G8. Tiers 2/3 **strongly typed end to end**. @@ -221,7 +221,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.Extensions.Reactive.Mocking` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.HotTesting.Reactive` namespace. Renames = breaking; additive evolution fine. ## 11. Risks @@ -259,7 +259,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ```csharp using (MockingService.Enable()) { - var vm = RecipeViewModel.Create(MockListFeed.Value(steps)); + var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); } ``` diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs index df534a7544..4247b5de7a 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/BindableGenerationContext.cs @@ -67,19 +67,22 @@ public bool IsGenerationNotDisable(ISymbol symbol) : null; /// - /// Spec 013 — whether the current assembly opted-in to mocking metadata generation - /// via [assembly: EnableFeedMocking]. When false, MVUX output is byte-identical. + /// Spec 013 — whether the MVUX mocking instrumentation should be emitted. On by default (the runtime + /// decides activation); emit is skipped only when [assembly: EnableFeedMocking(IsEnabled = false)] + /// explicitly opts out. /// public bool IsMockingEnabled() { if (EnableFeedMockingAttribute is null) { - return false; + return true; // attribute type not referenced → default on } - return Context.Compilation.Assembly.GetAttributes().Any(a => + var optOut = Context.Compilation.Assembly.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, EnableFeedMockingAttribute) - && (a.NamedArguments.FirstOrDefault(na => na.Key == "IsEnabled").Value.Value as bool? ?? true)); + && (a.NamedArguments.FirstOrDefault(na => na.Key == "IsEnabled").Value.Value as bool?) == false); + + return !optOut; } public bool IsFeed(ITypeSymbol type) diff --git a/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs deleted file mode 100644 index 1305927399..0000000000 --- a/src/Uno.Extensions.Reactive.Mocking/MockFeed.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Uno.Extensions.Reactive.Mocking; - -/// -/// Typed vocabulary to build pinned scalar feed states for mocking (spec 013 §4.1). Strongly typed -/// end to end; never accepts the tier-1 MessageEntry or any untyped envelope (D9/NG7). -/// -public static class MockFeed -{ - /// A feed pinned to a value (Some). - public static IFeed Value(T value) where T : notnull - => Pinned(b => b.Data(value)); - - /// A feed pinned to None (no value). - public static IFeed Empty() where T : notnull - => Pinned(b => b.Data(Option.None())); - - /// A feed pinned to Undefined (pre-first-emission). - public static IFeed Undefined() where T : notnull - => Pinned(b => b); - - /// A feed pinned to a transient/indeterminate loading state (IsExecuting stays true). - public static IFeed Loading() where T : notnull - => Pinned(b => b.IsTransient(true)); - - /// A feed pinned to an error. - public static IFeed Error(Exception error) where T : notnull - => Pinned(b => b.Error(error)); - - /// A feed pinned to a stale value with a transient progress (refreshing). - public static IFeed Refreshing(T staleValue) where T : notnull - => Pinned(b => b.Data(staleValue).IsTransient(true)); - - private static IFeed Pinned(Func, MessageBuilder> configure) - where T : notnull - { - Message message = configure(Message.Initial.With()); - return Feed.Create(_ => Yield(message)); - } - - private static async IAsyncEnumerable> Yield(Message message) - { - yield return message; - await Task.CompletedTask; - } -} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs b/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs deleted file mode 100644 index 987d94833e..0000000000 --- a/src/Uno.Extensions.Reactive.Mocking/MockListFeed.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; - -namespace Uno.Extensions.Reactive.Mocking; - -/// -/// Typed vocabulary to build pinned list-feed states for mocking (spec 013 §4.1). -/// -public static class MockListFeed -{ - /// A list-feed pinned to the given items (Some). - public static IListFeed Value(params T[] items) where T : notnull - => Pinned(b => b.Data((IImmutableList)items.ToImmutableList())); - - /// A list-feed pinned to None (no value). - public static IListFeed Empty() where T : notnull - => Pinned(b => b.Data(Option>.None())); - - /// A list-feed pinned to Some(empty list). - public static IListFeed EmptyList() where T : notnull - => Pinned(b => b.Data((IImmutableList)ImmutableList.Empty)); - - /// A list-feed pinned to Undefined (pre-first-emission). - public static IListFeed Undefined() where T : notnull - => Pinned(b => b); - - /// A list-feed pinned to a transient/indeterminate loading state. - public static IListFeed Loading() where T : notnull - => Pinned(b => b.IsTransient(true)); - - /// A list-feed pinned to an error. - public static IListFeed Error(Exception error) where T : notnull - => Pinned(b => b.Error(error)); - - /// A list-feed pinned to a stale value with a transient progress (refreshing). - public static IListFeed Refreshing(params T[] staleItems) where T : notnull - => Pinned(b => b.Data((IImmutableList)staleItems.ToImmutableList()).IsTransient(true)); - - private static IListFeed Pinned(Func>, MessageBuilder>> configure) - where T : notnull - { - Message> message = configure(Message>.Initial.With()); - return ListFeed.Create(_ => Yield(message)); - } - - private static async IAsyncEnumerable>> Yield(Message> message) - { - yield return message; - await Task.CompletedTask; - } -} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockModel.cs b/src/Uno.Extensions.Reactive.Mocking/MockModel.cs deleted file mode 100644 index 0725614fd9..0000000000 --- a/src/Uno.Extensions.Reactive.Mocking/MockModel.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Immutable; -using Uno.Extensions.Reactive.Core; - -namespace Uno.Extensions.Reactive.Mocking; - -/// -/// Runtime swap engine for MVUX mocking (spec 013, D11). Replaces the source of a model feed member at -/// its cache-level HotSwapFeed wrapper, reusing the hot-reload swap seam (IHotSwapState<T>). -/// The generated SetModel (tier 2/3) emits strongly-typed calls into these helpers. -/// -/// -/// Fail-hard (delta vs hot-reload's best-effort): if the member's feed is not wrapped — i.e. the model -/// was not constructed inside a scope — the swap throws instead of silently -/// doing nothing. -/// -public static class MockModel -{ - /// - /// Swaps the source of a scalar feed member. - /// - /// The model (or view-model) instance owning the feed. - /// The feed currently exposed by the member (the cached wrapper). - /// The mock feed to swap in. - public static void SwapFeed(object owner, IFeed current, IFeed replacement) - where T : notnull - { - var ctx = SourceContext.GetOrCreate(owner); - var state = ctx.GetOrCreateState(current); - if (state is not IHotSwapState hotSwap || !hotSwap.CanHotSwap) - { - throw new InvalidOperationException( - $"The feed for the mocked member is not swappable (no HotSwapFeed wrapper). " - + $"Ensure the model was constructed inside a MockingService.Enable() scope. Value type: {typeof(T)}."); - } - - hotSwap.HotSwap(replacement); - } - - /// - /// Swaps the source of a list-feed member. - /// - public static void SwapListFeed(object owner, IListFeed current, IListFeed replacement) - where T : notnull - { - var ctx = SourceContext.GetOrCreate(owner); - var currentFeed = ListFeed.AsFeed(current); - var state = ctx.GetOrCreateState(currentFeed); - if (state is not IHotSwapState> hotSwap || !hotSwap.CanHotSwap) - { - throw new InvalidOperationException( - $"The list-feed for the mocked member is not swappable (no HotSwapFeed wrapper). " - + $"Ensure the model was constructed inside a MockingService.Enable() scope. Item type: {typeof(T)}."); - } - - hotSwap.HotSwap(ListFeed.AsFeed(replacement)); - } -} diff --git a/src/Uno.Extensions.Reactive.Mocking/MockingService.cs b/src/Uno.Extensions.Reactive.Mocking/MockingService.cs deleted file mode 100644 index 6e441f1963..0000000000 --- a/src/Uno.Extensions.Reactive.Mocking/MockingService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Uno.Extensions.Reactive.Core; - -namespace Uno.Extensions.Reactive.Mocking; - -/// -/// Entry point that activates MVUX mocking (spec 013). Inside the returned scope, every -/// created (and its descendants) is mockable: its feeds are wrapped so their -/// source can be swapped at runtime. Outside any scope nothing is wrapped, so a live application pays -/// nothing (G9/R7). -/// -/// -/// Granularity is the caller's: open it once at assembly-init to cover a whole test run, or around a -/// single Create(...). The bit is captured on the context instance at construction, so a lazy first -/// subscription after the scope is disposed still wraps (D12). -/// -public static class MockingService -{ - /// - /// Opens a mocking-activation scope. Dispose it to stop marking future contexts as mockable; - /// contexts already created inside the scope stay mockable for their own lifetime. - /// - public static IDisposable Enable() - => SourceContext.EnableMocking(); -} diff --git a/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj b/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj deleted file mode 100644 index 8df6c0930a..0000000000 --- a/src/Uno.Extensions.Reactive.Mocking/Uno.Extensions.Reactive.Mocking.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs index 5f0f4cf3e2..efd6bf6132 100644 --- a/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs +++ b/src/Uno.Extensions.Reactive.Tests.MockingApp/RecipeModel.cs @@ -2,11 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Uno.Extensions.Reactive; -using Uno.Extensions.Reactive.Config; -// Opt-in: the MVUX generator emits FeedDependency/CtorDependency + [Model] into this assembly's metadata, -// which the consumer mocking generator (in the Tests assembly) reads. -[assembly: EnableFeedMocking] namespace Uno.Extensions.Reactive.Tests.MockingApp; diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs index fc8cf64abc..641e8ceea1 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs @@ -5,7 +5,7 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Uno.Extensions.Reactive.Core; -using Uno.Extensions.Reactive.Mocking; +using Uno.HotTesting.Reactive; using Uno.Extensions.Reactive.Testing; using Uno.Extensions.Reactive.Tests.MockingApp; @@ -40,7 +40,7 @@ public async Task When_CreateWithMock_Then_FeedEmitsMockedValues() { using (MockingService.Enable()) { - var vm = RecipeModelMockExtensions.Create(MockListFeed.Value(1, 2, 3)); + var vm = RecipeModelMockExtensions.Create(ListFeedMock.Value(1, 2, 3)); using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); @@ -53,13 +53,13 @@ public async Task When_SetModelReSwaps_Then_ReEmitsLive() { using (MockingService.Enable()) { - var vm = RecipeModelMockExtensions.Create(MockListFeed.Value(1)); + var vm = RecipeModelMockExtensions.Create(ListFeedMock.Value(1)); using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) .Should().BeEquivalentTo(new[] { 1 }); - vm.SetModel(new RecipeModelMock { Steps = MockListFeed.Value(7, 8) }); + vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Value(7, 8) }); (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) .Should().BeEquivalentTo(new[] { 7, 8 }); @@ -87,8 +87,8 @@ public void When_CommandOverridden_Then_VmCommandInvokesMock() var executed = false; var vm = RecipeModelMockExtensions.Create(new RecipeModelMock { - Steps = MockListFeed.Value(1), - Save = MockCommand.Callback(_ => executed = true), + Steps = ListFeedMock.Value(1), + Save = CommandMock.Callback(_ => executed = true), }); vm.Save.Should().NotBeNull(); diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs index fbc55c3426..0b3700179a 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_MockingRuntime.cs @@ -5,7 +5,7 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Uno.Extensions.Reactive.Core; -using Uno.Extensions.Reactive.Mocking; +using Uno.HotTesting.Reactive; using Uno.Extensions.Reactive.Testing; namespace Uno.Extensions.Reactive.Tests.Mocking; @@ -20,7 +20,7 @@ public class Given_MockingRuntime : FeedTests [TestMethod] public async Task When_MockFeed_Value_Then_EmitsValue() { - var (result, _) = MockFeed.Value(42).Record(); + var (result, _) = FeedMock.Value(42).Record(); await result.WaitForMessages(1); result.Last().Current.Data.SomeOrDefault().Should().Be(42); } @@ -28,7 +28,7 @@ public async Task When_MockFeed_Value_Then_EmitsValue() [TestMethod] public async Task When_MockListFeed_Value_Then_EmitsItems() { - var (result, _) = MockListFeed.Value(1, 2, 3).Record(); + var (result, _) = ListFeedMock.Value(1, 2, 3).Record(); await result.WaitForMessages(1); ((IImmutableList)result.Last().Current.Data.SomeOrDefault()!).Should().BeEquivalentTo(new[] { 1, 2, 3 }); } @@ -46,14 +46,14 @@ public async Task When_MockableFeedSwapped_ViaEngine_Then_ReEmits() { ctxHolder.RestoreCurrent(); - var original = MockFeed.Value("original"); + var original = FeedMock.Value("original"); var state = (StateImpl)ctxHolder.SourceContext.GetOrCreateState(original); var (result, _) = state.Record(); await result.WaitForMessages(1); result.Last().Current.Data.SomeOrDefault().Should().Be("original"); - MockModel.SwapFeed(ctxHolder, original, MockFeed.Value("mocked")); + MockingService.SwapFeed(ctxHolder, original, FeedMock.Value("mocked")); await result.WaitForMessages(2); result.Last().Current.Data.SomeOrDefault().Should().Be("mocked"); @@ -66,10 +66,10 @@ public void When_SwapFeed_OnNonMockableContext_Then_FailsHard() using var ctx = new FeedTestContext(); ctx.SourceContext.IsMockingActive.Should().BeFalse(); - var original = MockFeed.Value("x"); + var original = FeedMock.Value("x"); _ = ctx.SourceContext.GetOrCreateState(original); - var act = () => MockModel.SwapFeed(ctx, original, MockFeed.Value("y")); + var act = () => MockingService.SwapFeed(ctx, original, FeedMock.Value("y")); act.Should().Throw("fail-hard: a non-mockable feed cannot be swapped (D11)"); } diff --git a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj index 6b99b9d2dc..c662cf7545 100644 --- a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj +++ b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj @@ -26,9 +26,9 @@ - + - + diff --git a/src/Uno.Extensions.Reactive/AssemblyInfo.cs b/src/Uno.Extensions.Reactive/AssemblyInfo.cs index 908bb1ff2e..3a9e4096ef 100644 --- a/src/Uno.Extensions.Reactive/AssemblyInfo.cs +++ b/src/Uno.Extensions.Reactive/AssemblyInfo.cs @@ -8,4 +8,4 @@ [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.UI")] [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.WinUI")] [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.Messaging")] -[assembly: InternalsVisibleTo("Uno.Extensions.Reactive.Mocking")] +[assembly: InternalsVisibleTo("Uno.HotTesting.Reactive")] diff --git a/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs b/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs index 0f71d43fbd..cf85c21e2b 100644 --- a/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs +++ b/src/Uno.Extensions.Reactive/Config/EnableFeedMockingAttribute.cs @@ -4,10 +4,11 @@ namespace Uno.Extensions.Reactive.Config; /// -/// Opt-in for the MVUX mocking metadata generation (spec 013). When present on an assembly, the MVUX -/// generator emits the mocking seams (dependency attributes, the view-model null-inject construction -/// path and the command seam) required by the external mocking generator. When absent, MVUX output is -/// byte-identical to the non-mocking output (additive, zero-cost opt-out). +/// Configures MVUX mocking metadata generation (spec 013). The instrumentation (dependency attributes +/// + the command seam) is emitted by default — the runtime, not the generator, decides whether +/// mocking is active (via MockingService.Enable()). Add [assembly: EnableFeedMocking(IsEnabled = false)] +/// to opt out and restore byte-identical MVUX output. Follows the same on-by-default / opt-out model as +/// the other MVUX generation attributes. /// [AttributeUsage(AttributeTargets.Assembly)] public sealed class EnableFeedMockingAttribute : Attribute diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md b/src/Uno.HotTesting.Reactive.Generator/AnalyzerReleases.Shipped.md similarity index 100% rename from src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Shipped.md rename to src/Uno.HotTesting.Reactive.Generator/AnalyzerReleases.Shipped.md diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md b/src/Uno.HotTesting.Reactive.Generator/AnalyzerReleases.Unshipped.md similarity index 100% rename from src/Uno.Extensions.Reactive.Mocking.Generator/AnalyzerReleases.Unshipped.md rename to src/Uno.HotTesting.Reactive.Generator/AnalyzerReleases.Unshipped.md diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs similarity index 56% rename from src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs rename to src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs index c7e12b5dd3..56561020c2 100644 --- a/src/Uno.Extensions.Reactive.Mocking.Generator/FeedsMockGenerator.cs +++ b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs @@ -4,43 +4,40 @@ using System.Text; using Microsoft.CodeAnalysis; -namespace Uno.Extensions.Reactive.Mocking.Generator; +namespace Uno.HotTesting.Reactive.Generator; /// /// Consumer-side generator (spec 013, tiers 2/3). Runs in a test/preview project, reads the app /// metadata (models carrying FeedDependency/CtorDependency attributes + their generated /// view-models) and emits, per model: -/// - record {Model}Mock — required service-dependent inputs, optional derived overrides; +/// - record {Model}Mock — required service-dependent inputs, optional derived + command overrides; /// - {Vm}.Create(...) — null-inject construction (under the ambient MockingService scope); -/// - SetModel(this {Vm}, {Model}Mock) — typed swaps via the MockModel reflection engine. -/// Strongly typed end to end (D9); no tier-1 surface. Commands and the zero-arg Create()/Empty -/// come in a later increment. +/// - SetModel(this {Vm}, {Model}Mock) — strongly-typed swaps via MockingService. +/// Strongly typed end to end (D9); reuses the Uno.HotTesting.Reactive vocabulary (FeedMock / +/// ListFeedMock / CommandMock). /// [Generator] public sealed class FeedsMockGenerator : ISourceGenerator { private const string FeedDependencyAttribute = "Uno.Extensions.Reactive.Config.FeedDependencyAttribute"; - private const string CtorDependencyAttribute = "Uno.Extensions.Reactive.Config.CtorDependencyAttribute"; private const string ModelAttribute = "Uno.Extensions.Reactive.Bindings.ModelAttribute"; - private const string FeedInterface = "Uno.Extensions.Reactive.IFeed`1"; - private const string ListFeedInterface = "Uno.Extensions.Reactive.IListFeed`1"; + private const string HotTesting = "global::Uno.HotTesting.Reactive"; public void Initialize(GeneratorInitializationContext context) { } public void Execute(GeneratorExecutionContext context) { var compilation = context.Compilation; - var feedDepSymbol = compilation.GetTypeByMetadataName(FeedDependencyAttribute); - var ctorDepSymbol = compilation.GetTypeByMetadataName(CtorDependencyAttribute); - var modelAttrSymbol = compilation.GetTypeByMetadataName(ModelAttribute); - if (feedDepSymbol is null || modelAttrSymbol is null) + var feedDep = compilation.GetTypeByMetadataName(FeedDependencyAttribute); + var modelAttr = compilation.GetTypeByMetadataName(ModelAttribute); + if (feedDep is null || modelAttr is null) { return; // Core not referenced → nothing to do. } - foreach (var model in EnumerateModels(compilation, feedDepSymbol)) + foreach (var model in EnumerateModels(compilation, feedDep)) { - if (GenerateFor(model, feedDepSymbol, ctorDepSymbol, modelAttrSymbol) is { } generated) + if (GenerateFor(model, feedDep, modelAttr) is { } generated) { context.AddSource($"{model.ToDisplayString().Replace('.', '_')}.Mock.g.cs", generated); } @@ -71,10 +68,8 @@ IEnumerable Walk(INamespaceOrTypeSymbol ns) } } - // Current compilation. foreach (var t in Walk(compilation.Assembly.GlobalNamespace)) yield return t; - // Referenced assemblies (the app). foreach (var reference in compilation.References) { if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm) @@ -87,22 +82,19 @@ IEnumerable Walk(INamespaceOrTypeSymbol ns) private sealed class FeedMember { public string Name = ""; - public string FeedTypeFullName = ""; // e.g. global::Uno...IListFeed - public string ItemOrValueFullName = ""; // T + public string FeedTypeFullName = ""; + public string ItemOrValueFullName = ""; public bool IsList; - public bool IsDerived; // OnFeed set → optional override } - private string? GenerateFor(INamedTypeSymbol model, INamedTypeSymbol feedDep, INamedTypeSymbol? ctorDep, INamedTypeSymbol modelAttr) + private string? GenerateFor(INamedTypeSymbol model, INamedTypeSymbol feedDep, INamedTypeSymbol modelAttr) { - // Resolve the generated view-model via [Model(typeof(Vm))]. var modelAttrData = model.GetAttributes().FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, modelAttr)); if (modelAttrData?.ConstructorArguments is not { Length: 1 } args || args[0].Value is not INamedTypeSymbol vm) { return null; } - // Classify members from FeedDependency attributes. var inputs = new List(); // OnParameter set var derived = new List(); // OnFeed set @@ -115,7 +107,6 @@ private sealed class FeedMember var onParameter = attr.NamedArguments.FirstOrDefault(n => n.Key == "OnParameter").Value.Value as string; var onFeed = attr.NamedArguments.FirstOrDefault(n => n.Key == "OnFeed").Value.Value as string; - if (onParameter is null && onFeed is null) { continue; // independent → not part of the mock @@ -143,25 +134,22 @@ private sealed class FeedMember FeedTypeFullName = memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), ItemOrValueFullName = valueType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), IsList = isList, - IsDerived = onFeed is not null, }; (onFeed is not null ? derived : inputs).Add(fm); } // Commands: the generated VM exposes them as public IAsyncCommand properties, overridable via - // the __Mock_SetCommand seam (emitted by the MVUX generator under opt-in). + // the __Mock_SetCommand seam (emitted by the MVUX generator). var commands = vm.GetMembers() .OfType() .Where(pr => !pr.IsStatic && pr.DeclaredAccessibility == Accessibility.Public && pr.Type.ToDisplayString() == "Uno.Extensions.Reactive.IAsyncCommand") .Select(pr => pr.Name) .ToList(); - - var hasMockCommandSeam = vm.GetMembers("__Mock_SetCommand").Any(); - if (!hasMockCommandSeam) + if (!vm.GetMembers("__Mock_SetCommand").Any()) { - commands.Clear(); // no seam → cannot override commands + commands.Clear(); } if (inputs.Count == 0 && derived.Count == 0 && commands.Count == 0) @@ -170,92 +158,88 @@ private sealed class FeedMember } var vmFull = vm.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var modelFull = model.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var mockName = $"{model.Name}Mock"; var ns = model.ContainingNamespace.IsGlobalNamespace ? null : model.ContainingNamespace.ToDisplayString(); - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - if (ns is not null) - { - sb.AppendLine($"namespace {ns};"); - sb.AppendLine(); - } - - // The mock record. - sb.AppendLine($"public sealed record {mockName}"); - sb.AppendLine("{"); + // Record members. + var recordMembers = new StringBuilder(); foreach (var m in inputs) { - sb.AppendLine($"\tpublic required {m.FeedTypeFullName} {m.Name} {{ get; init; }}"); + recordMembers.AppendLine($"\tpublic required {m.FeedTypeFullName} {m.Name} {{ get; init; }}"); } foreach (var m in derived) { - sb.AppendLine($"\tpublic {m.FeedTypeFullName}? {m.Name} {{ get; init; }}"); + recordMembers.AppendLine($"\tpublic {m.FeedTypeFullName}? {m.Name} {{ get; init; }}"); } foreach (var c in commands) { - sb.AppendLine($"\tpublic global::Uno.Extensions.Reactive.IAsyncCommand? {c} {{ get; init; }}"); + recordMembers.AppendLine($"\tpublic global::Uno.Extensions.Reactive.IAsyncCommand? {c} {{ get; init; }}"); } - sb.AppendLine("}"); - sb.AppendLine(); - - // The factory + facade. - sb.AppendLine($"public static class {mockName}Extensions"); - sb.AppendLine("{"); - // Empty — every service-dependent input set to its type's Empty state. + // Empty initializer + Create(inputs) params/inits. var emptyInits = string.Join(", ", inputs.Select(m => m.IsList - ? $"{m.Name} = global::Uno.Extensions.Reactive.Mocking.MockListFeed.Empty<{m.ItemOrValueFullName}>()" - : $"{m.Name} = global::Uno.Extensions.Reactive.Mocking.MockFeed.Empty<{m.ItemOrValueFullName}>()")); - sb.AppendLine($"\tpublic static {mockName} Empty {{ get; }} = new() {{ {emptyInits} }};"); - sb.AppendLine(); - - // Create() — every input Empty. - sb.AppendLine($"\tpublic static {vmFull} Create() => Create(Empty);"); - sb.AppendLine(); - - // Create(inputs...) — required inputs as parameters. + ? $"{m.Name} = {HotTesting}.ListFeedMock.Empty<{m.ItemOrValueFullName}>()" + : $"{m.Name} = {HotTesting}.FeedMock.Empty<{m.ItemOrValueFullName}>()")); var createParams = string.Join(", ", inputs.Select(m => $"{m.FeedTypeFullName} {Camel(m.Name)}")); - var mockInit = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); - sb.AppendLine($"\tpublic static {vmFull} Create({createParams})"); - sb.AppendLine($"\t\t=> Create(new {mockName} {{ {mockInit} }});"); - sb.AppendLine(); - - // Create(mock) — null-inject construction + SetModel. - sb.AppendLine($"\tpublic static {vmFull} Create({mockName} mock)"); - sb.AppendLine("\t{"); - sb.AppendLine($"\t\tvar vm = new {vmFull}(default!);"); - sb.AppendLine("\t\tvm.SetModel(mock);"); - sb.AppendLine("\t\treturn vm;"); - sb.AppendLine("\t}"); - sb.AppendLine(); - - // SetModel — typed swaps via the reflection engine. - sb.AppendLine($"\tpublic static void SetModel(this {vmFull} vm, {mockName} mock)"); - sb.AppendLine("\t{"); - sb.AppendLine($"\t\tvar model = vm.Model;"); + var createInits = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); + + // SetModel body. + var setBody = new StringBuilder(); foreach (var m in inputs) { var swap = m.IsList ? "SwapListFeed" : "SwapFeed"; - sb.AppendLine($"\t\tglobal::Uno.Extensions.Reactive.Mocking.MockModel.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); + setBody.AppendLine($"\t\t{HotTesting}.MockingService.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); } foreach (var m in derived) { var swap = m.IsList ? "SwapListFeed" : "SwapFeed"; - sb.AppendLine($"\t\tif (mock.{m.Name} is not null)"); - sb.AppendLine($"\t\t\tglobal::Uno.Extensions.Reactive.Mocking.MockModel.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); + setBody.AppendLine($"\t\tif (mock.{m.Name} is not null)"); + setBody.AppendLine($"\t\t\t{HotTesting}.MockingService.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); } foreach (var c in commands) { - sb.AppendLine($"\t\tif (mock.{c} is not null)"); - sb.AppendLine($"\t\t\tvm.__Mock_SetCommand(\"{c}\", mock.{c});"); + setBody.AppendLine($"\t\tif (mock.{c} is not null)"); + setBody.AppendLine($"\t\t\tvm.__Mock_SetCommand(\"{c}\", mock.{c});"); } - sb.AppendLine("\t}"); - sb.AppendLine("}"); - return sb.ToString(); + var nsHeader = ns is null ? "" : $"namespace {ns};\n\n"; + var createFromInputs = inputs.Count == 0 + ? "" + : $$""" + + public static {{vmFull}} Create({{createParams}}) + => Create(new {{mockName}} { {{createInits}} }); + """; + + return $$""" + // + #nullable enable + {{nsHeader}}public sealed record {{mockName}} + { + {{recordMembers.ToString().TrimEnd()}} + } + + public static class {{mockName}}Extensions + { + public static {{mockName}} Empty { get; } = new() { {{emptyInits}} }; + + public static {{vmFull}} Create() => Create(Empty); + {{createFromInputs}} + public static {{vmFull}} Create({{mockName}} mock) + { + var vm = new {{vmFull}}(default!); + vm.SetModel(mock); + return vm; + } + + public static void SetModel(this {{vmFull}} vm, {{mockName}} mock) + { + var model = vm.Model; + {{setBody.ToString().TrimEnd()}} + } + } + + """; } private static bool TryGetFeed(ITypeSymbol type, out bool isList, out ITypeSymbol? valueType) @@ -264,8 +248,7 @@ private static bool TryGetFeed(ITypeSymbol type, out bool isList, out ITypeSymbo valueType = null; foreach (var intf in type.AllInterfaces.Concat(type is INamedTypeSymbol nt ? new[] { nt } : Array.Empty())) { - var def = intf.OriginalDefinition.ToDisplayString(); - if (def == "Uno.Extensions.Reactive.IListFeed" || intf.OriginalDefinition.MetadataName == "IListFeed`1") + if (intf.OriginalDefinition.MetadataName == "IListFeed`1") { isList = true; valueType = intf.TypeArguments.FirstOrDefault(); diff --git a/src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj b/src/Uno.HotTesting.Reactive.Generator/Uno.HotTesting.Reactive.Generator.csproj similarity index 87% rename from src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj rename to src/Uno.HotTesting.Reactive.Generator/Uno.HotTesting.Reactive.Generator.csproj index 3708f9b226..9c303a1fe8 100644 --- a/src/Uno.Extensions.Reactive.Mocking.Generator/Uno.Extensions.Reactive.Mocking.Generator.csproj +++ b/src/Uno.HotTesting.Reactive.Generator/Uno.HotTesting.Reactive.Generator.csproj @@ -5,8 +5,8 @@ false false - - Uno.Extensions.Reactive.Mocking + + Uno.HotTesting.Reactive $(NoWarn);RS2008 diff --git a/src/Uno.Extensions.Reactive.Mocking/MockCommand.cs b/src/Uno.HotTesting.Reactive/CommandMock.cs similarity index 85% rename from src/Uno.Extensions.Reactive.Mocking/MockCommand.cs rename to src/Uno.HotTesting.Reactive/CommandMock.cs index 0004a66b7e..76d8155ed3 100644 --- a/src/Uno.Extensions.Reactive.Mocking/MockCommand.cs +++ b/src/Uno.HotTesting.Reactive/CommandMock.cs @@ -1,13 +1,14 @@ using System; using System.ComponentModel; +using Uno.Extensions.Reactive; -namespace Uno.Extensions.Reactive.Mocking; +namespace Uno.HotTesting.Reactive; /// -/// Typed vocabulary to build mocked commands (spec 013 §4.1). All produce a strongly-typed +/// Typed vocabulary to build mocked commands (spec 013). All produce a strongly-typed /// suitable for a {Model}Mock command override. /// -public static class MockCommand +public static class CommandMock { /// An idle, executable no-op command. public static IAsyncCommand Idle() => new MockAsyncCommand(canExecute: true); @@ -25,17 +26,12 @@ public static IAsyncCommand Callback(Action onExecute, bool canExecute private sealed class MockAsyncCommand : IAsyncCommand { private readonly bool _canExecute; - private bool _isExecuting; public MockAsyncCommand(bool canExecute) => _canExecute = canExecute; public Action? OnExecute { get; init; } - public bool IsExecuting - { - get => _isExecuting; - init => _isExecuting = value; - } + public bool IsExecuting { get; init; } public event EventHandler? CanExecuteChanged; public event EventHandler? IsExecutingChanged; diff --git a/src/Uno.HotTesting.Reactive/MockingService.cs b/src/Uno.HotTesting.Reactive/MockingService.cs new file mode 100644 index 0000000000..0c805899b6 --- /dev/null +++ b/src/Uno.HotTesting.Reactive/MockingService.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Immutable; +using System.ComponentModel; +using Uno.Extensions.Reactive; +using Uno.Extensions.Reactive.Core; + +namespace Uno.HotTesting.Reactive; + +/// +/// Activates MVUX mocking and applies typed feed swaps (spec 013). Inside an scope, +/// every created (and its descendants) is mockable: its feeds are wrapped so +/// their source can be swapped at runtime. Outside any scope nothing is wrapped, so a live application +/// pays nothing (G9/R7). +/// +/// +/// Granularity is the caller's: open it once at assembly-init to cover a whole test run, or around a +/// single Create(...). The bit is captured on the context instance at construction, so a lazy first +/// subscription after the scope is disposed still wraps (D12). The swap helpers are strongly typed +/// (the generated SetModel emits concrete calls — no reflection, AOT-friendly) and fail-hard: +/// a feed that is not wrapped (model built outside a scope) throws instead of silently doing nothing. +/// +public static class MockingService +{ + /// + /// Opens a mocking-activation scope. Dispose it to stop marking future contexts as mockable; + /// contexts already created inside the scope stay mockable for their own lifetime. + /// + public static IDisposable Enable() + => SourceContext.EnableMocking(); + + /// + /// Swaps the source of a scalar feed member (called by generated SetModel). + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void SwapFeed(object owner, IFeed current, IFeed replacement) + where T : notnull + { + var ctx = SourceContext.GetOrCreate(owner); + var state = ctx.GetOrCreateState(current); + if (state is not IHotSwapState hotSwap || !hotSwap.CanHotSwap) + { + throw new InvalidOperationException( + $"The feed for the mocked member is not swappable (no HotSwapFeed wrapper). " + + $"Ensure the model was constructed inside a MockingService.Enable() scope. Value type: {typeof(T)}."); + } + + hotSwap.HotSwap(replacement); + } + + /// + /// Swaps the source of a list-feed member (called by generated SetModel). + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void SwapListFeed(object owner, IListFeed current, IListFeed replacement) + where T : notnull + { + var ctx = SourceContext.GetOrCreate(owner); + var currentFeed = ListFeed.AsFeed(current); + var state = ctx.GetOrCreateState(currentFeed); + if (state is not IHotSwapState> hotSwap || !hotSwap.CanHotSwap) + { + throw new InvalidOperationException( + $"The list-feed for the mocked member is not swappable (no HotSwapFeed wrapper). " + + $"Ensure the model was constructed inside a MockingService.Enable() scope. Item type: {typeof(T)}."); + } + + hotSwap.HotSwap(ListFeed.AsFeed(replacement)); + } +} From fa1d92890ba31f4a9b7beb1dac8208741cd3747c Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 19:44:24 +0000 Subject: [PATCH 14/19] fix(mocking): keep the ambient AsyncLocal in MockingService, not in SourceContext The ambient activation state is a mocking concern, not a Core one. MockingService now owns the AsyncLocal and registers a probe; SourceContext only keeps the per-instance IsMockingActive bit. In a live application the probe is never registered, so the bit stays false and the cost is zero. --- specs/013-mvux-mocking-previews/history.md | 11 +++++ .../Core/Given_MockingActivation.cs | 7 +-- .../Core/Internal/SourceContext.cs | 24 +++------- src/Uno.HotTesting.Reactive/MockingService.cs | 45 ++++++++++++++++--- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 3b07fbd602..4200bf49bf 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -113,6 +113,17 @@ Rebase sur `main` (PR #3154 / issue #3149 mergée) : **le vocabulaire de feeds m **Tests après refactor (verts) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, `Uno.HotTesting.Reactive.Tests` 22/22 (FeedMock existant non régressé). + +## v10 — review David (commentaire 31) : AsyncLocal hors de Core + +Retour de David sur `SourceContext` : *« si on a besoin d'un AsyncLocal pour le mocking, ça n'apporte rien de le mettre dans le SourceContext, on devrait le garder dans le MockingService »*. Juste — l'état d'activation ambient est une préoccupation **mocking**, pas Core. + +- **`MockingService` (dans `Uno.HotTesting.Reactive`) possède l'`AsyncLocal` ambient** + `Enable()`. +- **`SourceContext` (Core) ne garde que** le bit d'instance `IsMockingActive` + un **seam** `internal static Func? IsMockingActiveProbe`. À la création d'un contexte racine, `IsMockingActive = IsMockingActiveProbe?.Invoke() ?? false` ; un enfant hérite du parent. `MockingService` enregistre la probe (static ctor). +- **App live** : `MockingService` jamais touché → probe nulle → `IsMockingActive` toujours false → zéro coût (G9/R7 conservé). Le mécanisme reste D12 (bit per-contexte capturé à la construction, survit à une souscription lazy après dispose du scope). + +Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. + --- ## Registre final des décisions diff --git a/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs b/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs index f3d98e6fb4..5fb0162b57 100644 --- a/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs +++ b/src/Uno.Extensions.Reactive.Tests/Core/Given_MockingActivation.cs @@ -7,6 +7,7 @@ using Uno.Extensions.Reactive.Core; using Uno.Extensions.Reactive.Operators; using Uno.Extensions.Reactive.Testing; +using Uno.HotTesting.Reactive; namespace Uno.Extensions.Reactive.Tests.Core; @@ -36,7 +37,7 @@ public void When_NoScope_Then_ContextNotMockable_And_NoWrap() public void When_UnderScope_Then_ContextMockable_And_Wrapped() { FeedTestContext ctx; - using (SourceContext.EnableMocking()) + using (MockingService.Enable()) { ctx = new FeedTestContext(); } @@ -54,7 +55,7 @@ public void When_UnderScope_Then_ContextMockable_And_Wrapped() public void When_ScopeDisposed_Then_AlreadyCreatedContextStaysMockable_ButNewOnesDont() { FeedTestContext inside; - using (SourceContext.EnableMocking()) + using (MockingService.Enable()) { inside = new FeedTestContext(); } @@ -70,7 +71,7 @@ public void When_ScopeDisposed_Then_AlreadyCreatedContextStaysMockable_ButNewOne public async Task When_MockableStateSwapped_Then_ReEmits() { FeedTestContext ctxHolder; - using (SourceContext.EnableMocking()) + using (MockingService.Enable()) { ctxHolder = new FeedTestContext(); } diff --git a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs index 2aea658639..bc641f491d 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs @@ -33,11 +33,11 @@ public sealed class SourceContext : IAsyncDisposable private static readonly SourceContext _none = new(); private static readonly AsyncLocal _current = new(); - // Mocking (spec 013): ambient flag driving whether newly created contexts are mockable. - // Reuses the AsyncLocal ambient model already used for Current (no bespoke AsyncLocal in the mocking layer). - // A context inherits IsMockingActive at creation time (root <- ambient, child <- parent) so the bit - // survives a lazy first subscription even after the activation scope's `using` block has exited. - private static readonly AsyncLocal _isMockingAmbient = new(); + // Mocking (spec 013): the mocking layer (MockingService, in Uno.HotTesting.Reactive) owns the ambient + // activation state and registers this probe. A context captures IsMockingActive at creation from the + // probe (root) or its parent (child), so the bit survives a lazy first subscription even after the + // activation scope has exited. Null in a live app -> IsMockingActive is always false -> zero cost. + internal static Func? IsMockingActiveProbe; private static readonly ConditionalWeakTable _contexts = new(); /// @@ -216,7 +216,7 @@ private SourceContext(RootOwner ownerInfo) RootId = (uint)Interlocked.Increment(ref _nextRootId); States = _localStates = new StateStore(this); RequestSource = _localRequests = new NoneRequestSource(); // Currently we do not support messages directly on the root, using None allows AsyncFeed to complete enumeration - IsMockingActive = _isMockingAmbient.Value; // spec 013: inherit ambient mocking activation + IsMockingActive = IsMockingActiveProbe?.Invoke() ?? false; // spec 013: capture ambient mocking activation } // Creates a sub context @@ -287,18 +287,6 @@ private SourceContext(SourceContext parent, ISourceContextOwner owner, IStateSto /// internal bool IsMockingActive { get; } - /// - /// Opens an ambient mocking-activation scope: every created while the returned - /// disposable is alive (and their descendants) is marked . Disposal stops marking - /// future contexts; already-created contexts stay mockable for their own lifetime. - /// - /// Backing mechanism for MockingService.Enable() (spec 013 §13, D12). - internal static IDisposable EnableMocking() - { - var previous = _isMockingAmbient.Value; - _isMockingAmbient.Value = true; - return Utils.Disposable.Create(() => _isMockingAmbient.Value = previous); - } /// /// Sets the context as . diff --git a/src/Uno.HotTesting.Reactive/MockingService.cs b/src/Uno.HotTesting.Reactive/MockingService.cs index 0c805899b6..5497cb813c 100644 --- a/src/Uno.HotTesting.Reactive/MockingService.cs +++ b/src/Uno.HotTesting.Reactive/MockingService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Immutable; using System.ComponentModel; +using System.Threading; using Uno.Extensions.Reactive; using Uno.Extensions.Reactive.Core; @@ -13,20 +14,52 @@ namespace Uno.HotTesting.Reactive; /// pays nothing (G9/R7). /// /// -/// Granularity is the caller's: open it once at assembly-init to cover a whole test run, or around a -/// single Create(...). The bit is captured on the context instance at construction, so a lazy first -/// subscription after the scope is disposed still wraps (D12). The swap helpers are strongly typed -/// (the generated SetModel emits concrete calls — no reflection, AOT-friendly) and fail-hard: -/// a feed that is not wrapped (model built outside a scope) throws instead of silently doing nothing. +/// This service owns the ambient activation state (an ) and registers a probe +/// on so a newly created context captures its mockability at construction — +/// the bit lives on the context instance, so a lazy first subscription after the scope is disposed still +/// wraps (D12). The swap helpers are strongly typed (the generated SetModel emits concrete calls — +/// no reflection, AOT-friendly) and fail-hard: a feed that is not wrapped (model built outside a +/// scope) throws instead of silently doing nothing. /// public static class MockingService { + private static readonly AsyncLocal _ambient = new(); + + static MockingService() + { + // Register the probe Core reads at context creation. Registered only once the mocking layer is + // touched (i.e. Enable() has been called) — a live app never touches this type, so Core's probe + // stays null and no context is ever wrapped. + SourceContext.IsMockingActiveProbe = static () => _ambient.Value; + } + /// /// Opens a mocking-activation scope. Dispose it to stop marking future contexts as mockable; /// contexts already created inside the scope stay mockable for their own lifetime. /// public static IDisposable Enable() - => SourceContext.EnableMocking(); + { + var previous = _ambient.Value; + _ambient.Value = true; + return new Scope(previous); + } + + private sealed class Scope : IDisposable + { + private readonly bool _previous; + private bool _disposed; + + public Scope(bool previous) => _previous = previous; + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + _ambient.Value = _previous; + } + } + } /// /// Swaps the source of a scalar feed member (called by generated SetModel). From 73b05a5345416dad6d66dbe6246e2c9a50a08c08 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Aug 2026 21:10:21 +0000 Subject: [PATCH 15/19] docs(mocking): document the generated mocks and add a catalog sample Extended the reactive testing reference with the generated tier 2 and 3 layer, and added a named catalog sample together with its test. The generated factory class is now named {Vm}Mock and Empty moved onto the record. --- doc/Reference/Reactive/testing.md | 140 +++++++++++++++++- .../013-mvux-mocking-previews/architecture.md | 6 +- specs/013-mvux-mocking-previews/history.md | 9 ++ .../implementation.md | 2 +- specs/013-mvux-mocking-previews/spec.md | 18 +-- .../Mocking/Given_GeneratedMock.cs | 22 ++- .../Mocking/RecipeCatalog.cs | 22 +++ .../FeedsMockGenerator.cs | 11 +- 8 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs diff --git a/doc/Reference/Reactive/testing.md b/doc/Reference/Reactive/testing.md index a7d91411e7..a8fd2a8786 100644 --- a/doc/Reference/Reactive/testing.md +++ b/doc/Reference/Reactive/testing.md @@ -107,6 +107,140 @@ public sealed partial class MySuperPage : Page ``` The developer remains responsible for the property names, feed shapes, and -`DataContext` injection. These factories are the reusable runtime base for the -longer-term Hot Testing direction; this initial API does not promise or require a -generator. +`DataContext` injection. This handwritten approach needs no generator and is handy +for pages without a view-model; for a real generated view-model driven through +mocked states, use the generated mocks described below. + +## Generated view-model mocks (Hot Testing) + +The handwritten record above is enough for a page that has no view-model, but as +soon as you want to drive a **real generated view-model** — its real `Model`, its +real business logic — through mocked states, let the `Uno.HotTesting.Reactive` +generator build the plumbing for you. + +Reference the `Uno.HotTesting.Reactive` package in the **test or preview project** +(the one that references the app). For every MVUX model it finds, the generator +emits, next to the model: + +- a `record {Model}Mock` whose **required** members are exactly the + service-dependent feeds, and whose **optional** members are the derived feeds + and the commands; +- `{Vm}.Create(...)` factories that build the **real view-model** with its + services null-injected, then apply the mock; +- a `SetModel(this {Vm}, {Model}Mock)` extension that swaps each mocked feed. + +Given this model: + +```csharp +public partial record RecipeModel(IRecipeService Service) +{ + // service-dependent input + public IListFeed Steps => ListFeed.Async(Service.GetSteps); + + // derived — recomputes over whatever Steps emits + public IFeed StepsCount => Steps.Select(steps => steps.Count); + + // a command + public async ValueTask Save(CancellationToken ct) => await Service.Save(ct); +} +``` + +the generator produces `RecipeModelMock`, `RecipeViewModelMock.Create(...)` and +`SetModel`. You drive the page from a test or a preview head: + +```csharp +using Uno.HotTesting.Reactive; + +using (MockingService.Enable()) +{ + // Real RecipeViewModel + real RecipeModel, IRecipeService null-injected. + var vm = RecipeViewModelMock.Create(ListFeedMock.Value(step1, step2, step3)); + + // StepsCount is NOT mocked — it recomputes through the real Select over the + // mocked Steps, so the derived feed stays truthful. + + // Live transitions: keep calling SetModel to walk the states. + vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Loading() }); + vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Error(new TimeoutException()) }); +} +``` + +> [!IMPORTANT] +> Mocking is only active inside a `MockingService.Enable()` scope. Outside a +> scope nothing is wrapped, so a shipping application pays no cost — never +> reference `Uno.HotTesting.Reactive` from a published app head. + +### Only fill what matters + +Required members are the **service-dependent inputs**; the compiler lists them +for you. Derived members and commands are optional: + +```csharp +// every input at once, exhaustively +vm.SetModel(new RecipeModelMock +{ + Steps = ListFeedMock.Loading(), // required — the compiler asked for it + // StepsCount left unset → real derivation runs over the mocked Steps + // Save left unset → idle no-op command +}); + +// or pin a derived value directly, ignoring its inputs +vm.SetModel(RecipeModelMock.Empty with { StepsCount = FeedMock.Value(3) }); +``` + +### Mocking commands + +Commands have no feed to swap, so override them with the `CommandMock` vocabulary: + +```csharp +var executed = false; +var vm = RecipeViewModelMock.Create(new RecipeModelMock +{ + Steps = ListFeedMock.Value(step1), + Save = CommandMock.Callback(_ => executed = true), +}); + +vm.Save.Execute(null); // invokes the callback +``` + +`CommandMock` offers `Idle()`, `Disabled()`, `Executing()` and +`Callback(onExecute)`. + +### One-liners and named catalogs + +Because `Create` takes only the required inputs, the common cases are one call: + +```csharp +var loading = RecipeViewModelMock.Create(ListFeedMock.Loading()); +var empty = RecipeViewModelMock.Create(); // every input Empty +var ready = RecipeViewModelMock.Create(ListFeedMock.Value(step1, step2)); +``` + +Collect them into a hand-written catalog in your preview project, then bind a +page to one entry — real page, real view-model, pinned state: + +```csharp +public static class RecipeCatalog +{ + public static RecipeViewModel Loading => RecipeViewModelMock.Create(ListFeedMock.Loading()); + public static RecipeViewModel Empty => RecipeViewModelMock.Create(); + public static RecipeViewModel Basic => RecipeViewModelMock.Create(ListFeedMock.Value( + new Step("Toast the bread"), + new Step("Mash the avocado"))); +} +``` + +```xml + + +``` + +### Turning the instrumentation off + +The metadata the consumer generator reads is emitted **by default** (the runtime, +not the generator, decides activation). To restore byte-identical MVUX output, +opt out at the assembly level: + +```csharp +[assembly: EnableFeedMocking(IsEnabled = false)] +``` diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 15da95a478..0a9415620e 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -80,7 +80,7 @@ public record RecipeModelMock public IFeed? StepsCount { get; init; } // Derived → optional override; null = real business logic public IAsyncCommand? Save { get; init; } // command → optional; null = idle no-op } -public static class RecipeViewModelMockExtensions +public static class RecipeViewModelMock { public static RecipeViewModel Create(); // null-inject + SetModel(Empty) public static RecipeViewModel Create(IListFeed steps); // required inputs as params @@ -214,7 +214,7 @@ sequenceDiagram participant UI as FeedView Note over T,MG: build time — the Mocking generator reads app metadata
+ FeedDependency / CtorDependency attributes and emits
RecipeModelMock + Create(...) + SetModel - T->>MG: RecipeViewModel.Create(steps) + T->>MG: RecipeViewModelMock.Create(steps) MG->>VM: new RecipeViewModel(default!, ...) VM->>M: new RecipeModel(default!, ...) Note over M,W: context.IsMockingActive ON — every Model feed property
is cached as a HotSwapFeed wrapper @@ -234,7 +234,7 @@ The activation API is **decided** (D10): mocking exists only inside an explicit ```csharp using (MockingService.Enable()) { - var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); + var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); } ``` diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 4200bf49bf..40b611faae 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -124,6 +124,15 @@ Retour de David sur `SourceContext` : *« si on a besoin d'un AsyncLocal pour le Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. + +## v11 — doc + sample + polish naming factory (mar. 25/08) + +- **Doc** : `doc/Reference/Reactive/testing.md` (celle de #3149 sur `FeedMock` hand-written) étendue avec la couche générée tier 2/3 : scope `MockingService.Enable()`, `record {Model}Mock` (inputs required, derived + commandes optionnels), `{Vm}Mock.Create(...)`, `vm.SetModel(...)`, `CommandMock`, derived-survives, one-liners + catalogs nommés (tier 3), opt-out `[assembly: EnableFeedMocking(IsEnabled = false)]`. La phrase « no generator » de #3149 est mise à jour. +- **Sample** : `RecipeCatalog` (catalog nommé tier-3 : Loading/Empty/Basic/Failed) dans le projet de tests, + test `When_CatalogEntry_Then_PinnedState` (Given_GeneratedMock 5/5). +- **Polish naming (générateur consumer)** : la classe factory générée passe de `{Model}MockExtensions` à **`{Vm}Mock`** (`RecipeViewModelMock.Create(...)`) — lecture propre, proche de l'intention spec §7/§8 (le `{Vm}.Create` littéral est impossible cross-assembly). `Empty` déplacé **sur le record** (`RecipeModelMock.Empty`) pour la compo `with`. Spec §7/§8 alignée sur l'API réelle. + +Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 5/5, Uno.HotTesting.Reactive.Tests 22/22, Tests.Generator 80/80. + --- ## Registre final des décisions diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index db03bb2552..43be83973c 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -143,7 +143,7 @@ public static class MockingService [AssemblyCleanup] public static void Cleanup() => _scope.Dispose(); // or a single test -using (MockingService.Enable()) { var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); } +using (MockingService.Enable()) { var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); } ``` **Non-negotiable constraint:** context not mockable → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `SourceContext.IsMockingActive` (§2.2, D12) is the internal per-context gate the scope drives, not a switch app authors set. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 1df1f0dc2f..3ba2ca788e 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -71,7 +71,7 @@ flowchart TB ``` 1. **Tier 1 — Static/XAML, no VM:** `FeedView.Source` accepts a declared **`MessageEntry`** — a new authorable non-generic entry in **Core**, a **plain CLR object (deliberately NOT a `DependencyObject`)**, XAML element syntax; core axes as direct convenience properties and **custom axes first-class** via an axis collection (MVUX's open axis model). **Replacing** the `Source` entry instance pushes the new entry through the existing wrapper feed: the stream evolves like a real feed (no re-subscribe, no loading flash). The entry itself is **not observable** — a new instance is the unit of change. No heuristic envelope, no parallel DTO, no converter deliverable (an application-owned converter at `FeedView.Source` is illustration only). Tier 1 is an **isolated UI convenience** and never leaks into tiers 2/3. -2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.HotTesting.Reactive` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. +2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.HotTesting.Reactive` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}Mock.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. 3. **Tier 3 — Complete-model ergonomics:** `{Model}Mock.Empty`, `Create()` overloads whose **required parameters are exactly the service-dependent feeds**; **derived members are optional overrides** (unset → the real business logic runs over the mocked inputs; set → replaced — useful for tests); hand-extensible named catalogs (`BasicRecipe`, `RecipeWithSelection`…) for one-line preview binding. Strongly typed, no tier-1 abstractions. ## 4. Split of responsibilities @@ -117,7 +117,7 @@ sequenceDiagram participant W as HotSwap wrappers participant UI as FeedView - T->>G: RecipeViewModel.Create(steps) + T->>G: RecipeViewModelMock.Create(steps) G->>VM: new RecipeViewModel(default!, ...) Note over VM: context.IsMockingActive ON —
every Model feed property is
cached as a HotSwapFeed wrapper G->>W: SetModel(Empty with Steps = steps) @@ -160,7 +160,7 @@ The exhaustive route: build the **whole feed set** of the mock record, apply it ```csharp // Test / preview project — no DI graph, no fake service -var vm = RecipeViewModel.Create(); // real VM + real Model, services null-injected +var vm = RecipeViewModelMock.Create(); // real VM + real Model, services null-injected vm.SetModel(new RecipeModelMock // required init → the compiler lists every input to fill { @@ -182,12 +182,12 @@ Generated surface, `Create` overload rules and diagnostics: [architecture.md §2 The same engine, one call: `Create` takes **only the required feeds** — nothing else to fill in. ```csharp -var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); // one required input → one argument -var loading = RecipeViewModel.Create(ListFeedMock.Loading()); -var empty = RecipeViewModel.Create(); // = every input Empty +var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); // one required input → one argument +var loading = RecipeViewModelMock.Create(ListFeedMock.Loading()); +var empty = RecipeViewModelMock.Create(); // = every input Empty // Named catalogs, hand-written in the test/preview project -public static RecipeViewModel BasicRecipe => RecipeViewModel.Create(ListFeedMock.Value(AvocadoToast)); +public static RecipeViewModel BasicRecipe => RecipeViewModelMock.Create(ListFeedMock.Value(AvocadoToast)); ``` ```xml @@ -221,7 +221,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.HotTesting.Reactive` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}Mock.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.HotTesting.Reactive` namespace. Renames = breaking; additive evolution fine. ## 11. Risks @@ -259,7 +259,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ```csharp using (MockingService.Enable()) { - var vm = RecipeViewModel.Create(ListFeedMock.Value(steps)); + var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); } ``` diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs index 641e8ceea1..b4b049c529 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs @@ -40,7 +40,7 @@ public async Task When_CreateWithMock_Then_FeedEmitsMockedValues() { using (MockingService.Enable()) { - var vm = RecipeModelMockExtensions.Create(ListFeedMock.Value(1, 2, 3)); + var vm = RecipeViewModelMock.Create(ListFeedMock.Value(1, 2, 3)); using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); @@ -53,7 +53,7 @@ public async Task When_SetModelReSwaps_Then_ReEmitsLive() { using (MockingService.Enable()) { - var vm = RecipeModelMockExtensions.Create(ListFeedMock.Value(1)); + var vm = RecipeViewModelMock.Create(ListFeedMock.Value(1)); using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) @@ -71,7 +71,7 @@ public async Task When_CreateDefault_Then_InputsAreEmpty() { using (MockingService.Enable()) { - var vm = RecipeModelMockExtensions.Create(); // Empty → Steps = None + var vm = RecipeViewModelMock.Create(); // Empty → Steps = None using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); @@ -85,7 +85,7 @@ public void When_CommandOverridden_Then_VmCommandInvokesMock() using (MockingService.Enable()) { var executed = false; - var vm = RecipeModelMockExtensions.Create(new RecipeModelMock + var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(1), Save = CommandMock.Callback(_ => executed = true), @@ -97,4 +97,18 @@ public void When_CommandOverridden_Then_VmCommandInvokesMock() executed.Should().BeTrue("SetModel routed the mock command through __Mock_SetCommand"); } } + + [TestMethod] + public async Task When_CatalogEntry_Then_PinnedState() + { + // Tier-3 sample: a named catalog entry builds a real VM pinned to a state. + using (MockingService.Enable()) + { + var vm = RecipeCatalog.Basic; + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); + } + } } diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs new file mode 100644 index 0000000000..b89e0d004c --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs @@ -0,0 +1,22 @@ +using System; +using Uno.Extensions.Reactive.Tests.MockingApp; +using Uno.HotTesting.Reactive; + +namespace Uno.Extensions.Reactive.Tests.Mocking; + +/// +/// Spec 013 tier 3 — sample of a hand-written named catalog: the one-line preview pattern. Each entry +/// builds the real (real model, null-injected service) pinned to a state, +/// via the generated RecipeViewModelMock.Create(...). Access entries inside a +/// scope (e.g. a preview head or an assembly-init scope). +/// +public static class RecipeCatalog +{ + public static RecipeViewModel Loading => RecipeViewModelMock.Create(ListFeedMock.Loading()); + + public static RecipeViewModel Empty => RecipeViewModelMock.Create(); + + public static RecipeViewModel Basic => RecipeViewModelMock.Create(ListFeedMock.Value(1, 2, 3)); + + public static RecipeViewModel Failed => RecipeViewModelMock.Create(ListFeedMock.Error(new TimeoutException())); +} diff --git a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs index 56561020c2..0868a9d5a3 100644 --- a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs +++ b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs @@ -159,6 +159,7 @@ private sealed class FeedMember var vmFull = vm.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var mockName = $"{model.Name}Mock"; + var vmMockName = $"{vm.Name}Mock"; var ns = model.ContainingNamespace.IsGlobalNamespace ? null : model.ContainingNamespace.ToDisplayString(); // Record members. @@ -183,6 +184,10 @@ private sealed class FeedMember var createParams = string.Join(", ", inputs.Select(m => $"{m.FeedTypeFullName} {Camel(m.Name)}")); var createInits = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); + // Empty state lives on the record so it composes with `with` (spec §8). + recordMembers.AppendLine(); + recordMembers.AppendLine($"\tpublic static {mockName} Empty {{ get; }} = new() {{ {emptyInits} }};"); + // SetModel body. var setBody = new StringBuilder(); foreach (var m in inputs) @@ -219,11 +224,9 @@ private sealed class FeedMember {{recordMembers.ToString().TrimEnd()}} } - public static class {{mockName}}Extensions + public static class {{vmMockName}} { - public static {{mockName}} Empty { get; } = new() { {{emptyInits}} }; - - public static {{vmFull}} Create() => Create(Empty); + public static {{vmFull}} Create() => Create({{mockName}}.Empty); {{createFromInputs}} public static {{vmFull}} Create({{mockName}} mock) { From ba058219015d1a2fa4e16cb5f2e0e49417b0d42e Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Aug 2026 22:09:47 +0000 Subject: [PATCH 16/19] refactor(mocking): rename SetModel to SetMock and move the scope into Create Review feedback: the generated {Vm}Mock is now partial so applications can extend it with named catalogs, SetModel is renamed to SetMock, Create only takes the mock record, {Model}Mock.Empty stays on the record so it composes with `with`, and Create opens the MockingService.Enable() scope itself so user code never has to. Command mocking is deferred to a future version: the consumer generator no longer emits a command member nor the __Mock_SetCommand wiring. --- doc/Reference/Reactive/testing.md | 100 +++++++----------- .../013-mvux-mocking-previews/architecture.md | 30 +++--- specs/013-mvux-mocking-previews/history.md | 14 +++ .../implementation.md | 30 +++--- specs/013-mvux-mocking-previews/spec.md | 44 ++++---- .../Mocking/Given_GeneratedMock.cs | 84 +++++---------- .../Mocking/RecipeCatalog.cs | 21 ++-- .../FeedsMockGenerator.cs | 53 +++------- 8 files changed, 156 insertions(+), 220 deletions(-) diff --git a/doc/Reference/Reactive/testing.md b/doc/Reference/Reactive/testing.md index a8fd2a8786..c1fb3cf508 100644 --- a/doc/Reference/Reactive/testing.md +++ b/doc/Reference/Reactive/testing.md @@ -124,10 +124,10 @@ emits, next to the model: - a `record {Model}Mock` whose **required** members are exactly the service-dependent feeds, and whose **optional** members are the derived feeds - and the commands; -- `{Vm}.Create(...)` factories that build the **real view-model** with its - services null-injected, then apply the mock; -- a `SetModel(this {Vm}, {Model}Mock)` extension that swaps each mocked feed. + (a `{Model}Mock.Empty` pins every input to its empty state); +- a `partial class {Vm}Mock` with `Create(...)` factories that build the **real + view-model** with its services null-injected, then apply the mock; +- a `SetMock(this {Vm}, {Model}Mock)` extension that swaps each mocked feed. Given this model: @@ -139,100 +139,78 @@ public partial record RecipeModel(IRecipeService Service) // derived — recomputes over whatever Steps emits public IFeed StepsCount => Steps.Select(steps => steps.Count); - - // a command - public async ValueTask Save(CancellationToken ct) => await Service.Save(ct); } ``` the generator produces `RecipeModelMock`, `RecipeViewModelMock.Create(...)` and -`SetModel`. You drive the page from a test or a preview head: +`SetMock`. You drive the page from a test or a preview head — no scope to open, +`Create` opens it internally: ```csharp using Uno.HotTesting.Reactive; -using (MockingService.Enable()) -{ - // Real RecipeViewModel + real RecipeModel, IRecipeService null-injected. - var vm = RecipeViewModelMock.Create(ListFeedMock.Value(step1, step2, step3)); +// Real RecipeViewModel + real RecipeModel, IRecipeService null-injected. +var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(step1, step2, step3) }); - // StepsCount is NOT mocked — it recomputes through the real Select over the - // mocked Steps, so the derived feed stays truthful. +// StepsCount is NOT mocked — it recomputes through the real Select over the +// mocked Steps, so the derived feed stays truthful. - // Live transitions: keep calling SetModel to walk the states. - vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Loading() }); - vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Error(new TimeoutException()) }); -} +// Live transitions: keep calling SetMock to walk the states. +vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() }); +vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Error(new TimeoutException()) }); ``` > [!IMPORTANT] -> Mocking is only active inside a `MockingService.Enable()` scope. Outside a -> scope nothing is wrapped, so a shipping application pays no cost — never -> reference `Uno.HotTesting.Reactive` from a published app head. +> Mocking is only active for a view-model built by `Create` (which opens a +> `MockingService.Enable()` scope around construction). Outside such a scope +> nothing is wrapped, so a shipping application pays no cost — never reference +> `Uno.HotTesting.Reactive` from a published app head. ### Only fill what matters Required members are the **service-dependent inputs**; the compiler lists them -for you. Derived members and commands are optional: +for you. Derived members are optional: ```csharp // every input at once, exhaustively -vm.SetModel(new RecipeModelMock +vm.SetMock(new RecipeModelMock { Steps = ListFeedMock.Loading(), // required — the compiler asked for it // StepsCount left unset → real derivation runs over the mocked Steps - // Save left unset → idle no-op command }); -// or pin a derived value directly, ignoring its inputs -vm.SetModel(RecipeModelMock.Empty with { StepsCount = FeedMock.Value(3) }); -``` - -### Mocking commands - -Commands have no feed to swap, so override them with the `CommandMock` vocabulary: +// or start from Empty and change one axis with `with` +vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() }); -```csharp -var executed = false; -var vm = RecipeViewModelMock.Create(new RecipeModelMock -{ - Steps = ListFeedMock.Value(step1), - Save = CommandMock.Callback(_ => executed = true), -}); - -vm.Save.Execute(null); // invokes the callback +// or pin a derived value directly, ignoring its inputs +vm.SetMock(RecipeModelMock.Empty with { StepsCount = FeedMock.Value(3) }); ``` -`CommandMock` offers `Idle()`, `Disabled()`, `Executing()` and -`Callback(onExecute)`. +> [!NOTE] +> Mocking commands is not supported yet — it is planned for a future version. -### One-liners and named catalogs +### Named catalogs (one-line previews) -Because `Create` takes only the required inputs, the common cases are one call: +`{Vm}Mock` is generated as a **partial class**, so you extend it with named +catalog entries in your own file. Each entry builds the real view-model pinned to +a state through `Create`: ```csharp -var loading = RecipeViewModelMock.Create(ListFeedMock.Loading()); -var empty = RecipeViewModelMock.Create(); // every input Empty -var ready = RecipeViewModelMock.Create(ListFeedMock.Value(step1, step2)); -``` - -Collect them into a hand-written catalog in your preview project, then bind a -page to one entry — real page, real view-model, pinned state: - -```csharp -public static class RecipeCatalog +// your file, same namespace as the generated RecipeViewModelMock +public static partial class RecipeViewModelMock { - public static RecipeViewModel Loading => RecipeViewModelMock.Create(ListFeedMock.Loading()); - public static RecipeViewModel Empty => RecipeViewModelMock.Create(); - public static RecipeViewModel Basic => RecipeViewModelMock.Create(ListFeedMock.Value( - new Step("Toast the bread"), - new Step("Mash the avocado"))); + public static RecipeViewModel Loading => Create(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() }); + public static RecipeViewModel Empty => Create(); // every input Empty + public static RecipeViewModel Basic => Create(new RecipeModelMock + { + Steps = ListFeedMock.Value(new Step("Toast the bread"), new Step("Mash the avocado")), + }); } ``` ```xml - - + + ``` ### Turning the instrumentation off diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 0a9415620e..2c8aeaaf85 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -25,7 +25,7 @@ Every feed factory caches its instance via `AttachedProperty.GetOrCreate` keyed - `Model.Steps` returns the wrapper → the VM state subscribes to it → **swap propagates to the VM member**; - `StepsCount`'s `SelectFeed` composes on the same wrapper → **swap propagates through business logic** (live: a re-swap re-emits through `Select`); -- no `dynamic`, no duck-typed re-init needed for feeds: **`SetModel` = reflection over the context's `IHotSwapState` members**, calling `HotSwap` per mocked feed (D11), reusing the hot-reload driver but **fail-hard** — a member that cannot be swapped throws. No per-member generated handle. (The HR `dynamic` path stays untouched, HR-only.) +- no `dynamic`, no duck-typed re-init needed for feeds: **`SetMock` = reflection over the context's `IHotSwapState` members**, calling `HotSwap` per mocked feed (D11), reusing the hot-reload driver but **fail-hard** — a member that cannot be swapped throws. No per-member generated handle. (The HR `dynamic` path stays untouched, HR-only.) ```mermaid flowchart TB @@ -78,18 +78,18 @@ public record RecipeModelMock public static RecipeModelMock Empty { get; } = new() { Steps = ListFeedMock.Empty() }; public required IListFeed Steps { get; init; } // ServiceDependent input → required public IFeed? StepsCount { get; init; } // Derived → optional override; null = real business logic - public IAsyncCommand? Save { get; init; } // command → optional; null = idle no-op + // Command mocking is deferred to vNext — the record carries no command member for now. } -public static class RecipeViewModelMock +public static partial class RecipeViewModelMock { - public static RecipeViewModel Create(); // null-inject + SetModel(Empty) - public static RecipeViewModel Create(IListFeed steps); // required inputs as params - public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // typed swaps via hidden handles + public static RecipeViewModel Create(); // = Create(RecipeModelMock.Empty) + public static RecipeViewModel Create(RecipeModelMock mock); // opens MockingService.Enable() internally + public static void SetMock(this RecipeViewModel vm, RecipeModelMock mock); // typed swaps } ``` - `Create()` constructs the **real VM** via `new {Vm}(default!, …)`; **compile-time guard**: if `[CtorDependency(Eager=true)]` names parameter `p`, `Create` **requires** a real/fake `p` argument (or the generator emits an error diagnostic if no safe overload is possible). -- `SetModel` may be called repeatedly (live transitions, G6); `with`-expressions on the record make variants cheap (`Empty with { Steps = … }`). +- `SetMock` may be called repeatedly (live transitions, G6); `with`-expressions on the record make variants cheap (`Empty with { Steps = … }`). - `required init` on service-dependent inputs = compile-time completeness. **Derived members are optional overrides**: `null` (default) → the real derivation recomputes over the swapped inputs; non-null → that member's own wrapper is swapped too (the cache-level anchor wraps *every* feed property when the context is mockable, derived included) — lets a test pin a derived value without caring about its inputs. - **Tier 2 and tier 3 never accept `MessageEntry`, an untyped feed envelope, or any other tier-1 authoring abstraction. Their contracts remain `IFeed`, `IListFeed`, typed states and typed commands end to end.** @@ -213,16 +213,16 @@ sequenceDiagram participant W as HotSwapFeed wrappers participant UI as FeedView - Note over T,MG: build time — the Mocking generator reads app metadata
+ FeedDependency / CtorDependency attributes and emits
RecipeModelMock + Create(...) + SetModel + Note over T,MG: build time — the Mocking generator reads app metadata
+ FeedDependency / CtorDependency attributes and emits
RecipeModelMock + Create(...) + SetMock T->>MG: RecipeViewModelMock.Create(steps) MG->>VM: new RecipeViewModel(default!, ...) VM->>M: new RecipeModel(default!, ...) Note over M,W: context.IsMockingActive ON — every Model feed property
is cached as a HotSwapFeed wrapper - MG->>M: SetModel → reflection HotSwap over IHotSwapState members (fail-hard) + MG->>M: SetMock → reflection HotSwap over IHotSwapState members (fail-hard) M->>W: wrapper.Set(steps) W-->>UI: Steps emits the mock values W-->>UI: StepsCount recomputes through the real Select - T->>M: SetModel(...) again — Loading / Value / Error + T->>M: SetMock(...) again — Loading / Value / Error M->>W: re-swap W-->>UI: live transition, no re-subscribe ``` @@ -232,10 +232,8 @@ sequenceDiagram The activation API is **decided** (D10): mocking exists only inside an explicit scope. ```csharp -using (MockingService.Enable()) -{ - var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); -} +// Create opens the MockingService.Enable() scope internally, around construction: +var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(steps) }); ``` Rationale — **the wrap is not free**. §1 wraps each Model feed in a `HotSwapFeed`; that is one indirection per feed on every subscription path. Acceptable in a test/preview run, not in a live app. So the wrap must be **opt-in per scope**, never a framework-wide default: outside a scope, `AttachedProperty.GetOrCreate` caches the raw feed exactly as today. @@ -250,7 +248,7 @@ Resolved against the source: - **Eager vs lazy:** `Create(...)` pre-seeds a mockable context on the VM/Model owner (via the `PreConfigure`/`Set` seam) so a lazy first subscription **after** the `using` block still wraps — the bit lives on the context instance, not only on the ambient `AsyncLocal`. - **Wrap gate:** `StateImpl` ctor reads `context.IsMockingActive` instead of `FeedConfiguration.EffectiveHotReload` (D12). - **Nested / concurrent / lifetime:** the bit is per-context-instance → concurrent tests don't leak; contexts created inside a scope stay mockable for their own lifetime after `Dispose`. -- **No mock registry on the context needed:** swap is reflection over the context's `IHotSwapState` members (D11); overrides are applied by `SetModel` at swap time. +- **No mock registry on the context needed:** swap is reflection over the context's `IHotSwapState` members (D11); overrides are applied by `SetMock` at swap time. ## 7. Constraints @@ -259,5 +257,5 @@ Resolved against the source: - Tier 1 stays an isolated UI convenience. - **No wrap unless `SourceContext.IsMockingActive`** (§6, D10/D12): the per-feed `HotSwapFeed` indirection must never exist in a live app; a live-app context never has the bit set. - **Swap is reflection over `IHotSwapState`, fail-hard** (D11): no per-member generated hook; an un-swappable mocked member throws. -- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetModel`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, the `__Mock_SetCommand` command seam. +- Frozen names (Hot Design + tests): `{Model}Mock`, `Empty`, `Create`, `SetMock`, `MockingService.Enable`, `SourceContext.IsMockingActive`, attribute names, the `__Mock_SetCommand` command seam. - MVUX output byte-identical only when explicitly opted out (`EnableFeedMocking(IsEnabled = false)`); instrumentation is emitted by default. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 40b611faae..a85e9dd025 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -133,6 +133,20 @@ Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 5/5, Uno.HotTesting.Reactive.Tests 22/22, Tests.Generator 80/80. + +## v12 — review David (post-discussion staging PR #1, ven. 28/08) + +Six retours de David sur la PR, tous appliqués : + +1. **`{Vm}Mock` généré `partial`** — l'app étend la classe factory avec ses catalogs nommés (tier 3) dans son propre fichier, même namespace. +2. **`SetModel` → `SetMock`** (facade renommée). +3. **`Create` prend uniquement le record** — suppression des surcharges dénormalisées `Create(input…)`. Reste `Create()` (= `{Model}Mock.Empty`) et `Create({Model}Mock)`. +4. **`{Model}Mock.Empty`** confirmé (sur le record, tous inputs Empty) + exemple `vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() })`. +5. **Scope d'activation déplacé DANS `Create`** — `Create` ouvre `MockingService.Enable()` autour de la construction ; le code utilisateur n'ouvre plus de scope (le bit mockable capturé sur le contexte survit aux `SetMock` ultérieurs et souscriptions lazy, D12). +6. **Mocking de commande différé à vNext** — le générateur consumer n'émet plus de membre commande ni de câblage `__Mock_SetCommand` ; le seam MVUX reste disponible pour ce travail futur. `CommandMock` (vocabulaire) reste dans l'assembly, non câblé. + +Répercuté : doc `doc/Reference/Reactive/testing.md`, spec §7/§8/§10/§13 + archi §2.2/§5/§6, sample `RecipeViewModelMock` partial. Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. + --- ## Registre final des décisions diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index 43be83973c..07b76b39b7 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -12,7 +12,7 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi | `FeedView.Source` coercion bridge | `Uno.Extensions.Reactive.UI` | tier-1 | | Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, on by default (opt-out) | | Mock vocabulary (`FeedMock`/`ListFeedMock`/`CommandMock`/`FeedMockState`) | **`Uno.HotTesting.Reactive`** (new) | referenced by test/preview projects only | -| Mocking generator (`{Model}Mock`, `Create`, `SetModel`) | `Uno.HotTesting.Reactive` (analyzer asset) | runs in consumer project, reads app metadata | +| Mocking generator (`{Model}Mock`, `Create`, `SetMock`) | `Uno.HotTesting.Reactive` (analyzer asset) | runs in consumer project, reads app metadata | | Reflection swap driver (reused, fail-hard) | core | reuse hot-reload's `IHotSwapState` iteration; **throw on un-swappable member** (D11) | | `MockingService.Enable()` activation scope | `Uno.HotTesting.Reactive` | frozen name; sets `SourceContext.IsMockingActive` on the ambient/pre-seeded context (§6) | @@ -102,21 +102,21 @@ public record RecipeModelMock public static RecipeModelMock Empty { get; } // ServiceDependent → FeedMock/ListFeedMock.Empty public required IListFeed Steps { get; init; } // exactly the ServiceDependent set public IFeed? StepsCount { get; init; } // Derived → optional override; null = real derivation - public IAsyncCommand? Save { get; init; } // optional; default idle no-op + + public static RecipeModelMock Empty { get; } // every input pinned to its Empty state } -public static class RecipeViewModelMocking +public static partial class RecipeViewModelMock // partial → user extends with named catalogs { - public static RecipeViewModel Create(); // null-inject + SetModel(Empty) - public static RecipeViewModel Create(IListFeed steps); // per required input - public static RecipeViewModel Create(IRecipeService svc, IListFeed steps); // when CtorDependency(Eager) → service required - public static void SetModel(this RecipeViewModel vm, RecipeModelMock mock); // reflection HotSwap over IHotSwapState (fail-hard) + public static RecipeViewModel Create(); // = Create(RecipeModelMock.Empty) + public static RecipeViewModel Create(RecipeModelMock mock); // opens MockingService.Enable() internally + public static void SetMock(this RecipeViewModel vm, RecipeModelMock mock); // typed swaps (fail-hard) } ``` Rules: -- Required properties/parameters = the **ServiceDependent** input set. +- Required properties = the **ServiceDependent** input set; `Create` takes only the record (no denormalized per-input overloads). - **Derived members: optional overrides** — `null` (default) → real derivation recomputes over swapped inputs; set → that member's wrapper is swapped too. Independent members: untouched. -- Commands optional; default is an idle no-op. -- `SetModel` callable repeatedly → live transitions (`vm.SetModel(mock with { Steps = ... })`). +- **Command mocking is deferred to vNext**: the record carries no command member and `SetMock` wires none (the MVUX `__Mock_SetCommand` seam stays available for that future work). +- `Create` opens the `MockingService.Enable()` scope around construction, so user code never opens it. `SetMock` callable repeatedly → live transitions (`vm.SetMock(RecipeModelMock.Empty with { Steps = ... })`). - Concrete generic types preserved throughout; **no tier-1 type or conversion path is emitted**. - Diagnostic `MOCK0001` when a VM is reachable but its assembly lacks hooks (opt-in missing). @@ -143,7 +143,7 @@ public static class MockingService [AssemblyCleanup] public static void Cleanup() => _scope.Dispose(); // or a single test -using (MockingService.Enable()) { var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); } +var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(steps) }); // Create opens the scope internally ``` **Non-negotiable constraint:** context not mockable → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `SourceContext.IsMockingActive` (§2.2, D12) is the internal per-context gate the scope drives, not a switch app authors set. @@ -165,7 +165,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): e. `MockingService.Enable()` → `IsMockingActive` on the pre-seeded context: prove **no wrap when the context is not mockable**, and reflection swap is **fail-hard** on an un-swappable member (D11). - **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. - **P2 — Core: `SourceContext.IsMockingActive` + wrap gate in `StateImpl` + fail-hard reflection swap + attributes + analysis + `__Mock_SetCommand` seam** (MVUX gen). No per-feed swap hooks; no `__Mock_Create` (public ctors + ambient scope). -- **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetModel`). +- **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetMock`). - **P4 — Tier 3 catalogs + Hot Design checkpoint** (name freeze), docs. ## 8. Test plan @@ -185,7 +185,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): ### Runtime / UI (Skia) - Each pinned state renders; Loading keeps `IsExecuting`. - Successive `Source` entries evolve without re-subscribe (no loading flash); **mutating an assigned entry does not emit** — assigning a replacement does. -- `SetModel` drives Loading → Value → Error live; derived member updates on-screen after an input swap (D6 end-to-end). +- `SetMock` drives Loading → Value → Error live; derived member updates on-screen after an input swap (D6 end-to-end). - Command states drive `Button.IsEnabled`; hot reload does not clobber a mocked VM/context. ### Scoped activation (with §6 spike) @@ -195,9 +195,9 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): - Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. ### Contract freeze -- Reflection-discovery test for `{Model}Mock`/`Empty`/`Create`/`SetModel`/attribute names (Hot Design contract). +- Reflection-discovery test for `{Model}Mock`/`Empty`/`Create`/`SetMock`/attribute names (Hot Design contract). ## 9. Docs -- `doc/Learn/Mvux/Testing.md`: `MockingService.Enable()` scope (assembly-init vs per-test, and why it is never app-wide), typed vocabulary, `Create`/`SetModel`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. +- `doc/Learn/Mvux/Testing.md`: `MockingService.Enable()` scope (assembly-init vs per-test, and why it is never app-wide), typed vocabulary, `Create`/`SetMock`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. - `doc/Learn/Mvux/FeedView.md`: tier-1 entry authoring + custom axes; converter shown only as an application-owned illustration at `FeedView.Source` (not a deliverable). - `rules.md`: FEED3201–3203, MOCK0001. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 3ba2ca788e..84fe1b0a77 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -36,7 +36,7 @@ public IFeed StepsCount => Steps.Select(steps => steps.Count); // busines ```mermaid flowchart LR MOCK["ListFeedMock.Value(steps) - applied via SetModel"] + applied via SetMock"] subgraph MODEL["Real RecipeModel — services null-injected"] W["Steps stable HotSwapFeed wrapper @@ -58,7 +58,7 @@ flowchart TB T3["Tier 3 — complete-model ergonomics RecipeModelMock.Empty / Create(...) / named catalogs"] T2["Tier 2 — per-feed control on the real VM - RecipeModelMock record + SetModel = typed swaps"] + RecipeModelMock record + SetMock = typed swaps"] T3 --> T2 end subgraph CONV["UI authoring convenience — XAML, no VM"] @@ -71,7 +71,7 @@ flowchart TB ``` 1. **Tier 1 — Static/XAML, no VM:** `FeedView.Source` accepts a declared **`MessageEntry`** — a new authorable non-generic entry in **Core**, a **plain CLR object (deliberately NOT a `DependencyObject`)**, XAML element syntax; core axes as direct convenience properties and **custom axes first-class** via an axis collection (MVUX's open axis model). **Replacing** the `Source` entry instance pushes the new entry through the existing wrapper feed: the stream evolves like a real feed (no re-subscribe, no loading flash). The entry itself is **not observable** — a new instance is the unit of change. No heuristic envelope, no parallel DTO, no converter deliverable (an application-owned converter at `FeedView.Source` is illustration only). Tier 1 is an **isolated UI convenience** and never leaks into tiers 2/3. -2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.HotTesting.Reactive` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}Mock.Create(...)` factories (null-inject + apply mock), and `SetModel(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. Commands via a `??` seam. Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. +2. **Tier 2 — Externally-generated mocks over the real VM:** referencing `Uno.HotTesting.Reactive` in a **test/preview project** generates, per reachable Model: `record {Model}Mock` (required-init, compile-time completeness), `{Vm}Mock.Create(...)` factories (null-inject + apply mock), and `SetMock(vm, mock)` which **swaps** each mocked feed at the Model-feed anchor. **Command mocking is deferred to vNext** (the `__Mock_SetCommand` seam is emitted but the consumer generator does not wire command overrides yet). Contracts remain `IFeed` / `IListFeed`, typed states and typed commands **end to end** — tier 2 never accepts `MessageEntry` or any untyped envelope. 3. **Tier 3 — Complete-model ergonomics:** `{Model}Mock.Empty`, `Create()` overloads whose **required parameters are exactly the service-dependent feeds**; **derived members are optional overrides** (unset → the real business logic runs over the mocked inputs; set → replaced — useful for tests); hand-extensible named catalogs (`BasicRecipe`, `RecipeWithSelection`…) for one-line preview binding. Strongly typed, no tier-1 abstractions. ## 4. Split of responsibilities @@ -94,7 +94,7 @@ flowchart TB MG["Mocking generator (ships in Uno.HotTesting.Reactive)"] OUT["RecipeModelMock record - Create(...) factories · SetModel facade"] + Create(...) factories · SetMock facade"] MG --> OUT end ATTR -->|read as compiled metadata| MG @@ -105,7 +105,7 @@ flowchart TB a. **Dependency analysis** of each feed/command member + **ctor instrumentation** (detect eager service access that would NRE under null-inject); b. emits results as **metadata attributes** (also hand-declarable by the author — explicit declarations win/merge); c. emits **only** the seams the reflection swap cannot synthesize (`EditorBrowsable(Never)`, on by default — opt-out): the VM `__Mock_SetCommand` seam for commands (R2 — commands have no `IHotSwapState`). Construction needs no seam (public ctors + ambient scope, D12). **No per-feed `__Mock_Swap_{Member}` handles** — the swap itself is reflection over the Model's `IHotSwapState` members at runtime (D11), reusing the hot-reload driver. It must **not** reuse `__Reactive_UpdateModel` (which reassigns `__reactiveModel`/INPC and is unsafe here). -- **Mocking generator (ships in `Uno.HotTesting.Reactive`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetModel` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). +- **Mocking generator (ships in `Uno.HotTesting.Reactive`, runs in the consuming test/preview project):** reads the app assembly **metadata** (types + attributes — no syntax trees needed), generates `{Model}Mock` records, `Create` factories and the `SetMock` facade as **new external, generic and strongly typed types/extensions** (no cross-assembly partial). ## 5. End-to-end — a test drives a page through its states @@ -120,11 +120,11 @@ sequenceDiagram T->>G: RecipeViewModelMock.Create(steps) G->>VM: new RecipeViewModel(default!, ...) Note over VM: context.IsMockingActive ON —
every Model feed property is
cached as a HotSwapFeed wrapper - G->>W: SetModel(Empty with Steps = steps) + G->>W: SetMock(Empty with Steps = steps) W-->>VM: Steps swapped (reflection over IHotSwapState, fail-hard) VM-->>UI: StepsCount recomputes through the real Select UI-->>UI: renders pinned states - T->>W: SetModel(...) — Loading, Value, Error + T->>W: SetMock(...) — Loading, Value, Error W-->>UI: live transitions, no re-subscribe ``` @@ -156,19 +156,19 @@ Full authoring surface (custom axes, XAML examples, evolution contract): [archit ## 7. Tier 2 at a glance -The exhaustive route: build the **whole feed set** of the mock record, apply it with `SetModel`. +The exhaustive route: build the **whole feed set** of the mock record, apply it with `SetMock`. ```csharp // Test / preview project — no DI graph, no fake service var vm = RecipeViewModelMock.Create(); // real VM + real Model, services null-injected -vm.SetModel(new RecipeModelMock // required init → the compiler lists every input to fill +vm.SetMock(new RecipeModelMock // required init → the compiler lists every input to fill { Steps = ListFeedMock.Loading(), // pinned Loading, forever Tags = ListFeedMock.Empty(), }); -vm.SetModel(RecipeModelMock.Empty with { Steps = ListFeedMock.Error(timeout) }); // live re-swap +vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Error(timeout) }); // live re-swap ``` - Required members = exactly the **service-dependent** feeds (compile-time completeness, G4). @@ -179,15 +179,19 @@ Generated surface, `Create` overload rules and diagnostics: [architecture.md §2 ## 8. Tier 3 at a glance -The same engine, one call: `Create` takes **only the required feeds** — nothing else to fill in. +The same engine: `Create` takes **only the mock record** (its required members are exactly the service-dependent feeds). `Create()` uses `{Model}Mock.Empty`. ```csharp -var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); // one required input → one argument -var loading = RecipeViewModelMock.Create(ListFeedMock.Loading()); +var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(steps) }); +var loading = RecipeViewModelMock.Create(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() }); var empty = RecipeViewModelMock.Create(); // = every input Empty // Named catalogs, hand-written in the test/preview project -public static RecipeViewModel BasicRecipe => RecipeViewModelMock.Create(ListFeedMock.Value(AvocadoToast)); +// {Vm}Mock is generated `partial` — add named catalog entries in your own file: +public static partial class RecipeViewModelMock +{ + public static RecipeViewModel BasicRecipe => Create(new RecipeModelMock { Steps = ListFeedMock.Value(AvocadoToast) }); +} ``` ```xml @@ -195,7 +199,7 @@ public static RecipeViewModel BasicRecipe => RecipeViewModelMock.Create(ListFeed ``` -No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a preview head can keep re-issuing `SetModel` to walk states live (G6). Catalogs and pickers: [architecture.md §4](architecture.md). +No new mechanism: each overload is `Create()` + a `SetMock` of §7, so a preview head can keep re-issuing `SetMock` to walk states live (G6). Catalogs and pickers: [architecture.md §4](architecture.md). ## 9. Goals / Non-goals @@ -221,7 +225,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe ## 10. Frozen contracts (Hot Design + test code discover by name) -`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), `{Vm}Mock.Create(...)`, `SetModel`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam, and the `Uno.HotTesting.Reactive` namespace. Renames = breaking; additive evolution fine. +`{Model}Mock` record shape (required-init members, `Empty`, `with`-friendly), the `partial class {Vm}Mock` with `Create(...)`/`SetMock`, `MockingService.Enable()`, `SourceContext.IsMockingActive`, the dependency attributes, the VM `__Mock_SetCommand` command seam (reserved for vNext command mocking), and the `Uno.HotTesting.Reactive` namespace. Renames = breaking; additive evolution fine. ## 11. Risks @@ -241,7 +245,7 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe | --- | --- | | D1 | Tier-1 authoring = authorable non-generic `MessageEntry` **in Core**; **plain CLR, not a `DependencyObject`**; **not observable** (instance replacement is the unit of change); core axes as convenience properties, **custom axes** via `Axes` / `Set(MessageAxis, value)`; replacement pushes through the existing wrapper (natural feed evolution, no loading flash) | | D2 | Commands = `??` seam (no swap analog) | -| D3 | **Facade** (`SetModel` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public | +| D3 | **Facade** (`SetMock` / generated setters) in front of hidden hooks; `HotSwapFeed`/handles stay non-public | | D4 | ~~Dedicated `FeedConfiguration` mockable flag~~ **superseded (2026-08-24)**: the gate lives on **`SourceContext.IsMockingActive`** (per-context, set by `MockingService.Enable()` on the ambient context). No separate static, no bespoke `AsyncLocal`. See D11–D12 | | D5 | Mock codegen is **external** (consumer project); MVUX gen only analyzes + emits attributes & hidden hooks | | D6 | Swap anchored at **Model-feed cache level** so derivations survive (non-negotiable) | @@ -257,10 +261,8 @@ No new mechanism: each overload is `Create()` + a `SetModel` of §7, so a previe **Decided.** Mocking is turned on by an **explicit scope**, and only inside it: ```csharp -using (MockingService.Enable()) -{ - var vm = RecipeViewModelMock.Create(ListFeedMock.Value(steps)); -} +// Create opens the MockingService.Enable() scope internally, around construction: +var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(steps) }); ``` - **On demand only.** Wrapping every Model feed in a `HotSwapFeed` costs at runtime (one indirection per feed, per subscription path). That cost is acceptable in a test/preview run and **not** in a live app: outside an activation scope nothing is wrapped, and no published app head ever references the Mocking package (G9, R7, D7). diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs index b4b049c529..67661fdf2d 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/Given_GeneratedMock.cs @@ -12,10 +12,10 @@ namespace Uno.Extensions.Reactive.Tests.Mocking; /// -/// Spec 013 step B — end-to-end: the consumer generator's {Model}Mock / Create / SetModel drive a real -/// VM (real Model, null-injected service) through mocked feed states via the reflection swap engine. -/// Observation goes through the cached list-state (same state the bindable VM subscribes to), which the -/// swap targets — not a fresh subscription to the raw feed. +/// Spec 013 — end-to-end: the consumer generator's {Model}Mock / {Vm}Mock.Create / SetMock drive a real +/// VM (real Model, null-injected service) through mocked feed states. The activation scope lives inside +/// Create, so user code never opens it. Observation goes through the cached list-state (the same state +/// the bindable VM subscribes to), which the swap targets — not a fresh subscription to the raw feed. /// [TestClass] public class Given_GeneratedMock : FeedUITests @@ -23,7 +23,6 @@ public class Given_GeneratedMock : FeedUITests private static async Task?> CurrentItems(SourceContext ctx, IListFeed feed) { var (result, _) = ctx.GetOrCreateListState(feed).Record(); - // Wait until a defined (Some) message is observed. for (var i = 0; i < 50; i++) { if (result.Count > 0 && result.Last().Current.Data.IsSome(out var v)) @@ -38,77 +37,48 @@ public class Given_GeneratedMock : FeedUITests [TestMethod] public async Task When_CreateWithMock_Then_FeedEmitsMockedValues() { - using (MockingService.Enable()) - { - var vm = RecipeViewModelMock.Create(ListFeedMock.Value(1, 2, 3)); - using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + // No MockingService.Enable() here — Create opens the scope internally. + var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(1, 2, 3) }); + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); - var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); - items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); - } + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); } [TestMethod] - public async Task When_SetModelReSwaps_Then_ReEmitsLive() + public async Task When_SetMockReSwaps_Then_ReEmitsLive() { - using (MockingService.Enable()) - { - var vm = RecipeViewModelMock.Create(ListFeedMock.Value(1)); - using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.Value(1) }); + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); - (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) - .Should().BeEquivalentTo(new[] { 1 }); + (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) + .Should().BeEquivalentTo(new[] { 1 }); - vm.SetModel(new RecipeModelMock { Steps = ListFeedMock.Value(7, 8) }); + // Live re-swap — still works after Create's scope has closed (the context stays mockable). + vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Value(7, 8) }); - (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) - .Should().BeEquivalentTo(new[] { 7, 8 }); - } + (await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps)) + .Should().BeEquivalentTo(new[] { 7, 8 }); } [TestMethod] public async Task When_CreateDefault_Then_InputsAreEmpty() { - using (MockingService.Enable()) - { - var vm = RecipeViewModelMock.Create(); // Empty → Steps = None - using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); - - var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); - items.Should().BeNull("Empty pins the input to None"); - } - } - - [TestMethod] - public void When_CommandOverridden_Then_VmCommandInvokesMock() - { - using (MockingService.Enable()) - { - var executed = false; - var vm = RecipeViewModelMock.Create(new RecipeModelMock - { - Steps = ListFeedMock.Value(1), - Save = CommandMock.Callback(_ => executed = true), - }); + var vm = RecipeViewModelMock.Create(); // = RecipeModelMock.Empty → Steps = None + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); - vm.Save.Should().NotBeNull(); - vm.Save.CanExecute(null).Should().BeTrue(); - vm.Save.Execute(null); - executed.Should().BeTrue("SetModel routed the mock command through __Mock_SetCommand"); - } + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeNull("Empty pins the input to None"); } [TestMethod] public async Task When_CatalogEntry_Then_PinnedState() { - // Tier-3 sample: a named catalog entry builds a real VM pinned to a state. - using (MockingService.Enable()) - { - var vm = RecipeCatalog.Basic; - using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); + // Tier-3 sample: a named catalog entry (a partial of the generated RecipeViewModelMock). + var vm = RecipeViewModelMock.Basic; + using var _ = SourceContext.GetOrCreate(vm.Model).AsCurrent(); - var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); - items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); - } + var items = await CurrentItems(SourceContext.GetOrCreate(vm.Model), vm.Model.Steps); + items.Should().BeEquivalentTo(new[] { 1, 2, 3 }); } } diff --git a/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs b/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs index b89e0d004c..4717466d9a 100644 --- a/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs +++ b/src/Uno.Extensions.Reactive.Tests/Mocking/RecipeCatalog.cs @@ -1,22 +1,19 @@ -using System; using Uno.Extensions.Reactive.Tests.MockingApp; using Uno.HotTesting.Reactive; -namespace Uno.Extensions.Reactive.Tests.Mocking; +namespace Uno.Extensions.Reactive.Tests.MockingApp; /// -/// Spec 013 tier 3 — sample of a hand-written named catalog: the one-line preview pattern. Each entry -/// builds the real (real model, null-injected service) pinned to a state, -/// via the generated RecipeViewModelMock.Create(...). Access entries inside a -/// scope (e.g. a preview head or an assembly-init scope). +/// Spec 013 tier 3 — sample of named catalog entries added as a partial of the generated +/// factory. Each entry builds the real view-model pinned to a state +/// via Create(...) (which opens the activation scope internally). Bind a page to one entry for a +/// one-line preview: DataContext="{x:Bind RecipeViewModelMock.Basic}". /// -public static class RecipeCatalog +public static partial class RecipeViewModelMock { - public static RecipeViewModel Loading => RecipeViewModelMock.Create(ListFeedMock.Loading()); + public static RecipeViewModel Loading => Create(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() }); - public static RecipeViewModel Empty => RecipeViewModelMock.Create(); + public static RecipeViewModel Basic => Create(new RecipeModelMock { Steps = ListFeedMock.Value(1, 2, 3) }); - public static RecipeViewModel Basic => RecipeViewModelMock.Create(ListFeedMock.Value(1, 2, 3)); - - public static RecipeViewModel Failed => RecipeViewModelMock.Create(ListFeedMock.Error(new TimeoutException())); + public static RecipeViewModel Failed => Create(RecipeModelMock.Empty with { Steps = ListFeedMock.Error(new global::System.TimeoutException()) }); } diff --git a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs index 0868a9d5a3..d311986279 100644 --- a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs +++ b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs @@ -139,20 +139,10 @@ private sealed class FeedMember (onFeed is not null ? derived : inputs).Add(fm); } - // Commands: the generated VM exposes them as public IAsyncCommand properties, overridable via - // the __Mock_SetCommand seam (emitted by the MVUX generator). - var commands = vm.GetMembers() - .OfType() - .Where(pr => !pr.IsStatic && pr.DeclaredAccessibility == Accessibility.Public - && pr.Type.ToDisplayString() == "Uno.Extensions.Reactive.IAsyncCommand") - .Select(pr => pr.Name) - .ToList(); - if (!vm.GetMembers("__Mock_SetCommand").Any()) - { - commands.Clear(); - } + // Command mocking is deferred to vNext: the consumer generator emits no command overrides for + // now (the MVUX __Mock_SetCommand seam stays available for that future work). - if (inputs.Count == 0 && derived.Count == 0 && commands.Count == 0) + if (inputs.Count == 0 && derived.Count == 0) { return null; } @@ -172,17 +162,11 @@ private sealed class FeedMember { recordMembers.AppendLine($"\tpublic {m.FeedTypeFullName}? {m.Name} {{ get; init; }}"); } - foreach (var c in commands) - { - recordMembers.AppendLine($"\tpublic global::Uno.Extensions.Reactive.IAsyncCommand? {c} {{ get; init; }}"); - } // Empty initializer + Create(inputs) params/inits. var emptyInits = string.Join(", ", inputs.Select(m => m.IsList ? $"{m.Name} = {HotTesting}.ListFeedMock.Empty<{m.ItemOrValueFullName}>()" : $"{m.Name} = {HotTesting}.FeedMock.Empty<{m.ItemOrValueFullName}>()")); - var createParams = string.Join(", ", inputs.Select(m => $"{m.FeedTypeFullName} {Camel(m.Name)}")); - var createInits = string.Join(", ", inputs.Select(m => $"{m.Name} = {Camel(m.Name)}")); // Empty state lives on the record so it composes with `with` (spec §8). recordMembers.AppendLine(); @@ -201,21 +185,8 @@ private sealed class FeedMember setBody.AppendLine($"\t\tif (mock.{m.Name} is not null)"); setBody.AppendLine($"\t\t\t{HotTesting}.MockingService.{swap}<{m.ItemOrValueFullName}>(model, model.{m.Name}, mock.{m.Name});"); } - foreach (var c in commands) - { - setBody.AppendLine($"\t\tif (mock.{c} is not null)"); - setBody.AppendLine($"\t\t\tvm.__Mock_SetCommand(\"{c}\", mock.{c});"); - } var nsHeader = ns is null ? "" : $"namespace {ns};\n\n"; - var createFromInputs = inputs.Count == 0 - ? "" - : $$""" - - public static {{vmFull}} Create({{createParams}}) - => Create(new {{mockName}} { {{createInits}} }); - """; - return $$""" // #nullable enable @@ -224,18 +195,24 @@ private sealed class FeedMember {{recordMembers.ToString().TrimEnd()}} } - public static class {{vmMockName}} + public static partial class {{vmMockName}} { public static {{vmFull}} Create() => Create({{mockName}}.Empty); - {{createFromInputs}} + public static {{vmFull}} Create({{mockName}} mock) { - var vm = new {{vmFull}}(default!); - vm.SetModel(mock); - return vm; + // The activation scope is only needed while the VM/Model context is created: + // the mockable bit is captured on that context instance, so later SetMock calls + // (and lazy first subscriptions) still swap even after the scope is disposed. + using (global::Uno.HotTesting.Reactive.MockingService.Enable()) + { + var vm = new {{vmFull}}(default!); + vm.SetMock(mock); + return vm; + } } - public static void SetModel(this {{vmFull}} vm, {{mockName}} mock) + public static void SetMock(this {{vmFull}} vm, {{mockName}} mock) { var model = vm.Model; {{setBody.ToString().TrimEnd()}} From 29714cd93dd2a3245663f5c3c4fbb247f148cf8b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 2 Sep 2026 18:38:13 +0000 Subject: [PATCH 17/19] refactor(mocking): address the relevant code-quality review comments Applied: combined the nested if in ViewModelGenTool_3.Mocking, projected ctor.DeclaringSyntaxReferences, SimpleNameSyntax and the accesses with Select, and filtered the nested types and the references explicitly in FeedsMockGenerator. Declined, each answered in its thread: the readonly suggestion on Disposable._onDispose is a false positive (the field is a ref argument of Interlocked.Exchange), the == false simplification targets a bool? whose three-state semantics are intentional, and the Where suggestion filters a computed value. --- .../Bindables/ViewModelGenTool_3.Mocking.cs | 35 ++++++++++--------- .../FeedsMockGenerator.cs | 11 +++--- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs index de49ccad59..265990bad8 100644 --- a/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs +++ b/src/Uno.Extensions.Reactive.Generator/Bindables/ViewModelGenTool_3.Mocking.cs @@ -163,9 +163,8 @@ private Dictionary BuildFieldToParamMap(INamedTypeSymbol model, foreach (var ctor in AccessibleInstanceCtors(model)) { - foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + foreach (var node in ctor.DeclaringSyntaxReferences.Select(syntaxRef => syntaxRef.GetSyntax())) { - var node = syntaxRef.GetSyntax(); var body = (SyntaxNode?)(node as ConstructorDeclarationSyntax)?.Body ?? (node as ConstructorDeclarationSyntax)?.ExpressionBody?.Expression; if (body is null) @@ -225,9 +224,11 @@ private Dictionary BuildFieldToParamMap(INamedTypeSymbol model, foreach (var body in GetMemberBodies(member, compilation, out var semanticModelByTree)) { var semanticModel = semanticModelByTree(body.SyntaxTree); - foreach (var id in body.DescendantNodesAndSelf().OfType()) + foreach (var symbol in body + .DescendantNodesAndSelf() + .OfType() + .Select(id => semanticModel.GetSymbolInfo(id).Symbol)) { - var symbol = semanticModel.GetSymbolInfo(id).Symbol; if (symbol is null) { continue; @@ -265,12 +266,10 @@ private Dictionary BuildFieldToParamMap(INamedTypeSymbol model, }; if (backingName is not null && SymbolEqualityComparer.Default.Equals(symbol.ContainingType, model) - && fieldToParam.TryGetValue(backingName, out var paramName)) + && fieldToParam.TryGetValue(backingName, out var paramName) + && seenServices.Add(paramName)) { - if (seenServices.Add(paramName)) - { - services.Add(paramName); - } + services.Add(paramName); } } } @@ -354,9 +353,8 @@ void Mark(string param, string? member) foreach (var ctor in AccessibleInstanceCtors(model)) { - foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + foreach (var node in ctor.DeclaringSyntaxReferences.Select(syntaxRef => syntaxRef.GetSyntax())) { - var node = syntaxRef.GetSyntax(); var body = (SyntaxNode?)(node as ConstructorDeclarationSyntax)?.Body ?? (node as ConstructorDeclarationSyntax)?.ExpressionBody?.Expression; if (body is null) @@ -374,15 +372,18 @@ void Mark(string param, string? member) private void InspectEager(SyntaxNode body, SemanticModel semanticModel, HashSet ctorParamNames, Action mark, string? enclosingMember) { - foreach (var access in body.DescendantNodesAndSelf()) - { - // The receiver of a member-access / element-access is an eager dereference. - ExpressionSyntax? receiver = access switch + // The receiver of a member-access / element-access is an eager dereference. + var receivers = body + .DescendantNodesAndSelf() + .Select(access => access switch { MemberAccessExpressionSyntax mae => mae.Expression, ElementAccessExpressionSyntax eae => eae.Expression, - _ => null, - }; + _ => (ExpressionSyntax?)null, + }); + + foreach (var receiver in receivers) + { if (receiver is not IdentifierNameSyntax id) { continue; diff --git a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs index d311986279..84d7b26859 100644 --- a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs +++ b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs @@ -60,9 +60,9 @@ IEnumerable Walk(INamespaceOrTypeSymbol ns) else if (member is INamedTypeSymbol type) { if (HasFeedDep(type)) yield return type; - foreach (var nested in type.GetTypeMembers()) + foreach (var nested in type.GetTypeMembers().Where(HasFeedDep)) { - if (HasFeedDep(nested)) yield return nested; + yield return nested; } } } @@ -70,12 +70,9 @@ IEnumerable Walk(INamespaceOrTypeSymbol ns) foreach (var t in Walk(compilation.Assembly.GlobalNamespace)) yield return t; - foreach (var reference in compilation.References) + foreach (var asm in compilation.References.Select(compilation.GetAssemblyOrModuleSymbol).OfType()) { - if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm) - { - foreach (var t in Walk(asm.GlobalNamespace)) yield return t; - } + foreach (var t in Walk(asm.GlobalNamespace)) yield return t; } } From 149b9873e9e7e32abb0b3e55d22ab23f3c132986 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 2 Sep 2026 20:27:53 +0000 Subject: [PATCH 18/19] fix(ci): add the missing XML docs and fix markdownlint on the specs The repository enables GenerateDocumentationFile together with TreatWarningsAsErrors, so a public member without an XML comment fails the Release build even though a local Debug build stays green. Added /// on FeedsMockGenerator.Initialize and Execute, the same way FeedsGenerator already does. Also fixed MD032, MD012, MD022 and MD031 on the four spec 013 documents. --- .../013-mvux-mocking-previews/architecture.md | 3 +++ specs/013-mvux-mocking-previews/history.md | 10 +++++----- .../implementation.md | 18 ++++++++++++++++++ specs/013-mvux-mocking-previews/spec.md | 2 ++ .../FeedsMockGenerator.cs | 2 ++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/specs/013-mvux-mocking-previews/architecture.md b/specs/013-mvux-mocking-previews/architecture.md index 2c8aeaaf85..a4c9164982 100644 --- a/specs/013-mvux-mocking-previews/architecture.md +++ b/specs/013-mvux-mocking-previews/architecture.md @@ -51,6 +51,7 @@ Identity risk (R6): lambdas capturing locals/params produce fresh delegate targe ### 2.1 MVUX generator (Model's assembly — analysis + attributes + hidden hooks) **a) Dependency analysis** (Roslyn, source available): + - per feed/command member: walk initializer/getter body; **lambda/anonymous/local-function bodies = deferred boundary**; eager remainder binding to a ctor param (or param-assigned field) → `ServiceDependent(param)`; reference to another feed member → `DerivedFrom(member)`; else `Independent`. - **ctor instrumentation**: walk ctor bodies (incl. field/property initializers, primary-ctor captures used eagerly); any eager service dereference → the ctor is **unsafe under null-inject for that parameter**. @@ -65,6 +66,7 @@ Identity risk (R6): lambdas capturing locals/params produce fresh delegate targe (Names to bikeshed; semantics fixed: *input vs derived vs independent*, plus *ctor-eager* flags.) **c) Hidden hooks** (`EditorBrowsable(Never)`, emitted by default — opt-out via `EnableFeedMocking(IsEnabled = false)`): + - on the **Model partial**: **nothing per-feed** — the swap is reflection over `IHotSwapState` members at runtime (D11), reusing the hot-reload driver, fail-hard. The generator emits no `__Mock_Swap_{Member}`; - on the **VM partial**: **no construction seam** — null-inject uses the existing public ctors (`new {Vm}(default!, …)`) under an ambient `MockingService.Enable()` scope (D12: the `SourceContext` built at construction is mockable, captured on the instance). The only emitted seam is `__Mock_SetCommand(string name, IAsyncCommand)` (public, `EditorBrowsable(Never)`, fail-hard) which reassigns a command property post-construction — commands have no `IHotSwapState` and are unreachable by the reflection swap (R2). @@ -127,6 +129,7 @@ public sealed class AxisValue ### Coercion & evolution semantics `FeedView.OnSourceChanged`: + 1. `ISignal` (any feed/state) → passthrough, unchanged. 2. `IMessageEntry` → the view lazily creates **one entry-driven wrapper feed** (`MessageEntryFeed`, internal) and keeps it for the lifetime of the subscription. 3. anything else → today's behavior (ignored). No heuristic. diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index a85e9dd025..64e45245c2 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -1,6 +1,7 @@ # 013 — Historique des versions et décisions Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-architecture`, détruit avec la branche `dev/devid/spec-013-mvux-mocking` non poussée). Sources du merge : + - fichiers **recovery** locaux (reconstruits depuis les transcripts) — portaient la question ouverte « context scope » et la référence au commit `cd4c9ad` ; - fichiers **VS Code de David** (joints le 23/08 18:22) — `spec.md`/`impl.md` = état v1 (`8d589d9`, non rechargés), `archi.md` = état le plus récent (v4, post-`2618def`) ; - transcript Telegram complet de la discussion. @@ -12,6 +13,7 @@ Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-arch **Contexte.** Objectif posé par David : helpers de mocking pour les previews UI (Hot Design) et le testing d'apps consommant des feeds (simuler les états des feeds, pas tester les feeds). Deux POCs existants : PR **#3148** (Nick, spec 009 — XAML only, enveloppe POCO/JSON coercée dans `FeedView.Source`) et PR **#3147** (Steve, spec 012 — vocabulaire `Mocks` + générateur `{Vm}Mocks`/`CreateMock`). Vision à 3 niveaux de David : (1) statique dans le XAML, (2) structures de mock par-feed d'un VM, (3) helpers « modèle complet ». **Discussion & décisions :** + - Mon premier retour (socle 3147 + markup extension + catalogue) recadré par David : partir de **SON design** — `MessageEntry` pour la couche 1 (pas d'enveloppe magique, JSON→dynamic) et le **SwapFeed du hot-reload** pour la couche 2 (contrôle total, système 100 % malléable ; la couche 3 ne devient que des helpers au-dessus). - Faisabilité vérifiée dans le code : `MessageEntry`/`IMessageEntry` publics ; `MessageEntry.Empty` force l'axe Data → tue le canari « Undefined » (spec 012 §10.2) ; `HotSwapFeed`/`IHotSwapState`/`StateImpl` = seam existant (seul appelant : hot-reload) ; gate `HotReloadSupport.State` ; commandes non swap-backed (gap identifié). - Construction du VM : ni « vrai VM via DI » ni « ctor sans modèle » → **vrai VM + vrai Model**, services **null-injectés**, prouvé sûr par **analyse de dépendances au codegen** (« option 2 » de David). Fondement : les feeds MVUX sont des arrow-getters lazy (service capturé en closure, touché à l'énumération seulement) ; cas bloquant = accès service **eager dans le ctor**. « On ne contrôle pas comment nos users utilisent notre archi » → l'analyse + diagnostics sont obligatoires. @@ -21,6 +23,7 @@ Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-arch ## v1 — commit `8d589d9` (dim. 23/08 10:42) — le pivot « génération extérieure » + checkpoint **Discussion (23/08 matin) :** David réalise en review que le mocking doit être **consommable de l'extérieur** (projet de test qui référence l'app) → on ne peut pas injecter le code dans le VM/Model ; le gen MVUX ajoute des **hooks cachés** (sur le modèle de HR) et le gen de mocking prend le contrôle depuis l'extérieur. Son dump : `RecipeModelMock` record `required init` + `Empty`, `Create()`/`Create(steps)` (null-inject + `SetModel`), `SetModel` ≈ `__Reactive_UpdateModel`. Mes vérifications ont ajouté : + - `__Reactive_UpdateModel` inutilisable tel quel (réassigne `__reactiveModel`, `Unsafe.As` sur type étranger = UB) → **méthode dédiée cachée** (confirmé par David, pt 3). - **Dérivés doivent survivre** (pt « c'est tout le concept ») → découverte de l'ancrage : les feeds sont cachés par `AttachedProperty.GetOrCreate` avec identité stable → **wrap `HotSwapFeed` au niveau du cache Model-feed** ; les dérivations composent sur le wrapper → le swap traverse la logique métier ; `SetModel` = swaps typés, **plus de `dynamic`**. - **Attributs de dépendances** émis par l'analyse ET déclarables à la main (idée `[FeedShape(...)]` de David, renommée `[FeedDependency]`/`[CtorDependency]`) — nécessaires car le gen externe n'a pas les syntax trees. @@ -47,6 +50,7 @@ Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-arch ## v4 — révisions de David dans VS Code (commit `cd4c9ad`, perdu ; contenu = son `archi.md` joint) Réponses de David à ma question « OK avec ce découpage ? » — par édition directe de l'architecture : + - **`MessageEntry` reste un plain CLR object dans Core** — délibérément **PAS** un `DependencyObject` (aucune complexité property-system UI dans le message model). - **L'entry n'est pas observable** : muter `Data`/`Error`/`IsProgress`/`Axes` après assignation ne pousse rien ; **remplacer l'instance** est l'unité de changement. - Le converter JSON **n'est plus un livrable** : illustration **app-owned** attachée à `FeedView.Source`, doit retourner `IMessageEntry` ; la spec ne définit ni n'implémente de converter. @@ -71,7 +75,6 @@ Puis : **perte du workspace ACO** (node détruit, branche non poussée — commi - Reste au spike (P0-e) le **mécanisme seul** (contexte propriétaire, eager/lazy, `AsyncLocal` vs token porté, imbrication, concurrence, survie après `Dispose`, câblage vers le flag D4) — plus la forme de l'API. - Répercuté dans les 3 volets : spec §13 + G9 + R7 + D10, archi §1/§6/§7, impl §1/§2.2/§6/§7/§8/§9. - ## v7 — décision de David (dim. 24/08, soir) — gate per-context + swap réflexif - **Question tranchée (« où vit le flag mockable ? »)** : investigation source demandée par David. @@ -95,12 +98,12 @@ Landée sur `dev/devid/spec-013-mvux-mocking` (poussée staging PR #1), après l **Tests (réellement exécutés) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4 (Create+SetModel → VM réel → feed mické ; live re-swap ; `Create()` Empty→None ; override commande), Tests.Generator 80/80 (byte-identique préservé), Given_HotReload 8/8 (Core inchangé). **Reste (hors périmètre du cœur tier 2/3, à planifier avec David) :** + - `MockFeed.Message`/`Script` (dépendent du vocabulaire de #3147, non mergé). - Diagnostics `FEED3201–3203` / `MOCK0001` (analyse en place, diagnostics non émis). - Docs `doc/Learn/Mvux/Testing.md` + `FeedView.md` (§9), Tier 1 (on hold). - Remontée github : outbox ABO → PR #3165 (après review David). - ## v9 — réconciliation avec #3149 (FeedMock mergé) + review David (mar. 25/08) Rebase sur `main` (PR #3154 / issue #3149 mergée) : **le vocabulaire de feeds mockés existe déjà** dans une assembly dédiée `Uno.HotTesting.Reactive` (`FeedMock`/`ListFeedMock`, namespace + assembly `Uno.HotTesting.Reactive`, spec 009). Mon `Uno.Extensions.Reactive.Mocking` le dupliquait → **supprimé**. Décisions de naming/namespace suite à la review de David sur la staging PR #1 : @@ -113,7 +116,6 @@ Rebase sur `main` (PR #3154 / issue #3149 mergée) : **le vocabulaire de feeds m **Tests après refactor (verts) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, `Uno.HotTesting.Reactive.Tests` 22/22 (FeedMock existant non régressé). - ## v10 — review David (commentaire 31) : AsyncLocal hors de Core Retour de David sur `SourceContext` : *« si on a besoin d'un AsyncLocal pour le mocking, ça n'apporte rien de le mettre dans le SourceContext, on devrait le garder dans le MockingService »*. Juste — l'état d'activation ambient est une préoccupation **mocking**, pas Core. @@ -124,7 +126,6 @@ Retour de David sur `SourceContext` : *« si on a besoin d'un AsyncLocal pour le Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. - ## v11 — doc + sample + polish naming factory (mar. 25/08) - **Doc** : `doc/Reference/Reactive/testing.md` (celle de #3149 sur `FeedMock` hand-written) étendue avec la couche générée tier 2/3 : scope `MockingService.Enable()`, `record {Model}Mock` (inputs required, derived + commandes optionnels), `{Vm}Mock.Create(...)`, `vm.SetModel(...)`, `CommandMock`, derived-survives, one-liners + catalogs nommés (tier 3), opt-out `[assembly: EnableFeedMocking(IsEnabled = false)]`. La phrase « no generator » de #3149 est mise à jour. @@ -133,7 +134,6 @@ Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 5/5, Uno.HotTesting.Reactive.Tests 22/22, Tests.Generator 80/80. - ## v12 — review David (post-discussion staging PR #1, ven. 28/08) Six retours de David sur la PR, tous appliqués : diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index 07b76b39b7..e609267d96 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -19,6 +19,7 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi ## 2. Core (`Uno.Extensions.Reactive`) ### 2.1 Dependency attributes (emitted by MVUX gen AND hand-declarable; explicit wins/merges) + ```csharp namespace Uno.Extensions.Reactive.Config; @@ -39,14 +40,17 @@ public sealed class CtorDependencyAttribute : Attribute public bool Eager { get; init; } // true → NRE under null-inject; Create must require it } ``` + (David's `[FeedShape("Steps", ModelParameter=…)]` idea, renamed. Multiple per member allowed.) ### 2.2 Mockable gate + swap anchor + - **`SourceContext.IsMockingActive`** (per-context bit, D12 — distinct from `HotReload`, no global static, no bespoke `AsyncLocal`) — **set by the activation scope (§6), off by default**; context not mockable → no wrap, so a live app pays nothing (spec G9/R7). Read at wrap time in `StateImpl` ctor **instead of** `FeedConfiguration.EffectiveHotReload`. - When the owning context is mockable: feed factories wrap the cached instance in `HotSwapFeed` (the wrapper IS the cached value → stable identity; derivations compose on the wrapper). Minimal wiring: wrap inside `AttachedProperty.GetOrCreate` call sites in `Core/Feed.cs` / `Core/ListFeed.cs` factories (one helper reading the context bit). - **Swap = reflection over the context's `IHotSwapState` members** (D11), reusing the hot-reload driver (`BindableViewModelBase.HotReload`), **fail-hard**: a mocked member that cannot be swapped throws (no silent skip — the hot-reload delta). ### 2.3 Tier-1 core surfaces + - `Feed.Value` public factory (from #3148, additive). - Authorable non-generic `MessageEntry : IMessageEntry` — **plain CLR object, not a `DependencyObject`, not observable**; settable `Data` / `IsUndefined` / `Error` / `IsProgress`; `Axes` (`AxisValueCollection` of `AxisValue { string Axis; object? Value }`) + `Set(MessageAxis, object?)` code path. - Axis-identifier resolution against core + registered app axes; **unknown identifier → diagnostic**, never a silent drop. @@ -66,6 +70,7 @@ On by default (the runtime decides activation). Opt-out: `[assembly: EnableFeedM ## 4. Mocking package (`Uno.HotTesting.Reactive`) ### 4.1 Runtime vocabulary (all generic and strongly typed) + ```csharp public static class FeedMock { @@ -92,10 +97,13 @@ public static class CommandMock } public enum FeedMockState { Undefined, Loading, Empty, Value, Error, Refreshing } ``` + Built over public `Feed.Create` + `MessageBuilder` (vocabulary from #3147). **These APIs never accept the non-generic tier-1 `MessageEntry` or untyped envelopes.** Never referenced by a published app head (non-AOT, dev/test only — NG2/D7). ### 4.2 Generator (runs in the consumer/test project, metadata-driven) + For each Model/VM pair found in referenced assemblies with `__Mock_*` hooks + attributes: + ```csharp public record RecipeModelMock { @@ -112,7 +120,9 @@ public static partial class RecipeViewModelMock // partial → user extends wi public static void SetMock(this RecipeViewModel vm, RecipeModelMock mock); // typed swaps (fail-hard) } ``` + Rules: + - Required properties = the **ServiceDependent** input set; `Create` takes only the record (no denormalized per-input overloads). - **Derived members: optional overrides** — `null` (default) → real derivation recomputes over swapped inputs; set → that member's wrapper is swapped too. Independent members: untouched. - **Command mocking is deferred to vNext**: the record carries no command member and `SetMock` wires none (the MVUX `__Mock_SetCommand` seam stays available for that future work). @@ -121,6 +131,7 @@ Rules: - Diagnostic `MOCK0001` when a VM is reachable but its assembly lacks hooks (opt-in missing). ## 5. UI (`Uno.Extensions.Reactive.UI`) — tier 1 + - `FeedView.OnSourceChanged`: typed branch `IMessageEntry` → lazily create ONE `MessageEntryFeed` wrapper kept across `Source` changes; a subsequent `IMessageEntry` instance is **pushed** into the wrapper (subscription preserved, no state reset — natural-evolution contract, architecture §3). No heuristic. - **Mutations of an already-assigned entry are not observed** (plain CLR, not observable); a new instance is the unit of change. - XAML element syntax (``, …) — examples in architecture §3. @@ -149,6 +160,7 @@ var vm = RecipeViewModelMock.Create(new RecipeModelMock { Steps = ListFeedMock.V **Non-negotiable constraint:** context not mockable → **no `HotSwapFeed` wrap at all**. The wrap is one indirection per feed; it may never be injected into the feeds of a live app (spec G9/R7). `SourceContext.IsMockingActive` (§2.2, D12) is the internal per-context gate the scope drives, not a switch app authors set. Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): + - **Owner context = `SourceContext`** — already owns `States`/subscriptions, already ambient via `AsyncLocal Current`, already per-owner via `GetOrCreate(owner)`, with an eager pre-seed seam `PreConfigure(type, ctx)` / `Set(owner, ctx)`. It gains `bool IsMockingActive`. - **Eager vs lazy = solved by pre-seed**: `Create(...)` pre-seeds a mockable context on the VM/Model owner (`PreConfigure`/`Set`), so a lazy first subscription after the `using` block still wraps — the bit is on the context instance, not only on the ambient `AsyncLocal`. - **Ambient propagation**: the existing `AsyncLocal Current` carries mockability across async construction; no bespoke `AsyncLocal`. @@ -171,33 +183,39 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): ## 8. Test plan ### Core + - Every typed `FeedMock`/`ListFeedMock`/`CommandMock` state emits expected axes. - Authorable entry maps to Data/Error/Progress/Undefined correctly; custom axes map and diff correctly. - Consecutive entry instances produce correct core + custom axis diffs. - Wrap identity (`AttachedProperty` returns the same wrapper); swap propagation through `Select`/`Where` and chained derived feeds; live re-swap. ### Generators + - Classification fixtures (lazy/eager/derived/independent; ctor bodies, field/property initializers, primary-ctor captures). - Attribute emission; explicit-attribute override/merge; FEED3201–3203. - Byte-identical output when opted out; hooks hidden (`EditorBrowsable`) and typed (concrete generics). - Consumer generation against a compiled fixture assembly; required-input set = ServiceDependent set; eager-ctor → required service parameter; MOCK0001; **no tier-1/untyped surface in tier-2/3 output**. ### Runtime / UI (Skia) + - Each pinned state renders; Loading keeps `IsExecuting`. - Successive `Source` entries evolve without re-subscribe (no loading flash); **mutating an assigned entry does not emit** — assigning a replacement does. - `SetMock` drives Loading → Value → Error live; derived member updates on-screen after an input swap (D6 end-to-end). - Command states drive `Button.IsEnabled`; hot reload does not clobber a mocked VM/context. ### Scoped activation (with §6 spike) + - **Context not mockable → feeds are the raw instances** (no `HotSwapFeed` in the cache, no measurable overhead) — the G9 guard test. - **Fail-hard swap**: a mocked member with no `IHotSwapState` throws (D11), asserted. - Assembly-init scope covers every test of the run; a per-test scope covers only its own. - Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. ### Contract freeze + - Reflection-discovery test for `{Model}Mock`/`Empty`/`Create`/`SetMock`/attribute names (Hot Design contract). ## 9. Docs + - `doc/Learn/Mvux/Testing.md`: `MockingService.Enable()` scope (assembly-init vs per-test, and why it is never app-wide), typed vocabulary, `Create`/`SetMock`, derived-feeds-survive concept, eager-ctor guidance, non-AOT constraint, R4/R5 caveats. - `doc/Learn/Mvux/FeedView.md`: tier-1 entry authoring + custom axes; converter shown only as an application-owned illustration at `FeedView.Source` (not a deliverable). - `rules.md`: FEED3201–3203, MOCK0001. diff --git a/specs/013-mvux-mocking-previews/spec.md b/specs/013-mvux-mocking-previews/spec.md index 84fe1b0a77..3c8203d3db 100644 --- a/specs/013-mvux-mocking-previews/spec.md +++ b/specs/013-mvux-mocking-previews/spec.md @@ -204,6 +204,7 @@ No new mechanism: each overload is `Create()` + a `SetMock` of §7, so a preview ## 9. Goals / Non-goals **Goals** + - G1. Pin any service-dependent feed / list-feed / state / command of a real generated VM. - G2. **Derived feeds recompute over mocked inputs** (business logic survives); derived members remain individually overridable for tests. - G3. Mock generation happens **in the consumer project** (test/preview), against app metadata. @@ -215,6 +216,7 @@ No new mechanism: each overload is `Create()` + a `SetMock` of §7, so a preview - G9. **Zero cost on a live app**: the `HotSwapFeed` wrap is created only for feeds built inside an explicit activation scope (§13). No wrapper is ever injected into the feeds of a running application. **Non-goals** + - NG1. Behavioral/integration testing of services (this targets presentation state). - NG2. **AOT/trim compliance of the mocking path.** Mocking is dynamic injection, dev/test-time only (JIT). Accepted and documented; never ships in a published app. - NG3. Making arbitrary JSON graphs bindable on every platform (WinAppSDK dynamic-binding caveat). diff --git a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs index 84d7b26859..72fcb86ea4 100644 --- a/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs +++ b/src/Uno.HotTesting.Reactive.Generator/FeedsMockGenerator.cs @@ -23,8 +23,10 @@ public sealed class FeedsMockGenerator : ISourceGenerator private const string ModelAttribute = "Uno.Extensions.Reactive.Bindings.ModelAttribute"; private const string HotTesting = "global::Uno.HotTesting.Reactive"; + /// public void Initialize(GeneratorInitializationContext context) { } + /// public void Execute(GeneratorExecutionContext context) { var compilation = context.Compilation; From 4a3e1b39a08c3050849176c6185d30a4ffaa36ea Mon Sep 17 00:00:00 2001 From: David Date: Wed, 2 Sep 2026 20:44:42 +0000 Subject: [PATCH 19/19] docs(mocking): translate the history document to English Everything pushed to GitHub must be in English. The spec 013 history document was written in French during the design discussions, which failed the CI spell-checking validation (333 unknown words). spec.md, architecture.md and the reference doc were already clean. - history.md fully translated to English, keeping every SHA, type name, file path and decision id unchanged. - implementation.md: replaced three coined words the dictionary rejects (bikesheddable, Authorable, mockability) with plain English equivalents. Verified with the exact CI commands: cspell over 236 markdown files reports 0 issue, and markdownlint reports no violation. --- specs/013-mvux-mocking-previews/history.md | 230 +++++++++--------- .../implementation.md | 14 +- 2 files changed, 122 insertions(+), 122 deletions(-) diff --git a/specs/013-mvux-mocking-previews/history.md b/specs/013-mvux-mocking-previews/history.md index 64e45245c2..64d5c97f06 100644 --- a/specs/013-mvux-mocking-previews/history.md +++ b/specs/013-mvux-mocking-previews/history.md @@ -1,167 +1,167 @@ -# 013 — Historique des versions et décisions +# 013 — Version and decision history -Reconstruction après la perte du workspace ACO (`devid-feat-uno-extensions-architecture`, détruit avec la branche `dev/devid/spec-013-mvux-mocking` non poussée). Sources du merge : +Rebuilt after the loss of the ACO workspace (`devid-feat-uno-extensions-architecture`, destroyed together with the unpushed `dev/devid/spec-013-mvux-mocking` branch). Merge sources: -- fichiers **recovery** locaux (reconstruits depuis les transcripts) — portaient la question ouverte « context scope » et la référence au commit `cd4c9ad` ; -- fichiers **VS Code de David** (joints le 23/08 18:22) — `spec.md`/`impl.md` = état v1 (`8d589d9`, non rechargés), `archi.md` = état le plus récent (v4, post-`2618def`) ; -- transcript Telegram complet de la discussion. +- local **recovery** files (rebuilt from the transcripts) — they carried the open "context scope" question and the reference to commit `cd4c9ad`; +- **David's VS Code files** (attached 08-23 18:22) — `spec.md`/`impl.md` were the v1 state (`8d589d9`, never reloaded), `archi.md` was the most recent state (v4, post-`2618def`); +- the full Telegram transcript of the discussion. --- -## Phase 0 — Cadrage (sam. 22/08, soirée) +## Phase 0 — Framing (Sat 08-22, evening) -**Contexte.** Objectif posé par David : helpers de mocking pour les previews UI (Hot Design) et le testing d'apps consommant des feeds (simuler les états des feeds, pas tester les feeds). Deux POCs existants : PR **#3148** (Nick, spec 009 — XAML only, enveloppe POCO/JSON coercée dans `FeedView.Source`) et PR **#3147** (Steve, spec 012 — vocabulaire `Mocks` + générateur `{Vm}Mocks`/`CreateMock`). Vision à 3 niveaux de David : (1) statique dans le XAML, (2) structures de mock par-feed d'un VM, (3) helpers « modèle complet ». +**Context.** Goal set by David: mocking helpers for UI previews (Hot Design) and for testing apps that consume feeds (simulate feed states, not test the feeds themselves). Two existing POCs: PR **#3148** (Nick, spec 009 — XAML only, POCO/JSON envelope coerced at `FeedView.Source`) and PR **#3147** (Steve, spec 012 — `Mocks` vocabulary plus a `{Vm}Mocks`/`CreateMock` generator). David's three-level vision: (1) static in XAML, (2) per-feed mock structures on a view-model, (3) "complete model" helpers. -**Discussion & décisions :** +**Discussion and decisions:** -- Mon premier retour (socle 3147 + markup extension + catalogue) recadré par David : partir de **SON design** — `MessageEntry` pour la couche 1 (pas d'enveloppe magique, JSON→dynamic) et le **SwapFeed du hot-reload** pour la couche 2 (contrôle total, système 100 % malléable ; la couche 3 ne devient que des helpers au-dessus). -- Faisabilité vérifiée dans le code : `MessageEntry`/`IMessageEntry` publics ; `MessageEntry.Empty` force l'axe Data → tue le canari « Undefined » (spec 012 §10.2) ; `HotSwapFeed`/`IHotSwapState`/`StateImpl` = seam existant (seul appelant : hot-reload) ; gate `HotReloadSupport.State` ; commandes non swap-backed (gap identifié). -- Construction du VM : ni « vrai VM via DI » ni « ctor sans modèle » → **vrai VM + vrai Model**, services **null-injectés**, prouvé sûr par **analyse de dépendances au codegen** (« option 2 » de David). Fondement : les feeds MVUX sont des arrow-getters lazy (service capturé en closure, touché à l'énumération seulement) ; cas bloquant = accès service **eager dans le ctor**. « On ne contrôle pas comment nos users utilisent notre archi » → l'analyse + diagnostics sont obligatoires. -- Cross-assembly : le gen ne voit que les métadonnées d'un Model d'une autre assembly → accepté à ce stade : *mocking == assembly du Model* (contrats `MyModel.Empty`/`Create()` dispo seulement là). -- **Draft 1** écrit dans le repo (jamais committé, écrasé par le pivot v1) : tier-1 = DTO `FeedMock` + markup `{mvux:Mock}` ; tier-2 = `{Vm}Mocks`/`CreateMock` générés dans l'assembly du Model ; D1–D4 ouvertes. +- My first proposal (build on 3147 plus a markup extension and a catalog) was reframed by David: start from **his** design — `MessageEntry` for layer 1 (no magic envelope, no JSON to dynamic) and the **hot-reload SwapFeed** for layer 2 (full control, a fully malleable system; layer 3 then becomes helpers on top). +- Feasibility verified in the code: `MessageEntry`/`IMessageEntry` are public; `MessageEntry.Empty` forces the Data axis, which kills the "Undefined" canary (spec 012 §10.2); `HotSwapFeed`/`IHotSwapState`/`StateImpl` are an existing seam (only caller: hot reload); gate `HotReloadSupport.State`; commands are not swap-backed (gap identified). +- View-model construction: neither "real view-model through DI" nor "constructor without a model" — instead a **real view-model plus a real model**, services **null-injected**, proven safe by **dependency analysis at code generation time** (David's "option 2"). Rationale: MVUX feeds are lazy arrow getters (the service is captured in a closure and only touched at enumeration); the blocking case is an **eager service access in the constructor**. "We do not control how our users use our architecture", so the analysis and the diagnostics are mandatory. +- Cross-assembly: the generator only sees the metadata of a model living in another assembly, accepted at that stage as *mocking equals the model assembly* (the `MyModel.Empty`/`Create()` contracts are only available there). +- **Draft 1** written in the repository (never committed, overwritten by the v1 pivot): tier 1 was a `FeedMock` DTO plus a `{mvux:Mock}` markup extension; tier 2 was `{Vm}Mocks`/`CreateMock` generated in the model assembly; D1 to D4 were still open. -## v1 — commit `8d589d9` (dim. 23/08 10:42) — le pivot « génération extérieure » + checkpoint +## v1 — commit `8d589d9` (Sun 08-23 10:42) — the "external generation" pivot and checkpoint -**Discussion (23/08 matin) :** David réalise en review que le mocking doit être **consommable de l'extérieur** (projet de test qui référence l'app) → on ne peut pas injecter le code dans le VM/Model ; le gen MVUX ajoute des **hooks cachés** (sur le modèle de HR) et le gen de mocking prend le contrôle depuis l'extérieur. Son dump : `RecipeModelMock` record `required init` + `Empty`, `Create()`/`Create(steps)` (null-inject + `SetModel`), `SetModel` ≈ `__Reactive_UpdateModel`. Mes vérifications ont ajouté : +**Discussion (08-23 morning):** while reviewing, David realises that mocking has to be **consumable from the outside** (a test project referencing the app), so we cannot inject the code into the view-model or the model; the MVUX generator adds **hidden hooks** (following the hot-reload model) and the mocking generator takes control from the outside. His dump: a `RecipeModelMock` record with `required init` plus `Empty`, `Create()`/`Create(steps)` (null-inject plus `SetModel`), and `SetModel` close to `__Reactive_UpdateModel`. My verifications added: -- `__Reactive_UpdateModel` inutilisable tel quel (réassigne `__reactiveModel`, `Unsafe.As` sur type étranger = UB) → **méthode dédiée cachée** (confirmé par David, pt 3). -- **Dérivés doivent survivre** (pt « c'est tout le concept ») → découverte de l'ancrage : les feeds sont cachés par `AttachedProperty.GetOrCreate` avec identité stable → **wrap `HotSwapFeed` au niveau du cache Model-feed** ; les dérivations composent sur le wrapper → le swap traverse la logique métier ; `SetModel` = swaps typés, **plus de `dynamic`**. -- **Attributs de dépendances** émis par l'analyse ET déclarables à la main (idée `[FeedShape(...)]` de David, renommée `[FeedDependency]`/`[CtorDependency]`) — nécessaires car le gen externe n'a pas les syntax trees. -- **Instrumentation des ctors** (idée David) : accès service direct dans le ctor → `Create` exige le service en paramètre. -- Non-AOT accepté (pt 4) : mocking = injection dynamique, dev/test only. -- D1–D7 loggées ; D3 (façade) et D4 (flag dédié) tranchées par David. +- `__Reactive_UpdateModel` cannot be reused as is (it reassigns `__reactiveModel`, and `Unsafe.As` on a foreign type is undefined behaviour), so a **dedicated hidden method** is needed (confirmed by David, point 3). +- **Derived feeds must survive** ("that is the whole concept"), which led to the anchor discovery: feeds are cached by `AttachedProperty.GetOrCreate` with a stable identity, so we **wrap in `HotSwapFeed` at the model-feed cache level**; derivations compose on the wrapper, so the swap travels through the business logic and `SetModel` becomes a set of typed swaps with **no `dynamic`**. +- **Dependency attributes** emitted by the analysis and also hand-declarable (David's `[FeedShape(...)]` idea, renamed to `[FeedDependency]`/`[CtorDependency]`) — required because the external generator has no syntax trees. +- **Constructor instrumentation** (David's idea): a direct service access in the constructor makes `Create` require that service as a parameter. +- Non-AOT accepted (point 4): mocking is dynamic injection, for development and test only. +- D1 to D7 logged; D3 (facade) and D4 (dedicated flag) decided by David. -**Checkpoint demandé par David** : branche `dev/devid/spec-013-mvux-mocking` depuis `main@32faf32`, commit `8d589d9` (3 volets, 293 lignes). +**Checkpoint requested by David**: branch `dev/devid/spec-013-mvux-mocking` from `main@32faf32`, commit `8d589d9` (3 documents, 293 lines). -## v2 — commit `292fb5f` (10:47) — review David +## v2 — commit `292fb5f` (10:47) — David review -- **Dérivés overridables** : `{Model}Mock.StepsCount` nullable comme `Save` — non défini → vraie dérivation sur les inputs swappés ; défini → remplacé (utile pour les tests). -- **Tier 1 sans `FeedMock`** : « je ne vois pas l'intérêt d'un FeedMock à cet endroit » → `FeedView.Source` prend un **`MessageEntry` authorable** non-générique (le concept du framework lui-même). -- **Exemples XAML exigés** et ajoutés (loading pinné, POCO inline, error/empty/undefined en resources, state picker). -- **Contrat d'évolution naturelle** : changer l'instance dans `Source` **pousse** l'entry dans le wrapper existant (diff d'axes vs entry précédente) — interdiction de repasser par un loading state ; recréer le wrapper toléré seulement sans reset visible. -- Purge des références « v1/v2 » dans les documents (« on est en train de l'écrire cette spec, y'a pas de version qui existe »). +- **Derived members are overridable**: `{Model}Mock.StepsCount` is nullable like `Save` — left unset, the real derivation runs over the swapped inputs; set, it is replaced (useful for tests). +- **Tier 1 without `FeedMock`**: "I do not see the point of a FeedMock at that place", so `FeedView.Source` takes an **author-declared non-generic `MessageEntry`** (the framework concept itself). +- **XAML examples required** and added (pinned loading, inline POCO, error/empty/undefined as resources, state picker). +- **Natural evolution contract**: replacing the instance in `Source` **pushes** the entry into the existing wrapper (axis diff against the previous entry); transiting through a loading state is forbidden, and recreating the wrapper is tolerated only when no visible reset occurs. +- Removed the "v1/v2" references from the documents ("we are writing this spec right now, there is no existing version"). -## v3 — commit `2618def` (11:08) — review David +## v3 — commit `2618def` (11:08) — David review -- **Axes custom first-class** : une force de MVUX = l'extensibilité par axes → collection `Axes` (`AxisValue`) + `Set(MessageAxis, value)` en code ; identifier XAML résolu contre axes core + enregistrés, inconnu → diagnostic ; les axes custom participent au diff du wrapper. -- **Exemple JsonConverter** demandé (JSON → target object, type via `ConverterParameter` car la DP est `object`) et ajouté. -- Mes deux déductions de l'époque — `MessageEntry` en `DependencyObject` dans `.UI` (pour binder dans `Data`) et push sur mutation de DP — **seront annulées en v4**. +- **Custom axes are first-class**: one strength of MVUX is axis extensibility, hence an `Axes` collection (`AxisValue`) plus `Set(MessageAxis, value)` in code; the XAML identifier is resolved against core and registered axes, and an unknown identifier raises a diagnostic; custom axes take part in the wrapper diff. +- **A JsonConverter example** was requested (JSON to target object, type passed through `ConverterParameter` because the dependency property is `object`) and added. +- My two deductions at the time — `MessageEntry` as a `DependencyObject` in `.UI` (to bind inside `Data`) and a push on dependency-property mutation — **were reverted in v4**. -## v4 — révisions de David dans VS Code (commit `cd4c9ad`, perdu ; contenu = son `archi.md` joint) +## v4 — David revisions in VS Code (commit `cd4c9ad`, lost; content is his attached `archi.md`) -Réponses de David à ma question « OK avec ce découpage ? » — par édition directe de l'architecture : +David answered my question "are you fine with this split?" by editing the architecture directly: -- **`MessageEntry` reste un plain CLR object dans Core** — délibérément **PAS** un `DependencyObject` (aucune complexité property-system UI dans le message model). -- **L'entry n'est pas observable** : muter `Data`/`Error`/`IsProgress`/`Axes` après assignation ne pousse rien ; **remplacer l'instance** est l'unité de changement. -- Le converter JSON **n'est plus un livrable** : illustration **app-owned** attachée à `FeedView.Source`, doit retourner `IMessageEntry` ; la spec ne définit ni n'implémente de converter. -- Contrainte explicite : **tiers 2/3 strictement typés de bout en bout** — jamais de `MessageEntry`/enveloppe untyped dans leurs contrats ; le gen émet des types « external, generic and strongly typed ». -- `AxisValue.Axis` typé `string` (identifier XAML) ; l'instance `MessageAxis` typée passe par `Set(...)` en code. -- (Ses `spec.md`/`impl.md` joints = v1 `8d589d9`, non rechargés dans VS Code ; seule l'archi portait la v4 → les volets spec/impl restaurés remontent ces décisions.) +- **`MessageEntry` stays a plain CLR object in Core** — deliberately **not** a `DependencyObject` (no UI property-system complexity in the message model). +- **The entry is not observable**: mutating `Data`/`Error`/`IsProgress`/`Axes` after assignment pushes nothing; **replacing the instance** is the unit of change. +- The JSON converter **is no longer a deliverable**: it is an **application-owned** illustration attached at `FeedView.Source` that must return `IMessageEntry`; the spec neither defines nor implements a converter. +- Explicit constraint: **tiers 2 and 3 are strongly typed end to end** — never a `MessageEntry` or an untyped envelope in their contracts; the generator emits "external, generic and strongly typed" types. +- `AxisValue.Axis` is typed `string` (the XAML identifier); the typed `MessageAxis` instance goes through `Set(...)` in code. +- (His attached `spec.md`/`impl.md` were the v1 `8d589d9` state, never reloaded in VS Code; only the architecture carried v4, so the restored spec and implementation documents carry these decisions back.) -## v5 — dernier échange avant la perte (non committé) — question OUVERTE +## v5 — last exchange before the loss (never committed) — OPEN question -- David : le scope **VM** de tier-2 est peut-être accidentel ; la vraie frontière serait le **contexte** qui possède states/subscriptions (**`SourceContext`**, à vérifier en source). -- Syntaxe visée : `using (MockingService.Enable()) { var model = new MyModel(...); }` -- Sémantique à trancher par **spike** (P0-e) : ambient `AsyncLocal` vs global, scopes imbriqués, concurrence, contexte eager vs lazy, survie des contextes après `Dispose`, interaction avec le flag mockable (D4). -- **Aucune réponse acceptée tant que non vérifiée en source et reviewée par David.** +- David: the **view-model** scope of tier 2 may be accidental; the real boundary would be the **context** that owns states and subscriptions (**`SourceContext`**, to be verified in the source). +- Target syntax: `using (MockingService.Enable()) { var model = new MyModel(...); }` +- Semantics to be settled by a **spike** (P0-e): ambient `AsyncLocal` versus global, nested scopes, concurrency, eager versus lazy context, survival of contexts after `Dispose`, interaction with the mockable flag (D4). +- **No answer accepted until verified in the source and reviewed by David.** -Puis : **perte du workspace ACO** (node détruit, branche non poussée — commits `8d589d9`, `292fb5f`, `2618def`, `cd4c9ad` perdus). Reconstruction → restauration dans ce dossier (spawn `ext-mvux-mock`, 23/08 soir). +Then came the **loss of the ACO workspace** (node destroyed, branch never pushed — commits `8d589d9`, `292fb5f`, `2618def`, `cd4c9ad` lost). Rebuild and restore into this folder (spawn `ext-mvux-mock`, 08-23 evening). -## v6 — décision de David (dim. 24/08) — activation scopée TRANCHÉE +## v6 — David decision (Sun 08-24) — scoped activation SETTLED -- **Le `using (MockingService.Enable())` est certain**, ce n'est plus une question ouverte : c'est **lui** qui active le mocking. -- Granularité au choix de l'appelant : une assembly de tests qui veut le mocking « at large » ouvre le scope dans son **assembly init** ; sinon un scope par test. -- **Motif : le `HotSwapFeed` a un coût.** Activation à la demande uniquement — *« on ne veut pas injecter ce feed dans TOUS les feeds d'une app live »*. Hors scope → aucun wrap, le feed brut est caché comme aujourd'hui. -- Reste au spike (P0-e) le **mécanisme seul** (contexte propriétaire, eager/lazy, `AsyncLocal` vs token porté, imbrication, concurrence, survie après `Dispose`, câblage vers le flag D4) — plus la forme de l'API. -- Répercuté dans les 3 volets : spec §13 + G9 + R7 + D10, archi §1/§6/§7, impl §1/§2.2/§6/§7/§8/§9. +- **`using (MockingService.Enable())` is certain**, it is no longer an open question: it is what turns mocking on. +- Granularity is the caller's choice: a test assembly that wants mocking at large opens the scope in its **assembly init**; otherwise one scope per test. +- **Reason: `HotSwapFeed` has a cost.** Activation happens on demand only — *"we do not want to inject that feed into ALL the feeds of a live app"*. Outside a scope there is no wrap and the raw feed is cached exactly as today. +- The spike (P0-e) only had to settle the **mechanism** (owning context, eager versus lazy, `AsyncLocal` versus a carried token, nesting, concurrency, survival after `Dispose`, wiring to the D4 flag), not the API shape. +- Propagated to the three documents: spec §13 plus G9, R7 and D10; architecture §1/§6/§7; implementation §1/§2.2/§6/§7/§8/§9. -## v7 — décision de David (dim. 24/08, soir) — gate per-context + swap réflexif +## v7 — David decision (Sun 08-24, evening) — per-context gate and reflection swap -- **Question tranchée (« où vit le flag mockable ? »)** : investigation source demandée par David. - - Constat code : le hot reload wrappe dans `StateImpl.cs:74-77` (`EffectiveHotReload.HasFlag(State)` → `new HotSwapFeed`), gate = **static global** `FeedConfiguration.EffectiveHotReload` ; driver de swap **déjà réflexif** sur `IHotSwapState` (`BindableViewModelBase.HotReload.cs:457-467`). `SourceContext` porte déjà `AsyncLocal Current`, contextes par-owner (`GetOrCreate`), seam eager `PreConfigure`/`Set`, et un `IStateStore States` par contexte. - - **Décision David** : *« flag sur le SourceContext (`IsMockingActive`) + réflexion pour le swap avec fail-hard »*. Le static `FeedConfiguration.Mockable` (D4) est abandonné : c'est le **contexte** qui a besoin de l'info (D12). Pas d'`AsyncLocal` maison. Swap réflexif strict (D11). - - Point AOT (David) : un split 2-assemblies impose la réflexion de toute façon (générer `{Model}Mock` à côté du `Model` rendrait l'assembly mock creuse) → réflexion-core assumée, path dev/test-only non-AOT (D7/NG2). -- Le « spike P0-e » (mécanisme du scope) est **résolu**, plus un spike : il ride `SourceContext`. -- Répercuté : spec §13/§10/§5/§4 + D4(superseded)/D11/D12, archi §0/§1/§2.1/§5/§6/§7, impl §1/§2.2/§3/§6/§7/§8. +- **Question settled ("where does the mockable flag live?")**: source investigation requested by David. + - Code findings: hot reload wraps in `StateImpl.cs:74-77` (`EffectiveHotReload.HasFlag(State)` then `new HotSwapFeed`), so the gate is a **global static**, `FeedConfiguration.EffectiveHotReload`; the swap driver is **already reflection-based** over `IHotSwapState` (`BindableViewModelBase.HotReload.cs:457-467`). `SourceContext` already carries `AsyncLocal Current`, per-owner contexts (`GetOrCreate`), an eager `PreConfigure`/`Set` seam, and one `IStateStore States` per context. + - **David decision**: *"flag on the SourceContext (`IsMockingActive`) plus reflection for the swap, fail-hard"*. The `FeedConfiguration.Mockable` static (D4) is dropped: the **context** is what needs the information (D12). No home-grown `AsyncLocal`. Strict reflection swap (D11). + - AOT point (David): a two-assembly split forces reflection anyway (generating `{Model}Mock` next to the `Model` would leave the mock assembly hollow), so core reflection is accepted and the mocking path stays development and test only, non-AOT (D7/NG2). +- The "P0-e spike" (scope mechanism) is **resolved** and is no longer a spike: it rides on `SourceContext`. +- Propagated: spec §13/§10/§5/§4 plus D4 (superseded), D11 and D12; architecture §0/§1/§2.1/§5/§6/§7; implementation §1/§2.2/§3/§6/§7/§8. -## v8 — implémentation tier 2/3 (mar. 25/08) +## v8 — tier 2 and 3 implementation (Tue 08-25) -Landée sur `dev/devid/spec-013-mvux-mocking` (poussée staging PR #1), après la spec (`b029713cb`) : +Landed on `dev/devid/spec-013-mvux-mocking` (pushed to staging PR #1), after the spec (`b029713cb`): -- **Substrat core** (`62af49aa0`) : `SourceContext.IsMockingActive` (bit per-contexte hérité, pas de static séparé, pas d'AsyncLocal maison), `EnableMocking()` scope ambient, gate wrap dans `StateImpl` ctor (`|| context.IsMockingActive`). Swap réflexif via `IHotSwapState`. -- **Fix fail-hard (D11)** (`4b68b7e93`) : `StateImpl` implémente TOUJOURS `IHotSwapState` → ajout `IHotSwapState.CanHotSwap` (`=> _hotSwap is not null`) ; `MockModel` throw sur `!CanHotSwap` (un test manquant, jamais exécuté au départ, cachait ce bug — corrigé). -- **Passe générateur MVUX** (`4d02e6553`) : classification `FeedDependency` (service-dependent `OnParameter` / derived `OnFeed` / independent nu) + instrumentation ctor `CtorDependency(Eager=true)` ; opt-in `[assembly: EnableFeedMocking]`, byte-identique si absent. Seam VM `__Mock_SetCommand` (commandes, R2 ; pas de `__Mock_Create` — ctors publics + scope ambient, D12). -- **2e assembly `Uno.Extensions.Reactive.Mocking`** (`4b68b7e93`, `b6caeee03`, `6384a4d48`) : `MockingService.Enable()`, `MockModel.SwapFeed/SwapListFeed` (fail-hard), vocab `MockFeed`/`MockListFeed` (Value/Empty/EmptyList/Undefined/Loading/Error/Refreshing), `MockCommand` (Idle/Disabled/Executing/Callback). -- **Générateur consumer `Uno.Extensions.Reactive.Mocking.Generator`** (`4b68b7e93`, `6384a4d48`) : lit les métadonnées (assemblies référencées + compilation courante) → émet `record {Model}Mock` (inputs required, derived + commandes optionnels), `Empty`, `Create()`/`Create(inputs)`/`Create(mock)` (null-inject), `SetModel` (swaps typés + `__Mock_SetCommand`). -- **App-fixture `Uno.Extensions.Reactive.Tests.MockingApp`** : modèle réel + opt-in, pour tester le vrai flux 2-assemblies (les générateurs ne se chaînent pas en une compilation). +- **Core substrate** (`62af49aa0`): `SourceContext.IsMockingActive` (inherited per-context bit, no separate static, no home-grown `AsyncLocal`), `EnableMocking()` ambient scope, wrap gate in the `StateImpl` constructor (`|| context.IsMockingActive`). Reflection swap through `IHotSwapState`. +- **Fail-hard fix (D11)** (`4b68b7e93`): `StateImpl` ALWAYS implements `IHotSwapState`, hence the addition of `IHotSwapState.CanHotSwap` (`=> _hotSwap is not null`); `MockModel` throws on `!CanHotSwap` (a missing test, never executed at first, was hiding that bug — fixed). +- **MVUX generator pass** (`4d02e6553`): `FeedDependency` classification (service-dependent `OnParameter`, derived `OnFeed`, plain independent) plus constructor instrumentation `CtorDependency(Eager=true)`; opt-in `[assembly: EnableFeedMocking]`, byte-identical output when absent. View-model seam `__Mock_SetCommand` (commands, R2; no `__Mock_Create` since public constructors plus the ambient scope are enough, D12). +- **Second assembly `Uno.Extensions.Reactive.Mocking`** (`4b68b7e93`, `b6caeee03`, `6384a4d48`): `MockingService.Enable()`, `MockModel.SwapFeed`/`SwapListFeed` (fail-hard), the `MockFeed`/`MockListFeed` vocabulary (Value, Empty, EmptyList, Undefined, Loading, Error, Refreshing), and `MockCommand` (Idle, Disabled, Executing, Callback). +- **Consumer generator `Uno.Extensions.Reactive.Mocking.Generator`** (`4b68b7e93`, `6384a4d48`): reads the metadata (referenced assemblies plus the current compilation) and emits `record {Model}Mock` (required inputs, optional derived members and commands), `Empty`, `Create()`/`Create(inputs)`/`Create(mock)` (null-inject), and `SetModel` (typed swaps plus `__Mock_SetCommand`). +- **Fixture app `Uno.Extensions.Reactive.Tests.MockingApp`**: a real model with the opt-in, to exercise the true two-assembly flow (generators do not chain inside a single compilation). -**Tests (réellement exécutés) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4 (Create+SetModel → VM réel → feed mické ; live re-swap ; `Create()` Empty→None ; override commande), Tests.Generator 80/80 (byte-identique préservé), Given_HotReload 8/8 (Core inchangé). +**Tests (actually executed):** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4 (Create plus SetModel driving a real view-model to a mocked feed; live re-swap; `Create()` Empty to None; command override), Tests.Generator 80/80 (byte-identical output preserved), Given_HotReload 8/8 (Core unchanged). -**Reste (hors périmètre du cœur tier 2/3, à planifier avec David) :** +**Remaining (outside the tier 2 and 3 core, to be planned with David):** -- `MockFeed.Message`/`Script` (dépendent du vocabulaire de #3147, non mergé). -- Diagnostics `FEED3201–3203` / `MOCK0001` (analyse en place, diagnostics non émis). -- Docs `doc/Learn/Mvux/Testing.md` + `FeedView.md` (§9), Tier 1 (on hold). -- Remontée github : outbox ABO → PR #3165 (après review David). +- `MockFeed.Message`/`Script` (they depend on the #3147 vocabulary, not merged). +- Diagnostics `FEED3201` to `FEED3203` and `MOCK0001` (the analysis is in place, the diagnostics are not emitted). +- Documentation `doc/Learn/Mvux/Testing.md` and `FeedView.md` (§9), tier 1 (on hold). +- Upstream delivery: ABO outbox to PR #3165 (after David review). -## v9 — réconciliation avec #3149 (FeedMock mergé) + review David (mar. 25/08) +## v9 — reconciliation with #3149 (FeedMock merged) and David review (Tue 08-25) -Rebase sur `main` (PR #3154 / issue #3149 mergée) : **le vocabulaire de feeds mockés existe déjà** dans une assembly dédiée `Uno.HotTesting.Reactive` (`FeedMock`/`ListFeedMock`, namespace + assembly `Uno.HotTesting.Reactive`, spec 009). Mon `Uno.Extensions.Reactive.Mocking` le dupliquait → **supprimé**. Décisions de naming/namespace suite à la review de David sur la staging PR #1 : +Rebase on `main` (PR #3154 / issue #3149 merged): **the mocked-feed vocabulary already exists** in a dedicated assembly, `Uno.HotTesting.Reactive` (`FeedMock`/`ListFeedMock`, namespace and assembly `Uno.HotTesting.Reactive`, spec 009). My `Uno.Extensions.Reactive.Mocking` duplicated it, so it was **removed**. Naming and namespace decisions following David's review on staging PR #1: -- **Assembly unique `Uno.HotTesting.Reactive`** : tout le mocking runtime y vit (le `FeedMock`/`ListFeedMock` existants + les ajouts tier 2/3). Suppression de `Uno.Extensions.Reactive.Mocking`. -- **Naming `Mock`** (suffixe, cohérent avec `FeedMock`) : `MockFeed`→`FeedMock` (réutilisé), `MockListFeed`→`ListFeedMock` (réutilisé), `MockCommand`→**`CommandMock`** (ajouté). Surface publique de `FeedMock`/`ListFeedMock` verrouillée par `Given_PublicApi` (7 primitives : Empty/Error/Loading/Message/Refreshing/Undefined/Value) → réutilisée telle quelle (mon `EmptyList` retiré, `Empty`=None suffit). -- **Moteur de swap dans `MockingService`** (drop du type `MockModel`, jugé « mêlant ») : `MockingService.Enable()` + `MockingService.SwapFeed`/`SwapListFeed` (public `EditorBrowsable(Never)`, fail-hard). Le swap est **fortement typé généré** (pas de réflexion runtime) → **AOT-safe**, l'assembly garde `IsAotCompatible=true`. -- **Générateur consumer → `Uno.HotTesting.Reactive.Generator`** (analyzer/tool du package `Uno.HotTesting.Reactive`) ; émission en **raw string literals** (cohérence codegen) ; émet `FeedMock`/`ListFeedMock`/`CommandMock` + `MockingService.Swap` + `__Mock_SetCommand`. -- **Instrumentation MVUX émise par DÉFAUT** (plus opt-in) : les attributs `FeedDependency`/`CtorDependency` + le seam `__Mock_SetCommand` sont toujours émis ; c'est le **runtime** (`MockingService.Enable()`) qui décide l'activation. Opt-out possible : `[assembly: EnableFeedMocking(IsEnabled = false)]` → sortie MVUX byte-identique. Modèle on-par-défaut/opt-out comme les autres attributs MVUX. +- **A single assembly, `Uno.HotTesting.Reactive`**: the whole mocking runtime lives there (the existing `FeedMock`/`ListFeedMock` plus the tier 2 and 3 additions). `Uno.Extensions.Reactive.Mocking` deleted. +- **`Mock` naming** (suffix, consistent with `FeedMock`): `MockFeed` to `FeedMock` (reused), `MockListFeed` to `ListFeedMock` (reused), `MockCommand` to **`CommandMock`** (added). The public surface of `FeedMock`/`ListFeedMock` is locked by `Given_PublicApi` (7 primitives: Empty, Error, Loading, Message, Refreshing, Undefined, Value) and is reused as is (my `EmptyList` was dropped, `Empty` as None is enough). +- **Swap engine inside `MockingService`** (the `MockModel` type was dropped as confusing): `MockingService.Enable()` plus `MockingService.SwapFeed`/`SwapListFeed` (public, `EditorBrowsable(Never)`, fail-hard). The swap is **strongly typed and generated** (no runtime reflection), so it is **AOT-safe** and the assembly keeps `IsAotCompatible=true`. +- **Consumer generator renamed to `Uno.HotTesting.Reactive.Generator`** (analyzer and tool of the `Uno.HotTesting.Reactive` package); emission uses **raw string literals** (consistent with the rest of the code generation); it emits `FeedMock`/`ListFeedMock`/`CommandMock` plus `MockingService.Swap` and `__Mock_SetCommand`. +- **MVUX instrumentation emitted BY DEFAULT** (no longer opt-in): the `FeedDependency`/`CtorDependency` attributes and the `__Mock_SetCommand` seam are always emitted; the **runtime** (`MockingService.Enable()`) decides activation. Opt-out is available through `[assembly: EnableFeedMocking(IsEnabled = false)]`, which restores byte-identical MVUX output. This matches the on-by-default, opt-out model of the other MVUX attributes. -**Tests après refactor (verts) :** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, `Uno.HotTesting.Reactive.Tests` 22/22 (FeedMock existant non régressé). +**Tests after the refactor (green):** Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, `Uno.HotTesting.Reactive.Tests` 22/22 (the existing FeedMock is not regressed). -## v10 — review David (commentaire 31) : AsyncLocal hors de Core +## v10 — David review (comment 31): AsyncLocal out of Core -Retour de David sur `SourceContext` : *« si on a besoin d'un AsyncLocal pour le mocking, ça n'apporte rien de le mettre dans le SourceContext, on devrait le garder dans le MockingService »*. Juste — l'état d'activation ambient est une préoccupation **mocking**, pas Core. +David feedback on `SourceContext`: *"if we need an AsyncLocal for mocking, putting it in the SourceContext brings nothing, we should keep it in the MockingService"*. Correct — the ambient activation state is a **mocking** concern, not a Core one. -- **`MockingService` (dans `Uno.HotTesting.Reactive`) possède l'`AsyncLocal` ambient** + `Enable()`. -- **`SourceContext` (Core) ne garde que** le bit d'instance `IsMockingActive` + un **seam** `internal static Func? IsMockingActiveProbe`. À la création d'un contexte racine, `IsMockingActive = IsMockingActiveProbe?.Invoke() ?? false` ; un enfant hérite du parent. `MockingService` enregistre la probe (static ctor). -- **App live** : `MockingService` jamais touché → probe nulle → `IsMockingActive` toujours false → zéro coût (G9/R7 conservé). Le mécanisme reste D12 (bit per-contexte capturé à la construction, survit à une souscription lazy après dispose du scope). +- **`MockingService` (in `Uno.HotTesting.Reactive`) owns the ambient `AsyncLocal`** plus `Enable()`. +- **`SourceContext` (Core) only keeps** the instance bit `IsMockingActive` plus a **seam**, `internal static Func? IsMockingActiveProbe`. When a root context is created, `IsMockingActive = IsMockingActiveProbe?.Invoke() ?? false`; a child inherits from its parent. `MockingService` registers the probe in its static constructor. +- **Live application**: `MockingService` is never touched, so the probe is null, `IsMockingActive` is always false and the cost is zero (G9/R7 preserved). The mechanism is still D12 (per-context bit captured at construction, surviving a lazy subscription after the scope is disposed). -Tests inchangés/verts : Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. +Tests unchanged and green: Given_MockingActivation 4/4, Given_MockingRuntime 4/4, Given_GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. -## v11 — doc + sample + polish naming factory (mar. 25/08) +## v11 — documentation, sample and factory naming polish (Tue 08-25) -- **Doc** : `doc/Reference/Reactive/testing.md` (celle de #3149 sur `FeedMock` hand-written) étendue avec la couche générée tier 2/3 : scope `MockingService.Enable()`, `record {Model}Mock` (inputs required, derived + commandes optionnels), `{Vm}Mock.Create(...)`, `vm.SetModel(...)`, `CommandMock`, derived-survives, one-liners + catalogs nommés (tier 3), opt-out `[assembly: EnableFeedMocking(IsEnabled = false)]`. La phrase « no generator » de #3149 est mise à jour. -- **Sample** : `RecipeCatalog` (catalog nommé tier-3 : Loading/Empty/Basic/Failed) dans le projet de tests, + test `When_CatalogEntry_Then_PinnedState` (Given_GeneratedMock 5/5). -- **Polish naming (générateur consumer)** : la classe factory générée passe de `{Model}MockExtensions` à **`{Vm}Mock`** (`RecipeViewModelMock.Create(...)`) — lecture propre, proche de l'intention spec §7/§8 (le `{Vm}.Create` littéral est impossible cross-assembly). `Empty` déplacé **sur le record** (`RecipeModelMock.Empty`) pour la compo `with`. Spec §7/§8 alignée sur l'API réelle. +- **Documentation**: `doc/Reference/Reactive/testing.md` (the #3149 page about hand-written `FeedMock`) extended with the generated tier 2 and 3 layer: the `MockingService.Enable()` scope, `record {Model}Mock` (required inputs, optional derived members and commands), `{Vm}Mock.Create(...)`, `vm.SetModel(...)`, `CommandMock`, derived-survives, one-liners and named catalogs (tier 3), and the `[assembly: EnableFeedMocking(IsEnabled = false)]` opt-out. The "no generator" sentence from #3149 is updated. +- **Sample**: `RecipeCatalog` (a tier 3 named catalog: Loading, Empty, Basic, Failed) in the test project, plus the test `When_CatalogEntry_Then_PinnedState` (Given_GeneratedMock 5/5). +- **Naming polish (consumer generator)**: the generated factory class moves from `{Model}MockExtensions` to **`{Vm}Mock`** (`RecipeViewModelMock.Create(...)`), which reads cleanly and stays close to the intent of spec §7 and §8 (a literal `{Vm}.Create` is impossible cross-assembly). `Empty` moved **onto the record** (`RecipeModelMock.Empty`) so it composes with `with`. Spec §7 and §8 aligned with the real API. -Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 5/5, Uno.HotTesting.Reactive.Tests 22/22, Tests.Generator 80/80. +Tests: MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 5/5, Uno.HotTesting.Reactive.Tests 22/22, Tests.Generator 80/80. -## v12 — review David (post-discussion staging PR #1, ven. 28/08) +## v12 — David review (post-discussion on staging PR #1, Fri 08-28) -Six retours de David sur la PR, tous appliqués : +Six pieces of feedback from David on the PR, all applied: -1. **`{Vm}Mock` généré `partial`** — l'app étend la classe factory avec ses catalogs nommés (tier 3) dans son propre fichier, même namespace. -2. **`SetModel` → `SetMock`** (facade renommée). -3. **`Create` prend uniquement le record** — suppression des surcharges dénormalisées `Create(input…)`. Reste `Create()` (= `{Model}Mock.Empty`) et `Create({Model}Mock)`. -4. **`{Model}Mock.Empty`** confirmé (sur le record, tous inputs Empty) + exemple `vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() })`. -5. **Scope d'activation déplacé DANS `Create`** — `Create` ouvre `MockingService.Enable()` autour de la construction ; le code utilisateur n'ouvre plus de scope (le bit mockable capturé sur le contexte survit aux `SetMock` ultérieurs et souscriptions lazy, D12). -6. **Mocking de commande différé à vNext** — le générateur consumer n'émet plus de membre commande ni de câblage `__Mock_SetCommand` ; le seam MVUX reste disponible pour ce travail futur. `CommandMock` (vocabulaire) reste dans l'assembly, non câblé. +1. **The generated `{Vm}Mock` is `partial`** — the app extends the factory class with its named catalogs (tier 3) in its own file, in the same namespace. +2. **`SetModel` renamed to `SetMock`** (facade rename). +3. **`Create` only takes the record** — the denormalized `Create(input...)` overloads are removed. What remains is `Create()` (equal to `{Model}Mock.Empty`) and `Create({Model}Mock)`. +4. **`{Model}Mock.Empty`** confirmed (on the record, every input empty) plus the example `vm.SetMock(RecipeModelMock.Empty with { Steps = ListFeedMock.Loading() })`. +5. **Activation scope moved INSIDE `Create`** — `Create` opens `MockingService.Enable()` around construction, so user code no longer opens a scope (the mockable bit captured on the context survives later `SetMock` calls and lazy subscriptions, D12). +6. **Command mocking deferred to vNext** — the consumer generator no longer emits a command member nor the `__Mock_SetCommand` wiring; the MVUX seam stays available for that future work. `CommandMock` (the vocabulary) stays in the assembly, not wired up. -Répercuté : doc `doc/Reference/Reactive/testing.md`, spec §7/§8/§10/§13 + archi §2.2/§5/§6, sample `RecipeViewModelMock` partial. Tests : MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. +Propagated: documentation `doc/Reference/Reactive/testing.md`, spec §7/§8/§10/§13, architecture §2.2/§5/§6, and the `RecipeViewModelMock` partial sample. Tests: MockingActivation 4/4, MockingRuntime 4/4, GeneratedMock 4/4, Tests.Generator 80/80, Uno.HotTesting.Reactive.Tests 22/22. --- -## Registre final des décisions +## Final decision register -| # | Décision | Version | +| # | Decision | Version | | --- | --- | --- | -| D1 | Tier-1 = `MessageEntry` authorable non-générique **dans Core**, plain CLR (pas DO), **non observable**, axes core en propriétés directes + axes custom via `Axes`/`Set` ; remplacement d'instance = push dans le wrapper existant (évolution naturelle, pas de loading flash) | v2→v4 | -| D2 | Commandes via seam `??` (pas de swap analog) | v1 | -| D3 | Façade (`SetModel`/setters générés) devant les hooks ; `HotSwapFeed`/handles non publics | v1 | -| D4 | ~~Flag mockable dédié dans `FeedConfiguration`~~ **remplacé v7** → gate per-context `SourceContext.IsMockingActive` (D12) | v1→v7 | -| D5 | Codegen de mocking **externe** (projet consommateur) ; gen MVUX = analyse + attributs + hooks cachés | v1 | -| D6 | Swap ancré au **cache Model-feed** → les dérivés survivent (non négociable) ; dérivés néanmoins **overridables** individuellement | v1+v2 | -| D7 | Non-AOT du path mocking accepté (dev/test only) | v1 | -| D8 | Converters = illustrations app-owned à `FeedView.Source` (retournent `IMessageEntry`) ; rien d'implémenté par la feature | v4 | -| D9 | Tiers 2/3 strictement typés ; l'objet tier-1 confiné au tier 1 | v4 | -| D10 | **Activation scopée** : `using (MockingService.Enable())` — jamais un switch app-wide ; assembly init possible pour couvrir tout un run. Hors scope → **aucun wrap** (le `HotSwapFeed` coûte, interdit dans une app live). Seul le mécanisme interne reste à établir par le spike P0-e | v6 | -| D11 | **Swap réflexif fail-hard** : réutilise le driver hot-reload (`BindableViewModelBase.HotReload`, itération `IHotSwapState`) ; le générateur MVUX **n'émet aucun `__Mock_Swap_{Member}`**, seulement métadonnées + seam ctor null-inject/commande. **Delta vs hot reload : un membre non-swappable throw** (mocking strict, pas best-effort) | v7 | -| D12 | **Gate mockable = bit per-contexte `SourceContext.IsMockingActive`**, lu dans le ctor de `StateImpl` **au lieu** du static global `EffectiveHotReload` → seuls les contextes sous scope wrappent, le reste paie zéro (G9/R7 par construction). Pas de static séparé, pas d'`AsyncLocal` maison (on réutilise `AsyncLocal Current`). **Réflexion-core assumée vs AOT-strict** : le split 2-assemblies impose la réflexion de toute façon ; path mocking dev/test-only non-AOT (NG2/D7) | v7 | +| D1 | Tier 1 is an author-declared non-generic `MessageEntry` **in Core**, a plain CLR object (not a `DependencyObject`), **not observable**, with core axes as direct properties and custom axes through `Axes`/`Set`; replacing the instance pushes into the existing wrapper (natural evolution, no loading flash) | v2 to v4 | +| D2 | Commands go through a `??` seam (no swap analog) | v1 | +| D3 | A facade (`SetModel` and generated setters) sits in front of the hooks; `HotSwapFeed` and the handles stay non-public | v1 | +| D4 | ~~Dedicated mockable flag in `FeedConfiguration`~~ **replaced in v7** by the per-context gate `SourceContext.IsMockingActive` (D12) | v1 to v7 | +| D5 | Mocking code generation is **external** (consumer project); the MVUX generator only does analysis, attributes and hidden hooks | v1 | +| D6 | The swap is anchored at the **model-feed cache**, so derived feeds survive (non-negotiable); derived members remain individually overridable | v1 and v2 | +| D7 | The non-AOT nature of the mocking path is accepted (development and test only) | v1 | +| D8 | Converters are application-owned illustrations at `FeedView.Source` (returning `IMessageEntry`); the feature implements none | v4 | +| D9 | Tiers 2 and 3 are strictly typed; the tier 1 object is confined to tier 1 | v4 | +| D10 | **Scoped activation**: `using (MockingService.Enable())` — never an app-wide switch; an assembly init can cover a whole run. Outside a scope there is **no wrap** (`HotSwapFeed` has a cost and is forbidden in a live app). Only the internal mechanism was left to the P0-e spike | v6 | +| D11 | **Fail-hard reflection swap**: reuse the hot-reload driver (`BindableViewModelBase.HotReload`, iterating `IHotSwapState`); the MVUX generator emits **no `__Mock_Swap_{Member}`**, only metadata plus the null-inject constructor and command seam. **Difference from hot reload: a member that cannot be swapped throws** (mocking is strict, not best-effort) | v7 | +| D12 | **The mockable gate is the per-context bit `SourceContext.IsMockingActive`**, read in the `StateImpl` constructor **instead of** the global `EffectiveHotReload` static, so only contexts under a scope wrap and everything else pays nothing (G9/R7 by construction). No separate static and no home-grown `AsyncLocal` (we reuse `AsyncLocal Current`). **Core reflection accepted over strict AOT**: the two-assembly split forces reflection anyway, and the mocking path is development and test only, non-AOT (NG2/D7) | v7 | diff --git a/specs/013-mvux-mocking-previews/implementation.md b/specs/013-mvux-mocking-previews/implementation.md index e609267d96..fc72ac277d 100644 --- a/specs/013-mvux-mocking-previews/implementation.md +++ b/specs/013-mvux-mocking-previews/implementation.md @@ -1,6 +1,6 @@ # 013 — Implementation -Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fixed per `spec.md`. (Restored after workspace loss.) +Concrete surfaces, touch-list, phasing, tests. Names negotiable; semantics fixed per `spec.md`. (Restored after workspace loss.) ## 1. Packages & where things live @@ -8,7 +8,7 @@ Concrete surfaces, touch-list, phasing, tests. Names bikesheddable; semantics fi | --- | --- | --- | | Dependency attributes | `Uno.Extensions.Reactive` (core) | must survive as metadata in the app assembly | | Mockable gate + HotSwap wrap at feed cache | core | **`SourceContext.IsMockingActive`** (new per-context bit, D12) read in `StateImpl` ctor; wrap wired at the `AttachedProperty`/factory cache | -| Authorable `MessageEntry` + `AxisValue` (plain CLR) + internal `MessageEntryFeed` | core | tier-1, AOT-safe, **not** a `DependencyObject` | +| Author-declared `MessageEntry` + `AxisValue` (plain CLR) + internal `MessageEntryFeed` | core | tier-1, AOT-safe, **not** a `DependencyObject` | | `FeedView.Source` coercion bridge | `Uno.Extensions.Reactive.UI` | tier-1 | | Analysis + hidden hooks emission | `Uno.Extensions.Reactive.Generator` | on Model & VM partials, on by default (opt-out) | | Mock vocabulary (`FeedMock`/`ListFeedMock`/`CommandMock`/`FeedMockState`) | **`Uno.HotTesting.Reactive`** (new) | referenced by test/preview projects only | @@ -52,7 +52,7 @@ public sealed class CtorDependencyAttribute : Attribute ### 2.3 Tier-1 core surfaces - `Feed.Value` public factory (from #3148, additive). -- Authorable non-generic `MessageEntry : IMessageEntry` — **plain CLR object, not a `DependencyObject`, not observable**; settable `Data` / `IsUndefined` / `Error` / `IsProgress`; `Axes` (`AxisValueCollection` of `AxisValue { string Axis; object? Value }`) + `Set(MessageAxis, object?)` code path. +- Author-declared non-generic `MessageEntry : IMessageEntry` — **plain CLR object, not a `DependencyObject`, not observable**; settable `Data` / `IsUndefined` / `Error` / `IsProgress`; `Axes` (`AxisValueCollection` of `AxisValue { string Axis; object? Value }`) + `Set(MessageAxis, object?)` code path. - Axis-identifier resolution against core + registered app axes; **unknown identifier → diagnostic**, never a silent drop. - Internal `MessageEntryFeed` — entry-driven wrapper with `Push(IMessageEntry)`; each pushed entry emitted as the **axis diff** vs the previous one, **custom axes included**. @@ -163,7 +163,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): - **Owner context = `SourceContext`** — already owns `States`/subscriptions, already ambient via `AsyncLocal Current`, already per-owner via `GetOrCreate(owner)`, with an eager pre-seed seam `PreConfigure(type, ctx)` / `Set(owner, ctx)`. It gains `bool IsMockingActive`. - **Eager vs lazy = solved by pre-seed**: `Create(...)` pre-seeds a mockable context on the VM/Model owner (`PreConfigure`/`Set`), so a lazy first subscription after the `using` block still wraps — the bit is on the context instance, not only on the ambient `AsyncLocal`. -- **Ambient propagation**: the existing `AsyncLocal Current` carries mockability across async construction; no bespoke `AsyncLocal`. +- **Ambient propagation**: the existing `AsyncLocal Current` carries mocking activation across async construction; no bespoke `AsyncLocal`. - **Nested / concurrency / lifetime**: per-context-instance bit → concurrent tests don't leak; contexts created inside a scope stay mockable for their own lifetime after `Dispose`. - **Wiring**: `StateImpl` ctor reads `context.IsMockingActive` (replaces the `EffectiveHotReload` read); swap is reflection over `IHotSwapState` (D11). @@ -175,7 +175,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): c. null-inject construction on a lazy model; eager-ctor fixture NREs as predicted; d. feed-identity stability matrix (capture patterns) → informs FEED3202; e. `MockingService.Enable()` → `IsMockingActive` on the pre-seeded context: prove **no wrap when the context is not mockable**, and reflection swap is **fail-hard** on an un-swappable member (D11). -- **P1 — Tier 1** (core+UI): `Feed.Value`, authorable `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. +- **P1 — Tier 1** (core+UI): `Feed.Value`, author-declared `MessageEntry` + `AxisValue` (custom axes), `MessageEntryFeed` + push semantics, `FeedView` bridge, documentation-only converter illustration. Ships alone. - **P2 — Core: `SourceContext.IsMockingActive` + wrap gate in `StateImpl` + fail-hard reflection swap + attributes + analysis + `__Mock_SetCommand` seam** (MVUX gen). No per-feed swap hooks; no `__Mock_Create` (public ctors + ambient scope). - **P3 — Mocking package**: typed vocabulary + consumer generator (`{Model}Mock`/`Create`/`SetMock`). - **P4 — Tier 3 catalogs + Hot Design checkpoint** (name freeze), docs. @@ -185,7 +185,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): ### Core - Every typed `FeedMock`/`ListFeedMock`/`CommandMock` state emits expected axes. -- Authorable entry maps to Data/Error/Progress/Undefined correctly; custom axes map and diff correctly. +- Author-declared entry maps to Data/Error/Progress/Undefined correctly; custom axes map and diff correctly. - Consecutive entry instances produce correct core + custom axis diffs. - Wrap identity (`AttachedProperty` returns the same wrapper); swap propagation through `Select`/`Where` and chained derived feeds; live re-swap. @@ -208,7 +208,7 @@ Mechanism (resolved against source — `Core/Internal/SourceContext.cs`, D12): - **Context not mockable → feeds are the raw instances** (no `HotSwapFeed` in the cache, no measurable overhead) — the G9 guard test. - **Fail-hard swap**: a mocked member with no `IHotSwapState` throws (D11), asserted. - Assembly-init scope covers every test of the run; a per-test scope covers only its own. -- Nested `Enable()` scopes restore correctly; parallel tests do not leak mockability; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. +- Nested `Enable()` scopes restore correctly; parallel tests do not leak mocking activation; async construction retains the intended scope; lazy first subscription after scope disposal has defined behavior; existing contexts remain deterministic after `Dispose`. ### Contract freeze