From 25c4bbc67429bac8a8c468e02cb035a6dadf55bc Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 16:15:39 -0400 Subject: [PATCH 01/23] docs: plan shared fwlayout persistence Record the one-store architecture, JSON retirement scope, and test-first landing sequence for Avalonia and WinForms layout parity. --- .../2026-08-26-avalonia-uses-fwlayout.md | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md diff --git a/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md b/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md new file mode 100644 index 0000000000..66b2a20cb2 --- /dev/null +++ b/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md @@ -0,0 +1,473 @@ +# Avalonia Uses `.fwlayout` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the legacy XML layout inventory the single persisted source of project layout customization for both WinForms and Avalonia, then retire the unreleased Avalonia-only `.viewoverride.json` subsystem. + +**Architecture:** Keep `Inventory` as the authority that loads shipped layouts, merges project `ConfigurationSettings/*.fwlayout` overrides, and persists mutations. Add an xWorks adapter that copies the effective `XmlNode` into an immutable `ViewDefinitionSourceSnapshot`; keep XCore out of FwAvalonia. Avalonia menu commands will execute the existing legacy `Slice` handlers through the already-approved hidden-DataTree command adapter, then recompose from `Inventory`. The compiler's content fingerprint, not a process-wide `(class, layout)` identity cache, will isolate projects and notice changed XML. + +**Tech Stack:** C# 8 / .NET Framework 4.8, XCore `Inventory`, LINQ to XML, NUnit, PowerShell repository build/test scripts. + +--- + +## Decision record + +### One persistent store + +`ConfigurationSettings/*.fwlayout` is the only supported project layout customization format after this work. + +- WinForms and Avalonia read the same effective layout nodes from the project-keyed `Inventory`. +- Existing WinForms commands remain the only writers for field visibility, field order, and visible writing systems. +- Avalonia does not translate XML into a second persisted representation. +- Avalonia does not cache project A's effective layout for project B. + +### Boundary + +`FwAvalonia` remains independent of XCore, project folders, and mutable XML inventories. xWorks owns the adapter because it already references both XCore and FwAvalonia: + +```text +Configuration/Parts/*.fwlayout + + +project/ConfigurationSettings/*.fwlayout + | + v + XCore Inventory (effective XML) + | + v + xWorks immutable snapshot adapter + | + v + FwAvalonia ViewDefinitionCompiler + | + v + Avalonia DetailComposer +``` + +The adapter must clone XML to text before compilation. Compiler code must never retain a live `XmlNode` owned by `Inventory`. + +### Cache rule + +Remove `DetailComposer.CompilerSources.CompiledModels`, whose key omits project identity and XML content. Continue using `ViewDefinitionCompiler`, whose key includes a SHA-256 fingerprint of layout XML, parts XML, class, type, and base-class map. When a legacy command calls `Inventory.PersistOverrideElement`, the next snapshot has different XML and therefore a different compiler key without explicit invalidation. + +### Parts rule + +This change makes layout customization converge; it does not redesign part loading. Keep the existing immutable merged `*Parts.xml` snapshot in `DetailComposer`. Project customization currently persists effective `` elements, and `LayoutCache.InitializePartInventories` does not load project-level part overrides. A separate parts-inventory unification would be unrelated scope. + +### Existing JSON files + +Do not migrate or delete `.viewoverride.json` files. + +- The subsystem entered `main` in #964 and is not contained by a released FieldWorks tag. +- Automatic JSON-to-XML conversion would preserve a second compatibility contract while this change is explicitly retiring it. +- Deleting files from user project folders would be destructive. +- After this change, old files are inert. Developers using unreleased builds may delete them manually. + +### Pull request order + +Land this storage-convergence PR before the open customization PRs: + +1. This PR: shared `.fwlayout` reads/writes and JSON retirement. +2. Rebase [#1097](https://github.com/sillsdev/FieldWorks/pull/1097); keep the writing-system behavior, but replace any JSON store/editor work with the shared legacy command path. +3. Rebase draft [#1108](https://github.com/sillsdev/FieldWorks/pull/1108); remove stacked assumptions and verify its writing-system selection lands in `.fwlayout` only. + +Do not merge #1097 or #1108 first and then add migration code for their JSON output. + +## Retirement inventory + +Delete these production files in full: + +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs` +- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs` +- `Src/xWorks/Avalonia/DetailOverrideMigration.cs` + +Delete these tests because they test the retired format or migration path: + +- `Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs` +- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs` +- `Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs` + +Replace, rather than simply delete, `Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs`. Its useful assertions become `.fwlayout`/`Inventory` integration coverage. + +Edit these production files to remove references to the retired layer: + +- `Src/Common/FwAvalonia/Detail/DetailModel.cs` +- `Src/Common/FwAvalonia/FwAvalonia.csproj` +- `Src/xWorks/Avalonia/Composer/DetailComposer.cs` +- `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` +- `Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs` only if the post-command callback is implemented there + +Keep these general view-definition components: + +- `ViewDefinitionModel`, `XmlLayoutImporter`, and `DictionaryPartResolver` +- `ViewDefinitionSourceSnapshot`, `ViewDefinitionCompiler`, and its content cache +- `LayoutSourceLoader` for shipped layouts/parts and framework-neutral tests +- `ViewDefinitionJsonSerializer`; it serializes compiled definitions, not project override files +- `Newtonsoft.Json` references still used elsewhere; do not remove the package merely because the override serializer is gone + +## Implementation tasks + +### Task 1: Characterize `Inventory` as the source of effective layout XML + +**Files:** + +- Create: `Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs` +- Create: `Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs` + +- [ ] Write a failing test that builds test `layouts` and `parts` inventories, asks the new source for `LexEntry/detail/Normal`, and verifies the returned snapshot contains the shipped layout. + +- [ ] Write a failing test that calls `PersistOverrideElement` with a changed full `` and verifies a second snapshot contains the changed XML while the first snapshot remains unchanged. + +- [ ] Write a failing test for choice layouts: exact `choiceGuid` wins, then the no-`choiceGuid` layout is the fallback. Use the same four-key lookup as `DataTree.GetTemplateForObjLayout`: + +```csharp +inventory.GetElement("layout", new[] { className, "detail", layoutName, choiceGuid }); +inventory.GetElement("layout", new[] { className, "detail", layoutName, null }); +``` + +- [ ] Write a failing test that a missing derived-class layout walks to its base class and records the same base-class map the compiler uses for part resolution. + +- [ ] Run the red tests: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~InventoryViewDefinitionSourceTests" +``` + +Expected: fail because `InventoryViewDefinitionSource` does not exist. + +- [ ] Implement `InventoryViewDefinitionSource` in xWorks. Constructor inputs are the project layout `Inventory`, immutable merged parts XML, and metadata cache. Its public operation returns a `ViewDefinitionSourceSnapshot` or `null` for a missing layout. Clone the selected `XmlNode` with `OuterXml`; never return or retain the node. + +- [ ] Run the same filtered test command. + +Expected: all `InventoryViewDefinitionSourceTests` pass. + +- [ ] Commit: + +```text +test: characterize Inventory view snapshots +``` + +### Task 2: Make `DetailComposer` compile effective project layouts + +**Files:** + +- Modify: `Src/xWorks/Avalonia/Composer/DetailComposer.cs` +- Modify: `Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs` + +- [ ] Replace the old patch-resolver tests with failing integration tests named for the behavior, not the retired mechanism: + + - `Compose_InventoryVisibilityOverrideMatchesLegacyShowHiddenBehavior` + - `Compose_InventoryReorderOverrideChangesSiblingOrder` + - `Compose_SecondInventoryDoesNotSeeFirstProjectsOverride` + - `Compose_PersistedChangeIsVisibleOnNextCompose` + +- [ ] In each test, persist a full layout through `Inventory.PersistOverrideElement`; do not instantiate JSON types or call the compiler's internal cache directly. + +- [ ] Run the red tests: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~DetailComposerOverrideTests" +``` + +Expected: at least the visibility/reorder tests fail because composer still reads shipped layouts unless given a JSON patch resolver. + +- [ ] Replace `ViewDefinitionOverrideResolver` with a neutral snapshot/source resolver owned by xWorks. Thread it through both `Compose` overloads, `ComposeState`, and nested-object `CompileForObject` calls so descended `LexSense`, `MoForm`, and other layouts use the same project inventory. + +- [ ] Remove `CompilerSources.CompiledModels`. Preserve the immutable shipped `LayoutIndex` only as the fallback for callers/tests that do not supply a project source. + +- [ ] In `CompileForClass`, use the project source first. If no project inventory is available, preserve current shipped-layout fallback and logging. Pass every snapshot to `ViewDefinitionCompiler.Compile`, allowing its content fingerprint cache to deduplicate identical XML. + +- [ ] Preserve `SnapshotCompileCount` semantics by incrementing only when a new source snapshot is constructed, and update memoization tests to assert content reuse rather than the removed identity dictionary. + +- [ ] Run: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~DetailComposerOverrideTests|FullyQualifiedName~DetailComposer" +``` + +Expected: all selected composer tests pass; two inventories with the same class/layout key remain isolated. + +- [ ] Commit: + +```text +feat: compose Avalonia details from Inventory +``` + +### Task 3: Wire the product host to the project inventory + +**Files:** + +- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` +- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs` +- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs` + +- [ ] Add a failing host test proving Avalonia composition uses `Inventory.GetInventory("layouts", Cache.ProjectId.Name)` after `LayoutCache.InitializePartInventories` loads project overrides. + +- [ ] Add a failing switch test: show one record in Avalonia, persist a `.fwlayout` change through the inventory, switch or refresh, and assert the recomposed model reflects it. + +- [ ] Remove `m_viewOverrideStore`, `ViewOverrideStore`, and `ResolveViewOverride` from `RecordEditView`. + +- [ ] Lazily construct one `InventoryViewDefinitionSource` per project-keyed host. Supply it to both LexEntry and non-LexEntry `DetailComposer.Compose` calls. + +- [ ] Fail visibly in the log and use the existing first-slice/unsupported fallback if inventories are unavailable; do not silently read `.viewoverride.json` or bypass the repository's normal inventory initialization. + +- [ ] Run: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~RecordEditViewSwitchTests|FullyQualifiedName~DetailCommandAdapterHardeningTests" +``` + +Expected: selected host tests pass. + +- [ ] Commit: + +```text +feat: wire Avalonia host to project layouts +``` + +### Task 4: Route visibility and move commands through legacy writers + +**Files:** + +- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` +- Modify: `Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs` if needed for a post-execute callback +- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs` +- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs` + +- [ ] Add red tests for all five persistent layout commands: + + - `CmdAlwaysVisible` + - `CmdIfData` + - `CmdNormallyHidden` + - `CmdDataTree-MoveFieldUp` + - `CmdDataTree-MoveFieldDown` + + Each test must execute the native Avalonia menu item, assert the legacy command handler ran, assert a `.fwlayout` element was persisted through `Inventory`, and assert the Avalonia detail model recomposed from that XML. + +- [ ] Add a red test that a failed or ambiguous command target clears `CurrentSlice` and writes no layout. Persistent commands must fail closed rather than mutate the first row sharing an object. + +- [ ] Strengthen command targeting for persistent layout commands. Match the Avalonia field to the hidden legacy slice using object HVO, field name, layout context, and template occurrence/path. Keep the existing broader object fallback for non-persistent legacy commands, but require an exact unique target before enabling a visibility or move command. + +- [ ] Replace `BuildOverrideCommandInterceptor`, `VisibilityItem`, `MoveItem`, `ApplyFieldVisibility`, `ApplyMoveField`, and `MutateOverrideAndRefresh` with a thin command wrapper: + +```csharp +choice.OnClick(null, EventArgs.Empty); // existing Slice handler writes Inventory/.fwlayout +RefreshAvaloniaDetail(); // new snapshot sees changed effective XML +``` + + Use the normal xCore display properties for label, checked state, and enablement. Do not recalculate these from a second model editor. + +- [ ] For the WinForms fallback menu, refresh Avalonia after `ShowContextMenu` returns. A cancel may cause a harmless recompose; command execution must never leave Avalonia stale. + +- [ ] Run: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~DetailObjectCommandExecutionTests|FullyQualifiedName~DetailCommandAdapterHardeningTests|FullyQualifiedName~DetailContextMenuCompositionTests" +``` + +Expected: all selected menu/adapter tests pass and no `.viewoverride.json` file is created. + +- [ ] Commit: + +```text +feat: share legacy layout command writers +``` + +### Task 5: Retire the JSON override subsystem + +**Files:** + +- Delete all production and test files listed in **Retirement inventory**. +- Modify: `Src/Common/FwAvalonia/Detail/DetailModel.cs` +- Modify: `Src/Common/FwAvalonia/FwAvalonia.csproj` +- Modify: `Src/xWorks/Avalonia/Composer/DetailComposer.cs` +- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` + +- [ ] Delete the JSON patch store, serializer, model/operations, applier, differ, editor, and migration files. + +- [ ] Delete their dedicated tests and the xWorks migration adapter/tests. + +- [ ] Remove stale XML documentation and comments that claim `DetailField.ClassName`/`LayoutName` key a JSON override store. Retain these properties only if command targeting still needs their layout context; otherwise remove them and their stamping tests. + +- [ ] Remove the `Newtonsoft.Json` package from `FwAvalonia.csproj` only if this command proves the production project no longer uses it: + +```powershell +rg -n "Newtonsoft\.Json" Src/Common/FwAvalonia -g "*.cs" +``` + +Expected: if `ViewDefinitionJsonSerializer` still uses Newtonsoft, keep the package. + +- [ ] Prove no production or test reference remains: + +```powershell +rg -n "ViewDefinitionOverride|ViewOverrideOperation|viewoverride\.json|DetailOverrideMigration" Src +``` + +Expected: no matches. + +- [ ] Prove the project contains no duplicate customization writer: + +```powershell +rg -n "PersistOverrideElement|\.fwlayout|ConfigurationSettings" ` + Src/xWorks/Avalonia Src/Common/FwAvalonia -g "*.cs" +``` + +Expected: Avalonia host references point to `Inventory`/`.fwlayout`; no second extension or serializer appears. + +- [ ] Run: + +```powershell +.\build.ps1 -CommentHygiene -BuildTests +``` + +Expected: build succeeds with deleted SDK-globbed files absent. + +- [ ] Commit: + +```text +refactor: retire Avalonia JSON layout overrides +``` + +### Task 6: Verify persistence parity end to end + +**Files:** + +- Create: `Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs` +- Modify only production files exposed by a failing parity test. + +- [ ] Add an end-to-end test that begins with no project override, executes Avalonia `Normally hidden`, reloads inventories from disk, and verifies both Avalonia composition and legacy `DataTree` see `visibility="never"`. + +- [ ] Add the inverse test: execute legacy `Always visible`, reconstruct Avalonia, and verify it reads the same layout without translation. + +- [ ] Add reorder parity in both directions: Avalonia move affects WinForms order after reload; WinForms move affects Avalonia order after recompose. + +- [ ] Add a backup/synchronization contract test or static assertion that the only new project artifact is `*.fwlayout`. No `.viewoverride.json` should be present or required. + +- [ ] Run: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" ` + -TestFilter "FullyQualifiedName~LayoutPersistenceParityTests" +``` + +Expected: all two-framework round-trip tests pass. + +- [ ] Run the focused FwAvalonia suite after deleting override tests: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/Common/FwAvalonia/FwAvaloniaTests" +``` + +Expected: all FwAvalonia tests pass. + +- [ ] Run the focused xWorks suite: + +```powershell +.\test.ps1 -CommentHygiene ` + -TestProject "Src/xWorks/xWorksTests" +``` + +Expected: all xWorks tests pass, excluding tests already marked `Explicit` by the repository. + +- [ ] Commit: + +```text +test: prove shared layout persistence parity +``` + +### Task 7: Full validation and PR preparation + +**Files:** + +- Modify: this plan only if implementation discoveries require a recorded correction. +- Modify: PR description outside the repository. + +- [ ] Run the required full build: + +```powershell +.\build.ps1 -CommentHygiene +``` + +Expected: exit code 0. + +- [ ] Run the required full test suite: + +```powershell +.\test.ps1 -CommentHygiene +``` + +Expected: exit code 0 and no failed tests. + +- [ ] Verify the retirement and single-store invariants again: + +```powershell +rg -n "ViewDefinitionOverride|ViewOverrideOperation|viewoverride\.json|DetailOverrideMigration" Src +git diff --check origin/main...HEAD +gitlint --ignore body-is-missing --commits origin/main..HEAD +``` + +Expected: `rg` finds nothing; diff and gitlint exit 0. + +- [ ] Manually exercise one lexical-entry field in both modes against the same project: + + 1. In Avalonia, change visibility and move the field. + 2. Switch to Legacy and confirm both changes. + 3. Close/reopen FieldWorks and confirm both changes. + 4. Change the field back in Legacy. + 5. Switch to Avalonia and confirm the reversal. + 6. Inspect `ConfigurationSettings` and confirm only the relevant `.fwlayout` changed. + +- [ ] Update the PR description with: + + - one-store architecture and boundary + - exact retired files + - no-migration rationale for unreleased JSON files + - automated and manual evidence + - explicit sequencing instructions for #1097 and #1108 + +- [ ] Push without force and open the implementation PR against `main`. + +## Landing criteria + +The implementation PR is ready to land only when all are true: + +- Both UI frameworks render project overrides from the same effective `Inventory` XML. +- Avalonia visibility and move commands use legacy persistence handlers. +- A change made in either framework appears in the other after refresh/reload. +- No production reference to `.viewoverride.json` remains. +- No automatic JSON migration or destructive JSON cleanup ships. +- Compiler caches are content- and project-safe. +- Focused parity tests, full build, full test suite, comment hygiene, diff check, and gitlint pass. +- #1097 and #1108 are rebased to use the shared store before either lands. + +## Explicit non-goals + +- Redesigning the `.fwlayout` format or `Inventory` unification rules. +- Moving XCore dependencies into FwAvalonia. +- Adding a third persistence abstraction for hypothetical future Avalonia-only features. +- Migrating unreleased `.viewoverride.json` data. +- Expanding project-level `*Parts.xml` customization. +- Changing backup or Send/Receive filters; using the existing `.fwlayout` artifact removes the need. From 952997d5c443ee12c34fe5a020513e3fcddd9ecd Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 17:24:47 -0400 Subject: [PATCH 02/23] test: characterize Inventory view snapshots --- .../Composer/InventoryViewDefinitionSource.cs | 86 +++++++++ .../InventoryViewDefinitionSourceTests.cs | 163 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs diff --git a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs new file mode 100644 index 0000000000..d25db71f56 --- /dev/null +++ b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel.Core.KernelInterfaces; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Creates immutable view-definition snapshots from an effective project layout inventory. + /// + public sealed class InventoryViewDefinitionSource + { + private readonly Inventory _layouts; + private readonly string _partsXml; + private readonly IFwMetaDataCache _metadataCache; + + /// + /// Creates a source backed by the current effective layouts and immutable merged parts + /// XML. + /// + public InventoryViewDefinitionSource(Inventory layouts, string partsXml, + IFwMetaDataCache metadataCache) + { + _layouts = layouts ?? throw new ArgumentNullException(nameof(layouts)); + _partsXml = partsXml ?? throw new ArgumentNullException(nameof(partsXml)); + _metadataCache = metadataCache ?? throw new ArgumentNullException(nameof(metadataCache)); + } + + /// + /// Gets the effective detail layout snapshot, or null when the class hierarchy has no + /// match. + /// + public ViewDefinitionSourceSnapshot GetSnapshot(string className, string layoutName, + string choiceGuid = null) + { + var classId = _metadataCache.GetClassId(className); + var baseClassMap = new Dictionary(StringComparer.Ordinal); + string resolvedClassName; + string layoutXml; + + while (true) + { + resolvedClassName = _metadataCache.GetClassName(classId); + var layout = _layouts.GetElement("layout", + new[] { resolvedClassName, "detail", layoutName, choiceGuid }); + if (layout == null) + { + layout = _layouts.GetElement("layout", + new[] { resolvedClassName, "detail", layoutName, null }); + } + + if (layout != null) + { + layoutXml = layout.OuterXml; + break; + } + + if (classId == 0) + return null; + var baseId = _metadataCache.GetBaseClsId(classId); + if (baseId == classId) + return null; + baseClassMap[resolvedClassName] = _metadataCache.GetClassName(baseId); + classId = baseId; + } + + var chain = classId; + while (chain != 0) + { + var baseId = _metadataCache.GetBaseClsId(chain); + if (baseId == chain || baseId == 0) + break; + baseClassMap[_metadataCache.GetClassName(chain)] = _metadataCache.GetClassName(baseId); + chain = baseId; + } + + return new ViewDefinitionSourceSnapshot(resolvedClassName, "detail", layoutXml, + _partsXml, baseClassMap); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs new file mode 100644 index 0000000000..677e2aac21 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.LCModel; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + public class InventoryViewDefinitionSourceTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private const string PartsXml = @" + + + + +"; + + private string _projectPath; + + public override void TestSetup() + { + base.TestSetup(); + _projectPath = Path.Combine(Path.GetTempPath(), "fw-inventory-source-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectPath); + } + + public override void TestTearDown() + { + base.TestTearDown(); + if (Directory.Exists(_projectPath)) + Directory.Delete(_projectPath, true); + } + + [Test] + public void GetSnapshot_ReturnsShippedLayoutAndMergedParts() + { + const string layoutXml = @" + + + + +"; + var layouts = CreateLayoutInventory(layoutXml); + var parts = CreatePartsInventory(); + var source = CreateSource(layouts, parts); + + var snapshot = source.GetSnapshot("LexEntry", "Normal"); + + Assert.That(snapshot, Is.Not.Null); + Assert.That(XElement.Parse(snapshot.LayoutXml).Element("part")?.Attribute("ref")?.Value, + Is.EqualTo("CitationForm")); + Assert.That(XElement.Parse(snapshot.PartsXml).Descendants("part").Single().Attribute("id")?.Value, + Is.EqualTo("LexEntry-Detail-CitationForm")); + } + + [Test] + public void GetSnapshot_AfterPersistedOverrideReturnsNewXmlWithoutChangingPriorSnapshot() + { + const string layoutXml = @" + + + + +"; + var layouts = CreateLayoutInventory(layoutXml); + var source = CreateSource(layouts, CreatePartsInventory()); + var first = source.GetSnapshot("LexEntry", "Normal"); + var changed = new XmlDocument(); + changed.LoadXml(@" + +"); + + layouts.PersistOverrideElement(changed.DocumentElement); + var second = source.GetSnapshot("LexEntry", "Normal"); + + Assert.That(GetVisibility(first), Is.EqualTo("always")); + Assert.That(GetVisibility(second), Is.EqualTo("never")); + } + + [Test] + public void GetSnapshot_ChoiceGuidUsesExactLayoutThenFallsBackToLayoutWithoutChoiceGuid() + { + const string selectedGuid = "11111111-1111-1111-1111-111111111111"; + const string unknownGuid = "22222222-2222-2222-2222-222222222222"; + const string layoutXml = @" + + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var exact = source.GetSnapshot("LexEntry", "Normal", selectedGuid); + var fallback = source.GetSnapshot("LexEntry", "Normal", unknownGuid); + + Assert.That(XElement.Parse(exact.LayoutXml).Attribute("marker")?.Value, Is.EqualTo("exact")); + Assert.That(XElement.Parse(fallback.LayoutXml).Attribute("marker")?.Value, Is.EqualTo("fallback")); + } + + [Test] + public void GetSnapshot_MissingDerivedLayoutUsesBaseLayoutAndRecordsPartResolutionMap() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + + Assert.That(snapshot, Is.Not.Null); + Assert.That(snapshot.ClassName, Is.EqualTo("MoForm")); + Assert.That(snapshot.BaseClassMap, Is.EquivalentTo(new Dictionary + { + ["MoStemAllomorph"] = "MoForm" + })); + } + + private InventoryViewDefinitionSource CreateSource(Inventory layouts, Inventory parts) + { + return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, + Cache.MetaDataCacheAccessor); + } + + private Inventory CreateLayoutInventory(string xml) + { + var keyAttributes = new Dictionary + { + ["layout"] = new[] { "class", "type", "name", "choiceGuid" } + }; + var inventory = new Inventory("*.fwlayout", "/LayoutInventory/*", keyAttributes, + "InventoryViewDefinitionSourceTests", _projectPath); + inventory.LoadElements(xml, 0); + return inventory; + } + + private static Inventory CreatePartsInventory() + { + var keyAttributes = new Dictionary + { + ["part"] = new[] { "id" } + }; + var inventory = new Inventory("*Parts.xml", "/PartInventory/bin/*", keyAttributes, + "InventoryViewDefinitionSourceTests", "unused"); + inventory.LoadElements(PartsXml, 0); + return inventory; + } + + private static string GetVisibility(Common.FwAvalonia.ViewDefinition.ViewDefinitionSourceSnapshot snapshot) + { + return XElement.Parse(snapshot.LayoutXml).Element("part")?.Attribute("visibility")?.Value; + } + } +} From d1b44916614d3c840817f0b87552aafc3c448b1d Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 17:37:20 -0400 Subject: [PATCH 03/23] fix: make Inventory snapshots immutable --- .../Composer/InventoryViewDefinitionSource.cs | 16 ++++--- .../InventoryViewDefinitionSourceTests.cs | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs index d25db71f56..dc1cec4bca 100644 --- a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs +++ b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.LCModel.Core.KernelInterfaces; using XCore; @@ -69,18 +70,19 @@ public ViewDefinitionSourceSnapshot GetSnapshot(string className, string layoutN classId = baseId; } - var chain = classId; - while (chain != 0) + var ancestorClassId = classId; + while (ancestorClassId != 0) { - var baseId = _metadataCache.GetBaseClsId(chain); - if (baseId == chain || baseId == 0) + var baseId = _metadataCache.GetBaseClsId(ancestorClassId); + if (baseId == ancestorClassId || baseId == 0) break; - baseClassMap[_metadataCache.GetClassName(chain)] = _metadataCache.GetClassName(baseId); - chain = baseId; + baseClassMap[_metadataCache.GetClassName(ancestorClassId)] = + _metadataCache.GetClassName(baseId); + ancestorClassId = baseId; } return new ViewDefinitionSourceSnapshot(resolvedClassName, "detail", layoutXml, - _partsXml, baseClassMap); + _partsXml, new ReadOnlyDictionary(baseClassMap)); } } } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs index 677e2aac21..38782a6a14 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs @@ -125,6 +125,53 @@ public void GetSnapshot_MissingDerivedLayoutUsesBaseLayoutAndRecordsPartResoluti })); } + [Test] + public void GetSnapshot_BaseClassMapRejectsMutationThroughDictionaryContract() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + var map = (IDictionary)snapshot.BaseClassMap; + + Assert.That(() => map["MoStemAllomorph"] = "CmObject", + Throws.TypeOf()); + } + + [Test] + public void GetSnapshot_DerivedChoiceFallbackWinsBeforeBaseExactChoice() + { + const string selectedGuid = "11111111-1111-1111-1111-111111111111"; + const string layoutXml = @" + + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal", selectedGuid); + + Assert.That(XElement.Parse(snapshot.LayoutXml).Attribute("marker")?.Value, + Is.EqualTo("derived-fallback")); + } + + [Test] + public void GetSnapshot_NoLayoutInClassHierarchyReturnsNull() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + + Assert.That(snapshot, Is.Null); + } + private InventoryViewDefinitionSource CreateSource(Inventory layouts, Inventory parts) { return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, From f3877922c0270cd8450b151102a8138539688e5e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 17:44:20 -0400 Subject: [PATCH 04/23] docs: describe Inventory source null contract --- Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs index dc1cec4bca..ccc631b542 100644 --- a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs +++ b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs @@ -24,6 +24,7 @@ public sealed class InventoryViewDefinitionSource /// Creates a source backed by the current effective layouts and immutable merged parts /// XML. /// + /// A constructor argument is null. public InventoryViewDefinitionSource(Inventory layouts, string partsXml, IFwMetaDataCache metadataCache) { From f08dd1c2e46fc5c88837e947f793a2718451595a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 17:58:56 -0400 Subject: [PATCH 05/23] feat: compose Avalonia details from Inventory --- .../Avalonia/Composer/DetailComposer.cs | 114 +++---- .../Hosting/RecordEditView.Avalonia.cs | 7 +- .../Composer/DetailComposerOverrideTests.cs | 278 +++++++----------- .../Composer/DetailEditContextEditingTests.cs | 28 +- 4 files changed, 162 insertions(+), 265 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 94b0fd63d6..67006dd0ac 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -3,7 +3,6 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -41,12 +40,10 @@ public ComposedDetail(DetailModel model, IDetailEditContext editContext) } /// - /// Resolves the per-project sparse override patch for a compiled (class, layout), or null when the - /// project did not customize that layout. The host wires this to the - /// ViewDefinitionOverrideStore in the project ConfigurationSettings folder; tests supply an - /// in-memory resolver. Kept a delegate so the composer needs no reference to the file-backed store. + /// Resolves an immutable effective project layout snapshot, or null when no layout matches. /// - public delegate ViewDefinitionOverride ViewDefinitionOverrideResolver(string className, string layoutName); + public delegate ViewDefinitionSourceSnapshot ViewDefinitionSourceResolver(string className, + string layoutName, string choiceGuid); /// /// Composes the COMPLETE Lexical Edit view for an entry (sections 6/7): walks the compiled @@ -86,18 +83,13 @@ private static CompilerSources GetSources() } } - // Observable memoization: counts the expensive snapshot builds (layout - // lookup + layout.ToString() + fingerprint + compile). A repeat compose must not grow it. + // Counts source snapshots handed to the content-fingerprint compiler cache. private static int s_snapshotCompileCount; internal static int SnapshotCompileCount => s_snapshotCompileCount; /// - /// The loaded sources, immutable for the process lifetime: the layout lookup is - /// indexed once and compiled definitions are memoized per (starting class, layout), - /// so repeat composes and the per-item menu peeks never rebuild or re-fingerprint - /// the ~300KB parts snapshot. Class ids and the class hierarchy are fixed LCModel - /// metadata, so the memo is safe across caches. + /// The immutable shipped sources used when an effective project source has no layout. /// private sealed class CompilerSources { @@ -106,17 +98,12 @@ private sealed class CompilerSources // the right one (legacy distinguishes e.g. 11 RnGenericRec/Normal layouts only by // choiceGuid). public Dictionary<(string ClassName, string Type, string Name), List> LayoutIndex; - // Memoized per (starting class, layout, choiceGuid) -- choiceGuid is part of the - // identity so two - // record Types on the same class compile to two distinct models (never a cache collision). - public readonly ConcurrentDictionary<(int ClassId, string LayoutName, string ChoiceGuid), ViewDefinitionModel> CompiledModels - = new ConcurrentDictionary<(int, string, string), ViewDefinitionModel>(); } public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false, SlicePluginRegistry plugins = null, - ViewDefinitionOverrideResolver overrides = null) - => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides); + ViewDefinitionSourceResolver source = null) + => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, source); /// /// Compose the structured detail view for ANY record root + starting layout -- the @@ -129,7 +116,7 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH /// public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layoutName = "Normal", bool showHiddenFields = false, SlicePluginRegistry plugins = null, - ViewDefinitionOverrideResolver overrides = null, + ViewDefinitionSourceResolver source = null, string layoutChoiceField = null) { if (obj == null) throw new ArgumentNullException(nameof(obj)); @@ -141,7 +128,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou // picks the matching layout variant instead of the document-first one. var choiceGuid = ResolveLayoutChoiceGuid(cache, obj, layoutChoiceField); - var root = CompileForObject(cache, obj, layoutName, choiceGuid, overrides); + var root = CompileForObject(cache, obj, layoutName, choiceGuid, source); if (root == null) return null; @@ -150,7 +137,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou // bridges the gap (plugin factories run at render time, not compose). IDetailEditContext composedContext = null; var state = new ComposeState(cache, showHiddenFields, - plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides); + plugins ?? SlicePluginRegistry.Default, () => composedContext, source); state.EnterModel(root); foreach (var node in root.Roots) state.Walk(node, obj, 0); @@ -281,12 +268,10 @@ public FieldEditHandler HandlerFor(string stableId) } private readonly bool _showHidden; - // The per-project override resolver, threaded into every CompileForObject - // so a descended object's layout gets its own patch applied; plus the (class, layout) of the - // model currently being walked, captured onto each emitted field so the host's per-field - // gear-menu commands target the right override file. A stack so the entry context restores - // after a nested object's walk returns. - private readonly ViewDefinitionOverrideResolver _overrides; + // Descended objects use the same source. Model context stamps fields and restores + // after a + // nested walk. + private readonly ViewDefinitionSourceResolver _source; private readonly Stack<(string ClassName, string LayoutName)> _modelContext = new Stack<(string, string)>(); // The plugin registry consulted FIRST for every @@ -319,7 +304,8 @@ public FieldEditHandler HandlerFor(string stableId) // CHOICE-UNSAFE KEY: this cache key omits choiceGuid while the menu // binding is derived from the compiled layout's root, which can differ per choice variant. It is // correct ONLY because descent currently compiles every embedded object with choiceGuid=null - // (CompileForObjectWithOverrides), so within one compose there is no choice variance to collide. + // (CompileForObjectWithSource), so within one compose there is no choice variance to + // collide. // If descent is ever changed to thread choiceGuid through, change this key to // (ClassId, LayoutName, choiceGuid) in the SAME change, or this becomes a wrong-menu bug. private readonly Dictionary<(int ClassId, string LayoutName), (string MenuId, string HotlinksId)> _itemMenuBindings @@ -327,13 +313,13 @@ public FieldEditHandler HandlerFor(string stableId) public ComposeState(LcmCache cache, bool showHiddenFields, SlicePluginRegistry plugins, Func editContextAccessor, - ViewDefinitionOverrideResolver overrides = null) + ViewDefinitionSourceResolver source = null) { _cache = cache; _showHidden = showHiddenFields; _plugins = plugins; _editContextAccessor = editContextAccessor; - _overrides = overrides; + _source = source; _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; } @@ -497,10 +483,9 @@ void AddAll(IEnumerable systems) return _writingSystemFonts; } - // Every CompileForObject in the walk goes through here so the per-project - // override patch for the descended object's own (class, layout) is applied to its model too. - private ViewDefinitionModel CompileForObjectWithOverrides(ICmObject obj, string layoutName) - => CompileForObject(_cache, obj, layoutName, _overrides); + // Every descended object uses the root composition's effective project source. + private ViewDefinitionModel CompileForObjectWithSource(ICmObject obj, string layoutName) + => CompileForObject(_cache, obj, layoutName, _source); // Viewing parity: "show hidden fields" surfaces visibility=never fields and keeps empty // ifdata fields visible, exactly like legacy m_fShowAllFields. @@ -957,7 +942,8 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) // nested layout's fields INLINE for this same object, at depth+1 -- the // recursive // sub-view the legacy XmlView renders. WalkEmbeddedView reuses the - // CompileForObjectWithOverrides/EnterModel/Walk descent (the visited-set guards + // CompileForObjectWithSource/EnterModel/Walk descent (the visited-set + // guards // cycles); when the nested layout cannot be resolved it degrades to the // read-only ShortName row rather than vanishing. WalkEmbeddedView(node, obj, depth); @@ -2941,7 +2927,7 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) if (_itemMenuBindings.TryGetValue((item.ClassID, layoutName), out var cached)) return cached; - var compiled = CompileForObjectWithOverrides(item, layoutName); + var compiled = CompileForObjectWithSource(item, layoutName); string menu = null, hotlinks = null; if (compiled != null) { @@ -2983,7 +2969,7 @@ private void WalkEmbeddedView(ViewNode node, ICmObject obj, int depth) try { - var compiled = CompileForObjectWithOverrides(obj, layoutName); + var compiled = CompileForObjectWithSource(obj, layoutName); if (compiled != null && compiled.Roots.Count > 0) { EnterModel(compiled); @@ -3008,7 +2994,7 @@ private void DescendInto(ViewNode node, ICmObject target, int depth) if (!_visited.Add((target.Hvo, layoutName))) return; - var compiled = CompileForObjectWithOverrides(target, layoutName); + var compiled = CompileForObjectWithSource(target, layoutName); if (compiled != null && compiled.Roots.Count > 0) { // Rows from the descended model are stamped with ITS (class, @@ -3131,43 +3117,34 @@ internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject o => CompileForObject(cache, obj, layoutName, null, null); internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName, - ViewDefinitionOverrideResolver overrides) - => CompileForObject(cache, obj, layoutName, null, overrides); + ViewDefinitionSourceResolver source) + => CompileForObject(cache, obj, layoutName, null, source); /// - /// Compiles (with the legacy base-class walk) and, when supplies a - /// per-project patch for the resulting (class, layout), returns the patched model - /// CRITICAL: the cache () holds - /// the SHIPPED model only -- the override is applied on the way OUT to a fresh copy - /// ( is pure), so a patched project never poisons - /// the process-wide cache that other projects/classes read. + /// Compiles the effective project layout when supplied, otherwise using shipped sources. /// internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName, - string choiceGuid, ViewDefinitionOverrideResolver overrides) + string choiceGuid, ViewDefinitionSourceResolver source) + => CompileForClass(cache, obj.ClassID, layoutName, choiceGuid, source); + + private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, string layoutName, + string choiceGuid, ViewDefinitionSourceResolver source) { + var mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; + if (source != null) + { + var projectSnapshot = source(mdc.GetClassName(classId), layoutName, choiceGuid); + if (projectSnapshot != null) + { + Interlocked.Increment(ref s_snapshotCompileCount); + return Compiler.Compile(projectSnapshot); + } + } + var sources = GetSources(); if (sources == null) return null; - var shipped = sources.CompiledModels.GetOrAdd((obj.ClassID, layoutName, choiceGuid ?? string.Empty), - key => CompileForClass(cache, key.ClassId, key.LayoutName, key.ChoiceGuid, sources)); - if (shipped == null || overrides == null) - return shipped; - - // The compiled model's ClassName is the class where the layout was actually found (possibly a - // base class of obj.ClassID); key the override by that, matching how the patch was authored. - var patch = overrides(shipped.ClassName, shipped.LayoutName); - return patch == null || patch.IsEmpty - ? shipped - : ViewDefinitionOverrideApplier.Apply(shipped, patch); - } - - private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, string layoutName, - string choiceGuid, CompilerSources sources) - { - Interlocked.Increment(ref s_snapshotCompileCount); - - var mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; var baseClassMap = new Dictionary(StringComparer.Ordinal); var clsid = classId; XElement layout = null; @@ -3206,6 +3183,7 @@ private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, var snapshot = new ViewDefinitionSourceSnapshot(className, "detail", layout.ToString(), sources.PartsXml, baseClassMap); + Interlocked.Increment(ref s_snapshotCompileCount); return Compiler.Compile(snapshot); } diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 6349454c14..1fa33216e6 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -332,14 +332,12 @@ private void ShowAvaloniaEntry(ICmObject obj) try { composed = lexEntry != null - ? DetailComposer.Compose(lexEntry, Cache, showHidden, - overrides: ResolveViewOverride) + ? DetailComposer.Compose(lexEntry, Cache, showHidden) // Non-entry roots compose against the tool's configured layout // (m_layoutName, default "Normal"); a type-selected layout (m_layoutChoiceField, e.g. // Notebook RnGenericRec keyed on "Type") resolves to the right variant inside Compose. : DetailComposer.Compose(obj, Cache, string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, - overrides: ResolveViewOverride, layoutChoiceField: m_layoutChoiceField); if (composed != null) { @@ -586,8 +584,7 @@ private Func BuildOverrideCommandInterceptor(DetailF { if (Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var fieldObj)) { - var model = DetailComposer.CompileForObject(Cache, fieldObj, field.LayoutName, - ResolveViewOverride); + var model = DetailComposer.CompileForObject(Cache, fieldObj, field.LayoutName); if (model != null) location = ViewDefinitionOverrideEditor.LocateTarget(model, templateId); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs index e6096659ca..ae46a33b23 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs @@ -2,236 +2,162 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Xml; using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.Detail; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.LCModel; using SIL.LCModel.Core.Text; using SIL.LCModel.Infrastructure; +using XCore; namespace SIL.FieldWorks.XWorks { - /// - /// advanced-entry-view: end-to-end coverage that the per-field gear-menu commands actually change - /// what the Avalonia detail view composes, BY GOING THROUGH the override layer the menu - /// writes -- not the - /// legacy Inventory store. Visibility overrides hide/show rows under the same showHidden semantics - /// legacy slices use; reorder overrides move sibling rows; both survive a recompose; and applying an - /// override never poisons the process-wide compiled-model cache (a compose without the patch is - /// unaffected). The composer is the real product path; the resolver here stands in for the file store - /// (which has its own round-trip tests in FwAvaloniaTests). - /// [TestFixture] public class DetailComposerOverrideTests : MemoryOnlyBackendProviderTestBase { + private const string LayoutXml = @" + + + + + +"; + + private const string PartsXml = @" + + + + + + + +"; + private ILexEntry m_entry; - private IMoStemAllomorph m_morph; + private string m_projectPath; public override void TestSetup() { base.TestSetup(); + m_projectPath = Path.Combine(Path.GetTempPath(), + "fw-composer-inventory-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_projectPath); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => { m_entry = Cache.ServiceLocator.GetInstance().Create(); - m_morph = Cache.ServiceLocator.GetInstance().Create(); - m_entry.LexemeFormOA = m_morph; - m_morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); - var sense = Cache.ServiceLocator.GetInstance().Create(); - m_entry.SensesOS.Add(sense); - sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("source", Cache.DefaultAnalWs)); }); } - // An in-memory resolver standing in for the file-backed ViewDefinitionOverrideStore. - private static ViewDefinitionOverrideResolver Resolver(params ViewDefinitionOverride[] patches) + public override void TestTearDown() { - var byKey = patches.ToDictionary(p => (p.ClassName, p.LayoutName)); - return (cls, layout) => byKey.TryGetValue((cls, layout), out var patch) ? patch : null; - } - - private static ViewDefinitionOverride EntryPatch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "Normal", "detail", ops, null); - - // The template (override-key) StableId of an entry-level field: strip the runtime "@{hvo}" suffix. - private string EntryFieldTemplateId(string field) - { - var composed = DetailComposer.Compose(m_entry, Cache); - var row = composed.Model.Fields.First(f => f.Field == field && f.ClassName == "LexEntry"); - return ViewDefinitionOverrideEditor.StripRuntimeSuffix(row.StableId); + base.TestTearDown(); + if (Directory.Exists(m_projectPath)) + Directory.Delete(m_projectPath, true); } [Test] - public void Compose_StampsClassAndLayoutOnEntryFields() + public void Compose_InventoryVisibilityOverrideMatchesLegacyShowHiddenBehavior() { - var composed = DetailComposer.Compose(m_entry, Cache); - - var entryRows = composed.Model.Fields.Where(f => f.ObjectHvo == m_entry.Hvo).ToList(); - Assert.That(entryRows, Is.Not.Empty, "the entry must contribute at least one row"); - Assert.That(entryRows, Has.All.Property("ClassName").EqualTo("LexEntry")); - Assert.That(entryRows, Has.All.Property("LayoutName").EqualTo("Normal")); - } + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "visibility")); + PersistLayout(layouts, citationVisibility: "never"); + var source = CreateSource(layouts); - [Test] - public void Compose_StampsDescendedObjectsLayoutClass_NotTheEntrys() - { - var composed = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true); + var hidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, + source: source.GetSnapshot); + var shown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, + source: source.GetSnapshot); - // Sense rows are projected from the sense's own compiled layout, so they must carry LexSense. - var senseRows = composed.Model.Fields - .Where(f => f.ClassName == "LexSense" && f.LayoutName == "Normal").ToList(); - Assert.That(senseRows, Is.Not.Empty, - "descended sense rows must be stamped with their own layout class, not the entry's"); + Assert.That(hidden.Model.Fields.Any(field => field.Field == "CitationForm"), Is.False); + Assert.That(shown.Model.Fields.Any(field => field.Field == "CitationForm"), Is.True); } [Test] - public void Visibility_Never_HidesRow_UnlessShowHidden() + public void Compose_InventoryReorderOverrideChangesSiblingOrder() { - // Pick a visible entry field, force it to "Normally hidden". - var baseline = DetailComposer.Compose(m_entry, Cache); - var victim = baseline.Model.Fields.First(f => f.ClassName == "LexEntry" - && f.Kind == DetailFieldKind.Text); - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(victim.StableId); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, templateId, visibility: ViewVisibility.Never))); + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "reorder")); + PersistLayout(layouts, reverse: true); + var source = CreateSource(layouts); - var hidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(hidden.Model.Fields.Any(f => f.StableId == victim.StableId), Is.False, - "a Never field is hidden when showHidden is off"); + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); - var shown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - Assert.That(shown.Model.Fields.Any(f => f.StableId == victim.StableId), Is.True, - "a Never field reappears when showHidden is on"); + Assert.That(FieldNames(composed), Is.EqualTo(new[] { "Bibliography", "CitationForm" })); } [Test] - public void Visibility_IfData_HidesWhenEmpty_ShowsWhenNonEmpty() + public void Compose_SecondInventoryDoesNotSeeFirstProjectsOverride() { - // CitationForm is empty on this entry; force IfData and confirm the empty row hides, then - // give it data and confirm it shows. - var baseline = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true); - var citation = baseline.Model.Fields.FirstOrDefault(f => f.Field == "CitationForm" - && f.ClassName == "LexEntry"); - Assert.That(citation, Is.Not.Null, "the entry layout must offer a CitationForm row"); - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(citation.StableId); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, templateId, visibility: ViewVisibility.IfData))); - - var emptyHidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(emptyHidden.Model.Fields.Any(f => f.Field == "CitationForm"), Is.False, - "an empty IfData field hides when showHidden is off"); - - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, - () => m_entry.CitationForm.set_String(Cache.DefaultVernWs, - TsStringUtils.MakeString("casita", Cache.DefaultVernWs))); - - var nowShown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(nowShown.Model.Fields.Any(f => f.Field == "CitationForm"), Is.True, - "a non-empty IfData field shows even when showHidden is off"); + var firstLayouts = CreateLayoutInventory(Path.Combine(m_projectPath, "first")); + var secondLayouts = CreateLayoutInventory(Path.Combine(m_projectPath, "second")); + PersistLayout(firstLayouts, reverse: true); + PersistLayout(secondLayouts); + var firstSource = CreateSource(firstLayouts); + var secondSource = CreateSource(secondLayouts); + + var first = DetailComposer.Compose(m_entry, Cache, source: firstSource.GetSnapshot); + var second = DetailComposer.Compose(m_entry, Cache, source: secondSource.GetSnapshot); + + Assert.That(FieldNames(first), Is.EqualTo(new[] { "Bibliography", "CitationForm" })); + Assert.That(FieldNames(second), Is.EqualTo(new[] { "CitationForm", "Bibliography" })); } [Test] - public void Reorder_SwapsTwoSiblingRows_AndSurvivesRecompose() + public void Compose_PersistedChangeIsVisibleOnNextCompose() { - // Find two entry-level sibling fields under a shared parent (via the SAME LocateTarget the - // menu uses), then reorder them and assert the row order swapped in the composed model. - var model = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); - var siblings = FindSiblingPair(model); - Assert.That(siblings, Is.Not.Null, "the entry layout must have a parent with two locatable fields"); - - var (parentId, firstId, secondId, order) = siblings.Value; - var moved = order.ToList(); - var idx = moved.IndexOf(secondId); - moved[idx] = moved[idx - 1]; - moved[idx - 1] = secondId; // move 'second' up one - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.ReorderChildren, parentId, childOrder: moved))); - - var reordered = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - var firstPos = RowPosition(reordered.Model, firstId); - var secondPos = RowPosition(reordered.Model, secondId); - Assert.That(secondPos, Is.LessThan(firstPos), - "the reorder override must move the second sibling's row ahead of the first"); - - // Survives a fresh recompose with the same resolver (the override is the source of truth). - var again = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - Assert.That(RowPosition(again.Model, secondId), Is.LessThan(RowPosition(again.Model, firstId))); - } + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "refresh")); + var source = CreateSource(layouts); + var before = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); - [Test] - public void Override_DoesNotPoisonProcessWideCompiledCache() - { - var victimId = EntryFieldTemplateId(DetailComposer.Compose(m_entry, Cache) - .Model.Fields.First(f => f.ClassName == "LexEntry" && f.Kind == DetailFieldKind.Text).Field); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, victimId, visibility: ViewVisibility.Never))); - - // Compose WITH the override (mutates nothing but the returned copy). - DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, overrides: resolver); - - // A subsequent compose WITHOUT the override must see the shipped definition unchanged: the - // cached model was never patched in place. - var clean = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false); - Assert.That(clean.Model.Fields.Any(f => f.StableId.StartsWith(victimId)), Is.True, - "composing without the patch must see the shipped (unhidden) field — the cache stayed clean"); + PersistLayout(layouts, citationVisibility: "never"); + var after = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + + Assert.That(before.Model.Fields.Any(field => field.Field == "CitationForm"), Is.True); + Assert.That(after.Model.Fields.Any(field => field.Field == "CitationForm"), Is.False); } - [Test] - public void Override_UnknownStableId_IsNoOp_NotACrash() + private InventoryViewDefinitionSource CreateSource(Inventory layouts) { - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, "/#does/#not/#exist", visibility: ViewVisibility.Never))); - - var baseline = DetailComposer.Compose(m_entry, Cache); - var withStale = DetailComposer.Compose(m_entry, Cache, overrides: resolver); - - Assert.That(withStale.Model.Fields.Count, Is.EqualTo(baseline.Model.Fields.Count), - "a stale/unknown override target changes nothing"); - Assert.That(withStale.Model.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.True, - "the stale target is reported as a diagnostic, not silently dropped"); + var parts = new Inventory("*Parts.xml", "/PartInventory/bin/*", + new Dictionary { ["part"] = new[] { "id" } }, + "DetailComposerOverrideTests", "unused"); + parts.LoadElements(PartsXml, 0); + return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, + Cache.MetaDataCacheAccessor); } - private static int RowPosition(DetailModel model, string templateId) + private static Inventory CreateLayoutInventory(string projectPath) { - for (var i = 0; i < model.Fields.Count; i++) - { - if (ViewDefinitionOverrideEditor.StripRuntimeSuffix(model.Fields[i].StableId) == templateId) - return i; - } - - return -1; + var layouts = new Inventory("*.fwlayout", "/LayoutInventory/*", + new Dictionary + { + ["layout"] = new[] { "class", "type", "name", "choiceGuid" } + }, "DetailComposerOverrideTests", projectPath); + layouts.LoadElements(LayoutXml, 0); + return layouts; } - // Finds a parent node in the compiled model with at least two field children both locatable by id. - private static (string Parent, string First, string Second, IReadOnlyList Order)? - FindSiblingPair(ViewDefinitionModel model) + private static void PersistLayout(Inventory layouts, string citationVisibility = "always", + bool reverse = false) { - (string, string, string, IReadOnlyList)? result = null; - void Visit(ViewNode parent) - { - if (result != null) return; - var fieldChildren = parent.Children.Where(c => c.Kind == ViewNodeKind.Field).ToList(); - if (fieldChildren.Count >= 2) - { - result = (parent.StableId, fieldChildren[0].StableId, fieldChildren[1].StableId, - parent.Children.Select(c => c.StableId).ToList()); - return; - } - - foreach (var child in parent.Children) - Visit(child); - } - - foreach (var root in model.Roots) - Visit(root); - return result; + var first = reverse + ? "" + : ""; + var second = reverse + ? "" + : ""; + var document = new XmlDocument(); + document.LoadXml("" + + first + second + ""); + layouts.PersistOverrideElement(document.DocumentElement); } + + private static IReadOnlyList FieldNames(ComposedDetail composed) + => composed.Model.Fields.Select(field => field.Field).ToList(); } } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index edbe9f0aa5..a87cc2aeae 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -590,22 +590,18 @@ public void TestLangProj_WritingSystemStore_ContainsRtlAndKhmerFixtures() "Khmer fixture declares the Khmer script for automation/manual scenario setup"); } - // Compiled definitions are memoized per (class, layout) while sources stay - // loaded, so a repeat compose reuses every layout instead of rebuilding and - // re-fingerprinting the ~300KB parts snapshot. - [Test] - public void Compose_RepeatCompose_ServesCompiledLayoutsFromTheMemo() - { - Assert.That(DetailComposer.Compose(m_entry, Cache), Is.Not.Null, - "priming compose populates the (class, layout) memo"); - var compilesAfterFirst = DetailComposer.SnapshotCompileCount; - Assert.That(compilesAfterFirst, Is.GreaterThan(0), "the first compose really compiled"); - - var second = DetailComposer.Compose(m_entry, Cache); - Assert.That(second, Is.Not.Null); - Assert.That(second.Model.Fields, Is.Not.Empty, "the memoized models still compose fully"); - Assert.That(DetailComposer.SnapshotCompileCount, Is.EqualTo(compilesAfterFirst), - "a repeat compose must not rebuild any layout snapshot"); + [Test] + public void CompileForObject_RepeatContentReusesCompiledModel() + { + var first = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + var snapshotsAfterFirst = DetailComposer.SnapshotCompileCount; + + var second = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + + Assert.That(second, Is.SameAs(first), + "equal source content must reuse the fingerprint-cached compiled model"); + Assert.That(DetailComposer.SnapshotCompileCount, Is.EqualTo(snapshotsAfterFirst + 1), + "each source lookup constructs a snapshot before content deduplication"); } [Test] From c8608b9c68442a153312dd2664ecdd315b4a7397 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 18:16:21 -0400 Subject: [PATCH 06/23] fix: verify Inventory composition boundaries Exercise project layouts through nested objects, fallback, and refresh paths. Remove the counter; snapshot construction occurs upstream of the composer. Compiled-model identity now verifies fingerprint reuse directly. --- .../Avalonia/Composer/DetailComposer.cs | 16 --- .../Composer/DetailComposerOverrideTests.cs | 112 +++++++++++++++++- .../Composer/DetailEditContextEditingTests.cs | 3 - 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 67006dd0ac..e0453897d3 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using System.Xml.Linq; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; @@ -83,11 +82,6 @@ private static CompilerSources GetSources() } } - // Counts source snapshots handed to the content-fingerprint compiler cache. - private static int s_snapshotCompileCount; - - internal static int SnapshotCompileCount => s_snapshotCompileCount; - /// /// The immutable shipped sources used when an effective project source has no layout. /// @@ -3076,12 +3070,6 @@ internal static IReadOnlyList ResolveWritingSystems return WritingSystemServices.GetWritingSystemList(cache, magicId, forceIncludeEnglish: false); } - /// - /// Compiles the layout for an object's class, walking base classes the way legacy - /// DataTree - /// does (e.g. MoStemAllomorph -> MoForm) for both layout lookup and part resolution. - /// Memoized per (starting class, layout) for the lifetime of the loaded sources. - /// /// /// Resolve the layout-choice GUID for a record whose detail layout is type-selected via /// a layoutChoiceField (e.g. RnGenericRec/Normal keyed on the record's Type possibility). @@ -3135,10 +3123,7 @@ private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, { var projectSnapshot = source(mdc.GetClassName(classId), layoutName, choiceGuid); if (projectSnapshot != null) - { - Interlocked.Increment(ref s_snapshotCompileCount); return Compiler.Compile(projectSnapshot); - } } var sources = GetSources(); @@ -3183,7 +3168,6 @@ private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, var snapshot = new ViewDefinitionSourceSnapshot(className, "detail", layout.ToString(), sources.PartsXml, baseClassMap); - Interlocked.Increment(ref s_snapshotCompileCount); return Compiler.Compile(snapshot); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs index ae46a33b23..280ca413ed 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Xml; using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.LCModel; using SIL.LCModel.Core.Text; using SIL.LCModel.Infrastructure; @@ -24,6 +25,9 @@ public class DetailComposerOverrideTests : MemoryOnlyBackendProviderTestBase + + + "; private const string PartsXml = @" @@ -34,9 +38,16 @@ public class DetailComposerOverrideTests : MemoryOnlyBackendProviderTestBase + + + + + + "; private ILexEntry m_entry; + private ILexSense m_sense; private string m_projectPath; public override void TestSetup() @@ -52,14 +63,57 @@ public override void TestSetup() TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); m_entry.Bibliography.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("source", Cache.DefaultAnalWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(m_sense); + m_sense.Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); }); } public override void TestTearDown() { - base.TestTearDown(); - if (Directory.Exists(m_projectPath)) - Directory.Delete(m_projectPath, true); + try + { + base.TestTearDown(); + } + finally + { + if (Directory.Exists(m_projectPath)) + Directory.Delete(m_projectPath, true); + } + } + + [Test] + public void Compose_InventoryRootFieldsCarryLayoutContext() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "root-context")); + PersistLayout(layouts); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var rootFields = composed.Model.Fields.Where(field => field.ObjectHvo == m_entry.Hvo).ToList(); + + Assert.That(rootFields, Is.Not.Empty); + Assert.That(rootFields, Has.All.Property("ClassName").EqualTo("LexEntry")); + Assert.That(rootFields, Has.All.Property("LayoutName").EqualTo("Normal")); + } + + [Test] + public void Compose_NestedObjectUsesTheSameInventorySourceAndCarriesLayoutContext() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "nested-source")); + PersistLayout(layouts, includeSenses: true); + PersistSenseLayout(layouts); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var nested = composed.Model.Fields.Single(field => field.ObjectHvo == m_sense.Hvo + && field.Field == "Gloss"); + + Assert.That(nested.Label, Is.EqualTo("Project-only Gloss")); + Assert.That(nested.Values.Any(value => value.Value == "house"), Is.True); + Assert.That(nested.ClassName, Is.EqualTo("LexSense")); + Assert.That(nested.LayoutName, Is.EqualTo("Normal")); } [Test] @@ -121,6 +175,45 @@ public void Compose_PersistedChangeIsVisibleOnNextCompose() Assert.That(after.Model.Fields.Any(field => field.Field == "CitationForm"), Is.False); } + [Test] + public void CompileForObject_InventoryContentFingerprintReusesAndRefreshesCompiledModel() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "fingerprint")); + PersistLayout(layouts); + var source = CreateSource(layouts); + + var first = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + var sameContent = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + + PersistLayout(layouts, citationVisibility: "never"); + var changed = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + + Assert.That(sameContent, Is.SameAs(first)); + Assert.That(changed, Is.Not.SameAs(first)); + Assert.That(changed.Roots.First().Visibility, Is.EqualTo(ViewVisibility.Never)); + } + + [Test] + public void CompileForObject_NullSourceResultFallsBackToShippedLayout() + { + ViewDefinitionSourceResolver source = (className, layoutName, choiceGuid) => null; + + var compiled = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source); + + Assert.That(compiled, Is.Not.Null); + Assert.That(compiled.Roots, Has.Count.GreaterThan(2)); + } + + [Test] + public void CompileForObject_SourceExceptionPropagates() + { + ViewDefinitionSourceResolver source = (className, layoutName, choiceGuid) => + throw new InvalidOperationException("source failed"); + + Assert.That(() => DetailComposer.CompileForObject(Cache, m_entry, "Normal", source), + Throws.TypeOf().With.Message.EqualTo("source failed")); + } + private InventoryViewDefinitionSource CreateSource(Inventory layouts) { var parts = new Inventory("*Parts.xml", "/PartInventory/bin/*", @@ -143,7 +236,7 @@ private static Inventory CreateLayoutInventory(string projectPath) } private static void PersistLayout(Inventory layouts, string citationVisibility = "always", - bool reverse = false) + bool reverse = false, bool includeSenses = false) { var first = reverse ? "" @@ -153,7 +246,16 @@ private static void PersistLayout(Inventory layouts, string citationVisibility = : ""; var document = new XmlDocument(); document.LoadXml("" - + first + second + ""); + + first + second + (includeSenses ? "" : "") + + ""); + layouts.PersistOverrideElement(document.DocumentElement); + } + + private static void PersistSenseLayout(Inventory layouts) + { + var document = new XmlDocument(); + document.LoadXml("" + + ""); layouts.PersistOverrideElement(document.DocumentElement); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index a87cc2aeae..bb1cbc4bc2 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -594,14 +594,11 @@ public void TestLangProj_WritingSystemStore_ContainsRtlAndKhmerFixtures() public void CompileForObject_RepeatContentReusesCompiledModel() { var first = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); - var snapshotsAfterFirst = DetailComposer.SnapshotCompileCount; var second = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); Assert.That(second, Is.SameAs(first), "equal source content must reuse the fingerprint-cached compiled model"); - Assert.That(DetailComposer.SnapshotCompileCount, Is.EqualTo(snapshotsAfterFirst + 1), - "each source lookup constructs a snapshot before content deduplication"); } [Test] From 1b54998b2a3cfa9a3475ac926b11a6d4333b8e4f Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 18:34:55 -0400 Subject: [PATCH 07/23] feat: wire Avalonia host to project layouts --- .../Avalonia/Composer/DetailComposer.cs | 6 + .../Hosting/RecordEditView.Avalonia.cs | 198 +++--------------- .../DetailCommandAdapterHardeningTests.cs | 15 ++ .../Hosting/RecordEditViewSwitchTests.cs | 57 +++++ 4 files changed, 111 insertions(+), 165 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index e0453897d3..1ef9397c65 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -82,6 +82,12 @@ private static CompilerSources GetSources() } } + /// + /// Gets the immutable merged parts XML used by the shipped composition source. + /// + internal static string GetMergedPartsXml() + => GetSources()?.PartsXml; + /// /// The immutable shipped sources used when an effective project source has no layout. /// diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 1fa33216e6..2c8355c84d 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -12,7 +12,6 @@ using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.Seams; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.FieldWorks.Common.Framework.DetailControls; // Bare DataTree in this file means the legacy WinForms tree; the Avalonia twin stays qualified. using DataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; @@ -50,13 +49,8 @@ public partial class RecordEditView // open undo task is never orphaned (an orphan makes the shutdown Save throw "Commit at wrong place"). private readonly DetailEditContextHolder m_detailEditContext = new DetailEditContextHolder(); private AvaloniaDetailRefreshController m_avaloniaRefreshController; - // The per-project home of the sparse view-definition override patches that - // drive the Avalonia detail view's per-field Field Visibility / Move Field commands. Lazily built from - // the project ConfigurationSettings folder; the detail view reads it at Compose and the gear - // menu writes it. The legacy WinForms DataTree path NEVER touches this -- it keeps its - // Inventory - // store untouched. - private ViewDefinitionOverrideStore m_viewOverrideStore; + private InventoryViewDefinitionSource m_inventoryViewDefinitionSource; + private string m_inventoryViewDefinitionProjectName; // The approved baseline-adapter ids -- the ONLY routes allowed to drive hidden legacy // infrastructure while Avalonia is active. internal const string CommandMenuRoutingAdapterId = "command-menu-routing"; @@ -331,13 +325,16 @@ private void ShowAvaloniaEntry(ICmObject obj) ComposedDetail composed = null; try { + var source = GetInventoryViewDefinitionSource(); composed = lexEntry != null - ? DetailComposer.Compose(lexEntry, Cache, showHidden) + ? DetailComposer.Compose(lexEntry, Cache, showHidden, + source: source.GetSnapshot) // Non-entry roots compose against the tool's configured layout // (m_layoutName, default "Normal"); a type-selected layout (m_layoutChoiceField, e.g. // Notebook RnGenericRec keyed on "Type") resolves to the right variant inside Compose. : DetailComposer.Compose(obj, Cache, string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, + source: source.GetSnapshot, layoutChoiceField: m_layoutChoiceField); if (composed != null) { @@ -347,9 +344,8 @@ private void ShowAvaloniaEntry(ICmObject obj) } catch (Exception e) { - // The user silently gets the fixed first-slice view instead of the full entry; - // that degradation must be diagnosable from the log, not just a debugger. - Logger.WriteError("Full-entry composition failed; falling back to the first slice.", e); + // Host fallback is diagnosable for both lexical and unsupported record roots. + Logger.WriteError("Avalonia detail composition failed; using the host fallback.", e); } if (detail == null) @@ -386,6 +382,30 @@ private void ShowAvaloniaEntry(ICmObject obj) GetPersistedLabelColumnWidth, PersistLabelColumnWidth); } + private InventoryViewDefinitionSource GetInventoryViewDefinitionSource() + { + var projectName = Cache?.ProjectId?.Name; + if (string.IsNullOrEmpty(projectName)) + throw new InvalidOperationException("The project layout inventory key is unavailable."); + if (m_inventoryViewDefinitionSource != null + && m_inventoryViewDefinitionProjectName == projectName) + { + return m_inventoryViewDefinitionSource; + } + + var layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + if (layouts == null) + throw new InvalidOperationException("The project layout inventory is unavailable."); + var partsXml = DetailComposer.GetMergedPartsXml(); + if (string.IsNullOrEmpty(partsXml)) + throw new InvalidOperationException("The merged detail parts are unavailable."); + + m_inventoryViewDefinitionSource = new InventoryViewDefinitionSource(layouts, partsXml, + Cache.MetaDataCacheAccessor); + m_inventoryViewDefinitionProjectName = projectName; + return m_inventoryViewDefinitionSource; + } + /// /// Called when a writing system editor gains focus. Never throws: a focus /// event can race view teardown, so failures are logged instead. @@ -492,11 +512,7 @@ private void OnDetailMenuRequested(DetailMenuRequest request) // adapter menu remains the fallback if materialization fails. try { - // Retarget the per-field Field Visibility / Move Field commands - // to the project override layer for the Avalonia detail view; every other command (Help, - // inserts, writing-system menu, ...) keeps its normal mediator dispatch. - var interceptor = BuildOverrideCommandInterceptor(request.Field); - var items = XCoreMenuBridge.CreateMenuItems(window, idArray, interceptor); + var items = XCoreMenuBridge.CreateMenuItems(window, idArray); if (items.Count > 0) { // A keyboard-opened menu anchors under the row it came from; a @@ -535,154 +551,6 @@ private static System.Drawing.Point AdapterMenuScreenPoint(DetailMenuRequest req return new System.Drawing.Point(Math.Min(left.X, right.X), left.Y); } - // The per-(class, layout) override file lives in this project's - // ConfigurationSettings folder. Built lazily and - // reused; one store per view instance, so it caches the patches it has loaded. - private ViewDefinitionOverrideStore ViewOverrideStore - { - get - { - if (m_viewOverrideStore == null && Cache?.ProjectId?.ProjectFolder != null) - { - m_viewOverrideStore = new ViewDefinitionOverrideStore( - LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.ProjectFolder)); - } - - return m_viewOverrideStore; - } - } - - // The resolver the composer calls for each compiled (class, layout); null result = shipped - // definition. A load failure is logged, not fatal -- compose then uses the shipped - // definition. - private ViewDefinitionOverride ResolveViewOverride(string className, string layoutName) - => ViewOverrideStore?.TryGet(className, layoutName, - (path, error) => Logger.WriteError("Failed to load view-definition override '" + path - + "'; using the shipped definition.", error)); - - /// - /// Builds the interceptor that retargets the per-field Field Visibility and - /// Move Field commands to the project override layer for the Avalonia detail view. Returns null - /// (intercept nothing -- every command keeps its normal mediator dispatch) when the - /// clicked row - /// carries no (class, layout) context, e.g. the first-slice fallback rows; that keeps the legacy - /// behavior intact when the override layer cannot be addressed. - /// - private Func BuildOverrideCommandInterceptor(DetailField field) - { - if (field == null || string.IsNullOrEmpty(field.ClassName) || string.IsNullOrEmpty(field.LayoutName) - || ViewOverrideStore == null) - { - return null; - } - - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); - // Locate the clicked node in the field's OWN compiled model (with any current override - // already applied), so visibility checkmarks and move enablement reflect the live state. - ViewNodeLocation location = null; - try - { - if (Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var fieldObj)) - { - var model = DetailComposer.CompileForObject(Cache, fieldObj, field.LayoutName); - if (model != null) - location = ViewDefinitionOverrideEditor.LocateTarget(model, templateId); - } - } - catch (Exception e) - { - Logger.WriteError("Resolving the field's override target failed; the gear-menu field " - + "commands fall back to the legacy path for this row.", e); - return null; - } - - if (location == null) - return null; // unknown/stale target: leave commands on the legacy path rather than guess. - - return choice => - { - switch (choice.HelpId) - { - case "CmdAlwaysVisible": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.Always); - case "CmdIfData": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.IfData); - case "CmdNormallyHidden": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.Never); - case "CmdDataTree-MoveFieldUp": - return MoveItem(choice, field, location, up: true); - case "CmdDataTree-MoveFieldDown": - return MoveItem(choice, field, location, up: false); - default: - return null; // not a field command: keep its normal mediator dispatch. - } - }; - } - - // A Field Visibility menu item: checked when it is the field's current visibility, executes the - // SetVisibility override mutation (idempotent -- re-choosing the current value is a - // harmless write). - private DetailMenuItem VisibilityItem(ChoiceBase choice, DetailField field, - string templateId, ViewNodeLocation location, ViewVisibility target) - { - var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text); - var isChecked = location.Visibility == target; - return new DetailMenuItem(label, isEnabled: true, isChecked: isChecked, children: null, - execute: () => ApplyFieldVisibility(field, templateId, target)); - } - - // A Move Field item: disabled at the first sibling (up) / last sibling (down) / when alone. - private DetailMenuItem MoveItem(ChoiceBase choice, DetailField field, - ViewNodeLocation location, bool up) - { - var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text); - var canMove = up ? location.CanMoveUp : location.CanMoveDown; - return new DetailMenuItem(label, isEnabled: canMove, isChecked: false, children: null, - execute: canMove ? (Action)(() => ApplyMoveField(field, location, up)) : null); - } - - // Writes a SetVisibility op for the field's template id into the project override and recomposes. - private void ApplyFieldVisibility(DetailField field, string templateId, ViewVisibility target) - { - var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, templateId, - visibility: target); - MutateOverrideAndRefresh(field, op); - } - - // Writes a ReorderChildren op on the field's PARENT (the sibling order with this field swapped one - // position) into the project override and recomposes. A no-op when the move is not possible. - private void ApplyMoveField(DetailField field, ViewNodeLocation location, bool up) - { - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(location.SiblingOrder, location.Index, up); - if (moved == null || string.IsNullOrEmpty(location.ParentStableId)) - return; // first/last/only sibling, or a root-level row with no parent to reorder. - var op = new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, - location.ParentStableId, childOrder: moved); - MutateOverrideAndRefresh(field, op); - } - - // Loads-or-creates the (class, layout) override, folds the op in, saves it, and recomposes the - // Avalonia detail view so the change is visible immediately. The legacy DataTree/Inventory is untouched. - private void MutateOverrideAndRefresh(DetailField field, ViewOverrideOperation op) - { - try - { - var store = ViewOverrideStore; - if (store == null) - return; - - var existing = store.TryGet(field.ClassName, field.LayoutName) - ?? new ViewDefinitionOverride(field.ClassName, field.LayoutName, "detail", null, null); - var merged = ViewDefinitionOverrideEditor.MergeOperation(existing, op); - store.Save(merged); - RefreshAvaloniaDetail(); - } - catch (Exception e) - { - Logger.WriteError("Applying the field override failed.", e); - } - } - /// /// Follows a chooser jump link (e.g. "Edit the Publications list" on Publish In) the /// EXACT way the legacy chooser does on link click -- the dialog closes, then diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs index 10505e6163..ca4a35f3dd 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs @@ -211,6 +211,21 @@ public void LabelColumnWidth_IgnoresNonPositiveWidths() Assert.That(read.Invoke(m_view, null), Is.Null, "a negative width must not be persisted"); } + [Test] + public void AvaloniaComposition_UsesProjectLayoutInventoryInitializedByLayoutCache() + { + var expected = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + Assert.That(expected, Is.Not.Null, + "LayoutCache.InitializePartInventories should install the project inventory"); + + var source = GetField(m_view, "m_inventoryViewDefinitionSource"); + + Assert.That(source, Is.Not.Null, + "showing the record should lazily create the project view-definition source"); + Assert.That(GetField(source, "_layouts"), Is.SameAs(expected), + "the host source must use the project-keyed Inventory singleton"); + } + // ---------------------------------------------------------------------------------------- // Bootstrap helpers // ---------------------------------------------------------------------------------------- diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index 82c1a5a828..0ddb2a6a3f 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Infrastructure; @@ -143,6 +144,48 @@ public void LexiconEditTool_FlipNewLegacyNew_TearsDownThenRebuildsAvaloniaEntryF "flipping back to New must rebuild the refresh controller"); } + [Test] + public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFrameworkSwitch() + { + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + Assert.That(GetHostedDetailModel(control).Fields, + Has.Some.Property("Field").EqualTo("CitationForm"), + "precondition: the initial project layout includes Citation Form"); + + var layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + var original = layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }).Clone(); + var changed = new XmlDocument(); + changed.LoadXml("" + + ""); + try + { + layouts.PersistOverrideElement(changed.DocumentElement); + + m_propertyTable.SetProperty("UIMode", "Legacy", true); + DrainMediatorAndIdleQueues(); + m_propertyTable.SetProperty("UIMode", "New", true); + DrainMediatorAndIdleQueues(); + EnsureCurrentRecord(control); + + Assert.That(GetHostedDetailModel(control).Fields, + Has.None.Property("Field").EqualTo("CitationForm"), + "the next host composition should read the persisted inventory content"); + } + finally + { + layouts.PersistOverrideElement(original); + } + } + // Tools not registered for Avalonia fall back to legacy under // New mode. (domainTypeEdit = a Lists CmPossibility tool.) Analyses rides // the interlinear editor's Avalonia work -- see @@ -243,6 +286,20 @@ private static object GetPrivateFieldValue(object target, string fieldName) return field.GetValue(target); } + private static DetailModel GetHostedDetailModel(RecordEditView control) + { + var entryForm = GetPrivateField(control, "m_avaloniaEntryForm"); + Assert.That(entryForm, Is.Not.Null, "the Avalonia detail host should be initialized"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null, "the host content field should exist"); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null, "the Avalonia host should contain a detail tree"); + return tree.Model; + } + // DrainMediatorAndIdleQueues is inherited from XWorksAppTestBase. private void EnsureCurrentRecord(RecordEditView control) From ce6869964dcf9c4449d390af24bb3d8f4514c4ce Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 18:53:52 -0400 Subject: [PATCH 08/23] test: restore project layout fixture state --- .../Avalonia/Composer/DetailComposer.cs | 3 +- .../Hosting/RecordEditViewSwitchTests.cs | 87 ++++++++++++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 1ef9397c65..25a36af076 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -83,7 +83,8 @@ private static CompilerSources GetSources() } /// - /// Gets the immutable merged parts XML used by the shipped composition source. + /// Gets the immutable merged parts XML used by the shipped composition source, or null + /// when the shipped sources are unavailable. /// internal static string GetMergedPartsXml() => GetSources()?.PartsXml; diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index 0ddb2a6a3f..ce795645a4 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -21,6 +21,7 @@ public class RecordEditViewSwitchTests : XWorksAppTestBase { private PropertyTable m_propertyTable; private List m_createdObjects; + private string m_fixtureProjectFolder; protected override void Init() { @@ -29,8 +30,11 @@ protected override void Init() // The legacy DataTree's ShowObject (driven by EnsureDataTreeInitialized) needs the // legacy layout/parts Inventory loaded; that Inventory is keyed by the project path, so // give the in-memory test project a writable temp path before the inventory bootstrap. - Cache.ProjectId.Path = Path.Combine(Path.GetTempPath(), Cache.ProjectId.Name, - Cache.ProjectId.Name + ".junk"); + var projectName = Cache.ProjectId.Name; + m_fixtureProjectFolder = Path.Combine(Path.GetTempPath(), + "fw-record-edit-switch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_fixtureProjectFolder); + Cache.ProjectId.Path = Path.Combine(m_fixtureProjectFolder, projectName + ".junk"); } [SetUp] @@ -45,7 +49,8 @@ public void SetUpWindow() // EnsureDataTreeInitialized (LayoutCache loads the real lexicon .fwlayout/Parts). // Without it, DataTree.GetTemplateForObjLayout finds a null layout inventory and ShowObject // throws an NRE once the idle-queued show actually runs. - LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, Cache.ProjectId.Path); + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.ProjectFolder); m_createdObjects = new List(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); } @@ -64,6 +69,16 @@ public void TearDownWindow() } } + protected override void TearDown() + { + Inventory.RemoveInventory("layouts", Cache.ProjectId.Name); + Inventory.RemoveInventory("parts", Cache.ProjectId.Name); + var configurationDirectory = LcmFileHelper.GetConfigSettingsDir(m_fixtureProjectFolder); + DeleteEmptyFixtureDirectory(configurationDirectory, m_fixtureProjectFolder); + DeleteEmptyFixtureDirectory(m_fixtureProjectFolder, Path.GetTempPath()); + base.TearDown(); + } + [Test] public void LexiconEditTool_UsesLegacyDataTree_WhenUIModeIsLegacy() { @@ -161,6 +176,11 @@ public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFramework "precondition: the initial project layout includes Citation Form"); var layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + var configurationDirectory = LcmFileHelper.GetConfigSettingsDir( + Cache.ProjectId.ProjectFolder); + var overridePath = GetLexEntryOverridePath(configurationDirectory); + var overrideExisted = File.Exists(overridePath); + var overrideBytes = overrideExisted ? File.ReadAllBytes(overridePath) : null; var original = layouts.GetElement("layout", new[] { "LexEntry", "detail", "Normal", null }).Clone(); var changed = new XmlDocument(); @@ -182,8 +202,22 @@ public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFramework } finally { - layouts.PersistOverrideElement(original); + RestoreOverrideFile(configurationDirectory, overridePath, overrideExisted, + overrideBytes); + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.ProjectFolder); } + Assert.That(File.Exists(overridePath), Is.EqualTo(overrideExisted), + "the persistence test should restore the override file's prior existence"); + if (overrideExisted) + { + Assert.That(File.ReadAllBytes(overridePath), Is.EqualTo(overrideBytes), + "the persistence test should restore the override file's exact bytes"); + } + var restored = Inventory.GetInventory("layouts", Cache.ProjectId.Name).GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(restored.OuterXml, Is.EqualTo(original.OuterXml), + "the effective layout inventory should be restored for later tests"); } // Tools not registered for Avalonia fall back to legacy under @@ -300,6 +334,51 @@ private static DetailModel GetHostedDetailModel(RecordEditView control) return tree.Model; } + private static string GetLexEntryOverridePath(string configurationDirectory) + { + var fullDirectory = Path.GetFullPath(configurationDirectory); + var overridePath = Path.GetFullPath(Path.Combine(fullDirectory, "LexEntry.fwlayout")); + Assert.That(Path.GetDirectoryName(overridePath), + Is.EqualTo(fullDirectory).IgnoreCase, + "the override path must remain inside the fixture ConfigurationSettings directory"); + return overridePath; + } + + private static void RestoreOverrideFile(string configurationDirectory, string overridePath, + bool existed, byte[] bytes) + { + var validatedPath = GetLexEntryOverridePath(configurationDirectory); + Assert.That(overridePath, Is.EqualTo(validatedPath).IgnoreCase, + "cleanup must target the captured LexEntry override path"); + if (existed) + { + Directory.CreateDirectory(configurationDirectory); + File.WriteAllBytes(validatedPath, bytes); + } + else if (File.Exists(validatedPath)) + { + File.Delete(validatedPath); + } + } + + private static void DeleteEmptyFixtureDirectory(string directory, string expectedParent) + { + var fullDirectory = Path.GetFullPath(directory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullParent = Path.GetFullPath(expectedParent) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.Equals(Path.GetDirectoryName(fullDirectory), fullParent, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Fixture cleanup directory is outside its expected parent."); + } + if (Directory.Exists(fullDirectory) + && Directory.GetFileSystemEntries(fullDirectory).Length == 0) + { + Directory.Delete(fullDirectory); + } + } + // DrainMediatorAndIdleQueues is inherited from XWorksAppTestBase. private void EnsureCurrentRecord(RecordEditView control) From b2a194bab2c01c4742a7a61489f8de923276fde2 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 18:56:58 -0400 Subject: [PATCH 09/23] test: avoid leaking unique project fixtures --- .../Hosting/RecordEditViewSwitchTests.cs | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index ce795645a4..99002cffcf 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -21,7 +21,6 @@ public class RecordEditViewSwitchTests : XWorksAppTestBase { private PropertyTable m_propertyTable; private List m_createdObjects; - private string m_fixtureProjectFolder; protected override void Init() { @@ -30,11 +29,8 @@ protected override void Init() // The legacy DataTree's ShowObject (driven by EnsureDataTreeInitialized) needs the // legacy layout/parts Inventory loaded; that Inventory is keyed by the project path, so // give the in-memory test project a writable temp path before the inventory bootstrap. - var projectName = Cache.ProjectId.Name; - m_fixtureProjectFolder = Path.Combine(Path.GetTempPath(), - "fw-record-edit-switch-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(m_fixtureProjectFolder); - Cache.ProjectId.Path = Path.Combine(m_fixtureProjectFolder, projectName + ".junk"); + Cache.ProjectId.Path = Path.Combine(Path.GetTempPath(), Cache.ProjectId.Name, + Cache.ProjectId.Name + ".junk"); } [SetUp] @@ -69,16 +65,6 @@ public void TearDownWindow() } } - protected override void TearDown() - { - Inventory.RemoveInventory("layouts", Cache.ProjectId.Name); - Inventory.RemoveInventory("parts", Cache.ProjectId.Name); - var configurationDirectory = LcmFileHelper.GetConfigSettingsDir(m_fixtureProjectFolder); - DeleteEmptyFixtureDirectory(configurationDirectory, m_fixtureProjectFolder); - DeleteEmptyFixtureDirectory(m_fixtureProjectFolder, Path.GetTempPath()); - base.TearDown(); - } - [Test] public void LexiconEditTool_UsesLegacyDataTree_WhenUIModeIsLegacy() { @@ -361,24 +347,6 @@ private static void RestoreOverrideFile(string configurationDirectory, string ov } } - private static void DeleteEmptyFixtureDirectory(string directory, string expectedParent) - { - var fullDirectory = Path.GetFullPath(directory) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var fullParent = Path.GetFullPath(expectedParent) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!string.Equals(Path.GetDirectoryName(fullDirectory), fullParent, - StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("Fixture cleanup directory is outside its expected parent."); - } - if (Directory.Exists(fullDirectory) - && Directory.GetFileSystemEntries(fullDirectory).Length == 0) - { - Directory.Delete(fullDirectory); - } - } - // DrainMediatorAndIdleQueues is inherited from XWorksAppTestBase. private void EnsureCurrentRecord(RecordEditView control) From 6a5f119dcc7fc30a184dfa7657c53d79af5281d2 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 19:05:56 -0400 Subject: [PATCH 10/23] test: reload retained project layout inventory --- .../Avalonia/Hosting/RecordEditView.Avalonia.cs | 1 - .../Hosting/RecordEditViewSwitchTests.cs | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 2c8355c84d..ed7e1e45a3 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -344,7 +344,6 @@ private void ShowAvaloniaEntry(ICmObject obj) } catch (Exception e) { - // Host fallback is diagnosable for both lexical and unsupported record roots. Logger.WriteError("Avalonia detail composition failed; using the host fallback.", e); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index 99002cffcf..0e62b44d81 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -190,8 +190,7 @@ public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFramework { RestoreOverrideFile(configurationDirectory, overridePath, overrideExisted, overrideBytes); - LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, - Cache.ProjectId.ProjectFolder); + layouts.Reload(); } Assert.That(File.Exists(overridePath), Is.EqualTo(overrideExisted), "the persistence test should restore the override file's prior existence"); @@ -200,10 +199,21 @@ public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFramework Assert.That(File.ReadAllBytes(overridePath), Is.EqualTo(overrideBytes), "the persistence test should restore the override file's exact bytes"); } - var restored = Inventory.GetInventory("layouts", Cache.ProjectId.Name).GetElement("layout", + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(layouts), + "cleanup should retain the Inventory instance held by the live Avalonia source"); + var restored = layouts.GetElement("layout", new[] { "LexEntry", "detail", "Normal", null }); Assert.That(restored.OuterXml, Is.EqualTo(original.OuterXml), "the effective layout inventory should be restored for later tests"); + + m_propertyTable.SetProperty("UIMode", "Legacy", true); + DrainMediatorAndIdleQueues(); + m_propertyTable.SetProperty("UIMode", "New", true); + DrainMediatorAndIdleQueues(); + EnsureCurrentRecord(control); + Assert.That(GetHostedDetailModel(control).Fields, + Has.Some.Property("Field").EqualTo("CitationForm"), + "the retained project source should recompose from the restored inventory"); } // Tools not registered for Avalonia fall back to legacy under From 702a1aa525c3a87754654a9fb2d471a8b05ea03c Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 19:51:17 -0400 Subject: [PATCH 11/23] feat: share legacy layout command writers --- Src/Common/FwAvalonia/Detail/DetailModel.cs | 4 + .../FwAvalonia/Detail/LexiconFirstSlice.cs | 3 +- .../ViewDefinition/ViewDefinitionModel.cs | 71 ++++++- .../ViewDefinitionOverrideApplier.cs | 6 +- .../ViewDefinition/XmlLayoutImporter.cs | 55 ++++-- .../Avalonia/Composer/DetailComposer.cs | 64 ++++--- .../Hosting/RecordEditView.Avalonia.cs | 140 +++++++++++++- .../DetailCommandAdapterHardeningTests.cs | 68 +++++++ .../DetailObjectCommandExecutionTests.cs | 178 ++++++++++++++++++ 9 files changed, 531 insertions(+), 58 deletions(-) diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index cabdf10956..9afa82130a 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1622,6 +1622,10 @@ public DetailField( /// public string LayoutName { get; set; } + /// The owning caller part's structural address in the effective legacy + /// layout. + public string SourceCallerPath { get; set; } + /// /// The project's available CHARACTER-type style names /// the per-WS editor offers when restyling a selection (sourced by the composer from the project's diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index b582547709..f2ec86968f 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,8 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product); + source.LocalizationKey, automationId, HostRouting.Product, + sourceCallerPath: source.SourceCallerPath); private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index dda5313757..09a664468f 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -7,9 +7,72 @@ using System.Collections.ObjectModel; using System.Linq; using System.Text; +using System.Xml; +using System.Xml.Linq; namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition { + /// + /// Creates the same structural address for a legacy layout caller represented by either XML + /// API. + /// Same-name element ordinals make the address independent of whitespace and stable across + /// the cloned + /// effective layout documents used by the importer and legacy slice tree. + /// + public static class LegacyLayoutCallerPath + { + public static string Get(XElement caller) + { + if (caller == null) + return null; + var path = new Stack(); + var current = caller; + while (current.Parent != null && current.Parent.Name.LocalName != "layout") + { + path.Push(Segment(current.Name.LocalName, + current.ElementsBeforeSelf().Count(element => + element.Name.LocalName == current.Name.LocalName))); + current = current.Parent; + } + if (current.Parent == null || current.Parent.Name.LocalName != "layout") + return null; + path.Push(Segment(current.Name.LocalName, + current.ElementsBeforeSelf().Count(element => + element.Name.LocalName == current.Name.LocalName))); + return string.Join("/", path); + } + + public static string Get(XmlNode caller) + { + if (caller == null) + return null; + var path = new Stack(); + var current = caller; + while (current.ParentNode != null && current.ParentNode.LocalName != "layout") + { + path.Push(Segment(current.LocalName, SameNamePredecessorCount(current))); + current = current.ParentNode; + } + if (current.ParentNode == null || current.ParentNode.LocalName != "layout") + return null; + path.Push(Segment(current.LocalName, SameNamePredecessorCount(current))); + return string.Join("/", path); + } + + private static int SameNamePredecessorCount(XmlNode node) + { + var count = 0; + for (var sibling = node.PreviousSibling; sibling != null; sibling = sibling.PreviousSibling) + { + if (sibling.NodeType == XmlNodeType.Element && sibling.LocalName == node.LocalName) + count++; + } + return count; + } + + private static string Segment(string name, int ordinal) => $"{name}[{ordinal}]"; + } + /// /// Structural kind of a typed view-definition node. Mirrors the node types produced by the /// legacy XML Parts/Layout interpretation in SliceFactory/DataTree: @@ -385,7 +448,8 @@ public ViewNode( IReadOnlyList chooserLinks = null, ViewStringList enumStringList = null, IReadOnlyList visibleWritingSystems = null, - bool toggleValue = false) + bool toggleValue = false, + string sourceCallerPath = null) { ToggleValue = toggleValue; VisibleWritingSystems = visibleWritingSystems; @@ -421,11 +485,16 @@ public ViewNode( GhostInitMethod = ghostInitMethod; Condition = condition; ChooserLinks = chooserLinks ?? (IReadOnlyList)Array.Empty(); + SourceCallerPath = sourceCallerPath; } /// Deterministic identity derived from the node's path (stable across realizations). public string StableId { get; } + /// The structural address of the owning caller part in the effective legacy + /// layout. + public string SourceCallerPath { get; } + public ViewNodeKind Kind { get; } public string Label { get; } diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs index 3e3174fd66..ea1335cb59 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs @@ -296,7 +296,8 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string n.LocalizationKey, n.AutomationId, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue); + n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, + n.SourceCallerPath); // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh, // non-colliding identity (the renderer derives one from the new StableId by convention). @@ -307,6 +308,7 @@ private static ViewNode CloneWithId(ViewNode n, string newId) n.LocalizationKey, null, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue); + n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, + n.SourceCallerPath); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index c3104f8500..176ee4f1f1 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -165,13 +165,14 @@ private void ProcessPart( { var stableId = $"{parentPath}/#{output.Count}"; var refName = (string)callerEl.Attribute("ref"); + var sourceCallerPath = LegacyLayoutCallerPath.Get(callerEl); // Custom-field placeholder: or ref="_CustomFieldPlaceholder". if (callerEl.Attribute("customFields") != null || refName == "_CustomFieldPlaceholder") { output.Add(MakeLeaf(stableId, ViewNodeKind.CustomFieldPlaceholder, "(custom fields)", null, null, null, EditorClassification.GroupingNone, null, ViewVisibility.Always, - ViewExpansion.NotApplicable, indented, null)); + ViewExpansion.NotApplicable, indented, null, sourceCallerPath)); return; } @@ -219,7 +220,8 @@ private void ProcessPart( ParseExpansion(Attr(callerEl, "expansion")), indented, null, recoveredChildren, menuId: Attr(callerEl, "menu"), - hotlinksId: Attr(callerEl, "hotlinks"))); + hotlinksId: Attr(callerEl, "hotlinks"), + sourceCallerPath: sourceCallerPath)); } } return; @@ -233,7 +235,7 @@ private void ProcessPart( // Each sibling after the first needs its own stable id so they don't collide. var childStableId = i == 0 ? stableId : $"{parentPath}/#{output.Count}"; var node = CreateNode(contents[i], callerEl, parts, className, layoutType, childStableId, - indented, diagnostics); + indented, diagnostics, sourceCallerPath); if (node != null) { output.Add(node); @@ -249,7 +251,8 @@ private ViewNode CreateNode( string layoutType, string stableId, bool indented, - List diagnostics) + List diagnostics, + string sourceCallerPath = null) { var label = Attr(callerEl, "label") ?? Attr(contentEl, "label"); var abbreviation = Attr(callerEl, "abbr") ?? Attr(contentEl, "abbr"); @@ -342,7 +345,8 @@ private ViewNode CreateNode( } var children = new List(); - AddInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); + AddInlineChildren(childElements, parts, className, layoutType, stableId, children, + diagnostics, sourceCallerPath); // A jtview slice (editor="jtview") names the nested layout to compose for this // object in its caller's param (legacy SliceFactory jtview: param ?? node layout attr). @@ -394,7 +398,8 @@ private ViewNode CreateNode( localizationKey, automationId, routing, boldEmphasis, fontScalePercent, menuId, contextMenuId, hotlinksId, chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, - visibleWritingSystems: visibleWss); + visibleWritingSystems: visibleWss, + sourceCallerPath: sourceCallerPath); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -413,7 +418,8 @@ private ViewNode CreateNode( visibleWritingSystems: visibleWss, // Legacy toggleValue= on a boolean slice (the displayed checkbox is the // logical inverse of the stored property); carried so the composer inverts read+write. - toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false); + toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false, + sourceCallerPath: sourceCallerPath); } case "obj": case "seq": @@ -436,7 +442,8 @@ private ViewNode CreateNode( ghostLabel: Attr(contentEl, "ghostLabel") ?? Attr(callerEl, "ghostLabel"), // The layout's post-create hook rides the node so the composer's ghost // setter can invoke it the way GhostStringSliceView.MakeRealObject does. - ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod")); + ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod"), + sourceCallerPath: sourceCallerPath); } // Conditional display: / shows content only when the condition // passes (fails, for ifnot), evaluated via XmlVc.ConditionPasses. Preserved @@ -451,12 +458,12 @@ private ViewNode CreateNode( var children = new List(); AddConditionalChildren(contentEl, parts, className, layoutType, stableId, indented, - children, diagnostics); + children, diagnostics, sourceCallerPath); return new ViewNode(stableId, ViewNodeKind.Conditional, label, abbreviation, Attr(contentEl, "field"), null, EditorClassification.GroupingNone, null, visibility, expansion, indented, null, children, localizationKey, automationId, routing, menuId: menuId, contextMenuId: contextMenuId, hotlinksId: hotlinksId, - condition: condition); + condition: condition, sourceCallerPath: sourceCallerPath); } // holds branches (first passing one renders) and an optional @@ -488,17 +495,19 @@ private ViewNode CreateNode( var branchChildren = new List(); AddConditionalChildren(clause, parts, className, layoutType, branchId, indented, - branchChildren, diagnostics); + branchChildren, diagnostics, sourceCallerPath); branches.Add(new ViewNode(branchId, ViewNodeKind.Conditional, null, null, Attr(clause, "field"), null, EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.NotApplicable, indented, null, - branchChildren, condition: branchCondition)); + branchChildren, condition: branchCondition, + sourceCallerPath: sourceCallerPath)); } return new ViewNode(stableId, ViewNodeKind.ChoiceGroup, label, abbreviation, null, null, EditorClassification.GroupingNone, null, visibility, expansion, indented, null, branches, localizationKey, automationId, routing, menuId: menuId, - contextMenuId: contextMenuId, hotlinksId: hotlinksId); + contextMenuId: contextMenuId, hotlinksId: hotlinksId, + sourceCallerPath: sourceCallerPath); } default: @@ -590,7 +599,8 @@ private void AddConditionalChildren( string parentPath, bool indented, List output, - List diagnostics) + List diagnostics, + string sourceCallerPath) { foreach (var child in container.Elements()) { @@ -604,7 +614,7 @@ private void AddConditionalChildren( default: { var node = CreateNode(child, child, parts, className, layoutType, - $"{parentPath}/#{output.Count}", indented, diagnostics); + $"{parentPath}/#{output.Count}", indented, diagnostics, sourceCallerPath); if (node != null) output.Add(node); break; @@ -671,12 +681,14 @@ private void AddInlineChildren( string layoutType, string parentPath, List output, - List diagnostics) + List diagnostics, + string sourceCallerPath) { foreach (var child in childElements) { var stableId = $"{parentPath}/#{output.Count}"; - var node = CreateNode(child, child, parts, className, layoutType, stableId, false, diagnostics); + var node = CreateNode(child, child, parts, className, layoutType, stableId, false, + diagnostics, sourceCallerPath); if (node != null) { output.Add(node); @@ -722,7 +734,8 @@ private void AddInjectedChildren( continue; } - var node = CreateNode(content, child, parts, "", layoutType, stableId, false, diagnostics); + var node = CreateNode(content, child, parts, "", layoutType, stableId, false, diagnostics, + LegacyLayoutCallerPath.Get(child)); if (node != null) { output.Add(node); @@ -754,9 +767,11 @@ private static ViewNode MakeLeaf( string stableId, ViewNodeKind kind, string label, string abbreviation, string field, string editor, EditorClassification classification, string ws, ViewVisibility visibility, ViewExpansion expansion, bool indented, string targetLayout, - string localizationKey = null, string automationId = null, HostRouting routing = HostRouting.Inherit) + string sourceCallerPath = null, string localizationKey = null, string automationId = null, + HostRouting routing = HostRouting.Inherit) => new ViewNode(stableId, kind, label, abbreviation, field, editor, classification, ws, visibility, - expansion, indented, targetLayout, System.Array.Empty(), localizationKey, automationId, routing); + expansion, indented, targetLayout, System.Array.Empty(), localizationKey, automationId, + routing, sourceCallerPath: sourceCallerPath); private static string Attr(XElement el, string name) => (string)el.Attribute(name); diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 25a36af076..6345f95d32 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -275,6 +275,7 @@ public FieldEditHandler HandlerFor(string stableId) private readonly ViewDefinitionSourceResolver _source; private readonly Stack<(string ClassName, string LayoutName)> _modelContext = new Stack<(string, string)>(); + private readonly Stack _sourceCallerPaths = new Stack(); // The plugin registry consulted FIRST for every // custom slice, plus the deferred accessor for the edit context plugin factories // receive (resolved when the factory runs, after Compose has built the context). @@ -345,6 +346,8 @@ private void AddField(DetailField field) field.ClassName = ctx.ClassName; field.LayoutName = ctx.LayoutName; } + if (_sourceCallerPaths.Count > 0) + field.SourceCallerPath = _sourceCallerPaths.Peek(); Fields.Add(field); } @@ -499,35 +502,37 @@ public void Walk(ViewNode node, ICmObject obj, int depth) if (IsHidden(node) || depth > MaxDepth) return; - switch (node.Kind) + _sourceCallerPaths.Push(node.SourceCallerPath); + try { - case ViewNodeKind.Field: - WalkField(node, obj, depth); - break; - case ViewNodeKind.Group: - WalkGroup(node, obj, depth); - break; - case ViewNodeKind.ObjectAtom: - WalkObjectAtom(node, obj, depth); - break; - case ViewNodeKind.Sequence: - WalkSequence(node, obj, depth); - break; - case ViewNodeKind.CustomFieldPlaceholder: - // Runtime expansion of `customFields="here"` from - // live MDC metadata. - WalkCustomFields(node, obj, depth); - break; - case ViewNodeKind.Conditional: - // Legacy / -- content composes only when the per-object - // condition - // passes (DataTree.ProcessSubpartNode cases "if"/"ifnot"). - WalkConditional(node, obj, depth); - break; - case ViewNodeKind.ChoiceGroup: - // Legacy -- first passing (or the ) only. - WalkChoiceGroup(node, obj, depth); - break; + switch (node.Kind) + { + case ViewNodeKind.Field: + WalkField(node, obj, depth); + break; + case ViewNodeKind.Group: + WalkGroup(node, obj, depth); + break; + case ViewNodeKind.ObjectAtom: + WalkObjectAtom(node, obj, depth); + break; + case ViewNodeKind.Sequence: + WalkSequence(node, obj, depth); + break; + case ViewNodeKind.CustomFieldPlaceholder: + WalkCustomFields(node, obj, depth); + break; + case ViewNodeKind.Conditional: + WalkConditional(node, obj, depth); + break; + case ViewNodeKind.ChoiceGroup: + WalkChoiceGroup(node, obj, depth); + break; + } + } + finally + { + _sourceCallerPaths.Pop(); } } @@ -787,7 +792,8 @@ private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) return new ViewNode($"{placeholder.StableId}/custom:{fieldName}", ViewNodeKind.Field, _mdc.GetFieldLabel(flid), null, fieldName, rawEditor, EditorClassification.Known, wsSpec, ViewVisibility.Always, ViewExpansion.NotApplicable, placeholder.Indented, - null, null, menuId: "mnuDataTree-Help"); + null, null, menuId: "mnuDataTree-Help", + sourceCallerPath: placeholder.SourceCallerPath); } // The node's chooserLink wins; else the row derives its tool like the legacy path. diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index ed7e1e45a3..7f609ea3c8 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -12,6 +12,7 @@ using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.FieldWorks.Common.Framework.DetailControls; // Bare DataTree in this file means the legacy WinForms tree; the Avalonia twin stays qualified. using DataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; @@ -511,7 +512,7 @@ private void OnDetailMenuRequested(DetailMenuRequest request) // adapter menu remains the fallback if materialization fails. try { - var items = XCoreMenuBridge.CreateMenuItems(window, idArray); + var items = CreateNativeDetailMenuItems(request.Field, idArray); if (items.Count > 0) { // A keyboard-opened menu anchors under the row it came from; a @@ -528,6 +529,7 @@ private void OnDetailMenuRequested(DetailMenuRequest request) } window.ShowContextMenu(idArray, AdapterMenuScreenPoint(request), null, null); + RefreshAvaloniaDetail(); } catch (Exception e) { @@ -535,6 +537,50 @@ private void OnDetailMenuRequested(DetailMenuRequest request) } } + private IReadOnlyList CreateNativeDetailMenuItems(DetailField field, + string[] menuIds) + { + var window = m_propertyTable.GetValue("window"); + return XCoreMenuBridge.CreateMenuItems(window, menuIds, + choice => CreateLegacyCommandMenuItem(field, choice)); + } + + private DetailMenuItem CreateLegacyCommandMenuItem(DetailField field, ChoiceBase choice) + { + var persistent = IsPersistentLayoutCommand(choice); + var hasTarget = persistent + ? EnsurePersistentMenuCommandTarget(field) + : EnsureMenuCommandTarget(field.ObjectHvo, field.Field); + var display = choice.GetDisplayProperties(); + var captured = choice; + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), + hasTarget && display.Enabled, display.Checked, null, () => + { + var canExecute = persistent + ? EnsurePersistentMenuCommandTarget(field) + : EnsureMenuCommandTarget(field.ObjectHvo, field.Field); + if (!canExecute) + return; + captured.OnClick(null, EventArgs.Empty); + RefreshAvaloniaDetail(); + }); + } + + private static bool IsPersistentLayoutCommand(ChoiceBase choice) + { + switch (choice?.HelpId) + { + case "CmdAlwaysVisible": + case "CmdIfData": + case "CmdNormallyHidden": + case "CmdDataTree-MoveFieldUp": + case "CmdDataTree-MoveFieldDown": + return true; + default: + return false; + } + } + // The adapter fallback needs a raw screen point: cursor position for a right-click, // the anchor's bottom-left otherwise. Both corners are mapped since // RTL flow mirrors X in PointToScreen. @@ -593,6 +639,11 @@ internal static FwLinkArgs CreateFollowLinkArgs(DetailLinkRequest request) // handlers require. Created lazily on first right-click; never attached/visible while the // Avalonia is active. private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) + { + EnsureMenuCommandTarget(targetHvo, fieldName); + } + + private bool EnsureMenuCommandTarget(int targetHvo, string fieldName) { // The active-host contract is enforced, not just documented: driving the hidden // legacy DataTree is legal only through an adapter id the host's contract lists. The @@ -616,7 +667,7 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) // No current record: drop any target left by a previous interaction, same // fail-loud rule as the no-slice-found path below. m_dataEntryForm.ClearCurrentSlice(); - return; + return false; } m_dataEntryForm.ShowObject(current, m_layoutName, m_layoutChoiceField, current, true); @@ -624,7 +675,7 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) { // The row carries no object, so no slice can be its target. m_dataEntryForm.ClearCurrentSlice(); - return; + return false; } // Targeting hardening: the legacy command handlers act on m_dataEntryForm.CurrentSlice, @@ -637,10 +688,10 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) // than silently leaving the wrong (or stale) CurrentSlice pointed, which would make the command // mutate the wrong object or, for Merge's class guard, silently fail. if (TrySetCurrentSliceForRow(targetHvo, fieldName)) - return; + return true; if (RealizeLazySlicesAndRetry(targetHvo, fieldName)) - return; + return true; // Fail loud, not silent: if we still cannot produce a slice for the target we must NOT leave // CurrentSlice pointed at whatever the previous interaction selected (it would mis-target the @@ -651,6 +702,85 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) "Detail menu command adapter found no DataTree slice for target hvo {0} field '{1}'; " + "CurrentSlice was cleared so the command no-ops rather than mis-targeting another object.", targetHvo, fieldName ?? string.Empty)); + return false; + } + + private bool EnsurePersistentMenuCommandTarget(DetailField field) + { + if (!EnsureMenuCommandTarget(field.ObjectHvo, field.Field)) + return false; + + var candidates = new List(); + foreach (var sliceObj in m_dataEntryForm.Slices) + { + if (sliceObj is Slice slice && slice.Object != null && !slice.IsLazyPlaceholder) + candidates.Add(slice); + } + var identities = candidates.Select(slice => PersistentSliceIdentity(slice)).ToList(); + var index = ChoosePersistentTargetSliceIndex(identities, field.ObjectHvo, field.Field, + field.ClassName, field.LayoutName, field.SourceCallerPath); + if (index < 0) + { + m_dataEntryForm.ClearCurrentSlice(); + Logger.WriteEvent(string.Format( + "Detail layout command found no unique slice for '{0}' at '{1}'; CurrentSlice was cleared.", + field.Field ?? string.Empty, field.SourceCallerPath ?? string.Empty)); + return false; + } + m_dataEntryForm.SetCurrentSliceForCommandTarget(candidates[index]); + return true; + } + + internal static int ChoosePersistentTargetSliceIndex( + IReadOnlyList<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> candidates, + int targetHvo, string fieldName, string className, string layoutName, string callerPath) + { + if (candidates == null || string.IsNullOrEmpty(callerPath)) + return -1; + var match = -1; + for (var i = 0; i < candidates.Count; i++) + { + var candidate = candidates[i]; + if (candidate.Hvo != targetHvo + || !string.Equals(candidate.FieldName, fieldName, StringComparison.Ordinal) + || !string.Equals(candidate.ClassName, className, StringComparison.Ordinal) + || !string.Equals(candidate.LayoutName, layoutName, StringComparison.Ordinal) + || !string.Equals(candidate.CallerPath, callerPath, StringComparison.Ordinal)) + { + continue; + } + if (match >= 0) + return -1; + match = i; + } + return match; + } + + private (int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath) + PersistentSliceIdentity(Slice slice) + { + if (slice?.Key == null) + return (0, null, null, null, null); + XmlNode layout = null; + XmlNode part = null; + foreach (var keyItem in slice.Key) + { + if (!(keyItem is XmlNode node)) + continue; + if (node.Name == "layout") + { + layout = node; + part = null; + } + else if (layout != null && node.Name == "part" + && node.Attributes?["ref"] != null && LegacyLayoutCallerPath.Get(node) != null) + { + part = node; + } + } + return (slice.Object?.Hvo ?? 0, SliceFieldName(slice), + layout?.Attributes?["class"]?.Value, layout?.Attributes?["name"]?.Value, + LegacyLayoutCallerPath.Get(part)); } /// diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs index ca4a35f3dd..602603f2cb 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs @@ -9,9 +9,11 @@ using System.Reflection; using System.Windows.Forms; using System.Xml; +using System.Xml.Linq; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -226,6 +228,72 @@ public void AvaloniaComposition_UsesProjectLayoutInventoryInitializedByLayoutCac "the host source must use the project-keyed Inventory singleton"); } + [Test] + public void ImportedCallerPath_IsCanonicalWhenPartsSkipOrExpandOutput() + { + var parts = new DictionaryPartResolver(XElement.Parse(@" + + + + + + + + +")); + const string layoutXml = @" + + + + +"; + + var model = new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts); + var xml = new XmlDocument(); + xml.LoadXml(layoutXml); + var legacyCaller = xml.SelectSingleNode("/layout/part[@ref='Multiple']"); + + Assert.That(model.Roots.Take(2).Select(node => node.SourceCallerPath), + Is.All.EqualTo("part[1]"), + "every output expanded from one caller must retain the same source identity"); + Assert.That(model.Roots[2].SourceCallerPath, Is.EqualTo("part[2]"), + "a skipped caller must not collapse the source address to the output index"); + Assert.That(LegacyLayoutCallerPath.Get(legacyCaller), Is.EqualTo("part[1]"), + "the XmlNode slice key and XElement importer clones must compute the same identity"); + } + + [Test] + public void ChoosePersistentTargetSliceIndex_UsesCallerPathToDisambiguateDuplicateFields() + { + var candidates = new List<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> + { + (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), + (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]") + }; + + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]"); + + Assert.That(index, Is.EqualTo(1), + "the imported caller path should select the same layout part as the legacy slice key"); + } + + [Test] + public void ChoosePersistentTargetSliceIndex_AmbiguousExactPath_FailsClosed() + { + var candidates = new List<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> + { + (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), + (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]") + }; + + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"); + + Assert.That(index, Is.EqualTo(-1), + "persistent layout commands require one exact slice and must reject ambiguous matches"); + } + // ---------------------------------------------------------------------------------------- // Bootstrap helpers // ---------------------------------------------------------------------------------------- diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 1539a74cb5..7c19cedc00 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -16,6 +16,7 @@ using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; +using SIL.LCModel.Core.Text; using SIL.LCModel.Infrastructure; using XCore; // Both namespaces above define DataTree; the adapter tests mean the legacy WinForms one. @@ -55,6 +56,10 @@ public class DetailObjectCommandExecutionTests : XWorksAppTestBase private List m_createdObjects; private ILexEntry m_entry; private RecordEditView m_view; + private Inventory m_layouts; + private string m_layoutOverridePath; + private bool m_layoutOverrideExisted; + private byte[] m_layoutOverrideBytes; protected override void Init() { @@ -87,6 +92,16 @@ public void SetUpWindow() // Without it, DataTree.GetTemplateForObjLayout finds a null layout inventory and ShowObject // throws an NRE. This is the same bootstrap the DictionaryConfigurationMigrator tests use. LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, Cache.ProjectId.Path); + m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + var configurationDirectory = LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path); + m_layoutOverridePath = Path.GetFullPath(Path.Combine(configurationDirectory, + "LexEntry.fwlayout")); + Assert.That(Path.GetDirectoryName(m_layoutOverridePath), + Is.EqualTo(Path.GetFullPath(configurationDirectory)).IgnoreCase); + m_layoutOverrideExisted = File.Exists(m_layoutOverridePath); + m_layoutOverrideBytes = m_layoutOverrideExisted + ? File.ReadAllBytes(m_layoutOverridePath) + : null; m_createdObjects = new List(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); @@ -105,6 +120,7 @@ public void SetUpWindow() [TearDown] public void TearDownWindow() { + RestoreLayoutOverride(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyTestData); m_createdObjects = null; m_entry = null; @@ -118,6 +134,85 @@ public void TearDownWindow() } } + [TestCase("CmdAlwaysVisible", "Always visible", "ifdata", "always")] + [TestCase("CmdIfData", "Normally hidden, unless non-empty", "always", "ifdata")] + [TestCase("CmdNormallyHidden", "Normally hidden", "always", "never")] + [TestCase("CmdDataTree-MoveFieldUp", "Move Up", null, "up")] + [TestCase("CmdDataTree-MoveFieldDown", "Move Down", null, "down")] + public void PersistentLayoutCommand_UsesLegacyWriter_PersistsAndRecomposes( + string commandId, string label, string initialVisibility, string expectedChange) + { + if (initialVisibility != null) + PersistCitationVisibility(initialVisibility); + RefreshAvaloniaDetail(); + if (expectedChange == "up") + MoveCitationDownThroughNativeCommand(); + + var beforeModel = GetHostedDetailModel(); + var field = beforeModel.Fields.Single(f => f.Field == "CitationForm"); + var beforeIndex = beforeModel.Fields.ToList().IndexOf(field); + var layoutBefore = CurrentLexEntryLayout().OuterXml; + + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, label); + Assert.That(item, Is.Not.Null, commandId + " should materialize through the native menu"); + Assert.That(item.IsEnabled, Is.True, commandId + " should be enabled for Citation Form"); + item.Execute(); + + var persisted = CurrentLexEntryLayout(); + Assert.That(persisted.OuterXml, Is.Not.EqualTo(layoutBefore), + commandId + " should run the legacy Slice handler and change its Inventory layout"); + Assert.That(File.Exists(m_layoutOverridePath), Is.True, + commandId + " should persist through Inventory to the project .fwlayout file"); + + var afterModel = GetHostedDetailModel(); + Assert.That(afterModel, Is.Not.SameAs(beforeModel), + commandId + " should refresh the Avalonia model from the changed XML"); + if (expectedChange == "never") + { + Assert.That(afterModel.Fields, Has.None.Property("Field").EqualTo("CitationForm")); + } + else if (expectedChange == "up" || expectedChange == "down") + { + var afterIndex = afterModel.Fields.ToList().FindIndex(f => f.Field == "CitationForm"); + Assert.That(Math.Sign(afterIndex - beforeIndex), + Is.EqualTo(expectedChange == "up" ? -1 : 1), + commandId + " should recompose Citation Form in the persisted direction"); + } + else + { + var part = persisted.SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part.Attributes["visibility"].Value, Is.EqualTo(expectedChange)); + Assert.That(afterModel.Fields, Has.Some.Property("Field").EqualTo("CitationForm")); + } + + var configurationDirectory = Path.GetDirectoryName(m_layoutOverridePath); + Assert.That(Directory.GetFiles(configurationDirectory, "*.viewoverride.json"), Is.Empty); + } + + [Test] + public void PersistentLayoutCommand_MissingExactIdentity_ClearsTargetAndDoesNotWrite() + { + PersistCitationVisibility("ifdata"); + RefreshAvaloniaDetail(); + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + field.SourceCallerPath = "part[999]"; + var beforeBytes = File.ReadAllBytes(m_layoutOverridePath); + + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, "Always visible"); + var dataTree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + + Assert.That(item, Is.Not.Null.And.Property("IsEnabled").False); + Assert.That(dataTree.CurrentSlice, Is.Null, + "a persistent command with no unique exact identity must clear the legacy target"); + item.Execute(); + Assert.That(File.ReadAllBytes(m_layoutOverridePath), Is.EqualTo(beforeBytes), + "a disabled persistent command must not invoke the legacy Inventory writer"); + } + // ---------------------------------------------------------------------------------------- // Insert Sense // ---------------------------------------------------------------------------------------- @@ -419,6 +514,16 @@ private IReadOnlyList BuildItems(string[] menuIds) return XCoreMenuBridge.CreateMenuItems(window, menuIds); } + private IReadOnlyList CreateNativeMenuItems(DetailField field, string[] menuIds) + { + var method = typeof(RecordEditView).GetMethod("CreateNativeDetailMenuItems", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, + "the product host should expose its native menu materialization seam"); + return (IReadOnlyList)method.Invoke(m_view, + new object[] { field, menuIds }); + } + private void InvokeItem(IReadOnlyList items, string label) { var item = FindItem(items, label); @@ -473,6 +578,77 @@ private int RefreshedDetailFieldCount() return DetailComposer.Compose(m_entry, Cache).Model.Fields.Count; } + private void RefreshAvaloniaDetail() + { + var refresh = typeof(RecordEditView).GetMethod("RefreshAvaloniaDetail", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(refresh, Is.Not.Null); + refresh.Invoke(m_view, null); + DrainMediatorAndIdleQueues(); + } + + private DetailModel GetHostedDetailModel() + { + var entryForm = (DetailHostControl)GetField(m_view, "m_avaloniaEntryForm"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null); + return tree.Model; + } + + private XmlNode CurrentLexEntryLayout() + { + var layout = m_layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(layout, Is.Not.Null); + return layout; + } + + private void PersistCitationVisibility(string visibility) + { + var changed = CurrentLexEntryLayout().Clone(); + var part = changed.SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part, Is.Not.Null); + var attribute = part.Attributes["visibility"] + ?? changed.OwnerDocument.CreateAttribute("visibility"); + attribute.Value = visibility; + if (attribute.OwnerElement == null) + part.Attributes.Append(attribute); + m_layouts.PersistOverrideElement(changed); + } + + private void MoveCitationDownThroughNativeCommand() + { + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, "Move Down"); + Assert.That(item, Is.Not.Null.And.Property("IsEnabled").True, + "Move Down must establish a real legacy-slice predecessor for Move Up"); + item.Execute(); + } + + private void RestoreLayoutOverride() + { + if (m_layouts == null || string.IsNullOrEmpty(m_layoutOverridePath)) + return; + if (m_layoutOverrideExisted) + { + Directory.CreateDirectory(Path.GetDirectoryName(m_layoutOverridePath)); + File.WriteAllBytes(m_layoutOverridePath, m_layoutOverrideBytes); + } + else if (File.Exists(m_layoutOverridePath)) + { + File.Delete(m_layoutOverridePath); + } + m_layouts.Reload(); + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(m_layouts)); + } + // ---------------------------------------------------------------------------------------- // Bootstrap helpers (mirrors RecordEditViewActiveHostContractTests) // ---------------------------------------------------------------------------------------- @@ -482,6 +658,8 @@ private void CreateTestEntry() var stemMorphType = GetMorphTypeOrCreateOne("stem"); var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); m_entry = AddLexeme(m_createdObjects, "command-entry", stemMorphType, "first gloss", noun); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("citation", Cache.DefaultVernWs)); } private void AddSense(string gloss) From 61501bf782f6af54b1eee341eb00eb0a9626b31a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 20:30:35 -0400 Subject: [PATCH 12/23] fix: revalidate legacy menu commands --- .../ViewDefinition/ViewDefinitionModel.cs | 15 ++++--- .../Hosting/RecordEditView.Avalonia.cs | 32 ++++++++++----- .../Avalonia/Hosting/XCoreMenuBridge.cs | 13 ++----- .../DetailObjectCommandExecutionTests.cs | 39 ++++++++++++++++++- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 09a664468f..bd94c23ec7 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -13,14 +13,15 @@ namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition { /// - /// Creates the same structural address for a legacy layout caller represented by either XML - /// API. - /// Same-name element ordinals make the address independent of whitespace and stable across - /// the cloned - /// effective layout documents used by the importer and legacy slice tree. + /// Provides a canonical identity for an element in an effective legacy layout. The identity + /// lets independently cloned XML representations recognize the same layout caller. /// public static class LegacyLayoutCallerPath { + /// + /// Returns the caller's canonical layout-relative identity. Returns null when the caller + /// is null or does not belong to a layout. + /// public static string Get(XElement caller) { if (caller == null) @@ -42,6 +43,10 @@ public static string Get(XElement caller) return string.Join("/", path); } + /// + /// Returns the caller's canonical layout-relative identity. Returns null when the caller + /// is null or does not belong to a layout. + /// public static string Get(XmlNode caller) { if (caller == null) diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 7f609ea3c8..2fc52b763d 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -541,26 +541,31 @@ private IReadOnlyList CreateNativeDetailMenuItems(DetailField fi string[] menuIds) { var window = m_propertyTable.GetValue("window"); + var hasBroadTarget = EnsureMenuCommandTarget(field.ObjectHvo, field.Field); + var hasPersistentTarget = hasBroadTarget + && TrySetPersistentMenuCommandTarget(field, false); return XCoreMenuBridge.CreateMenuItems(window, menuIds, - choice => CreateLegacyCommandMenuItem(field, choice)); + choice => CreateLegacyCommandMenuItem(field, choice, hasPersistentTarget)); } - private DetailMenuItem CreateLegacyCommandMenuItem(DetailField field, ChoiceBase choice) + private DetailMenuItem CreateLegacyCommandMenuItem(DetailField field, ChoiceBase choice, + bool hasPersistentTarget) { var persistent = IsPersistentLayoutCommand(choice); - var hasTarget = persistent - ? EnsurePersistentMenuCommandTarget(field) - : EnsureMenuCommandTarget(field.ObjectHvo, field.Field); var display = choice.GetDisplayProperties(); var captured = choice; return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), - hasTarget && display.Enabled, display.Checked, null, () => + (!persistent || hasPersistentTarget) && display.Enabled, + display.Checked, null, () => { var canExecute = persistent ? EnsurePersistentMenuCommandTarget(field) : EnsureMenuCommandTarget(field.ObjectHvo, field.Field); if (!canExecute) return; + var currentDisplay = captured.GetDisplayProperties(); + if (!currentDisplay.Visible || !currentDisplay.Enabled) + return; captured.OnClick(null, EventArgs.Empty); RefreshAvaloniaDetail(); }); @@ -709,7 +714,11 @@ private bool EnsurePersistentMenuCommandTarget(DetailField field) { if (!EnsureMenuCommandTarget(field.ObjectHvo, field.Field)) return false; + return TrySetPersistentMenuCommandTarget(field, true); + } + private bool TrySetPersistentMenuCommandTarget(DetailField field, bool clearOnFailure) + { var candidates = new List(); foreach (var sliceObj in m_dataEntryForm.Slices) { @@ -721,10 +730,13 @@ private bool EnsurePersistentMenuCommandTarget(DetailField field) field.ClassName, field.LayoutName, field.SourceCallerPath); if (index < 0) { - m_dataEntryForm.ClearCurrentSlice(); - Logger.WriteEvent(string.Format( - "Detail layout command found no unique slice for '{0}' at '{1}'; CurrentSlice was cleared.", - field.Field ?? string.Empty, field.SourceCallerPath ?? string.Empty)); + if (clearOnFailure) + { + m_dataEntryForm.ClearCurrentSlice(); + Logger.WriteEvent(string.Format( + "Detail layout command found no unique slice for '{0}' at '{1}'; CurrentSlice was cleared.", + field.Field ?? string.Empty, field.SourceCallerPath ?? string.Empty)); + } return false; } m_dataEntryForm.SetCurrentSliceForCommandTarget(candidates[index]); diff --git a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs index 5efffbd95e..f68e2fd1a0 100644 --- a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs +++ b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs @@ -30,15 +30,10 @@ public static IReadOnlyList CreateMenuItems(XWindow window, stri => CreateMenuItems(window, menuIds, null); /// - /// As , but lets the host RETARGET specific leaf - /// commands for the Avalonia detail view (advanced-entry-view). For each command leaf, the - /// is offered the leaf (so the host can - /// read the localized label and command id from it); if it returns a non-null - /// , that item (its label/checked/enabled/execute) is used INSTEAD of - /// the default xCore-dispatched item. Returning null leaves the command on its normal mediator - /// path. This is how the per-field Field Visibility / Move Field commands route to the project - /// override layer while Help and every other item keep working unchanged. The interceptor only - /// sees leaf commands (submenus pass through). + /// Builds the menu and lets the host replace command leaves. + /// Native replacements preserve host-specific targeting and execution. + /// Returning null keeps normal xCore dispatch. + /// Submenus are not intercepted. /// public static IReadOnlyList CreateMenuItems(XWindow window, string[] menuIds, Func interceptor) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 7c19cedc00..9d4ad9a862 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -203,16 +203,51 @@ public void PersistentLayoutCommand_MissingExactIdentity_ClearsTargetAndDoesNotW var items = CreateNativeMenuItems(field, new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); var item = FindItem(items, "Always visible"); + var writingSystems = FindItem(items, "Writing Systems"); var dataTree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); Assert.That(item, Is.Not.Null.And.Property("IsEnabled").False); - Assert.That(dataTree.CurrentSlice, Is.Null, - "a persistent command with no unique exact identity must clear the legacy target"); + Assert.That(writingSystems, Is.Not.Null); + Assert.That(FindItem(writingSystems.Children, "Configure...").IsEnabled, Is.True, + "failure to find an exact persistent target must not disable broad legacy commands"); item.Execute(); + Assert.That(dataTree.CurrentSlice, Is.Null, + "executing a persistent command with no exact identity must clear the legacy target"); Assert.That(File.ReadAllBytes(m_layoutOverridePath), Is.EqualTo(beforeBytes), "a disabled persistent command must not invoke the legacy Inventory writer"); } + [Test] + public void PersistentMoveCommand_DisabledBeforeExecute_DoesNotWrite() + { + RefreshAvaloniaDetail(); + MoveCitationDownThroughNativeCommand(); + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + var staleItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var staleMoveUp = FindItem(staleItems, "Move Up"); + Assert.That(staleMoveUp, Is.Not.Null.And.Property("IsEnabled").True); + + var currentItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + FindItem(currentItems, "Move Up").Execute(); + field.SourceCallerPath = GetHostedDetailModel().Fields + .Single(f => f.Field == "CitationForm").SourceCallerPath; + var refreshedItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + Assert.That(FindItem(refreshedItems, "Move Up").IsEnabled, Is.False, + "returning Citation Form to its first movable position must disable Move Up"); + var beforeBytes = File.ReadAllBytes(m_layoutOverridePath); + var beforeModel = GetHostedDetailModel(); + + staleMoveUp.Execute(); + + Assert.That(File.ReadAllBytes(m_layoutOverridePath), Is.EqualTo(beforeBytes), + "click-time display state must prevent a stale disabled move from reaching the writer"); + Assert.That(GetHostedDetailModel(), Is.SameAs(beforeModel), + "a stale disabled command must return before dispatch and detail recomposition"); + } + // ---------------------------------------------------------------------------------------- // Insert Sense // ---------------------------------------------------------------------------------------- From 5c79f56dbd20eb8c722494950f6380b921b71f4a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 20:36:43 -0400 Subject: [PATCH 13/23] docs: remove stale menu interceptor note --- Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs index f68e2fd1a0..9c91591ed2 100644 --- a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs +++ b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs @@ -82,9 +82,6 @@ private static List Convert(ChoiceGroup group, Func Date: Wed, 26 Aug 2026 20:44:49 -0400 Subject: [PATCH 14/23] refactor: retire Avalonia JSON layout overrides --- Src/Common/FwAvalonia/Detail/DetailModel.cs | 20 +- .../DetailOverrideRenderingTests.cs | 117 ------ .../ViewDefinitionOverrideApplierTests.cs | 175 --------- .../ViewDefinitionOverrideDifferTests.cs | 153 -------- .../ViewDefinitionOverrideEdgeCaseTests.cs | 197 ---------- .../ViewDefinitionOverrideEditorTests.cs | 167 --------- ...ViewDefinitionOverrideFileMigratorTests.cs | 104 ------ ...ewDefinitionOverrideJsonSerializerTests.cs | 176 --------- .../ViewDefinitionOverrideMigratorTests.cs | 81 ----- .../ViewDefinitionOverrideStoreTests.cs | 139 ------- .../ViewDefinitionOverrideApplier.cs | 314 ---------------- .../ViewDefinitionOverrideDiffer.cs | 343 ------------------ .../ViewDefinitionOverrideEditor.cs | 208 ----------- .../ViewDefinitionOverrideFileMigrator.cs | 71 ---- .../ViewDefinitionOverrideJsonSerializer.cs | 186 ---------- .../ViewDefinitionOverrideMigrator.cs | 63 ---- .../ViewDefinitionOverrideStore.cs | 143 -------- .../Avalonia/DetailOverrideMigration.cs | 68 ---- .../Composer/DetailOverrideMigrationTests.cs | 91 ----- .../DetailObjectCommandExecutionTests.cs | 3 - 20 files changed, 9 insertions(+), 2810 deletions(-) delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs delete mode 100644 Src/xWorks/Avalonia/DetailOverrideMigration.cs delete mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 9afa82130a..03bec31a97 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1604,21 +1604,19 @@ public DetailField( public int ObjectHvo { get; } /// - /// The class of the compiled view definition this row was projected from (advanced-entry-view): - /// the entry's own fields carry "LexEntry"; a row from a descended object (a sense, an - /// allomorph) - /// carries that object's layout class. Paired with it keys the per-project - /// ViewDefinitionOverride store so the per-field gear-menu commands (Field - /// Visibility / Move - /// Field) target the right layout. Set by the composer at compose time (null on rows built outside - /// the full-entry composer, e.g. the first-slice fallback). + /// The class of the compiled view definition this row was projected from. The entry's own + /// fields carry "LexEntry"; a row from a descended object carries that object's layout + /// class. + /// Paired with , it identifies the exact legacy layout command + /// target. + /// Set by the composer at compose time; null on rows built outside the full-entry + /// composer. /// public string ClassName { get; set; } /// - /// The layout name of the compiled view definition this row was projected from (e.g. - /// "Normal"). - /// See . + /// The layout name of the compiled view definition this row was projected from, such as + /// "Normal". See . /// public string LayoutName { get; set; } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs deleted file mode 100644 index 54e8c63524..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.Linq; -using Avalonia.Automation; -using Avalonia.Controls; -using Avalonia.Headless.NUnit; -using Avalonia.Threading; -using Avalonia.VisualTree; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.Detail; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// advanced-entry-view (view layer): the per-field gear-menu commands work by changing the composed - /// model the detail view renders -- hiding a Never row, showing a non-empty IfData row, and - /// reordering - /// siblings. These headless tests prove the renders EXACTLY the - /// rows the (patched) model carries, in model order. The composer's filtering/reorder semantics are - /// covered in xWorksTests; here we prove the visible detail view follows the model so the round trip is - /// closed at the rendering edge. - /// - [TestFixture] - public class DetailOverrideRenderingTests - { - private static DetailField TextField(string id, string label) - => new DetailField(id, label, label, null, DetailFieldKind.Text, - EditorClassification.Known, id, null, HostRouting.Inherit, - new List { new DetailWsValue("en", "value") }, - null, null, isEditable: true, indent: 0, objectHvo: 1234); - - private static DataTree Render(params DetailField[] fields) - { - var model = new DetailModel("LexEntry", "Normal", fields.ToList(), - new List()); - var view = new DataTree(model, null, null, null, null, null); - var window = new Window { Content = view, Width = 480, Height = 360 }; - window.Show(); - Dispatcher.UIThread.RunJobs(); - return view; - } - - private static List RenderedLabelIds(DataTree view) - => view.GetVisualDescendants().OfType() - .Select(t => AutomationProperties.GetAutomationId(t)) - .Where(id => !string.IsNullOrEmpty(id) && id.EndsWith(".Label")) - .ToList(); - - [AvaloniaTest] - public void DetailView_RendersOnlyTheRowsInTheModel() - { - // A model with "B" hidden (as a Never visibility override would drop it from compose) shows - // only A and C. - var view = Render(TextField("a", "Alpha"), TextField("c", "Gamma")); - - var labels = RenderedLabelIds(view); - Assert.That(labels, Has.Member("a.Label")); - Assert.That(labels, Has.Member("c.Label")); - Assert.That(labels, Has.No.Member("b.Label"), - "a row omitted from the model (a hidden field) does not render"); - } - - [AvaloniaTest] - public void DetailView_RendersRowsInModelOrder_SoAReorderIsVisible() - { - // The reorder override produces a model whose fields are in the new order; the view must - // follow that order top-to-bottom. - var view = Render(TextField("c", "Gamma"), TextField("a", "Alpha"), TextField("b", "Beta")); - - var order = RenderedLabelIds(view); - Assert.That(order.IndexOf("c.Label"), Is.LessThan(order.IndexOf("a.Label"))); - Assert.That(order.IndexOf("a.Label"), Is.LessThan(order.IndexOf("b.Label")), - "rows render in model order, so a reordered model reorders the view"); - } - - // The applier (Layer 2 -> Layer 3) is what the composer runs at CompileForObject: - // prove the patched IR the view is built from carries the visibility/order - // the menu wrote, end to end. - [Test] - public void Applier_AppliesVisibilityAndReorder_ToTheCompiledIR() - { - var shipped = new ViewDefinitionModel("LexEntry", "Normal", "detail", new[] - { - Group("g", FieldNode("g/a"), FieldNode("g/b"), FieldNode("g/c")) - }, null); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never), - new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/c", "g/b", "g/a" }) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/c", "g/b", "g/a" }), - "the reorder op reorders the children the composer walks"); - Assert.That(children.Single(c => c.StableId == "g/a").Visibility, - Is.EqualTo(ViewVisibility.Never), "the visibility op flips the node's visibility"); - } - - private static ViewNode FieldNode(string id) - => new ViewNode(id, ViewNodeKind.Field, id, null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, - false, null, null); - - private static ViewNode Group(string id, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, id, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs deleted file mode 100644 index 2011ef3afe..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Override load side: applying a sparse patch to the shipped definition reproduces the customized - /// definition, and is the inverse of the differ for representable changes. Pure logic. - /// - [TestFixture] - public class ViewDefinitionOverrideApplierTests - { - private static ViewNode FieldNode(string id, string label, - ViewVisibility vis = ViewVisibility.Always, string field = "F", string editor = "string") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, editor, - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - private static ViewDefinitionOverride Empty() - => new ViewDefinitionOverride("LexEntry", "detail", "jtview", null, null); - - [Test] - public void Apply_EmptyPatch_ReproducesBaseExactly() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, Empty()); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(shipped.ToSnapshot())); - } - - // Every node is rebuilt on apply, so a clone that omits a field strips it tree-wide once - // any override exists. These three fields are outside ToSnapshot() and aren't covered by - // the EmptyPatch test. - [Test] - public void Apply_PreservesNodeFieldsNoOperationTouches() - { - var writingSystems = new[] { "fr", "seh" }; - var options = new ViewStringList(new[] { "IsElsewhereForm", "IsAbstractForm" }, "AllomorphStatus"); - var shipped = Model(GroupNode("g", "Group", - new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "multistring", - EditorClassification.Known, "vern", ViewVisibility.Always, - ViewExpansion.NotApplicable, false, null, null, - enumStringList: options, visibleWritingSystems: writingSystems, toggleValue: true))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var rebuilt = applied.Roots[0].Children[0]; - Assert.That(rebuilt.Visibility, Is.EqualTo(ViewVisibility.Never), "the operation still applies"); - Assert.That(rebuilt.VisibleWritingSystems, Is.EqualTo(writingSystems), - "a per-field writing-system subset survives the rebuild"); - Assert.That(rebuilt.ToggleValue, Is.True, "a toggle value survives the rebuild"); - Assert.That(rebuilt.EnumStringList?.Ids, Is.EqualTo(options.Ids), - "an enum option list survives the rebuild"); - } - - [Test] - public void Apply_AddNode_InsertsAtParentIndex() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/new", - label: "New", parentStableId: "g", index: 1, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a", "g/new" })); - Assert.That(children[1].Label, Is.EqualTo("New")); - } - - [Test] - public void Apply_DuplicateNode_CopiesLeafUnderNewId() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy", - parentStableId: "g", index: 1, sourceStableId: "g/a") - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a", "g/a-copy" })); - Assert.That(children[1].Label, Is.EqualTo("A"), "the duplicate copies the source's content"); - Assert.That(children[1].Field, Is.EqualTo("F")); - } - - [Test] - public void Apply_DuplicateNode_SourceWithChildren_ReportsDiagnostic_AndDoesNotInsert() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - // Try to duplicate the group 'g' (which has a child) under the root -- not yet - // supported. - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g-copy", - parentStableId: null, index: 1, sourceStableId: "g") - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "duplicate-with-children-unsupported"), Is.True); - Assert.That(applied.Roots.Select(r => r.StableId), Is.EqualTo(new[] { "g" }), "the unsupported duplicate is not inserted"); - } - - [Test] - public void Apply_StalePatchTarget_IsReportedAsDiagnostic_NotFatal() - { - var shipped = Model(FieldNode("a", "A")); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] { new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "ghost", - visibility: ViewVisibility.Never) }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.True); - // The real node is untouched. - Assert.That(applied.Roots.Single().Visibility, Is.EqualTo(ViewVisibility.Always)); - } - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_VisibilityLabelHide() - { - var shipped = Model(GroupNode("g", "Group", - FieldNode("g/a", "A"), FieldNode("g/b", "B"), FieldNode("g/c", "C"))); - // Customer: relabel + hide one + change visibility -- all representable, all fully - // captured. - var customized = Model(GroupNode("g", "Group", - FieldNode("g/a", "Headword", ViewVisibility.Never), FieldNode("g/c", "C"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - } - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_Reorder() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/b", "B"), FieldNode("g/a", "A"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs deleted file mode 100644 index 6f67906f56..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Diffing a shipped definition against a project-customized copy yields a sparse, stable-id-keyed - /// override; non-representable customizations surface as diagnostics, never silent drops. Pure - /// logic -- no Avalonia runtime. - /// - [TestFixture] - public class ViewDefinitionOverrideDifferTests - { - private static ViewNode FieldNode(string id, string label, - ViewVisibility vis = ViewVisibility.Always, string field = "F", string editor = "string") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, editor, - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - [Test] - public void Diff_IdenticalDefinitions_IsEmpty() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.IsEmpty, Is.True); - Assert.That(diff.Operations, Is.Empty); - Assert.That(diff.Diagnostics, Is.Empty); - Assert.That(diff.FormatVersion, Is.EqualTo(ViewDefinitionOverride.CurrentFormatVersion)); - } - - [Test] - public void Diff_VisibilityChange_EmitsSetVisibility() - { - var shipped = Model(FieldNode("a", "A", ViewVisibility.Always)); - var overridden = Model(FieldNode("a", "A", ViewVisibility.Never)); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - var op = diff.Operations[0]; - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("a")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - Assert.That(diff.Diagnostics, Is.Empty); - } - - [Test] - public void Diff_LabelChange_EmitsSetLabel() - { - var shipped = Model(FieldNode("a", "Lexeme Form")); - var overridden = Model(FieldNode("a", "Headword")); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - Assert.That(diff.Operations[0].Kind, Is.EqualTo(ViewOverrideOperationKind.SetLabel)); - Assert.That(diff.Operations[0].Label, Is.EqualTo("Headword")); - } - - [Test] - public void Diff_ChildReorder_EmitsReorderChildren_WithNewOrder() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/b", "B"), FieldNode("g/a", "A"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var reorder = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.ReorderChildren); - Assert.That(reorder.StableId, Is.EqualTo("g")); - Assert.That(reorder.ChildOrder, Is.EqualTo(new[] { "g/b", "g/a" })); - // The children themselves are unchanged, so they must not generate spurious ops. - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - } - - [Test] - public void Diff_NodeOnlyInOverride_EmitsAddNode_WithParentAndIndex() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/custom", "Custom"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var add = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.AddNode); - Assert.That(add.StableId, Is.EqualTo("g/custom")); - Assert.That(add.ParentStableId, Is.EqualTo("g"), "the added node records its parent"); - Assert.That(add.Index, Is.EqualTo(1), "the added node records its insertion index among siblings"); - Assert.That(add.NodeKind, Is.EqualTo(ViewNodeKind.Field)); - Assert.That(add.Label, Is.EqualTo("Custom")); - // A customer addition is representable, not a lossy diagnostic. - Assert.That(diff.Diagnostics.Any(d => d.Code == "override-added-node"), Is.False); - } - - [Test] - public void Diff_NodeOnlyInShipped_EmitsHideNode() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var hide = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.HideNode); - Assert.That(hide.StableId, Is.EqualTo("g/b")); - } - - [Test] - public void Diff_BindingOrEditorChange_IsReportedUnrepresentable_NotSilentlyPatched() - { - // Same StableId, but the override changed the editor AND the label. The editor change is not a - // representable sparse patch, so the whole node is reported and NO label op is emitted for it. - var shipped = Model(FieldNode("a", "A", editor: "string")); - var overridden = Model(FieldNode("a", "A-renamed", editor: "integer")); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations, Is.Empty, "an unrepresentable change must not produce a (wrong) sparse patch"); - var diag = diff.Diagnostics.Single(d => d.Code == "override-unrepresentable-change"); - Assert.That(diag.NodePath, Is.EqualTo("a")); - Assert.That(diag.Severity, Is.EqualTo(ViewDiagnosticSeverity.Warning)); - } - - [Test] - public void Diff_Operations_AreDeterministicallyOrderedByStableId() - { - var shipped = Model( - FieldNode("zeta", "Z", ViewVisibility.Always), - FieldNode("alpha", "A", ViewVisibility.Always)); - var overridden = Model( - FieldNode("zeta", "Z", ViewVisibility.Never), - FieldNode("alpha", "A", ViewVisibility.Never)); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var ids = diff.Operations.Select(o => o.StableId).ToList(); - Assert.That(ids, Is.EqualTo(new[] { "alpha", "zeta" }), "operations must be ordered by StableId"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs deleted file mode 100644 index f307cc21f9..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.IO; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Boundary/edge-case hardening for the override pipeline: malformed-JSON enum handling (controlled - /// InvalidDataException, never a raw NRE/ArgumentException), id-collision rejection on insert ops, - /// the AddNode round-trip the existing suite lacked, and AddNode index clamping. - /// - [TestFixture] - public class ViewDefinitionOverrideEdgeCaseTests - { - private static ViewNode FieldNode(string id, string label, string field = "F") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - private static ViewDefinitionOverride Patch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "detail", "jtview", ops, null); - - // ----- malformed-JSON enum handling ----- - - [Test] - public void Deserialize_GarbageVisibility_ThrowsControlledInvalidData() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never))); - var bad = json.Replace("\"Never\"", "\"Bogus\""); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf(), "an unknown enum token is a controlled data error"); - } - - [Test] - public void Deserialize_NullVisibility_ThrowsControlledInvalidData_NotNullRef() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never))); - var bad = json.Replace("\"Never\"", "null"); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf(), "a null enum token must not throw a raw NullReferenceException"); - } - - [Test] - public void Deserialize_UnknownOpKind_ThrowsInvalidData() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, "a", label: "X"))); - // Replace the wire op name (whatever it is) with a bogus one. - var bad = System.Text.RegularExpressions.Regex.Replace(json, "\"op\"\\s*:\\s*\"[^\"]+\"", "\"op\": \"frobnicate\""); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf()); - } - - // ----- id-collision rejection on insert ops ----- - - [Test] - public void Apply_AddNode_CollidingId_IsRejectedWithDiagnostic_NotInserted() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/a", - label: "Dup", parentStableId: "g", index: 1, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always)); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-duplicate-id"), Is.True); - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a" }), - "a colliding addNode id is not inserted, preserving id uniqueness"); - } - - [Test] - public void Apply_DuplicateNode_CollidingId_IsRejectedWithDiagnostic() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a", - parentStableId: "g", index: 1, sourceStableId: "g/a")); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-duplicate-id"), Is.True); - Assert.That(applied.Roots[0].Children.Count, Is.EqualTo(1)); - } - - // ----- AddNode round-trip (was missing) + index clamping ----- - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_WithAddedNode() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/new", "New", "NewField"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot()), - "the add-node round-trip reproduces the customized model exactly"); - } - - [TestCase(0, new[] { "g/new", "g/a", "g/b" })] - [TestCase(1, new[] { "g/a", "g/new", "g/b" })] - [TestCase(99, new[] { "g/a", "g/b", "g/new" })] // clamped to count - [TestCase(-5, new[] { "g/new", "g/a", "g/b" })] // clamped to 0 - public void Apply_AddNode_IndexIsClampedToBounds(int index, string[] expectedOrder) - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/new", - label: "New", parentStableId: "g", index: index, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always)); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(expectedOrder)); - } - - [Test] - public void RoundTrip_AddedNode_SurvivesTheJsonWireLane_IncludingWritingSystem() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/new", "New", "NewField"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var reloaded = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, reloaded); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot()), - "the added node (with its writing system) survives serialize → deserialize → apply"); - } - - [Test] - public void RoundTrip_RootLevelReorder_IsReproduced() - { - // Reordering the top-level fields is a common customization; it must round-trip, not be dropped. - var shipped = Model(FieldNode("r1", "R1"), FieldNode("r2", "R2"), FieldNode("r3", "R3")); - var customized = Model(FieldNode("r3", "R3"), FieldNode("r1", "R1"), FieldNode("r2", "R2")); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots.Select(r => r.StableId), Is.EqualTo(new[] { "r3", "r1", "r2" })); - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.False, - "the root-level reorder op is not falsely reported as a stale target"); - } - - [Test] - public void Reparent_IsReportedAsDiagnostic_NotSilentlyDropped() - { - // Moving a node to a different parent is not representable as a sparse patch -- but - // it must be - // reported, never silently lost. - var shipped = Model(GroupNode("g1", "G1", FieldNode("a", "A")), GroupNode("g2", "G2")); - var customized = Model(GroupNode("g1", "G1"), GroupNode("g2", "G2", FieldNode("a", "A"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - - Assert.That(patch.Diagnostics.Any(d => d.Code == "override-reparent-unrepresentable"), Is.True, - "a reparented node is reported, not dropped"); - } - - [Test] - public void Diff_IdenticalModels_ProducesEmptyPatch() - { - var model = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = ViewDefinitionOverrideDiffer.Diff(model, model); - Assert.That(patch.Operations, Is.Empty); - } - - [Test] - public void Apply_ReorderChildren_PartialOrder_KeepsUnnamedAtEnd() - { - var shipped = Model(GroupNode("g", "Group", - FieldNode("g/a", "A"), FieldNode("g/b", "B"), FieldNode("g/c", "C"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/c" })); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(new[] { "g/c", "g/a", "g/b" }), - "named ids move first; the rest keep their original relative order"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs deleted file mode 100644 index c05f87433f..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// advanced-entry-view: the pure "where does the per-field gear-menu command land" logic -- - /// strip the - /// runtime hvo suffix, locate a node's parent + sibling order + visibility in a compiled definition, - /// compute the moved sibling order, and fold one operation into an existing override (idempotently). - /// No XCore/LCModel. - /// - [TestFixture] - public class ViewDefinitionOverrideEditorTests - { - private static ViewNode Field(string id, ViewVisibility vis = ViewVisibility.Always) - => new ViewNode(id, ViewNodeKind.Field, id, null, "F", "string", - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode Group(string id, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, id, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "Normal", "detail", roots, null); - - [TestCase("/#0/#1@1234", "/#0/#1")] - [TestCase("/#0/#1@1234/item3", "/#0/#1/item3")] - [TestCase("/#0/#1@1234/pic0", "/#0/#1/pic0")] - [TestCase("/#0/#1", "/#0/#1")] // already a template id (no hvo) - [TestCase("", "")] - [TestCase(null, null)] - public void StripRuntimeSuffix_RemovesHvo_KeepsTrailingPath(string runtime, string expected) - { - Assert.That(ViewDefinitionOverrideEditor.StripRuntimeSuffix(runtime), Is.EqualTo(expected)); - } - - [Test] - public void LocateTarget_ReturnsParentSiblingOrderIndexAndVisibility() - { - var model = Model(Group("g", Field("g/a"), Field("g/b", ViewVisibility.IfData), Field("g/c"))); - - var loc = ViewDefinitionOverrideEditor.LocateTarget(model, "g/b"); - - Assert.That(loc, Is.Not.Null); - Assert.That(loc.ParentStableId, Is.EqualTo("g")); - Assert.That(loc.SiblingOrder, Is.EqualTo(new[] { "g/a", "g/b", "g/c" })); - Assert.That(loc.Index, Is.EqualTo(1)); - Assert.That(loc.Visibility, Is.EqualTo(ViewVisibility.IfData)); - Assert.That(loc.CanMoveUp, Is.True); - Assert.That(loc.CanMoveDown, Is.True); - } - - [Test] - public void LocateTarget_RootLevelNode_HasNullParent() - { - var model = Model(Field("r0"), Field("r1")); - - var loc = ViewDefinitionOverrideEditor.LocateTarget(model, "r0"); - - Assert.That(loc.ParentStableId, Is.Null); - Assert.That(loc.Index, Is.EqualTo(0)); - Assert.That(loc.CanMoveUp, Is.False, "the first root node cannot move up"); - Assert.That(loc.CanMoveDown, Is.True); - } - - [Test] - public void LocateTarget_UnknownId_ReturnsNull() - { - var model = Model(Group("g", Field("g/a"))); - Assert.That(ViewDefinitionOverrideEditor.LocateTarget(model, "nope"), Is.Null); - } - - [Test] - public void ComputeMovedOrder_Up_SwapsWithPrevious() - { - var order = new[] { "a", "b", "c" }; - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 2, up: true); - Assert.That(moved, Is.EqualTo(new[] { "a", "c", "b" })); - } - - [Test] - public void ComputeMovedOrder_Down_SwapsWithNext() - { - var order = new[] { "a", "b", "c" }; - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 0, up: false); - Assert.That(moved, Is.EqualTo(new[] { "b", "a", "c" })); - } - - [Test] - public void ComputeMovedOrder_FirstUp_LastDown_OnlyChild_AreNull() - { - var order = new[] { "a", "b" }; - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 0, up: true), Is.Null, - "the first sibling cannot move up"); - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 1, up: false), Is.Null, - "the last sibling cannot move down"); - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(new[] { "solo" }, 0, up: true), Is.Null, - "a single child cannot move"); - } - - [Test] - public void MergeOperation_AppendsNewTarget() - { - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", null, null); - var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, op); - - Assert.That(merged.Operations.Count, Is.EqualTo(1)); - Assert.That(merged.Operations[0].StableId, Is.EqualTo("g/a")); - } - - [Test] - public void MergeOperation_ReplacesSameKindAndTarget_KeepsOthers() - { - var first = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never); - var unrelated = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/b", - visibility: ViewVisibility.IfData); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", - new[] { first, unrelated }, null); - var replacement = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Always); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, replacement); - - Assert.That(merged.Operations.Count, Is.EqualTo(2), "the same target+kind is replaced, not duplicated"); - var aOp = merged.Operations.Single(o => o.StableId == "g/a"); - Assert.That(aOp.Visibility, Is.EqualTo(ViewVisibility.Always)); - Assert.That(merged.Operations.Single(o => o.StableId == "g/b").Visibility, - Is.EqualTo(ViewVisibility.IfData), "an unrelated op is preserved"); - } - - [Test] - public void MergeOperation_DifferentKindSameTarget_BothKept() - { - var vis = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g", - visibility: ViewVisibility.Never); - var reorder = new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/b", "g/a" }); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", new[] { vis }, null); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, reorder); - - Assert.That(merged.Operations.Count, Is.EqualTo(2), - "a reorder on the same id as a visibility op is a different concern and is kept"); - } - - [Test] - public void MergeOperation_DoesNotMutateInput() - { - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", null, null); - ViewDefinitionOverrideEditor.MergeOperation(patch, - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "x", - visibility: ViewVisibility.Never)); - Assert.That(patch.Operations.Count, Is.EqualTo(0), "the source override is never mutated"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs deleted file mode 100644 index 0a2e7895bf..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Project-file side: reads a whole-copy .fwlayout override from disk, diffs it against the - /// shipped layout, and writes the canonical JSON patch -- verified with temp files and inline - /// XML - /// (no XCore/Inventory). - /// - [TestFixture] - public class ViewDefinitionOverrideFileMigratorTests - { - private const string PartsXml = @" - - - - - - - -"; - - private const string ShippedLayout = @" - - - -"; - - private const string OverrideLayout = @" - - - -"; - - private static IPartResolver Parts() => new DictionaryPartResolver(XElement.Parse(PartsXml)); - - private string _overrideFile; - private string _outputFile; - - [SetUp] - public void SetUp() - { - _overrideFile = Path.Combine(Path.GetTempPath(), "fwlayout-" + Guid.NewGuid().ToString("N") + ".fwlayout"); - _outputFile = Path.Combine(Path.GetTempPath(), "patch-" + Guid.NewGuid().ToString("N") + ".json"); - } - - [TearDown] - public void TearDown() - { - if (File.Exists(_overrideFile)) File.Delete(_overrideFile); - if (File.Exists(_outputFile)) File.Delete(_outputFile); - } - - [Test] - public void MigrateOverrideFile_ReadsOverride_ReturnsPatch_AndWritesJsonFile() - { - File.WriteAllText(_overrideFile, OverrideLayout); - - var patch = ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts(), _outputFile); - - // Returned patch captures the customer's edit. - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - - // And the canonical JSON patch file was written and round-trips. - Assert.That(File.Exists(_outputFile), Is.True); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(_outputFile)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - - [Test] - public void MigrateOverrideFile_NoCustomization_WritesEmptyPatch() - { - File.WriteAllText(_overrideFile, ShippedLayout); - - var patch = ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts(), _outputFile); - - Assert.That(patch.IsEmpty, Is.True); - Assert.That(File.Exists(_outputFile), Is.True, "an empty patch is still written (records that the layout was reconciled)"); - } - - [Test] - public void MigrateOverrideFile_MissingFile_Throws() - { - Assert.That(() => ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts()), - Throws.InstanceOf()); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs deleted file mode 100644 index edf6b48a0c..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// The per-project override patch serializes to deterministic canonical JSON and round-trips - /// losslessly, including its audit diagnostics. Pure logic -- no Avalonia runtime. - /// - [TestFixture] - public class ViewDefinitionOverrideJsonSerializerTests - { - private static ViewDefinitionOverride SampleWithAllOpKinds() - { - var ops = new List - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never), - new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, "b", label: "Headword"), - new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/b", "g/a" }), - new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, "c") - }; - var diags = new List - { - new ViewDiagnostic(ViewDiagnosticSeverity.Info, "override-added-node", "customer-added", "g/x") - }; - return new ViewDefinitionOverride("LexEntry", "detail", "jtview", ops, diags); - } - - [Test] - public void RoundTrip_PreservesHeaderOperationsAndDiagnostics() - { - var original = SampleWithAllOpKinds(); - - var json = ViewDefinitionOverrideJsonSerializer.Serialize(original); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(json); - - Assert.That(restored.FormatVersion, Is.EqualTo(original.FormatVersion)); - Assert.That(restored.ClassName, Is.EqualTo("LexEntry")); - Assert.That(restored.LayoutName, Is.EqualTo("detail")); - Assert.That(restored.LayoutType, Is.EqualTo("jtview")); - - Assert.That(restored.Operations.Count, Is.EqualTo(4)); - - var vis = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility); - Assert.That(vis.StableId, Is.EqualTo("a")); - Assert.That(vis.Visibility, Is.EqualTo(ViewVisibility.Never)); - - var label = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetLabel); - Assert.That(label.StableId, Is.EqualTo("b")); - Assert.That(label.Label, Is.EqualTo("Headword")); - - var reorder = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.ReorderChildren); - Assert.That(reorder.StableId, Is.EqualTo("g")); - Assert.That(reorder.ChildOrder, Is.EqualTo(new[] { "g/b", "g/a" })); - - var hide = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.HideNode); - Assert.That(hide.StableId, Is.EqualTo("c")); - - Assert.That(restored.Diagnostics.Count, Is.EqualTo(1)); - Assert.That(restored.Diagnostics[0].Code, Is.EqualTo("override-added-node")); - Assert.That(restored.Diagnostics[0].Severity, Is.EqualTo(ViewDiagnosticSeverity.Info)); - Assert.That(restored.Diagnostics[0].NodePath, Is.EqualTo("g/x")); - } - - [Test] - public void Serialize_IsDeterministic() - { - var patch = SampleWithAllOpKinds(); - Assert.That(ViewDefinitionOverrideJsonSerializer.Serialize(patch), - Is.EqualTo(ViewDefinitionOverrideJsonSerializer.Serialize(patch))); - } - - [Test] - public void Serialize_OmitsDiagnostics_WhenThereAreNone() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] { new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, "c") }, - diagnostics: null); - - var json = ViewDefinitionOverrideJsonSerializer.Serialize(patch); - - Assert.That(json, Does.Not.Contain("diagnostics"), - "a clean override must not carry an empty diagnostics array"); - } - - [Test] - public void Deserialize_WrongFormatVersion_Throws() - { - const string json = "{ \"formatVersion\": 99, \"class\": \"LexEntry\", \"operations\": [] }"; - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(json), - Throws.TypeOf()); - } - - [Test] - public void RoundTrip_PreservesAddNode_WithParentIndexAndIdentity() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/custom", - visibility: ViewVisibility.IfData, label: "Custom", - parentStableId: "g", index: 2, nodeKind: ViewNodeKind.Field, - field: "Custom", editor: "string") - }, - diagnostics: null); - - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - - var add = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.AddNode); - Assert.That(add.StableId, Is.EqualTo("g/custom")); - Assert.That(add.ParentStableId, Is.EqualTo("g")); - Assert.That(add.Index, Is.EqualTo(2)); - Assert.That(add.NodeKind, Is.EqualTo(ViewNodeKind.Field)); - Assert.That(add.Label, Is.EqualTo("Custom")); - Assert.That(add.Field, Is.EqualTo("Custom")); - Assert.That(add.Editor, Is.EqualTo("string")); - Assert.That(add.Visibility, Is.EqualTo(ViewVisibility.IfData)); - } - - [Test] - public void RoundTrip_PreservesDuplicateNode() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy", - parentStableId: "g", index: 1, sourceStableId: "g/a") - }, null); - - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - - var dup = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.DuplicateNode); - Assert.That(dup.StableId, Is.EqualTo("g/a-copy")); - Assert.That(dup.SourceStableId, Is.EqualTo("g/a")); - Assert.That(dup.ParentStableId, Is.EqualTo("g")); - Assert.That(dup.Index, Is.EqualTo(1)); - } - - [Test] - public void DiffThenSerialize_RoundTrips() - { - var shipped = new ViewDefinitionModel("LexEntry", "detail", "jtview", - new[] - { - new ViewNode("a", ViewNodeKind.Field, "A", null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, - false, null, null) - }, null); - var overridden = new ViewDefinitionModel("LexEntry", "detail", "jtview", - new[] - { - new ViewNode("a", ViewNodeKind.Field, "A", null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Never, ViewExpansion.NotApplicable, - false, null, null) - }, null); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(diff)); - - Assert.That(restored.Operations.Count, Is.EqualTo(1)); - Assert.That(restored.Operations[0].Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(restored.Operations[0].Visibility, Is.EqualTo(ViewVisibility.Never)); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs deleted file mode 100644 index 5612610aee..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// A legacy whole-copy .fwlayout override imports + diffs into a sparse patch capturing - /// exactly the customer's edits. Reuses the real over inline - /// XML -- - /// no XCore/file I/O. - /// - [TestFixture] - public class ViewDefinitionOverrideMigratorTests - { - private const string PartsXml = @" - - - - - - - -"; - - private static IPartResolver Parts() => new DictionaryPartResolver(XElement.Parse(PartsXml)); - - private const string ShippedLayout = @" - - - -"; - - [Test] - public void MigrateLayout_NoCustomization_ProducesEmptyPatch() - { - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(ShippedLayout, ShippedLayout, Parts()); - Assert.That(patch.IsEmpty, Is.True); - } - - [Test] - public void MigrateLayout_VisibilityCustomization_ProducesSetVisibilityPatch() - { - // The project hid Bibliography (ifdata -> never), the legacy whole-copy override. - const string overridden = @" - - - -"; - - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(ShippedLayout, overridden, Parts()); - - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1"), "Bibliography is the second root part"); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - } - - [Test] - public void MigrateLayoutToJson_RoundTripsToTheSamePatch() - { - const string overridden = @" - - - -"; - - var json = ViewDefinitionOverrideMigrator.MigrateLayoutToJson( - XElement.Parse(ShippedLayout), XElement.Parse(overridden), Parts()); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(json); - - Assert.That(restored.Operations.Single().Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs deleted file mode 100644 index 75d860a917..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// The per-project override store round-trips a patch through the ConfigurationSettings-folder JSON - /// file (one per class+layout), loads lazily, caches per key, and treats an empty patch as "delete - /// the file" (undo-to-base leaves no stale override). Corrupt or mislabeled files degrade to "no - /// override" rather than crashing compose. - /// - [TestFixture] - public class ViewDefinitionOverrideStoreTests - { - private string _dir; - - [SetUp] - public void SetUp() - { - _dir = Path.Combine(Path.GetTempPath(), "viewoverride-store-" + Guid.NewGuid().ToString("N")); - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_dir)) - Directory.Delete(_dir, recursive: true); - } - - private static ViewDefinitionOverride Patch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "Normal", "detail", ops, null); - - private static ViewOverrideOperation Vis(string id, ViewVisibility vis) - => new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, id, visibility: vis); - - [Test] - public void Save_ThenTryGet_RoundTripsThroughDisk() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - - // A fresh store (no in-memory cache) must read the same patch back from the file. - var reloaded = new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"); - - Assert.That(reloaded, Is.Not.Null); - Assert.That(reloaded.Operations.Count, Is.EqualTo(1)); - Assert.That(reloaded.Operations[0].StableId, Is.EqualTo("/#0")); - Assert.That(reloaded.Operations[0].Visibility, Is.EqualTo(ViewVisibility.Never)); - } - - [Test] - public void TryGet_NoFile_ReturnsNull() - { - Assert.That(new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void Save_WritesToPredictablePerClassLayoutFile() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Always))); - - var expected = Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"); - Assert.That(File.Exists(expected), Is.True); - Assert.That(store.PathFor("LexEntry", "Normal"), Is.EqualTo(expected)); - } - - [Test] - public void Save_EmptyPatch_DeletesTheFile() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - Assert.That(File.Exists(store.PathFor("LexEntry", "Normal")), Is.True); - - store.Save(Patch()); // emptied — the project no longer customizes this layout - - Assert.That(File.Exists(store.PathFor("LexEntry", "Normal")), Is.False, - "an empty override deletes the file so the loader sees the shipped definition"); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void TryGet_DistinctKeys_AreIsolated() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - store.Save(new ViewDefinitionOverride("LexSense", "Normal", "detail", - new[] { Vis("/#1", ViewVisibility.IfData) }, null)); - - Assert.That(store.TryGet("LexEntry", "Normal").Operations[0].StableId, Is.EqualTo("/#0")); - Assert.That(store.TryGet("LexSense", "Normal").Operations[0].StableId, Is.EqualTo("/#1")); - Assert.That(store.TryGet("LexSense", "Other"), Is.Null); - } - - [Test] - public void TryGet_CorruptFile_ReportsErrorAndReturnsNull() - { - Directory.CreateDirectory(_dir); - File.WriteAllText(Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"), "{ not valid json"); - var store = new ViewDefinitionOverrideStore(_dir); - - Exception captured = null; - var result = store.TryGet("LexEntry", "Normal", (path, e) => captured = e); - - Assert.That(result, Is.Null, "a corrupt file degrades to no-override, never a crash"); - Assert.That(captured, Is.Not.Null, "the load failure is surfaced to the caller for logging"); - } - - [Test] - public void TryGet_HeaderMismatch_IsIgnored() - { - // A file whose JSON header disagrees with the requested key (renamed/hand-edited) is not used. - Directory.CreateDirectory(_dir); - var foreignPatch = new ViewDefinitionOverride("LexSense", "Normal", "detail", - new[] { Vis("/#0", ViewVisibility.Never) }, null); - File.WriteAllText(Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"), - ViewDefinitionOverrideJsonSerializer.Serialize(foreignPatch)); - - Assert.That(new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void TryGet_CachesAcrossCalls_AndSaveRefreshesCache() - { - var store = new ViewDefinitionOverrideStore(_dir); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Null); - - // Save updates the in-memory cache, so the next TryGet returns the new patch without re-reading. - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Not.Null); - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs deleted file mode 100644 index ea1335cb59..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ /dev/null @@ -1,314 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Applies a sparse to a shipped - /// to produce the project-customized model. The inverse of : - /// for representable customizations, Apply(base, Diff(base, custom)) reproduces custom. Pure logic over the - /// immutable IR -- no XCore/Inventory or live cache. - /// - /// Patches that reference a StableId no longer present in the shipped base are reported as diagnostics on - /// the result rather than throwing (stale patches are quarantined per-operation, not fatal). - /// - public static class ViewDefinitionOverrideApplier - { - private const string RootParentKey = ""; // normalized key for a null (root-level) parent - - public static ViewDefinitionModel Apply(ViewDefinitionModel shipped, ViewDefinitionOverride patch) - { - if (shipped == null) throw new ArgumentNullException(nameof(shipped)); - if (patch == null) throw new ArgumentNullException(nameof(patch)); - - var setVisibility = new Dictionary(StringComparer.Ordinal); - var setLabel = new Dictionary(StringComparer.Ordinal); - var hide = new HashSet(StringComparer.Ordinal); - var reorder = new Dictionary>(StringComparer.Ordinal); - var addByParent = new Dictionary>(StringComparer.Ordinal); - var duplicateByParent = new Dictionary>(StringComparer.Ordinal); - - foreach (var op in patch.Operations) - { - switch (op.Kind) - { - case ViewOverrideOperationKind.SetVisibility: - if (op.Visibility.HasValue) setVisibility[op.StableId] = op.Visibility.Value; - break; - case ViewOverrideOperationKind.SetLabel: - setLabel[op.StableId] = op.Label; - break; - case ViewOverrideOperationKind.HideNode: - hide.Add(op.StableId); - break; - case ViewOverrideOperationKind.ReorderChildren: - reorder[op.StableId] = op.ChildOrder; - break; - case ViewOverrideOperationKind.AddNode: - AppendByParent(addByParent, op); - break; - case ViewOverrideOperationKind.DuplicateNode: - AppendByParent(duplicateByParent, op); - break; - } - } - - SortByIndexThenId(addByParent); - SortByIndexThenId(duplicateByParent); - - var diagnostics = new List(shipped.Diagnostics); - var baseById = FlattenBase(shipped.Roots); - var context = new ApplyContext( - setVisibility, setLabel, hide, reorder, addByParent, duplicateByParent, baseById, diagnostics); - - var newRoots = context.RebuildChildren(RootParentKey, shipped.Roots); - - // Report patch operations whose target/parent StableId no longer exists (stale patch), per-op. - context.ReportUnresolved(patch); - - return new ViewDefinitionModel( - shipped.ClassName, shipped.LayoutName, shipped.LayoutType, newRoots, diagnostics); - } - - private sealed class ApplyContext - { - private readonly Dictionary _setVisibility; - private readonly Dictionary _setLabel; - private readonly HashSet _hide; - private readonly Dictionary> _reorder; - private readonly Dictionary> _addByParent; - private readonly Dictionary> _duplicateByParent; - private readonly Dictionary _baseById; - private readonly List _diagnostics; - private readonly HashSet _seenIds = new HashSet(StringComparer.Ordinal); - - public ApplyContext( - Dictionary setVisibility, - Dictionary setLabel, - HashSet hide, - Dictionary> reorder, - Dictionary> addByParent, - Dictionary> duplicateByParent, - Dictionary baseById, - List diagnostics) - { - _setVisibility = setVisibility; - _setLabel = setLabel; - _hide = hide; - _reorder = reorder; - _addByParent = addByParent; - _duplicateByParent = duplicateByParent; - _baseById = baseById; - _diagnostics = diagnostics; - } - - public List RebuildChildren(string parentKey, IReadOnlyList baseChildren) - { - var result = new List(); - foreach (var child in baseChildren) - { - _seenIds.Add(child.StableId); - if (_hide.Contains(child.StableId)) - continue; - result.Add(RebuildNode(child)); - } - - // Insert customer-added nodes under this parent at their recorded indices. - if (_addByParent.TryGetValue(parentKey, out var added)) - { - foreach (var addOp in added) - { - _seenIds.Add(addOp.StableId); - var addedNode = CreateAddedNode(addOp); - if (addedNode != null) - result.Insert(ClampIndex(addOp.Index, result.Count), addedNode); - } - } - - // Insert duplicate-of-shipped-node copies under this parent. - if (_duplicateByParent.TryGetValue(parentKey, out var duplicates)) - { - foreach (var dupOp in duplicates) - { - _seenIds.Add(dupOp.StableId); - var node = CreateDuplicateNode(dupOp); - if (node != null) - result.Insert(ClampIndex(dupOp.Index, result.Count), node); - } - } - - // Reorder this parent's children if the patch reorders them. - if (_reorder.TryGetValue(parentKey, out var order)) - result = ApplyOrder(result, order); - - return result; - } - - private ViewNode RebuildNode(ViewNode node) - { - var visibility = _setVisibility.TryGetValue(node.StableId, out var v) ? v : node.Visibility; - var label = _setLabel.TryGetValue(node.StableId, out var l) ? l : node.Label; - var children = RebuildChildren(node.StableId, node.Children); - return CloneWith(node, visibility, label, children); - } - - private ViewNode CreateAddedNode(ViewOverrideOperation addOp) - { - if (_baseById.ContainsKey(addOp.StableId)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "override-duplicate-id", - $"addNode '{addOp.StableId}' collides with an existing node id; skipped to preserve id uniqueness", - addOp.StableId)); - return null; - } - var kind = addOp.NodeKind ?? ViewNodeKind.Field; - var classification = string.IsNullOrEmpty(addOp.Editor) - ? EditorClassification.GroupingNone - : EditorClassification.Known; - var children = RebuildChildren(addOp.StableId, Array.Empty()); - return new ViewNode( - addOp.StableId, kind, addOp.Label, null, addOp.Field, addOp.Editor, - classification, addOp.WritingSystem, addOp.Visibility ?? ViewVisibility.Always, - ViewExpansion.NotApplicable, false, null, children); - } - - // Returns the duplicated node, or null (with a diagnostic) when the source is missing or has - // children (subtree duplication is not supported; never a silent wrong copy). - private ViewNode CreateDuplicateNode(ViewOverrideOperation dupOp) - { - if (string.IsNullOrEmpty(dupOp.SourceStableId) || - !_baseById.TryGetValue(dupOp.SourceStableId, out var source)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "duplicate-source-missing", - $"duplicateNode '{dupOp.StableId}' references source '{dupOp.SourceStableId}', which is not in the shipped definition", - dupOp.StableId)); - return null; - } - - if (source.Children.Count > 0) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "duplicate-with-children-unsupported", - $"duplicateNode '{dupOp.StableId}' copies '{dupOp.SourceStableId}', which has children; subtree duplication is not yet supported", - dupOp.StableId)); - return null; - } - - if (_baseById.ContainsKey(dupOp.StableId)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "override-duplicate-id", - $"duplicateNode '{dupOp.StableId}' collides with an existing node id; skipped to preserve id uniqueness", - dupOp.StableId)); - return null; - } - return CloneWithId(source, dupOp.StableId); - } - - private static List ApplyOrder(List nodes, IReadOnlyList order) - { - var byId = nodes.ToDictionary(n => n.StableId, StringComparer.Ordinal); - var ordered = new List(); - foreach (var id in order) - { - if (byId.TryGetValue(id, out var n)) - { - ordered.Add(n); - byId.Remove(id); - } - } - // Any children not named in the order keep their original relative position at the end. - foreach (var n in nodes) - { - if (byId.ContainsKey(n.StableId)) - ordered.Add(n); - } - return ordered; - } - - public void ReportUnresolved(ViewDefinitionOverride patch) - { - foreach (var op in patch.Operations) - { - var isInsert = op.Kind == ViewOverrideOperationKind.AddNode - || op.Kind == ViewOverrideOperationKind.DuplicateNode; - var key = isInsert ? (op.ParentStableId ?? RootParentKey) : op.StableId; - // The root is always a valid target (root inserts and root-level reorder); a parent needs that parent. - if (key == RootParentKey) - continue; - if (_seenIds.Contains(key)) - continue; - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-stale-target", - $"override operation '{op.Kind}' references '{key}', which is not in the shipped definition", - key)); - } - } - - private static int ClampIndex(int? index, int count) - => Math.Max(0, Math.Min(index ?? count, count)); - } - - private static void AppendByParent(Dictionary> map, ViewOverrideOperation op) - { - var key = op.ParentStableId ?? RootParentKey; - if (!map.TryGetValue(key, out var list)) - map[key] = list = new List(); - list.Add(op); - } - - private static void SortByIndexThenId(Dictionary> map) - { - foreach (var list in map.Values) - list.Sort((a, b) => - { - var byIndex = (a.Index ?? 0).CompareTo(b.Index ?? 0); - return byIndex != 0 ? byIndex : string.CompareOrdinal(a.StableId, b.StableId); - }); - } - - private static Dictionary FlattenBase(IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node) - { - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = node; - foreach (var child in node.Children) - Visit(child); - } - - foreach (var root in roots) - Visit(root); - return map; - } - - // Reconstruct an immutable node with overridden visibility/label/children, copying every - // other field. Every trailing optional constructor argument must be passed, or that - // field is stripped. - private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string label, IReadOnlyList children) - => new ViewNode( - n.StableId, n.Kind, label, n.Abbreviation, n.Field, n.RawEditor, n.EditorClassification, - n.WritingSystem, visibility, n.Expansion, n.Indented, n.TargetLayout, children, - n.LocalizationKey, n.AutomationId, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, - n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, - n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, - n.SourceCallerPath); - - // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh, - // non-colliding identity (the renderer derives one from the new StableId by convention). - private static ViewNode CloneWithId(ViewNode n, string newId) - => new ViewNode( - newId, n.Kind, n.Label, n.Abbreviation, n.Field, n.RawEditor, n.EditorClassification, - n.WritingSystem, n.Visibility, n.Expansion, n.Indented, n.TargetLayout, n.Children, - n.LocalizationKey, null, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, - n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, - n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, - n.SourceCallerPath); - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs deleted file mode 100644 index d0bf1c5f59..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The kind of sparse override operation a customer layout customization maps to. Deliberately small: - /// the representable customer edits over the shipped definition. Anything outside this set is reported - /// as a diagnostic, never silently dropped. - /// - public enum ViewOverrideOperationKind - { - /// Change a node's (legacy visibility= edit). - SetVisibility, - - /// Override a node's label text (legacy per-project relabeling). - SetLabel, - - /// Reorder a node's children (same child set, different order). - ReorderChildren, - - /// A node present in the shipped definition that the override removed/hid. - HideNode, - - /// A node the customer added that is not in the shipped definition (with parent + index). - AddNode, - - /// Duplicate an existing shipped node under a new id (legacy copy-with-suffix authoring op). - /// Authoring-only: the differ never infers it; it is applied/serialized for hand-authored patches. - DuplicateNode - } - - /// - /// One sparse override operation, keyed by the shipped node's . This is - /// the delta-against-stable-identity model for a per-project layout customization. - /// - public sealed class ViewOverrideOperation - { - public ViewOverrideOperation( - ViewOverrideOperationKind kind, - string stableId, - ViewVisibility? visibility = null, - string label = null, - IReadOnlyList childOrder = null, - string parentStableId = null, - int? index = null, - ViewNodeKind? nodeKind = null, - string field = null, - string editor = null, - string sourceStableId = null, - string writingSystem = null) - { - Kind = kind; - StableId = stableId ?? throw new ArgumentNullException(nameof(stableId)); - Visibility = visibility; - Label = label; - ChildOrder = childOrder ?? (IReadOnlyList)Array.Empty(); - ParentStableId = parentStableId; - Index = index; - NodeKind = nodeKind; - Field = field; - Editor = editor; - SourceStableId = sourceStableId; - WritingSystem = writingSystem; - } - - public ViewOverrideOperationKind Kind { get; } - - /// The shipped node this operation patches (for AddNode, the new node's id). - public string StableId { get; } - - /// New visibility for (also carried on AddNode). - public ViewVisibility? Visibility { get; } - - /// New label for (also carried on AddNode). - public string Label { get; } - - /// New child order (StableIds) for . - public IReadOnlyList ChildOrder { get; } - - /// For : the parent the new node is inserted under. - public string ParentStableId { get; } - - /// For : the insertion index among the parent's children. - public int? Index { get; } - - /// For : the new node's structural kind. - public ViewNodeKind? NodeKind { get; } - - /// For : the new node's field binding. - public string Field { get; } - - /// For : the new node's raw editor. - public string Editor { get; } - - /// For : the new node's writing system. - public string WritingSystem { get; } - - /// For : the shipped node to copy from. - public string SourceStableId { get; } - - /// Deterministic summary used for snapshot/round-trip tests. - public override string ToString() - { - switch (Kind) - { - case ViewOverrideOperationKind.SetVisibility: - return $"setVisibility {StableId} -> {Visibility}"; - case ViewOverrideOperationKind.SetLabel: - return $"setLabel {StableId} -> {Label}"; - case ViewOverrideOperationKind.ReorderChildren: - return $"reorderChildren {StableId} -> [{string.Join(",", ChildOrder)}]"; - case ViewOverrideOperationKind.HideNode: - return $"hideNode {StableId}"; - case ViewOverrideOperationKind.AddNode: - return $"addNode {StableId} under {ParentStableId}@{Index} ({NodeKind})"; - case ViewOverrideOperationKind.DuplicateNode: - return $"duplicateNode {StableId} from {SourceStableId} under {ParentStableId}@{Index}"; - default: - return $"{Kind} {StableId}"; - } - } - } - - /// - /// A sparse per-project override: the ordered set of representable operations against a shipped - /// definition, plus diagnostics for every customization that is NOT representable (so "migrated" - /// carries no silent asterisk). - /// - public sealed class ViewDefinitionOverride - { - /// The override-format version. - public const int CurrentFormatVersion = 1; - - public ViewDefinitionOverride( - string className, - string layoutName, - string layoutType, - IReadOnlyList operations, - IReadOnlyList diagnostics, - int formatVersion = CurrentFormatVersion) - { - ClassName = className; - LayoutName = layoutName; - LayoutType = layoutType; - Operations = operations ?? (IReadOnlyList)Array.Empty(); - Diagnostics = diagnostics ?? (IReadOnlyList)Array.Empty(); - FormatVersion = formatVersion; - } - - public int FormatVersion { get; } - public string ClassName { get; } - public string LayoutName { get; } - public string LayoutType { get; } - public IReadOnlyList Operations { get; } - public IReadOnlyList Diagnostics { get; } - - /// True when the override carries no operations (the project did not customize this layout). - public bool IsEmpty => Operations.Count == 0; - } - - /// - /// Computes a sparse from a shipped definition and the same - /// layout as customized by a project. Both inputs are the typed IR the importer already produces, so - /// the diff keys on - /// -- the identity scheme the semantic baselines already use -- instead of a second one. - /// - /// Representable edits (visibility, label, child reorder, node hidden) become operations; everything - /// else (added nodes, changed binding/editor/kind) becomes an explicit diagnostic. Output is - /// deterministic: operations and diagnostics are ordered by StableId then kind. - /// - public static class ViewDefinitionOverrideDiffer - { - private const string RootParentKey = ""; // matches the applier's normalized root-parent key - - public static ViewDefinitionOverride Diff(ViewDefinitionModel shipped, ViewDefinitionModel overridden) - { - if (shipped == null) throw new ArgumentNullException(nameof(shipped)); - if (overridden == null) throw new ArgumentNullException(nameof(overridden)); - - var shippedNodes = Flatten(shipped.Roots); - var overriddenNodes = Flatten(overridden.Roots); - var shippedParents = BuildParentIndex(shipped.Roots); - var overriddenParents = BuildParentIndex(overridden.Roots); - - var operations = new List(); - var diagnostics = new List(); - - foreach (var pair in shippedNodes) - { - var stableId = pair.Key; - var shippedNode = pair.Value; - - if (!overriddenNodes.TryGetValue(stableId, out var overriddenNode)) - { - // The customer removed/hid this shipped node. - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, stableId)); - continue; - } - - // A change to binding/editor/kind is not a representable sparse override; report it rather - // than emit a wrong patch (never a silent drop). - if (shippedNode.Kind != overriddenNode.Kind || - !string.Equals(shippedNode.Field, overriddenNode.Field, StringComparison.Ordinal) || - !string.Equals(shippedNode.RawEditor, overriddenNode.RawEditor, StringComparison.Ordinal)) - { - diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-unrepresentable-change", - $"node '{stableId}' changed binding/editor/kind in the override; not representable as a sparse patch", - stableId)); - continue; - } - - if (shippedParents.TryGetValue(stableId, out var shippedPlace) - && overriddenParents.TryGetValue(stableId, out var overriddenPlace) - && !string.Equals(shippedPlace.ParentId, overriddenPlace.ParentId, StringComparison.Ordinal)) - { - diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-reparent-unrepresentable", - $"node '{stableId}' moved to a different parent in the override; reparenting is not representable as a sparse patch", - stableId)); - continue; - } - - if (shippedNode.Visibility != overriddenNode.Visibility) - { - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, - stableId, visibility: overriddenNode.Visibility)); - } - - if (!string.Equals(shippedNode.Label, overriddenNode.Label, StringComparison.Ordinal)) - { - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, - stableId, label: overriddenNode.Label)); - } - - AppendReorderIfNeeded(operations, stableId, shippedNode, overriddenNode); - } - - foreach (var stableId in overriddenNodes.Keys) - { - if (shippedNodes.ContainsKey(stableId)) - continue; - - // A customer-added node: representable as an AddNode op carrying the parent + insert index - // and the new node's identity. (An applier must order AddNode ops parent-before-child; the - // parent reference makes that ordering recoverable even though ops sort by StableId.) - var added = overriddenNodes[stableId]; - overriddenParents.TryGetValue(stableId, out var place); - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, stableId, - visibility: added.Visibility, label: added.Label, - parentStableId: place.ParentId, index: place.Index, - nodeKind: added.Kind, field: added.Field, editor: added.RawEditor, - writingSystem: added.WritingSystem)); - } - - AppendReorderIfNeeded(operations, RootParentKey, - shipped.Roots.Select(r => r.StableId).ToList(), - overridden.Roots.Select(r => r.StableId).ToList()); - - operations.Sort(CompareOperations); - diagnostics.Sort((a, b) => - { - var byPath = string.CompareOrdinal(a.NodePath, b.NodePath); - return byPath != 0 ? byPath : string.CompareOrdinal(a.Code, b.Code); - }); - - return new ViewDefinitionOverride( - overridden.ClassName, overridden.LayoutName, overridden.LayoutType, operations, diagnostics); - } - - private static void AppendReorderIfNeeded( - List operations, string stableId, ViewNode shippedNode, ViewNode overriddenNode) - => AppendReorderIfNeeded(operations, stableId, - shippedNode.Children.Select(c => c.StableId).ToList(), - overriddenNode.Children.Select(c => c.StableId).ToList()); - - // Emits a ReorderChildren op (keyed by parent, or RootParentKey for the root list) when the child - // SET is identical and only the order differs. Added/removed children are handled elsewhere. - private static void AppendReorderIfNeeded( - List operations, string key, - List shippedOrder, List overriddenOrder) - { - if (shippedOrder.Count != overriddenOrder.Count) - return; - if (!new HashSet(shippedOrder).SetEquals(overriddenOrder)) - return; - if (shippedOrder.SequenceEqual(overriddenOrder, StringComparer.Ordinal)) - return; - - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, - key, childOrder: overriddenOrder)); - } - - private static int CompareOperations(ViewOverrideOperation a, ViewOverrideOperation b) - { - var byId = string.CompareOrdinal(a.StableId, b.StableId); - return byId != 0 ? byId : a.Kind.CompareTo(b.Kind); - } - - private static Dictionary Flatten(IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node) - { - // StableIds are unique per definition; if a malformed tree repeats one, keep the first so the - // diff is deterministic rather than order-dependent. - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = node; - foreach (var child in node.Children) - Visit(child); - } - - foreach (var root in roots) - Visit(root); - return map; - } - - // Maps each node's StableId to its parent's StableId (null for roots) and its index among siblings, - // so an AddNode op records where a customer-added node was inserted. - private static Dictionary BuildParentIndex( - IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node, string parentId, int index) - { - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = (parentId, index); - for (var i = 0; i < node.Children.Count; i++) - Visit(node.Children[i], node.StableId, i); - } - - for (var i = 0; i < roots.Count; i++) - Visit(roots[i], null, i); - return map; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs deleted file mode 100644 index 1c4f2577e5..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The runtime "where the per-field gear-menu command lands" helper for the Avalonia detail view - /// (advanced-entry-view). Two pure jobs over the immutable IR + override model, both unit-testable - /// without any XCore/Inventory/LCModel dependency: - /// - /// - /// -- given a compiled and - /// a - /// node's template , returns the node's current visibility, - /// its parent StableId (null at the root), the parent's ordered child StableIds, and the node's - /// index among them. This is what "Move Field"/"Field Visibility" need to build a - /// -- the parent + sibling order the legacy code read - /// from the - /// live DataTree, here read from the composed definition instead. - /// -- folds one new operation into an existing - /// , replacing any prior op of the same kind+target (the gear - /// menu re-setting a field's visibility supersedes the last choice; a second move supersedes the - /// last reorder) and appending otherwise. Pure: returns a new override, never mutates the input. - /// - /// - /// Anything stored here keys on the template StableId (the runtime "{stableId}@{hvo}" suffix must be - /// stripped by the caller via ), because StableIds are layout-local - /// paths and the override store is keyed by (ClassName, LayoutName). - /// - public static class ViewDefinitionOverrideEditor - { - /// - /// The runtime field StableId carries an "@{hvo}" object suffix (DetailComposer's - /// StableId(node, obj)); the override targets the template id, so strip from the LAST '@'. - /// Suffixed forms like "{id}@{hvo}/item3" or "{id}@{hvo}/pic0" keep their trailing path segment - /// after the hvo is removed, matching the template id the importer assigned. - /// - public static string StripRuntimeSuffix(string runtimeStableId) - { - if (string.IsNullOrEmpty(runtimeStableId)) - return runtimeStableId; - var at = runtimeStableId.IndexOf('@'); - if (at < 0) - return runtimeStableId; - // Everything before '@' is the template id; any path after the hvo (e.g. "/item3") rides along. - var afterHvo = runtimeStableId.IndexOf('/', at); - return afterHvo < 0 - ? runtimeStableId.Substring(0, at) - : runtimeStableId.Substring(0, at) + runtimeStableId.Substring(afterHvo); - } - - /// - /// Locates in . Returns null when the - /// id is not present (a stale/unknown target -- the caller treats that as a no-op, not a - /// crash). - /// - public static ViewNodeLocation LocateTarget(ViewDefinitionModel model, string templateStableId) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - if (string.IsNullOrEmpty(templateStableId)) - return null; - - // Root-level scan first (parent is null). - var rootIndex = IndexOf(model.Roots, templateStableId); - if (rootIndex >= 0) - { - return new ViewNodeLocation(model.Roots[rootIndex].Visibility, null, - model.Roots.Select(n => n.StableId).ToList(), rootIndex); - } - - foreach (var root in model.Roots) - { - var found = LocateUnder(root, templateStableId); - if (found != null) - return found; - } - - return null; - } - - private static ViewNodeLocation LocateUnder(ViewNode parent, string templateStableId) - { - var index = IndexOf(parent.Children, templateStableId); - if (index >= 0) - { - return new ViewNodeLocation(parent.Children[index].Visibility, parent.StableId, - parent.Children.Select(n => n.StableId).ToList(), index); - } - - foreach (var child in parent.Children) - { - var found = LocateUnder(child, templateStableId); - if (found != null) - return found; - } - - return null; - } - - private static int IndexOf(IReadOnlyList nodes, string id) - { - for (var i = 0; i < nodes.Count; i++) - { - if (string.Equals(nodes[i].StableId, id, StringComparison.Ordinal)) - return i; - } - - return -1; - } - - /// - /// Returns the sibling order produced by moving the node at one - /// position toward the front ( = true) or back. Returns null when the move - /// is not possible (first sibling can't move up, last can't move down, single child can't move), - /// so the caller leaves the override untouched and disables the menu item. - /// - public static IReadOnlyList ComputeMovedOrder(IReadOnlyList siblingOrder, - int currentIndex, bool up) - { - if (siblingOrder == null || siblingOrder.Count < 2) - return null; - if (currentIndex < 0 || currentIndex >= siblingOrder.Count) - return null; - var swapWith = up ? currentIndex - 1 : currentIndex + 1; - if (swapWith < 0 || swapWith >= siblingOrder.Count) - return null; - - var reordered = siblingOrder.ToList(); - var tmp = reordered[currentIndex]; - reordered[currentIndex] = reordered[swapWith]; - reordered[swapWith] = tmp; - return reordered; - } - - /// - /// Folds into : a same-kind, same-target operation - /// replaces the existing one (so a field's visibility/reorder is idempotent across repeated menu - /// use); otherwise the op is appended. Pure -- the input override is never mutated. - /// - public static ViewDefinitionOverride MergeOperation(ViewDefinitionOverride patch, ViewOverrideOperation op) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - if (op == null) throw new ArgumentNullException(nameof(op)); - - var ops = new List(); - var replaced = false; - foreach (var existing in patch.Operations) - { - if (existing.Kind == op.Kind - && string.Equals(existing.StableId, op.StableId, StringComparison.Ordinal)) - { - ops.Add(op); - replaced = true; - } - else - { - ops.Add(existing); - } - } - - if (!replaced) - ops.Add(op); - - return new ViewDefinitionOverride(patch.ClassName, patch.LayoutName, patch.LayoutType, - ops, patch.Diagnostics, patch.FormatVersion); - } - } - - /// - /// Where a node sits in a compiled definition: its current visibility, its parent's StableId (null - /// at the root), the parent's ordered child StableIds, and the node's index among them. The address - /// the gear-menu commands turn into a . - /// - public sealed class ViewNodeLocation - { - public ViewNodeLocation(ViewVisibility visibility, string parentStableId, - IReadOnlyList siblingOrder, int index) - { - Visibility = visibility; - ParentStableId = parentStableId; - SiblingOrder = siblingOrder ?? Array.Empty(); - Index = index; - } - - /// The node's current visibility (after any override already applied to the model). - public ViewVisibility Visibility { get; } - - /// The parent node's template StableId, or null when the node is at the root. - public string ParentStableId { get; } - - /// The parent's children in document order (template StableIds), including this node. - public IReadOnlyList SiblingOrder { get; } - - /// This node's index within . - public int Index { get; } - - /// True when the node can move toward the front (not already first). - public bool CanMoveUp => SiblingOrder.Count > 1 && Index > 0; - - /// True when the node can move toward the back (not already last). - public bool CanMoveDown => SiblingOrder.Count > 1 && Index >= 0 && Index < SiblingOrder.Count - 1; - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs deleted file mode 100644 index 0bb6b84735..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// File-level driver for the legacy-override -> sparse-patch migration. Reads a project's - /// whole-copy - /// .fwlayout override from disk, diffs it against the shipped layout via - /// , and writes the canonical JSON patch file. - /// - /// The only piece left to the XCore caller is providing the shipped layout element - /// (resolved - /// from Inventory) and the part resolver -- those are passed in, so this whole - /// orchestration is - /// unit-testable with temp files and inline XML, with no XCore/Inventory dependency. - /// - public static class ViewDefinitionOverrideFileMigrator - { - /// - /// Migrates the override file at against - /// . If is non-empty, the canonical - /// JSON patch is written there. Returns the patch (also when no file is written). - /// - public static ViewDefinitionOverride MigrateOverrideFile( - XElement shippedLayout, - string overrideFilePath, - IPartResolver parts, - string outputPatchPath = null, - IViewDefinitionImporter importer = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (string.IsNullOrEmpty(overrideFilePath)) throw new ArgumentNullException(nameof(overrideFilePath)); - if (parts == null) throw new ArgumentNullException(nameof(parts)); - if (!File.Exists(overrideFilePath)) - throw new FileNotFoundException("Override layout file not found.", overrideFilePath); - - var overriddenLayout = LoadLayout(overrideFilePath); - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(shippedLayout, overriddenLayout, parts, importer); - - if (!string.IsNullOrEmpty(outputPatchPath)) - { - var dir = Path.GetDirectoryName(outputPatchPath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - File.WriteAllText(outputPatchPath, ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - } - - return patch; - } - - // The legacy override file (Inventory.PersistOverrideElement) is a copy of the customized - // element. Accept either a file whose root is or one that wraps it. - private static XElement LoadLayout(string path) - { - var root = XElement.Load(path); - if (root.Name.LocalName == "layout") - return root; - var layout = root.Descendants().FirstOrDefault(e => e.Name.LocalName == "layout"); - if (layout == null) - throw new InvalidDataException($"No element found in override file '{path}'."); - return layout; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs deleted file mode 100644 index 018a6cfdf6..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Canonical JSON wire format for a per-project : deterministic, - /// sparse, keyed by StableId, with a `formatVersion` header. Mirrors the conventions of - /// (Newtonsoft, ordered keys, defaults omitted) so the - /// override store and the base store read alike and diff cleanly under review. - /// - public static class ViewDefinitionOverrideJsonSerializer - { - // Stable wire tokens for the operation kinds (camelCase, decoupled from the C# enum names). - private static readonly Dictionary OpToWire = - new Dictionary - { - { ViewOverrideOperationKind.SetVisibility, "setVisibility" }, - { ViewOverrideOperationKind.SetLabel, "setLabel" }, - { ViewOverrideOperationKind.ReorderChildren, "reorderChildren" }, - { ViewOverrideOperationKind.HideNode, "hideNode" }, - { ViewOverrideOperationKind.AddNode, "addNode" }, - { ViewOverrideOperationKind.DuplicateNode, "duplicateNode" } - }; - - private static readonly Dictionary WireToOp = - OpToWire.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.Ordinal); - - public static string Serialize(ViewDefinitionOverride patch) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - - var root = new JObject - { - ["formatVersion"] = patch.FormatVersion, - ["class"] = patch.ClassName, - ["name"] = patch.LayoutName, - ["type"] = patch.LayoutType, - ["operations"] = new JArray(patch.Operations.Select(WriteOperation)) - }; - - // Diagnostics are the audit record: present only when the override had non-representable parts. - if (patch.Diagnostics.Count > 0) - root["diagnostics"] = new JArray(patch.Diagnostics.Select(WriteDiagnostic)); - - return root.ToString(Formatting.Indented); - } - - public static ViewDefinitionOverride Deserialize(string json) - { - if (string.IsNullOrEmpty(json)) throw new ArgumentNullException(nameof(json)); - var root = JObject.Parse(json); - - var version = (int?)root["formatVersion"] ?? -1; - if (version != ViewDefinitionOverride.CurrentFormatVersion) - throw new InvalidDataException( - $"Unsupported override formatVersion {version} (expected {ViewDefinitionOverride.CurrentFormatVersion})."); - - var operations = ((JArray)root["operations"] ?? new JArray()).Select(ReadOperation).ToList(); - var diagnostics = ((JArray)root["diagnostics"] ?? new JArray()).Select(ReadDiagnostic).ToList(); - - return new ViewDefinitionOverride( - (string)root["class"] ?? "", - (string)root["name"] ?? "", - (string)root["type"] ?? "detail", - operations, - diagnostics, - version); - } - - private static JObject WriteOperation(ViewOverrideOperation op) - { - var o = new JObject - { - ["op"] = OpToWire[op.Kind], - ["id"] = op.StableId - }; - switch (op.Kind) - { - case ViewOverrideOperationKind.SetVisibility: - o["visibility"] = op.Visibility?.ToString(); - break; - case ViewOverrideOperationKind.SetLabel: - o["label"] = op.Label; - break; - case ViewOverrideOperationKind.ReorderChildren: - o["childOrder"] = new JArray(op.ChildOrder); - break; - case ViewOverrideOperationKind.HideNode: - break; - case ViewOverrideOperationKind.AddNode: - o["parent"] = op.ParentStableId; - o["index"] = op.Index; - o["nodeKind"] = op.NodeKind?.ToString(); - if (op.Label != null) o["label"] = op.Label; - if (op.Field != null) o["field"] = op.Field; - if (op.Editor != null) o["editor"] = op.Editor; - if (op.WritingSystem != null) o["ws"] = op.WritingSystem; - if (op.Visibility.HasValue) o["visibility"] = op.Visibility.Value.ToString(); - break; - case ViewOverrideOperationKind.DuplicateNode: - o["source"] = op.SourceStableId; - o["parent"] = op.ParentStableId; - o["index"] = op.Index; - break; - } - return o; - } - - private static ViewOverrideOperation ReadOperation(JToken token) - { - var o = (JObject)token; - var wire = (string)o["op"]; - if (wire == null || !WireToOp.TryGetValue(wire, out var kind)) - throw new InvalidDataException($"Unknown override operation '{wire}'."); - - var stableId = (string)o["id"]; - switch (kind) - { - case ViewOverrideOperationKind.SetVisibility: - var visText = (string)o["visibility"]; - var vis = ParseEnum(visText, "visibility"); - return new ViewOverrideOperation(kind, stableId, visibility: vis); - case ViewOverrideOperationKind.SetLabel: - return new ViewOverrideOperation(kind, stableId, label: (string)o["label"]); - case ViewOverrideOperationKind.ReorderChildren: - var order = ((JArray)o["childOrder"] ?? new JArray()).Select(t => (string)t).ToList(); - return new ViewOverrideOperation(kind, stableId, childOrder: order); - case ViewOverrideOperationKind.AddNode: - var addKindText = (string)o["nodeKind"]; - var addKind = addKindText == null - ? (ViewNodeKind?)null - : ParseEnum(addKindText, "nodeKind"); - var addVisText = (string)o["visibility"]; - var addVis = addVisText == null - ? (ViewVisibility?)null - : ParseEnum(addVisText, "visibility"); - return new ViewOverrideOperation(kind, stableId, - visibility: addVis, label: (string)o["label"], - parentStableId: (string)o["parent"], index: (int?)o["index"], - nodeKind: addKind, field: (string)o["field"], editor: (string)o["editor"], - writingSystem: (string)o["ws"]); - case ViewOverrideOperationKind.DuplicateNode: - return new ViewOverrideOperation(kind, stableId, - parentStableId: (string)o["parent"], index: (int?)o["index"], - sourceStableId: (string)o["source"]); - default: - return new ViewOverrideOperation(kind, stableId); - } - } - - private static JObject WriteDiagnostic(ViewDiagnostic diag) - => new JObject - { - ["severity"] = diag.Severity.ToString(), - ["code"] = diag.Code, - ["path"] = diag.NodePath, - ["message"] = diag.Message - }; - - private static ViewDiagnostic ReadDiagnostic(JToken token) - { - var o = (JObject)token; - var severity = ParseEnum((string)o["severity"], "severity"); - return new ViewDiagnostic(severity, (string)o["code"], (string)o["message"], (string)o["path"]); - } - - // Parses an enum value from committed JSON, turning a null/garbage token into a controlled - // InvalidDataException (the load path catches it) rather than a raw ArgumentException/NRE. - private static TEnum ParseEnum(string text, string field) where TEnum : struct - { - if (string.IsNullOrEmpty(text) || !Enum.TryParse(text, ignoreCase: false, out var value) - || !Enum.IsDefined(typeof(TEnum), value)) - throw new InvalidDataException($"Invalid {field} value '{text}' in override patch."); - return value; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs deleted file mode 100644 index 204d2ab2c9..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Xml.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Migrates a legacy whole-copy .fwlayout override into a sparse, stable-id-keyed - /// . It imports both the shipped layout and the - /// project's customized copy to the typed IR (reusing ), then diffs them - /// by . Because a legacy override copies the shipped <layout> - /// under the same name, the imported StableIds align by position, so the diff is exactly the customer's - /// edits -- replacing the lossy whole-tree LayoutMerger with per-node operations. - /// - /// This is the framework-neutral migration core: it takes XML in and produces the patch. The thin - /// remaining wrapper (read the shipped layout from Inventory and the override file from the - /// project ConfigurationSettings folder, then write the patch file) is the XCore-coupled driver layer, - /// kept out of here so the migration logic stays unit-testable with inline XML. - /// - public static class ViewDefinitionOverrideMigrator - { - /// Migrates one shipped/overridden <layout> pair into a sparse override patch. - public static ViewDefinitionOverride MigrateLayout( - XElement shippedLayout, - XElement overriddenLayout, - IPartResolver parts, - IViewDefinitionImporter importer = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (overriddenLayout == null) throw new ArgumentNullException(nameof(overriddenLayout)); - if (parts == null) throw new ArgumentNullException(nameof(parts)); - - importer = importer ?? new XmlLayoutImporter(); - var shippedModel = importer.Import(shippedLayout, parts); - var overriddenModel = importer.Import(overriddenLayout, parts); - return ViewDefinitionOverrideDiffer.Diff(shippedModel, overriddenModel); - } - - /// String overload for callers/tests holding the layout XML as text. - public static ViewDefinitionOverride MigrateLayout( - string shippedLayoutXml, - string overriddenLayoutXml, - IPartResolver parts, - IViewDefinitionImporter importer = null) - { - if (shippedLayoutXml == null) throw new ArgumentNullException(nameof(shippedLayoutXml)); - if (overriddenLayoutXml == null) throw new ArgumentNullException(nameof(overriddenLayoutXml)); - return MigrateLayout(XElement.Parse(shippedLayoutXml), XElement.Parse(overriddenLayoutXml), parts, importer); - } - - /// Migrates and serializes the patch to canonical JSON in one step (the committed artifact). - public static string MigrateLayoutToJson( - XElement shippedLayout, - XElement overriddenLayout, - IPartResolver parts, - IViewDefinitionImporter importer = null) - => ViewDefinitionOverrideJsonSerializer.Serialize( - MigrateLayout(shippedLayout, overriddenLayout, parts, importer)); - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs deleted file mode 100644 index 99c8c210d4..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The per-project home of the sparse patches that drive the - /// Avalonia detail view's per-field "Field Visibility"/"Move Field" commands: sparse JSON - /// patch documents keyed by StableId, stored as files in the project ConfigurationSettings folder. - /// One file per (class, layout); the override layer -- not the legacy Inventory store -- is - /// what Compose - /// actually reads. - /// - /// Pure FwAvalonia: the caller (the xWorks host) resolves the project ConfigurationSettings folder - /// from LcmFileHelper.GetConfigSettingsDir and hands the path here, so this stays LCModel-free - /// and unit-testable with a temp directory. Patches load lazily and cache per (class, layout); each - /// mutation re-serializes the one file it touched (via ). - /// - public sealed class ViewDefinitionOverrideStore - { - // Distinct extension so these never collide with the legacy whole-copy "{Class}.fwlayout" files - // in the same folder, and so the file name reads as "what + which layout" for support staff. - internal const string FileExtension = ".viewoverride.json"; - - private readonly string _directory; - private readonly Dictionary<(string Class, string Layout), ViewDefinitionOverride> _cache - = new Dictionary<(string, string), ViewDefinitionOverride>(); - private readonly object _sync = new object(); - - public ViewDefinitionOverrideStore(string configurationSettingsDirectory) - { - _directory = configurationSettingsDirectory - ?? throw new ArgumentNullException(nameof(configurationSettingsDirectory)); - } - - /// - /// The patch for (, ), or null when the - /// project never customized that layout. Loads from disk on first access and caches; a corrupt or - /// version-mismatched file is treated as "no override" (load failure is reported to - /// rather than crashing compose -- the legacy Inventory - /// drops stale - /// overrides too). - /// - public ViewDefinitionOverride TryGet(string className, string layoutName, - Action onLoadError = null) - { - if (string.IsNullOrEmpty(className) || string.IsNullOrEmpty(layoutName)) - return null; - - var key = (className, layoutName); - lock (_sync) - { - if (_cache.TryGetValue(key, out var cached)) - return cached; - - ViewDefinitionOverride loaded = null; - var path = PathFor(className, layoutName); - try - { - if (File.Exists(path)) - { - var patch = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(path)); - // Guard against a hand-edited/renamed file whose header disagrees with its name. - if (string.Equals(patch.ClassName, className, StringComparison.Ordinal) - && string.Equals(patch.LayoutName, layoutName, StringComparison.Ordinal)) - { - loaded = patch.IsEmpty ? null : patch; - } - } - } - catch (Exception e) - { - onLoadError?.Invoke(path, e); - loaded = null; - } - - _cache[key] = loaded; - return loaded; - } - } - - /// - /// Persists for its (ClassName, LayoutName) and refreshes the - /// cache. An - /// empty patch deletes the file (the project no longer customizes that layout), so an undo-to-base - /// leaves no stale override behind -- the same "no file = shipped definition" contract - /// the loader - /// relies on. - /// - public void Save(ViewDefinitionOverride patch) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - if (string.IsNullOrEmpty(patch.ClassName) || string.IsNullOrEmpty(patch.LayoutName)) - throw new ArgumentException("Override must carry a class and layout name to be stored."); - - var key = (patch.ClassName, patch.LayoutName); - var path = PathFor(patch.ClassName, patch.LayoutName); - lock (_sync) - { - if (patch.IsEmpty) - { - if (File.Exists(path)) - File.Delete(path); - _cache[key] = null; - return; - } - - Directory.CreateDirectory(_directory); - File.WriteAllText(path, ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - _cache[key] = patch; - } - } - - /// The on-disk path for a (class, layout) patch (also the file the loader - /// reads). - public string PathFor(string className, string layoutName) - => Path.Combine(_directory, MakeFileName(className, layoutName)); - - // "{Class}.{Layout}.viewoverride.json" -- sanitized so an exotic layout name can never - // escape the - // folder or collide with a path separator (layout names are inventory tokens, but be defensive). - internal static string MakeFileName(string className, string layoutName) - { - var safeClass = Sanitize(className); - var safeLayout = Sanitize(layoutName); - return safeClass + "." + safeLayout + FileExtension; - } - - private static string Sanitize(string token) - { - if (string.IsNullOrEmpty(token)) - return "_"; - var invalid = Path.GetInvalidFileNameChars(); - return new string(token.Select(c => Array.IndexOf(invalid, c) >= 0 || c == '.' ? '_' : c).ToArray()); - } - } -} diff --git a/Src/xWorks/Avalonia/DetailOverrideMigration.cs b/Src/xWorks/Avalonia/DetailOverrideMigration.cs deleted file mode 100644 index 76eeeaf5cb..0000000000 --- a/Src/xWorks/Avalonia/DetailOverrideMigration.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Xml; -using System.Xml.Linq; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; -using XCore; - -namespace SIL.FieldWorks.XWorks -{ - /// - /// xWorks adapter that migrates a project's legacy whole-copy .fwlayout override into a sparse - /// canonical JSON patch. It bridges the live to - /// the framework-neutral, fully-tested migration core - /// ( + ). - /// - /// The caller supplies the pristine shipped layout (resolved from the appropriate - /// non-overridden source); this adapter does not decide the baseline -- that choice (e.g. a - /// base - /// inventory vs. the project inventory whose overrides are already merged) belongs to the caller and - /// is the one piece needing a real-project smoke test before production use. - /// - public static class DetailOverrideMigration - { - /// - /// Framework-neutral core: shipped layout + parts inventory as XElements. Unit-testable - /// with inline - /// XML -- it composes the tested and - /// . - /// - public static ViewDefinitionOverride MigrateProjectOverride( - XElement shippedLayout, - XElement partsInventory, - string overrideFilePath, - string outputPatchPath = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (partsInventory == null) throw new ArgumentNullException(nameof(partsInventory)); - - var parts = new DictionaryPartResolver(partsInventory); - return ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - shippedLayout, overrideFilePath, parts, outputPatchPath); - } - - /// - /// Live- bridge: adapts the shipped layout node and the parts inventory root - /// (System.Xml) to the XElement core. The must be the pristine - /// shipped layout (see the type remarks on baseline selection). - /// - public static ViewDefinitionOverride MigrateProjectOverride( - XmlNode shippedLayout, - Inventory partsInventory, - string overrideFilePath, - string outputPatchPath = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (partsInventory == null) throw new ArgumentNullException(nameof(partsInventory)); - - return MigrateProjectOverride( - XElement.Parse(shippedLayout.OuterXml), - XElement.Parse(partsInventory.Root.OuterXml), - overrideFilePath, - outputPatchPath); - } - } -} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs deleted file mode 100644 index fe22289a9a..0000000000 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace SIL.FieldWorks.XWorks -{ - /// - /// The xWorks override-migration adapter composes the live inventory's - /// parts + a shipped layout into the tested migration core. This exercises the framework-neutral - /// XElement overload with inline XML + a temp override file (the live-Inventory overload is a - /// thin XmlNode->XElement bridge over this same core, build-verified by the xWorks build). - /// - [TestFixture] - public class DetailOverrideMigrationTests - { - private const string PartsXml = @" - - - - - - - -"; - - private const string ShippedLayout = @" - - - -"; - - private const string OverrideLayout = @" - - - -"; - - private string _overrideFile; - private string _outputFile; - - [SetUp] - public void SetUp() - { - _overrideFile = Path.Combine(Path.GetTempPath(), "fw-" + Guid.NewGuid().ToString("N") + ".fwlayout"); - _outputFile = Path.Combine(Path.GetTempPath(), "patch-" + Guid.NewGuid().ToString("N") + ".json"); - } - - [TearDown] - public void TearDown() - { - if (File.Exists(_overrideFile)) File.Delete(_overrideFile); - if (File.Exists(_outputFile)) File.Delete(_outputFile); - } - - [Test] - public void MigrateProjectOverride_FromXElements_ProducesPatch_AndWritesJson() - { - File.WriteAllText(_overrideFile, OverrideLayout); - - var patch = DetailOverrideMigration.MigrateProjectOverride( - XElement.Parse(ShippedLayout), XElement.Parse(PartsXml), _overrideFile, _outputFile); - - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - - Assert.That(File.Exists(_outputFile), Is.True); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(_outputFile)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - - [Test] - public void MigrateProjectOverride_NoCustomization_ProducesEmptyPatch() - { - File.WriteAllText(_overrideFile, ShippedLayout); - - var patch = DetailOverrideMigration.MigrateProjectOverride( - XElement.Parse(ShippedLayout), XElement.Parse(PartsXml), _overrideFile, _outputFile); - - Assert.That(patch.IsEmpty, Is.True); - } - } -} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 9d4ad9a862..4fff6c169f 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -186,9 +186,6 @@ public void PersistentLayoutCommand_UsesLegacyWriter_PersistsAndRecomposes( Assert.That(part.Attributes["visibility"].Value, Is.EqualTo(expectedChange)); Assert.That(afterModel.Fields, Has.Some.Property("Field").EqualTo("CitationForm")); } - - var configurationDirectory = Path.GetDirectoryName(m_layoutOverridePath); - Assert.That(Directory.GetFiles(configurationDirectory, "*.viewoverride.json"), Is.Empty); } [Test] From 54f4c87acc2dec5af5e54568bf75a5d2542f1eba Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 20:59:22 -0400 Subject: [PATCH 15/23] test: preserve neutral detail rendering coverage --- .../FwAvaloniaTests/DetailRenderingTests.cs | 68 +++++++++++++++++++ ...ts.cs => ProjectLayoutCompositionTests.cs} | 6 +- 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs rename Src/xWorks/xWorksTests/Avalonia/Composer/{DetailComposerOverrideTests.cs => ProjectLayoutCompositionTests.cs} (98%) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs new file mode 100644 index 0000000000..b53cf4d0e9 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + [TestFixture] + public class DetailRenderingTests + { + private static DetailField TextField(string id, string label) + => new DetailField(id, label, label, null, DetailFieldKind.Text, + EditorClassification.Known, id, null, HostRouting.Inherit, + new List { new DetailWsValue("en", "value") }, + null, null, isEditable: true, indent: 0, objectHvo: 1234); + + private static DataTree Render(params DetailField[] fields) + { + var model = new DetailModel("LexEntry", "Normal", fields.ToList(), + new List()); + var view = new DataTree(model, null, null, null, null, null); + var window = new Window { Content = view, Width = 480, Height = 360 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return view; + } + + private static List RenderedLabelIds(DataTree view) + => view.GetVisualDescendants().OfType() + .Select(t => AutomationProperties.GetAutomationId(t)) + .Where(id => !string.IsNullOrEmpty(id) && id.EndsWith(".Label")) + .ToList(); + + [AvaloniaTest] + public void DetailView_RendersOnlyTheRowsInTheModel() + { + var view = Render(TextField("a", "Alpha"), TextField("c", "Gamma")); + + var labels = RenderedLabelIds(view); + Assert.That(labels, Has.Member("a.Label")); + Assert.That(labels, Has.Member("c.Label")); + Assert.That(labels, Has.No.Member("b.Label"), + "a row omitted from the model does not render"); + } + + [AvaloniaTest] + public void DetailView_RendersRowsInModelOrder_SoAReorderIsVisible() + { + var view = Render(TextField("c", "Gamma"), TextField("a", "Alpha"), + TextField("b", "Beta")); + + var order = RenderedLabelIds(view); + Assert.That(order.IndexOf("c.Label"), Is.LessThan(order.IndexOf("a.Label"))); + Assert.That(order.IndexOf("a.Label"), Is.LessThan(order.IndexOf("b.Label")), + "rows render in model order"); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs similarity index 98% rename from Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs rename to Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs index 280ca413ed..613740c9fa 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs @@ -17,7 +17,7 @@ namespace SIL.FieldWorks.XWorks { [TestFixture] - public class DetailComposerOverrideTests : MemoryOnlyBackendProviderTestBase + public class ProjectLayoutCompositionTests : MemoryOnlyBackendProviderTestBase { private const string LayoutXml = @" @@ -218,7 +218,7 @@ private InventoryViewDefinitionSource CreateSource(Inventory layouts) { var parts = new Inventory("*Parts.xml", "/PartInventory/bin/*", new Dictionary { ["part"] = new[] { "id" } }, - "DetailComposerOverrideTests", "unused"); + "ProjectLayoutCompositionTests", "unused"); parts.LoadElements(PartsXml, 0); return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, Cache.MetaDataCacheAccessor); @@ -230,7 +230,7 @@ private static Inventory CreateLayoutInventory(string projectPath) new Dictionary { ["layout"] = new[] { "class", "type", "name", "choiceGuid" } - }, "DetailComposerOverrideTests", projectPath); + }, "ProjectLayoutCompositionTests", projectPath); layouts.LoadElements(LayoutXml, 0); return layouts; } From e8312a694bc9b452c2f5eafaa75fadbe6738cd6a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 21:27:24 -0400 Subject: [PATCH 16/23] test: prove shared layout persistence parity --- .../Hosting/LayoutPersistenceParityTests.cs | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs new file mode 100644 index 0000000000..6f3addd014 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -0,0 +1,421 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.Framework.DetailControls; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using XCore; +using LegacyDataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + [NonParallelizable] + [Apartment(System.Threading.ApartmentState.STA)] + public class LayoutPersistenceParityTests : XWorksAppTestBase + { + private PropertyTable m_propertyTable; + private List m_createdObjects; + private ILexEntry m_entry; + private RecordEditView m_view; + private Inventory m_layouts; + private string m_configurationDirectory; + private string m_overridePath; + private bool m_overrideExisted; + private byte[] m_overrideBytes; + private string m_originalLayoutXml; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, + m_application.DefaultConfigurationPathname); + Cache.ProjectId.Path = Path.Combine(Path.GetTempPath(), Cache.ProjectId.Name, + Cache.ProjectId.Name + ".junk"); + } + + [SetUp] + public void SetUpWindow() + { + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + TestLocalizationManagerBootstrap.EnsureInitialized(); + TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.Path); + m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + m_configurationDirectory = Path.GetFullPath( + LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path)); + m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, + "LexEntry.fwlayout")); + AssertPathIsInConfigurationSettings(m_overridePath); + m_overrideExisted = File.Exists(m_overridePath); + m_overrideBytes = m_overrideExisted ? File.ReadAllBytes(m_overridePath) : null; + m_originalLayoutXml = CurrentLexEntryLayout().OuterXml; + + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(m_view, Is.Not.Null); + EnsureCurrentRecord(); + Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + } + + [TearDown] + public void TearDownWindow() + { + try + { + RestoreLayoutOverride(); + } + finally + { + try + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyTestData); + } + finally + { + m_createdObjects = null; + m_entry = null; + m_view = null; + m_layouts = null; + m_overridePath = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + m_window.Dispose(); + m_window = null; + } + } + } + + [Test] + public void AvaloniaNormallyHidden_ReloadsIntoAvaloniaAndLegacyDataTree() + { + ResetToNoProjectOverride(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + + ExecuteNative(field, "Normally hidden"); + + Assert.That(GetHostedDetailModel().Fields, + Has.None.Property("Field").EqualTo("CitationForm")); + m_layouts.Reload(); + AssertCitationVisibility("never"); + var legacyTree = LegacyTree(); + var legacyLayouts = (Inventory)GetField(legacyTree, "m_layoutInventory"); + Assert.That(legacyLayouts, Is.SameAs(m_layouts)); + var part = legacyLayouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }) + .SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part.Attributes?["visibility"]?.Value, Is.EqualTo("never")); + } + + [Test] + public void LegacyAlwaysVisible_ReloadsIntoReconstructedAvaloniaHost() + { + ResetToNoProjectOverride(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + ExecuteNative(field, "Normally hidden, unless non-empty"); + m_layouts.Reload(); + AssertCitationVisibility("ifdata"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyTree = LegacyTree(); + legacyTree.SetCurrentSliceForCommandTarget(legacyTree.Slices.Single(slice => + slice.Flid == LexEntryTags.kflidCitationForm)); + + ExecuteLegacy("Always visible"); + + m_layouts.Reload(); + AssertCitationVisibility("always"); + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel().Fields, + Has.Some.Property("Field").EqualTo("CitationForm")); + } + + [Test] + public void AvaloniaMove_ReloadsInWinFormsOrder() + { + ResetToNoProjectOverride(); + var beforeModel = GetHostedDetailModel(); + var beforeIndex = FieldIndex(beforeModel, "CitationForm"); + var citation = beforeModel.Fields.Single(item => item.Field == "CitationForm"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var beforeLegacyIndex = LegacyTree().Slices.FindIndex(slice => + slice.Flid == LexEntryTags.kflidCitationForm); + + ExecuteNative(citation, "Move Down"); + + var afterAvaloniaIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); + Assert.That(afterAvaloniaIndex, Is.GreaterThan(beforeIndex)); + m_layouts.Reload(); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyIndex = LegacyTree().Slices.FindIndex(slice => + slice.Flid == LexEntryTags.kflidCitationForm); + Assert.That(legacyIndex, Is.GreaterThan(beforeLegacyIndex)); + } + + [Test] + public void WinFormsMove_ReloadsInAvaloniaOrder() + { + ResetToNoProjectOverride(); + var beforeIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyTree = LegacyTree(); + legacyTree.SetCurrentSliceForCommandTarget(legacyTree.Slices.Single(slice => + slice.Flid == LexEntryTags.kflidCitationForm)); + + ExecuteLegacy("Move Down"); + + m_layouts.Reload(); + RefreshAvaloniaDetail(); + Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), + Is.GreaterThan(beforeIndex)); + } + + [Test] + public void PersistentLayoutCommand_CreatesOnlyProjectFwlayoutArtifact() + { + ResetToNoProjectOverride(); + var filesBefore = ConfigurationFiles(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + + ExecuteNative(field, "Normally hidden"); + + var created = ConfigurationFiles().Except(filesBefore, + StringComparer.OrdinalIgnoreCase).ToArray(); + Assert.That(created.Select(path => path.ToUpperInvariant()), + Is.EqualTo(new[] { m_overridePath.ToUpperInvariant() })); + Assert.That(created.Select(Path.GetExtension), Is.All.EqualTo(".fwlayout")); + } + + private void ResetToNoProjectOverride() + { + if (File.Exists(m_overridePath)) + File.Delete(m_overridePath); + m_layouts.Reload(); + Assert.That(File.Exists(m_overridePath), Is.False); + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel().Fields, + Has.Some.Property("Field").EqualTo("CitationForm")); + } + + private void ExecuteNative(DetailField field, string label) + { + var method = typeof(RecordEditView).GetMethod("CreateNativeDetailMenuItems", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + var items = (IReadOnlyList)method.Invoke(m_view, + new object[] { field, new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId } }); + ExecuteItem(items, label); + } + + private void ExecuteLegacy(string label) + { + var window = m_propertyTable.GetValue("window"); + var items = XCoreMenuBridge.CreateMenuItems(window, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + ExecuteItem(items, label); + m_window.Mediator.IdleQueue.Clear(); + } + + private static void ExecuteItem(IReadOnlyList items, string label) + { + var item = FindItem(items, label); + Assert.That(item, Is.Not.Null, "expected the '{0}' command", label); + Assert.That(item.IsEnabled, Is.True, "expected the '{0}' command to be enabled", label); + item.Execute(); + } + + private static DetailMenuItem FindItem(IReadOnlyList items, string label) + { + foreach (var item in items) + { + if (string.Equals(item.Label, label, StringComparison.Ordinal)) + return item; + var nested = FindItem(item.Children, label); + if (nested != null) + return nested; + } + return null; + } + + private void EnsureAdapter(int targetHvo, string fieldName) + { + var method = typeof(RecordEditView).GetMethod("EnsureMenuCommandAdapter", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + method.Invoke(m_view, new object[] { targetHvo, fieldName }); + } + + private void RefreshAvaloniaDetail() + { + var refresh = typeof(RecordEditView).GetMethod("RefreshAvaloniaDetail", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(refresh, Is.Not.Null); + refresh.Invoke(m_view, null); + DrainMediatorAndIdleQueues(); + } + + private DetailModel GetHostedDetailModel() + { + var entryForm = (DetailHostControl)GetField(m_view, "m_avaloniaEntryForm"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null); + return tree.Model; + } + + private LegacyDataTree LegacyTree() + { + var tree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + Assert.That(tree.Slices, Is.Not.Empty); + return tree; + } + + private XmlNode CurrentLexEntryLayout() + { + var layout = m_layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(layout, Is.Not.Null); + return layout; + } + + private void AssertCitationVisibility(string expected) + { + var part = CurrentLexEntryLayout().SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part, Is.Not.Null); + Assert.That(part.Attributes?["visibility"]?.Value, Is.EqualTo(expected)); + } + + private static int FieldIndex(DetailModel model, string fieldName) + { + return model.Fields.ToList().FindIndex(field => field.Field == fieldName); + } + + private string[] ConfigurationFiles() + { + if (!Directory.Exists(m_configurationDirectory)) + return Array.Empty(); + return Directory.GetFiles(m_configurationDirectory, "*", SearchOption.AllDirectories) + .Select(Path.GetFullPath).OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private void AssertPathIsInConfigurationSettings(string path) + { + var relative = Path.GetFullPath(path).Substring(m_configurationDirectory.Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.That(relative, Is.Not.Empty); + Assert.That(relative.StartsWith(".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal), Is.False); + Assert.That(Path.IsPathRooted(relative), Is.False); + } + + private void RestoreLayoutOverride() + { + if (m_layouts == null || string.IsNullOrEmpty(m_overridePath)) + return; + AssertPathIsInConfigurationSettings(m_overridePath); + if (m_overrideExisted) + { + Directory.CreateDirectory(m_configurationDirectory); + File.WriteAllBytes(m_overridePath, m_overrideBytes); + } + else if (File.Exists(m_overridePath)) + { + File.Delete(m_overridePath); + } + m_layouts.Reload(); + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(m_layouts)); + Assert.That(File.Exists(m_overridePath), Is.EqualTo(m_overrideExisted)); + if (m_overrideExisted) + Assert.That(File.ReadAllBytes(m_overridePath), Is.EqualTo(m_overrideBytes)); + Assert.That(CurrentLexEntryLayout().OuterXml, Is.EqualTo(m_originalLayoutXml)); + if (m_view != null && !m_view.IsDisposed) + { + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel().Fields, + Has.Some.Property("Field").EqualTo("CitationForm")); + } + } + + private void CreateTestEntry() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + m_entry = AddLexeme(m_createdObjects, "layout-parity-entry", stemMorphType, + "first gloss", noun); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("citation", Cache.DefaultVernWs)); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("bibliography", Cache.DefaultAnalWs)); + } + + private void DestroyTestData() + { + if (m_createdObjects == null) + return; + foreach (var obj in m_createdObjects) + { + if (obj.IsValidObject && obj is ILexEntry) + obj.Delete(); + } + } + + private void LoadRecordEditView(string toolValue) + { + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + var controlNode = windowConfiguration.SelectSingleNode(string.Format( + "//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", + toolValue)); + Assert.That(controlNode, Is.Not.Null); + m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", toolValue, true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + private void EnsureCurrentRecord() + { + if (m_view.Clerk.CurrentObject?.Hvo != m_entry.Hvo) + { + m_view.Clerk.JumpToRecord(m_entry.Hvo); + DrainMediatorAndIdleQueues(); + } + Assert.That(m_view.Clerk.CurrentObject?.Hvo, Is.EqualTo(m_entry.Hvo)); + } + + private static object GetField(object target, string fieldName) + { + var field = target.GetType().GetField(fieldName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "missing private field: " + fieldName); + return field.GetValue(target); + } + } +} From 618c0339f6b6e29322952ee476823d1936c603eb Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 21:44:58 -0400 Subject: [PATCH 17/23] test: verify reloaded layout host parity --- .../Hosting/LayoutPersistenceParityTests.cs | 79 +++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs index 6f3addd014..c8a41ab31c 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -32,11 +32,15 @@ public class LayoutPersistenceParityTests : XWorksAppTestBase private ILexEntry m_entry; private RecordEditView m_view; private Inventory m_layouts; + private Inventory m_previousLayouts; + private Inventory m_previousParts; + private bool m_inventoryRegistrationCaptured; private string m_configurationDirectory; private string m_overridePath; private bool m_overrideExisted; private byte[] m_overrideBytes; private string m_originalLayoutXml; + private int m_originalCitationIndex; protected override void Init() { @@ -50,6 +54,7 @@ protected override void Init() [SetUp] public void SetUpWindow() { + m_inventoryRegistrationCaptured = false; m_window = new MockFwXWindow(m_application, m_configFilePath); ((MockFwXWindow)m_window).Init(Cache); m_propertyTable = m_window.PropTable; @@ -57,9 +62,17 @@ public void SetUpWindow() m_window.LoadUI(m_configFilePath); TestLocalizationManagerBootstrap.EnsureInitialized(); TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); - LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, - Cache.ProjectId.Path); + m_previousLayouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + m_previousParts = Inventory.GetInventory("parts", Cache.ProjectId.Name); + m_inventoryRegistrationCaptured = true; + if (m_previousLayouts == null || m_previousParts == null) + { + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.Path); + } m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + Assert.That(m_layouts, Is.Not.Null); + Assert.That(Inventory.GetInventory("parts", Cache.ProjectId.Name), Is.Not.Null); m_configurationDirectory = Path.GetFullPath( LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path)); m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, @@ -80,6 +93,7 @@ public void SetUpWindow() Assert.That(m_view, Is.Not.Null); EnsureCurrentRecord(); Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + m_originalCitationIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); } [TearDown] @@ -97,16 +111,25 @@ public void TearDownWindow() } finally { - m_createdObjects = null; - m_entry = null; - m_view = null; - m_layouts = null; - m_overridePath = null; - m_propertyTable?.RemoveLocalAndGlobalSettings(); - m_propertyTable = null; - if (m_window != null && !m_window.IsDisposed) - m_window.Dispose(); - m_window = null; + try + { + m_createdObjects = null; + m_entry = null; + m_view = null; + m_overridePath = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + m_window.Dispose(); + m_window = null; + } + finally + { + RestoreInventoryRegistrations(); + m_layouts = null; + m_previousLayouts = null; + m_previousParts = null; + } } } } @@ -121,15 +144,23 @@ public void AvaloniaNormallyHidden_ReloadsIntoAvaloniaAndLegacyDataTree() Assert.That(GetHostedDetailModel().Fields, Has.None.Property("Field").EqualTo("CitationForm")); + var modelBeforeReload = GetHostedDetailModel(); m_layouts.Reload(); + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel(), Is.Not.SameAs(modelBeforeReload)); + Assert.That(GetHostedDetailModel().Fields, + Has.None.Property("Field").EqualTo("CitationForm")); AssertCitationVisibility("never"); var legacyTree = LegacyTree(); + legacyTree.RefreshList(true); var legacyLayouts = (Inventory)GetField(legacyTree, "m_layoutInventory"); Assert.That(legacyLayouts, Is.SameAs(m_layouts)); var part = legacyLayouts.GetElement("layout", new[] { "LexEntry", "detail", "Normal", null }) .SelectSingleNode("part[@ref='CitationFormAllV']"); Assert.That(part.Attributes?["visibility"]?.Value, Is.EqualTo("never")); + Assert.That(legacyTree.Slices, + Has.None.Property("Flid").EqualTo(LexEntryTags.kflidCitationForm)); } [Test] @@ -359,11 +390,31 @@ private void RestoreLayoutOverride() if (m_view != null && !m_view.IsDisposed) { RefreshAvaloniaDetail(); - Assert.That(GetHostedDetailModel().Fields, - Has.Some.Property("Field").EqualTo("CitationForm")); + Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), + Is.EqualTo(m_originalCitationIndex)); } } + private void RestoreInventoryRegistrations() + { + if (!m_inventoryRegistrationCaptured) + return; + RestoreInventoryRegistration("layouts", m_previousLayouts); + RestoreInventoryRegistration("parts", m_previousParts); + m_inventoryRegistrationCaptured = false; + } + + private void RestoreInventoryRegistration(string key, Inventory previous) + { + if (ReferenceEquals(Inventory.GetInventory(key, Cache.ProjectId.Name), previous)) + return; + if (previous == null) + Inventory.RemoveInventory(key, Cache.ProjectId.Name); + else + Inventory.SetInventory(key, Cache.ProjectId.Name, previous); + Assert.That(Inventory.GetInventory(key, Cache.ProjectId.Name), Is.SameAs(previous)); + } + private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); From f0d47a5614a81172f31cbbeeb86aa65f7b3f7096 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 22:04:17 -0400 Subject: [PATCH 18/23] test: make layout parity cleanup failure-safe --- .../Hosting/LayoutPersistenceParityTests.cs | 321 ++++++++++++------ 1 file changed, 223 insertions(+), 98 deletions(-) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs index c8a41ab31c..f2e851f623 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.ExceptionServices; using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; @@ -36,9 +37,11 @@ public class LayoutPersistenceParityTests : XWorksAppTestBase private Inventory m_previousParts; private bool m_inventoryRegistrationCaptured; private string m_configurationDirectory; + private string m_configurationBoundary; + private bool m_configurationDirectoryExisted; + private Dictionary m_configurationSnapshot; + private HashSet m_configurationDirectories; private string m_overridePath; - private bool m_overrideExisted; - private byte[] m_overrideBytes; private string m_originalLayoutXml; private int m_originalCitationIndex; @@ -55,85 +58,69 @@ protected override void Init() public void SetUpWindow() { m_inventoryRegistrationCaptured = false; - m_window = new MockFwXWindow(m_application, m_configFilePath); - ((MockFwXWindow)m_window).Init(Cache); - m_propertyTable = m_window.PropTable; - m_propertyTable.RemoveLocalAndGlobalSettings(); - m_window.LoadUI(m_configFilePath); - TestLocalizationManagerBootstrap.EnsureInitialized(); - TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); m_previousLayouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); m_previousParts = Inventory.GetInventory("parts", Cache.ProjectId.Name); m_inventoryRegistrationCaptured = true; - if (m_previousLayouts == null || m_previousParts == null) - { - LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, - Cache.ProjectId.Path); - } - m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); - Assert.That(m_layouts, Is.Not.Null); - Assert.That(Inventory.GetInventory("parts", Cache.ProjectId.Name), Is.Not.Null); - m_configurationDirectory = Path.GetFullPath( - LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path)); - m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, - "LexEntry.fwlayout")); - AssertPathIsInConfigurationSettings(m_overridePath); - m_overrideExisted = File.Exists(m_overridePath); - m_overrideBytes = m_overrideExisted ? File.ReadAllBytes(m_overridePath) : null; - m_originalLayoutXml = CurrentLexEntryLayout().OuterXml; - - m_createdObjects = new List(); - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); - m_propertyTable.SetProperty("UIMode", "New", true); - m_propertyTable.SetPropertyPersistence("UIMode", false); - LoadRecordEditView("lexiconEdit"); - DrainMediatorAndIdleQueues(); - m_view = m_propertyTable.GetValue("currentContentControlObject", null) - as RecordEditView; - Assert.That(m_view, Is.Not.Null); - EnsureCurrentRecord(); - Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); - m_originalCitationIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); - } - - [TearDown] - public void TearDownWindow() - { try { - RestoreLayoutOverride(); + m_configurationDirectory = Path.GetFullPath( + LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path)) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + m_configurationBoundary = m_configurationDirectory + Path.DirectorySeparatorChar; + CaptureConfigurationSettings(); + m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, + "LexEntry.fwlayout")); + AssertPathIsInConfigurationSettings(m_overridePath); + + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + TestLocalizationManagerBootstrap.EnsureInitialized(); + TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); + if (m_previousLayouts == null || m_previousParts == null) + { + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.Path); + } + m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + Assert.That(m_layouts, Is.Not.Null); + Assert.That(Inventory.GetInventory("parts", Cache.ProjectId.Name), Is.Not.Null); + m_originalLayoutXml = CurrentLexEntryLayout().OuterXml; + + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(m_view, Is.Not.Null); + EnsureCurrentRecord(); + Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + m_originalCitationIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); } - finally + catch { try { - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyTestData); + CleanupTestState(false); } - finally + catch { - try - { - m_createdObjects = null; - m_entry = null; - m_view = null; - m_overridePath = null; - m_propertyTable?.RemoveLocalAndGlobalSettings(); - m_propertyTable = null; - if (m_window != null && !m_window.IsDisposed) - m_window.Dispose(); - m_window = null; - } - finally - { - RestoreInventoryRegistrations(); - m_layouts = null; - m_previousLayouts = null; - m_previousParts = null; - } } + throw; } } + [TearDown] + public void TearDownWindow() + { + CleanupTestState(true); + } + [Test] public void AvaloniaNormallyHidden_ReloadsIntoAvaloniaAndLegacyDataTree() { @@ -229,16 +216,26 @@ public void WinFormsMove_ReloadsInAvaloniaOrder() public void PersistentLayoutCommand_CreatesOnlyProjectFwlayoutArtifact() { ResetToNoProjectOverride(); - var filesBefore = ConfigurationFiles(); + var filesBefore = ConfigurationFileSnapshot(); var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); ExecuteNative(field, "Normally hidden"); - var created = ConfigurationFiles().Except(filesBefore, + var filesAfter = ConfigurationFileSnapshot(); + var created = filesAfter.Keys.Except(filesBefore.Keys, + StringComparer.OrdinalIgnoreCase).ToArray(); + var removed = filesBefore.Keys.Except(filesAfter.Keys, StringComparer.OrdinalIgnoreCase).ToArray(); Assert.That(created.Select(path => path.ToUpperInvariant()), Is.EqualTo(new[] { m_overridePath.ToUpperInvariant() })); Assert.That(created.Select(Path.GetExtension), Is.All.EqualTo(".fwlayout")); + Assert.That(filesAfter[m_overridePath], Is.Not.Empty); + Assert.That(removed, Is.Empty); + foreach (var file in filesBefore) + { + Assert.That(filesAfter, Does.ContainKey(file.Key)); + Assert.That(filesAfter[file.Key], Is.EqualTo(file.Value)); + } } private void ResetToNoProjectOverride() @@ -349,59 +346,187 @@ private static int FieldIndex(DetailModel model, string fieldName) return model.Fields.ToList().FindIndex(field => field.Field == fieldName); } - private string[] ConfigurationFiles() + private Dictionary ConfigurationFileSnapshot() { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!Directory.Exists(m_configurationDirectory)) - return Array.Empty(); - return Directory.GetFiles(m_configurationDirectory, "*", SearchOption.AllDirectories) - .Select(Path.GetFullPath).OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray(); + return snapshot; + foreach (var path in Directory.GetFiles(m_configurationDirectory, "*", + SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + AssertPathIsInConfigurationSettings(fullPath); + snapshot.Add(fullPath, File.ReadAllBytes(fullPath)); + } + return snapshot; } private void AssertPathIsInConfigurationSettings(string path) { - var relative = Path.GetFullPath(path).Substring(m_configurationDirectory.Length) - .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - Assert.That(relative, Is.Not.Empty); - Assert.That(relative.StartsWith(".." + Path.DirectorySeparatorChar, - StringComparison.Ordinal), Is.False); - Assert.That(Path.IsPathRooted(relative), Is.False); + var fullPath = Path.GetFullPath(path); + Assert.That(fullPath.StartsWith(m_configurationBoundary, + StringComparison.OrdinalIgnoreCase), Is.True); + } + + private void CaptureConfigurationSettings() + { + m_configurationDirectoryExisted = Directory.Exists(m_configurationDirectory); + m_configurationSnapshot = new Dictionary( + StringComparer.OrdinalIgnoreCase); + m_configurationDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!m_configurationDirectoryExisted) + return; + foreach (var file in ConfigurationFileSnapshot()) + m_configurationSnapshot.Add(RelativeConfigurationPath(file.Key), file.Value); + foreach (var directory in Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(directory); + AssertPathIsInConfigurationSettings(fullPath); + m_configurationDirectories.Add(RelativeConfigurationPath(fullPath)); + } + } + + private string RelativeConfigurationPath(string path) + { + var fullPath = Path.GetFullPath(path); + if (!fullPath.StartsWith(m_configurationBoundary, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Path is outside ConfigurationSettings: " + fullPath); + return fullPath.Substring(m_configurationBoundary.Length); } - private void RestoreLayoutOverride() + private string FullConfigurationPath(string relativePath) { - if (m_layouts == null || string.IsNullOrEmpty(m_overridePath)) + if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath)) + throw new InvalidOperationException("ConfigurationSettings path must be relative."); + var fullPath = Path.GetFullPath(Path.Combine(m_configurationDirectory, relativePath)); + AssertPathIsInConfigurationSettings(fullPath); + return fullPath; + } + + private void RestoreConfigurationSettings(bool assertBehavior) + { + if (m_configurationSnapshot == null) return; - AssertPathIsInConfigurationSettings(m_overridePath); - if (m_overrideExisted) + var currentFiles = ConfigurationFileSnapshot(); + foreach (var file in currentFiles.Keys) { - Directory.CreateDirectory(m_configurationDirectory); - File.WriteAllBytes(m_overridePath, m_overrideBytes); + var relative = RelativeConfigurationPath(file); + if (!m_configurationSnapshot.ContainsKey(relative)) + File.Delete(file); } - else if (File.Exists(m_overridePath)) + foreach (var file in m_configurationSnapshot) { - File.Delete(m_overridePath); + var fullPath = FullConfigurationPath(file.Key); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + File.WriteAllBytes(fullPath, file.Value); } + RestoreConfigurationDirectories(); + AssertConfigurationSettingsRestored(); + if (m_layouts == null) + return; m_layouts.Reload(); + if (!assertBehavior || string.IsNullOrEmpty(m_originalLayoutXml)) + return; Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(m_layouts)); - Assert.That(File.Exists(m_overridePath), Is.EqualTo(m_overrideExisted)); - if (m_overrideExisted) - Assert.That(File.ReadAllBytes(m_overridePath), Is.EqualTo(m_overrideBytes)); Assert.That(CurrentLexEntryLayout().OuterXml, Is.EqualTo(m_originalLayoutXml)); - if (m_view != null && !m_view.IsDisposed) + if (m_view == null || m_view.IsDisposed) + return; + RefreshAvaloniaDetail(); + Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), + Is.EqualTo(m_originalCitationIndex)); + } + + private void RestoreConfigurationDirectories() + { + if (Directory.Exists(m_configurationDirectory)) + { + var directories = Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories).OrderByDescending(path => path.Length).ToArray(); + foreach (var directory in directories) + { + var fullPath = Path.GetFullPath(directory); + AssertPathIsInConfigurationSettings(fullPath); + if (!m_configurationDirectories.Contains(RelativeConfigurationPath(fullPath)) + && !Directory.EnumerateFileSystemEntries(fullPath).Any()) + { + Directory.Delete(fullPath, false); + } + } + } + foreach (var directory in m_configurationDirectories.OrderBy(path => path.Length)) + Directory.CreateDirectory(FullConfigurationPath(directory)); + if (!m_configurationDirectoryExisted && Directory.Exists(m_configurationDirectory) + && !Directory.EnumerateFileSystemEntries(m_configurationDirectory).Any()) { - RefreshAvaloniaDetail(); - Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), - Is.EqualTo(m_originalCitationIndex)); + Directory.Delete(m_configurationDirectory, false); } } - private void RestoreInventoryRegistrations() + private void AssertConfigurationSettingsRestored() { - if (!m_inventoryRegistrationCaptured) + Assert.That(Directory.Exists(m_configurationDirectory), + Is.EqualTo(m_configurationDirectoryExisted)); + if (!m_configurationDirectoryExisted) return; - RestoreInventoryRegistration("layouts", m_previousLayouts); - RestoreInventoryRegistration("parts", m_previousParts); - m_inventoryRegistrationCaptured = false; + var restored = ConfigurationFileSnapshot().ToDictionary( + item => RelativeConfigurationPath(item.Key), item => item.Value, + StringComparer.OrdinalIgnoreCase); + Assert.That(restored.Keys, Is.EquivalentTo(m_configurationSnapshot.Keys)); + foreach (var file in m_configurationSnapshot) + Assert.That(restored[file.Key], Is.EqualTo(file.Value)); + var restoredDirectories = Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories).Select(RelativeConfigurationPath).ToArray(); + Assert.That(restoredDirectories, Is.EquivalentTo(m_configurationDirectories)); + } + + private void CleanupTestState(bool assertBehavior) + { + Exception firstFailure = null; + CaptureCleanupFailure(() => RestoreConfigurationSettings(assertBehavior), ref firstFailure); + if (m_createdObjects != null) + { + CaptureCleanupFailure(() => NonUndoableUnitOfWorkHelper.Do( + Cache.ActionHandlerAccessor, DestroyTestData), ref firstFailure); + } + CaptureCleanupFailure(() => m_propertyTable?.RemoveLocalAndGlobalSettings(), + ref firstFailure); + if (m_window != null && !m_window.IsDisposed) + CaptureCleanupFailure(() => m_window.Dispose(), ref firstFailure); + if (m_inventoryRegistrationCaptured) + { + CaptureCleanupFailure(() => RestoreInventoryRegistration("layouts", m_previousLayouts), + ref firstFailure); + CaptureCleanupFailure(() => RestoreInventoryRegistration("parts", m_previousParts), + ref firstFailure); + m_inventoryRegistrationCaptured = false; + } + m_createdObjects = null; + m_entry = null; + m_view = null; + m_layouts = null; + m_previousLayouts = null; + m_previousParts = null; + m_propertyTable = null; + m_window = null; + m_overridePath = null; + m_configurationSnapshot = null; + m_configurationDirectories = null; + if (firstFailure != null) + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + + private static void CaptureCleanupFailure(Action action, ref Exception firstFailure) + { + try + { + action(); + } + catch (Exception error) + { + if (firstFailure == null) + firstFailure = error; + } } private void RestoreInventoryRegistration(string key, Inventory previous) From 8e015c37b704e2b0514b003e714925ea10b68956 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 22:15:32 -0400 Subject: [PATCH 19/23] test: publish layout snapshot atomically --- .../Hosting/LayoutPersistenceParityTests.cs | 76 ++++++++++++------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs index f2e851f623..4b19d71896 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -38,9 +38,7 @@ public class LayoutPersistenceParityTests : XWorksAppTestBase private bool m_inventoryRegistrationCaptured; private string m_configurationDirectory; private string m_configurationBoundary; - private bool m_configurationDirectoryExisted; - private Dictionary m_configurationSnapshot; - private HashSet m_configurationDirectories; + private ConfigurationSettingsSnapshot m_configurationSnapshot; private string m_overridePath; private string m_originalLayoutXml; private int m_originalCitationIndex; @@ -58,6 +56,7 @@ protected override void Init() public void SetUpWindow() { m_inventoryRegistrationCaptured = false; + m_configurationSnapshot = null; m_previousLayouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); m_previousParts = Inventory.GetInventory("parts", Cache.ProjectId.Name); m_inventoryRegistrationCaptured = true; @@ -68,6 +67,7 @@ public void SetUpWindow() .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); m_configurationBoundary = m_configurationDirectory + Path.DirectorySeparatorChar; CaptureConfigurationSettings(); + Assert.That(m_configurationSnapshot, Is.Not.Null); m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, "LexEntry.fwlayout")); AssertPathIsInConfigurationSettings(m_overridePath); @@ -370,21 +370,23 @@ private void AssertPathIsInConfigurationSettings(string path) private void CaptureConfigurationSettings() { - m_configurationDirectoryExisted = Directory.Exists(m_configurationDirectory); - m_configurationSnapshot = new Dictionary( - StringComparer.OrdinalIgnoreCase); - m_configurationDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); - if (!m_configurationDirectoryExisted) - return; - foreach (var file in ConfigurationFileSnapshot()) - m_configurationSnapshot.Add(RelativeConfigurationPath(file.Key), file.Value); - foreach (var directory in Directory.GetDirectories(m_configurationDirectory, "*", - SearchOption.AllDirectories)) + var directoryExisted = Directory.Exists(m_configurationDirectory); + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + if (directoryExisted) { - var fullPath = Path.GetFullPath(directory); - AssertPathIsInConfigurationSettings(fullPath); - m_configurationDirectories.Add(RelativeConfigurationPath(fullPath)); + foreach (var file in ConfigurationFileSnapshot()) + files.Add(RelativeConfigurationPath(file.Key), file.Value); + foreach (var directory in Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(directory); + AssertPathIsInConfigurationSettings(fullPath); + directories.Add(RelativeConfigurationPath(fullPath)); + } } + m_configurationSnapshot = new ConfigurationSettingsSnapshot(directoryExisted, + files, directories); } private string RelativeConfigurationPath(string path) @@ -412,10 +414,10 @@ private void RestoreConfigurationSettings(bool assertBehavior) foreach (var file in currentFiles.Keys) { var relative = RelativeConfigurationPath(file); - if (!m_configurationSnapshot.ContainsKey(relative)) + if (!m_configurationSnapshot.Files.ContainsKey(relative)) File.Delete(file); } - foreach (var file in m_configurationSnapshot) + foreach (var file in m_configurationSnapshot.Files) { var fullPath = FullConfigurationPath(file.Key); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); @@ -447,16 +449,19 @@ private void RestoreConfigurationDirectories() { var fullPath = Path.GetFullPath(directory); AssertPathIsInConfigurationSettings(fullPath); - if (!m_configurationDirectories.Contains(RelativeConfigurationPath(fullPath)) + if (!m_configurationSnapshot.Directories.Contains( + RelativeConfigurationPath(fullPath)) && !Directory.EnumerateFileSystemEntries(fullPath).Any()) { Directory.Delete(fullPath, false); } } } - foreach (var directory in m_configurationDirectories.OrderBy(path => path.Length)) + foreach (var directory in m_configurationSnapshot.Directories + .OrderBy(path => path.Length)) Directory.CreateDirectory(FullConfigurationPath(directory)); - if (!m_configurationDirectoryExisted && Directory.Exists(m_configurationDirectory) + if (!m_configurationSnapshot.DirectoryExisted + && Directory.Exists(m_configurationDirectory) && !Directory.EnumerateFileSystemEntries(m_configurationDirectory).Any()) { Directory.Delete(m_configurationDirectory, false); @@ -466,18 +471,19 @@ private void RestoreConfigurationDirectories() private void AssertConfigurationSettingsRestored() { Assert.That(Directory.Exists(m_configurationDirectory), - Is.EqualTo(m_configurationDirectoryExisted)); - if (!m_configurationDirectoryExisted) + Is.EqualTo(m_configurationSnapshot.DirectoryExisted)); + if (!m_configurationSnapshot.DirectoryExisted) return; var restored = ConfigurationFileSnapshot().ToDictionary( item => RelativeConfigurationPath(item.Key), item => item.Value, StringComparer.OrdinalIgnoreCase); - Assert.That(restored.Keys, Is.EquivalentTo(m_configurationSnapshot.Keys)); - foreach (var file in m_configurationSnapshot) + Assert.That(restored.Keys, Is.EquivalentTo(m_configurationSnapshot.Files.Keys)); + foreach (var file in m_configurationSnapshot.Files) Assert.That(restored[file.Key], Is.EqualTo(file.Value)); var restoredDirectories = Directory.GetDirectories(m_configurationDirectory, "*", SearchOption.AllDirectories).Select(RelativeConfigurationPath).ToArray(); - Assert.That(restoredDirectories, Is.EquivalentTo(m_configurationDirectories)); + Assert.That(restoredDirectories, + Is.EquivalentTo(m_configurationSnapshot.Directories)); } private void CleanupTestState(bool assertBehavior) @@ -511,7 +517,6 @@ private void CleanupTestState(bool assertBehavior) m_window = null; m_overridePath = null; m_configurationSnapshot = null; - m_configurationDirectories = null; if (firstFailure != null) ExceptionDispatchInfo.Capture(firstFailure).Throw(); } @@ -529,6 +534,23 @@ private static void CaptureCleanupFailure(Action action, ref Exception firstFail } } + private sealed class ConfigurationSettingsSnapshot + { + internal ConfigurationSettingsSnapshot(bool directoryExisted, + Dictionary files, HashSet directories) + { + DirectoryExisted = directoryExisted; + Files = files; + Directories = directories; + } + + internal bool DirectoryExisted { get; } + + internal Dictionary Files { get; } + + internal HashSet Directories { get; } + } + private void RestoreInventoryRegistration(string key, Inventory previous) { if (ReferenceEquals(Inventory.GetInventory(key, Cache.ProjectId.Name), previous)) From c1f422b326d596c7e1c8871d481abd1f5bb7c3d8 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 22:38:15 -0400 Subject: [PATCH 20/23] fix: preserve project layout source authority --- .../2026-08-26-avalonia-uses-fwlayout.md | 8 ++ .../Avalonia/Composer/DetailComposer.cs | 6 +- .../Hosting/RecordEditView.Avalonia.cs | 73 +++++++++++++++---- .../Composer/ProjectLayoutCompositionTests.cs | 10 ++- .../DetailCommandAdapterHardeningTests.cs | 24 +++--- .../Hosting/LayoutPersistenceParityTests.cs | 34 ++++++++- 6 files changed, 122 insertions(+), 33 deletions(-) diff --git a/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md b/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md index 66b2a20cb2..00f80ea82e 100644 --- a/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md +++ b/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md @@ -49,6 +49,14 @@ The adapter must clone XML to text before compilation. Compiler code must never Remove `DetailComposer.CompilerSources.CompiledModels`, whose key omits project identity and XML content. Continue using `ViewDefinitionCompiler`, whose key includes a SHA-256 fingerprint of layout XML, parts XML, class, type, and base-class map. When a legacy command calls `Inventory.PersistOverrideElement`, the next snapshot has different XML and therefore a different compiler key without explicit invalidation. +**Implementation correction:** The Task 2 instruction to preserve `SnapshotCompileCount` was +not implemented. Arbitrary source resolvers may return reused snapshot instances, so an +incrementing static count would not describe compiler work or source freshness reliably. The +observable replacement contract is +`CompileForObject_InventoryContentFingerprintReusesAndRefreshesCompiledModel`: identical +snapshot content returns the same compiled model instance, while changed content returns a +different model with the changed behavior. + ### Parts rule This change makes layout customization converge; it does not redesign part loading. Keep the existing immutable merged `*Parts.xml` snapshot in `DetailComposer`. Project customization currently persists effective `` elements, and `LayoutCache.InitializePartInventories` does not load project-level part overrides. A separate parts-inventory unification would be unrelated scope. diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 6345f95d32..05953fa27b 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -269,9 +269,6 @@ public FieldEditHandler HandlerFor(string stableId) } private readonly bool _showHidden; - // Descended objects use the same source. Model context stamps fields and restores - // after a - // nested walk. private readonly ViewDefinitionSourceResolver _source; private readonly Stack<(string ClassName, string LayoutName)> _modelContext = new Stack<(string, string)>(); @@ -3135,8 +3132,7 @@ private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, if (source != null) { var projectSnapshot = source(mdc.GetClassName(classId), layoutName, choiceGuid); - if (projectSnapshot != null) - return Compiler.Compile(projectSnapshot); + return projectSnapshot == null ? null : Compiler.Compile(projectSnapshot); } var sources = GetSources(); diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 2fc52b763d..39534c8412 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -41,6 +41,54 @@ namespace SIL.FieldWorks.XWorks /// public partial class RecordEditView { + internal readonly struct PersistentCommandTargetIdentity + : IEquatable + { + internal PersistentCommandTargetIdentity(int hvo, string fieldName, + string className, string layoutName, string callerPath) + { + Hvo = hvo; + FieldName = fieldName; + ClassName = className; + LayoutName = layoutName; + CallerPath = callerPath; + } + + internal int Hvo { get; } + + internal string FieldName { get; } + + internal string ClassName { get; } + + internal string LayoutName { get; } + + internal string CallerPath { get; } + + public bool Equals(PersistentCommandTargetIdentity other) + { + return Hvo == other.Hvo + && string.Equals(FieldName, other.FieldName, StringComparison.Ordinal) + && string.Equals(ClassName, other.ClassName, StringComparison.Ordinal) + && string.Equals(LayoutName, other.LayoutName, StringComparison.Ordinal) + && string.Equals(CallerPath, other.CallerPath, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + => obj is PersistentCommandTargetIdentity other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = Hvo; + hash = (hash * 397) ^ (FieldName?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (ClassName?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (LayoutName?.GetHashCode() ?? 0); + return (hash * 397) ^ (CallerPath?.GetHashCode() ?? 0); + } + } + } + private UIFramework m_activeUIFramework; private readonly EditControlFactory m_lexicalEditControlFactory; private readonly UIFrameworkSelectionService m_frameworkSelectionService = new UIFrameworkSelectionService(); @@ -726,8 +774,9 @@ private bool TrySetPersistentMenuCommandTarget(DetailField field, bool clearOnFa candidates.Add(slice); } var identities = candidates.Select(slice => PersistentSliceIdentity(slice)).ToList(); - var index = ChoosePersistentTargetSliceIndex(identities, field.ObjectHvo, field.Field, + var target = new PersistentCommandTargetIdentity(field.ObjectHvo, field.Field, field.ClassName, field.LayoutName, field.SourceCallerPath); + var index = ChoosePersistentTargetSliceIndex(identities, target); if (index < 0) { if (clearOnFailure) @@ -744,23 +793,16 @@ private bool TrySetPersistentMenuCommandTarget(DetailField field, bool clearOnFa } internal static int ChoosePersistentTargetSliceIndex( - IReadOnlyList<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> candidates, - int targetHvo, string fieldName, string className, string layoutName, string callerPath) + IReadOnlyList candidates, + PersistentCommandTargetIdentity target) { - if (candidates == null || string.IsNullOrEmpty(callerPath)) + if (candidates == null || string.IsNullOrEmpty(target.CallerPath)) return -1; var match = -1; for (var i = 0; i < candidates.Count; i++) { - var candidate = candidates[i]; - if (candidate.Hvo != targetHvo - || !string.Equals(candidate.FieldName, fieldName, StringComparison.Ordinal) - || !string.Equals(candidate.ClassName, className, StringComparison.Ordinal) - || !string.Equals(candidate.LayoutName, layoutName, StringComparison.Ordinal) - || !string.Equals(candidate.CallerPath, callerPath, StringComparison.Ordinal)) - { + if (!candidates[i].Equals(target)) continue; - } if (match >= 0) return -1; match = i; @@ -768,11 +810,10 @@ internal static int ChoosePersistentTargetSliceIndex( return match; } - private (int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath) - PersistentSliceIdentity(Slice slice) + private PersistentCommandTargetIdentity PersistentSliceIdentity(Slice slice) { if (slice?.Key == null) - return (0, null, null, null, null); + return default; XmlNode layout = null; XmlNode part = null; foreach (var keyItem in slice.Key) @@ -790,7 +831,7 @@ internal static int ChoosePersistentTargetSliceIndex( part = node; } } - return (slice.Object?.Hvo ?? 0, SliceFieldName(slice), + return new PersistentCommandTargetIdentity(slice.Object?.Hvo ?? 0, SliceFieldName(slice), layout?.Attributes?["class"]?.Value, layout?.Attributes?["name"]?.Value, LegacyLayoutCallerPath.Get(part)); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs index 613740c9fa..7aaf7ddbc8 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs @@ -194,12 +194,20 @@ public void CompileForObject_InventoryContentFingerprintReusesAndRefreshesCompil } [Test] - public void CompileForObject_NullSourceResultFallsBackToShippedLayout() + public void CompileForObject_ProjectSourceMissingLayoutReturnsNull() { ViewDefinitionSourceResolver source = (className, layoutName, choiceGuid) => null; var compiled = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source); + Assert.That(compiled, Is.Null); + } + + [Test] + public void CompileForObject_NoProjectSourceFallsBackToShippedLayout() + { + var compiled = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + Assert.That(compiled, Is.Not.Null); Assert.That(compiled.Roots, Has.Count.GreaterThan(2)); } diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs index 602603f2cb..ab856676dd 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs @@ -265,15 +265,18 @@ public void ImportedCallerPath_IsCanonicalWhenPartsSkipOrExpandOutput() [Test] public void ChoosePersistentTargetSliceIndex_UsesCallerPathToDisambiguateDuplicateFields() { - var candidates = new List<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> + var candidates = new List { - (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), - (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]") + new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), + new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]") }; - - var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, + var target = new RecordEditView.PersistentCommandTargetIdentity( m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]"); + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, target); + Assert.That(index, Is.EqualTo(1), "the imported caller path should select the same layout part as the legacy slice key"); } @@ -281,14 +284,15 @@ public void ChoosePersistentTargetSliceIndex_UsesCallerPathToDisambiguateDuplica [Test] public void ChoosePersistentTargetSliceIndex_AmbiguousExactPath_FailsClosed() { - var candidates = new List<(int Hvo, string FieldName, string ClassName, string LayoutName, string CallerPath)> + var target = new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"); + var candidates = new List { - (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), - (m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]") + target, + target }; - var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, - m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"); + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, target); Assert.That(index, Is.EqualTo(-1), "persistent layout commands require one exact slice and must reject ambiguous matches"); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs index 4b19d71896..20a7b5614e 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -154,6 +154,10 @@ public void AvaloniaNormallyHidden_ReloadsIntoAvaloniaAndLegacyDataTree() public void LegacyAlwaysVisible_ReloadsIntoReconstructedAvaloniaHost() { ResetToNoProjectOverride(); + var originalView = m_view; + var originalHost = GetField(m_view, "m_avaloniaEntryForm"); + var originalSource = GetField(m_view, "m_inventoryViewDefinitionSource"); + var originalModel = GetHostedDetailModel(); var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); ExecuteNative(field, "Normally hidden, unless non-empty"); m_layouts.Reload(); @@ -167,7 +171,12 @@ public void LegacyAlwaysVisible_ReloadsIntoReconstructedAvaloniaHost() m_layouts.Reload(); AssertCitationVisibility("always"); - RefreshAvaloniaDetail(); + ReconstructAvaloniaHost(); + Assert.That(m_view, Is.Not.SameAs(originalView)); + Assert.That(GetField(m_view, "m_avaloniaEntryForm"), Is.Not.SameAs(originalHost)); + Assert.That(GetField(m_view, "m_inventoryViewDefinitionSource"), + Is.Not.SameAs(originalSource)); + Assert.That(GetHostedDetailModel(), Is.Not.SameAs(originalModel)); Assert.That(GetHostedDetailModel().Fields, Has.Some.Property("Field").EqualTo("CitationForm")); } @@ -598,6 +607,29 @@ private void LoadRecordEditView(string toolValue) m_propertyTable.SetPropertyPersistence("currentContentControl", false); } + private void ReconstructAvaloniaHost() + { + var originalView = m_view; + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + var browseControl = windowConfiguration.SelectSingleNode( + "//tool[@value='lexiconBrowse']/control"); + Assert.That(browseControl, Is.Not.Null); + m_propertyTable.SetProperty("currentContentControlParameters", browseControl, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", "lexiconBrowse", true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + DrainMediatorAndIdleQueues(); + Assert.That(originalView.IsDisposed, Is.True); + + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(m_view, Is.Not.Null); + EnsureCurrentRecord(); + Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + } + private void EnsureCurrentRecord() { if (m_view.Clerk.CurrentObject?.Hvo != m_entry.Hvo) From f5599e00de2ba6eb9af9b0447ed4df443568ac2f Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 22:41:14 -0400 Subject: [PATCH 21/23] test: cover missing nested project layouts --- .../Composer/ProjectLayoutCompositionTests.cs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs index 7aaf7ddbc8..62054b1b43 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs @@ -116,6 +116,23 @@ public void Compose_NestedObjectUsesTheSameInventorySourceAndCarriesLayoutContex Assert.That(nested.LayoutName, Is.EqualTo("Normal")); } + [Test] + public void Compose_MissingNestedProjectLayoutUsesCallerInjectedChildren() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "nested-fallback"), + "" + + ""); + PersistLayout(layouts, includeSenses: true, injectSenseGloss: true); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var nested = composed.Model.Fields.Single(field => field.ObjectHvo == m_sense.Hvo + && field.Field == "Gloss"); + + Assert.That(nested.Label, Is.EqualTo("Project-only Gloss")); + Assert.That(nested.Values.Any(value => value.Value == "house"), Is.True); + } + [Test] public void Compose_InventoryVisibilityOverrideMatchesLegacyShowHiddenBehavior() { @@ -232,19 +249,20 @@ private InventoryViewDefinitionSource CreateSource(Inventory layouts) Cache.MetaDataCacheAccessor); } - private static Inventory CreateLayoutInventory(string projectPath) + private static Inventory CreateLayoutInventory(string projectPath, + string layoutXml = LayoutXml) { var layouts = new Inventory("*.fwlayout", "/LayoutInventory/*", new Dictionary { ["layout"] = new[] { "class", "type", "name", "choiceGuid" } }, "ProjectLayoutCompositionTests", projectPath); - layouts.LoadElements(LayoutXml, 0); + layouts.LoadElements(layoutXml, 0); return layouts; } private static void PersistLayout(Inventory layouts, string citationVisibility = "always", - bool reverse = false, bool includeSenses = false) + bool reverse = false, bool includeSenses = false, bool injectSenseGloss = false) { var first = reverse ? "" @@ -253,8 +271,11 @@ private static void PersistLayout(Inventory layouts, string citationVisibility = ? "" : ""; var document = new XmlDocument(); + var senses = injectSenseGloss + ? "" + : ""; document.LoadXml("" - + first + second + (includeSenses ? "" : "") + + first + second + (includeSenses ? senses : "") + ""); layouts.PersistOverrideElement(document.DocumentElement); } From 52b6ac4358729115a15fdeb418c467df4e01b0b1 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 26 Aug 2026 22:59:15 -0400 Subject: [PATCH 22/23] docs: evict completed implementation plan --- .../2026-08-26-avalonia-uses-fwlayout.md | 481 ------------------ 1 file changed, 481 deletions(-) delete mode 100644 Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md diff --git a/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md b/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md deleted file mode 100644 index 00f80ea82e..0000000000 --- a/Docs/superpowers/plans/2026-08-26-avalonia-uses-fwlayout.md +++ /dev/null @@ -1,481 +0,0 @@ -# Avalonia Uses `.fwlayout` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the legacy XML layout inventory the single persisted source of project layout customization for both WinForms and Avalonia, then retire the unreleased Avalonia-only `.viewoverride.json` subsystem. - -**Architecture:** Keep `Inventory` as the authority that loads shipped layouts, merges project `ConfigurationSettings/*.fwlayout` overrides, and persists mutations. Add an xWorks adapter that copies the effective `XmlNode` into an immutable `ViewDefinitionSourceSnapshot`; keep XCore out of FwAvalonia. Avalonia menu commands will execute the existing legacy `Slice` handlers through the already-approved hidden-DataTree command adapter, then recompose from `Inventory`. The compiler's content fingerprint, not a process-wide `(class, layout)` identity cache, will isolate projects and notice changed XML. - -**Tech Stack:** C# 8 / .NET Framework 4.8, XCore `Inventory`, LINQ to XML, NUnit, PowerShell repository build/test scripts. - ---- - -## Decision record - -### One persistent store - -`ConfigurationSettings/*.fwlayout` is the only supported project layout customization format after this work. - -- WinForms and Avalonia read the same effective layout nodes from the project-keyed `Inventory`. -- Existing WinForms commands remain the only writers for field visibility, field order, and visible writing systems. -- Avalonia does not translate XML into a second persisted representation. -- Avalonia does not cache project A's effective layout for project B. - -### Boundary - -`FwAvalonia` remains independent of XCore, project folders, and mutable XML inventories. xWorks owns the adapter because it already references both XCore and FwAvalonia: - -```text -Configuration/Parts/*.fwlayout - + -project/ConfigurationSettings/*.fwlayout - | - v - XCore Inventory (effective XML) - | - v - xWorks immutable snapshot adapter - | - v - FwAvalonia ViewDefinitionCompiler - | - v - Avalonia DetailComposer -``` - -The adapter must clone XML to text before compilation. Compiler code must never retain a live `XmlNode` owned by `Inventory`. - -### Cache rule - -Remove `DetailComposer.CompilerSources.CompiledModels`, whose key omits project identity and XML content. Continue using `ViewDefinitionCompiler`, whose key includes a SHA-256 fingerprint of layout XML, parts XML, class, type, and base-class map. When a legacy command calls `Inventory.PersistOverrideElement`, the next snapshot has different XML and therefore a different compiler key without explicit invalidation. - -**Implementation correction:** The Task 2 instruction to preserve `SnapshotCompileCount` was -not implemented. Arbitrary source resolvers may return reused snapshot instances, so an -incrementing static count would not describe compiler work or source freshness reliably. The -observable replacement contract is -`CompileForObject_InventoryContentFingerprintReusesAndRefreshesCompiledModel`: identical -snapshot content returns the same compiled model instance, while changed content returns a -different model with the changed behavior. - -### Parts rule - -This change makes layout customization converge; it does not redesign part loading. Keep the existing immutable merged `*Parts.xml` snapshot in `DetailComposer`. Project customization currently persists effective `` elements, and `LayoutCache.InitializePartInventories` does not load project-level part overrides. A separate parts-inventory unification would be unrelated scope. - -### Existing JSON files - -Do not migrate or delete `.viewoverride.json` files. - -- The subsystem entered `main` in #964 and is not contained by a released FieldWorks tag. -- Automatic JSON-to-XML conversion would preserve a second compatibility contract while this change is explicitly retiring it. -- Deleting files from user project folders would be destructive. -- After this change, old files are inert. Developers using unreleased builds may delete them manually. - -### Pull request order - -Land this storage-convergence PR before the open customization PRs: - -1. This PR: shared `.fwlayout` reads/writes and JSON retirement. -2. Rebase [#1097](https://github.com/sillsdev/FieldWorks/pull/1097); keep the writing-system behavior, but replace any JSON store/editor work with the shared legacy command path. -3. Rebase draft [#1108](https://github.com/sillsdev/FieldWorks/pull/1108); remove stacked assumptions and verify its writing-system selection lands in `.fwlayout` only. - -Do not merge #1097 or #1108 first and then add migration code for their JSON output. - -## Retirement inventory - -Delete these production files in full: - -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs` -- `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs` -- `Src/xWorks/Avalonia/DetailOverrideMigration.cs` - -Delete these tests because they test the retired format or migration path: - -- `Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs` -- `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs` -- `Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs` - -Replace, rather than simply delete, `Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs`. Its useful assertions become `.fwlayout`/`Inventory` integration coverage. - -Edit these production files to remove references to the retired layer: - -- `Src/Common/FwAvalonia/Detail/DetailModel.cs` -- `Src/Common/FwAvalonia/FwAvalonia.csproj` -- `Src/xWorks/Avalonia/Composer/DetailComposer.cs` -- `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` -- `Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs` only if the post-command callback is implemented there - -Keep these general view-definition components: - -- `ViewDefinitionModel`, `XmlLayoutImporter`, and `DictionaryPartResolver` -- `ViewDefinitionSourceSnapshot`, `ViewDefinitionCompiler`, and its content cache -- `LayoutSourceLoader` for shipped layouts/parts and framework-neutral tests -- `ViewDefinitionJsonSerializer`; it serializes compiled definitions, not project override files -- `Newtonsoft.Json` references still used elsewhere; do not remove the package merely because the override serializer is gone - -## Implementation tasks - -### Task 1: Characterize `Inventory` as the source of effective layout XML - -**Files:** - -- Create: `Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs` -- Create: `Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs` - -- [ ] Write a failing test that builds test `layouts` and `parts` inventories, asks the new source for `LexEntry/detail/Normal`, and verifies the returned snapshot contains the shipped layout. - -- [ ] Write a failing test that calls `PersistOverrideElement` with a changed full `` and verifies a second snapshot contains the changed XML while the first snapshot remains unchanged. - -- [ ] Write a failing test for choice layouts: exact `choiceGuid` wins, then the no-`choiceGuid` layout is the fallback. Use the same four-key lookup as `DataTree.GetTemplateForObjLayout`: - -```csharp -inventory.GetElement("layout", new[] { className, "detail", layoutName, choiceGuid }); -inventory.GetElement("layout", new[] { className, "detail", layoutName, null }); -``` - -- [ ] Write a failing test that a missing derived-class layout walks to its base class and records the same base-class map the compiler uses for part resolution. - -- [ ] Run the red tests: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~InventoryViewDefinitionSourceTests" -``` - -Expected: fail because `InventoryViewDefinitionSource` does not exist. - -- [ ] Implement `InventoryViewDefinitionSource` in xWorks. Constructor inputs are the project layout `Inventory`, immutable merged parts XML, and metadata cache. Its public operation returns a `ViewDefinitionSourceSnapshot` or `null` for a missing layout. Clone the selected `XmlNode` with `OuterXml`; never return or retain the node. - -- [ ] Run the same filtered test command. - -Expected: all `InventoryViewDefinitionSourceTests` pass. - -- [ ] Commit: - -```text -test: characterize Inventory view snapshots -``` - -### Task 2: Make `DetailComposer` compile effective project layouts - -**Files:** - -- Modify: `Src/xWorks/Avalonia/Composer/DetailComposer.cs` -- Modify: `Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs` - -- [ ] Replace the old patch-resolver tests with failing integration tests named for the behavior, not the retired mechanism: - - - `Compose_InventoryVisibilityOverrideMatchesLegacyShowHiddenBehavior` - - `Compose_InventoryReorderOverrideChangesSiblingOrder` - - `Compose_SecondInventoryDoesNotSeeFirstProjectsOverride` - - `Compose_PersistedChangeIsVisibleOnNextCompose` - -- [ ] In each test, persist a full layout through `Inventory.PersistOverrideElement`; do not instantiate JSON types or call the compiler's internal cache directly. - -- [ ] Run the red tests: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~DetailComposerOverrideTests" -``` - -Expected: at least the visibility/reorder tests fail because composer still reads shipped layouts unless given a JSON patch resolver. - -- [ ] Replace `ViewDefinitionOverrideResolver` with a neutral snapshot/source resolver owned by xWorks. Thread it through both `Compose` overloads, `ComposeState`, and nested-object `CompileForObject` calls so descended `LexSense`, `MoForm`, and other layouts use the same project inventory. - -- [ ] Remove `CompilerSources.CompiledModels`. Preserve the immutable shipped `LayoutIndex` only as the fallback for callers/tests that do not supply a project source. - -- [ ] In `CompileForClass`, use the project source first. If no project inventory is available, preserve current shipped-layout fallback and logging. Pass every snapshot to `ViewDefinitionCompiler.Compile`, allowing its content fingerprint cache to deduplicate identical XML. - -- [ ] Preserve `SnapshotCompileCount` semantics by incrementing only when a new source snapshot is constructed, and update memoization tests to assert content reuse rather than the removed identity dictionary. - -- [ ] Run: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~DetailComposerOverrideTests|FullyQualifiedName~DetailComposer" -``` - -Expected: all selected composer tests pass; two inventories with the same class/layout key remain isolated. - -- [ ] Commit: - -```text -feat: compose Avalonia details from Inventory -``` - -### Task 3: Wire the product host to the project inventory - -**Files:** - -- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` -- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs` -- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs` - -- [ ] Add a failing host test proving Avalonia composition uses `Inventory.GetInventory("layouts", Cache.ProjectId.Name)` after `LayoutCache.InitializePartInventories` loads project overrides. - -- [ ] Add a failing switch test: show one record in Avalonia, persist a `.fwlayout` change through the inventory, switch or refresh, and assert the recomposed model reflects it. - -- [ ] Remove `m_viewOverrideStore`, `ViewOverrideStore`, and `ResolveViewOverride` from `RecordEditView`. - -- [ ] Lazily construct one `InventoryViewDefinitionSource` per project-keyed host. Supply it to both LexEntry and non-LexEntry `DetailComposer.Compose` calls. - -- [ ] Fail visibly in the log and use the existing first-slice/unsupported fallback if inventories are unavailable; do not silently read `.viewoverride.json` or bypass the repository's normal inventory initialization. - -- [ ] Run: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~RecordEditViewSwitchTests|FullyQualifiedName~DetailCommandAdapterHardeningTests" -``` - -Expected: selected host tests pass. - -- [ ] Commit: - -```text -feat: wire Avalonia host to project layouts -``` - -### Task 4: Route visibility and move commands through legacy writers - -**Files:** - -- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` -- Modify: `Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs` if needed for a post-execute callback -- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs` -- Modify: `Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs` - -- [ ] Add red tests for all five persistent layout commands: - - - `CmdAlwaysVisible` - - `CmdIfData` - - `CmdNormallyHidden` - - `CmdDataTree-MoveFieldUp` - - `CmdDataTree-MoveFieldDown` - - Each test must execute the native Avalonia menu item, assert the legacy command handler ran, assert a `.fwlayout` element was persisted through `Inventory`, and assert the Avalonia detail model recomposed from that XML. - -- [ ] Add a red test that a failed or ambiguous command target clears `CurrentSlice` and writes no layout. Persistent commands must fail closed rather than mutate the first row sharing an object. - -- [ ] Strengthen command targeting for persistent layout commands. Match the Avalonia field to the hidden legacy slice using object HVO, field name, layout context, and template occurrence/path. Keep the existing broader object fallback for non-persistent legacy commands, but require an exact unique target before enabling a visibility or move command. - -- [ ] Replace `BuildOverrideCommandInterceptor`, `VisibilityItem`, `MoveItem`, `ApplyFieldVisibility`, `ApplyMoveField`, and `MutateOverrideAndRefresh` with a thin command wrapper: - -```csharp -choice.OnClick(null, EventArgs.Empty); // existing Slice handler writes Inventory/.fwlayout -RefreshAvaloniaDetail(); // new snapshot sees changed effective XML -``` - - Use the normal xCore display properties for label, checked state, and enablement. Do not recalculate these from a second model editor. - -- [ ] For the WinForms fallback menu, refresh Avalonia after `ShowContextMenu` returns. A cancel may cause a harmless recompose; command execution must never leave Avalonia stale. - -- [ ] Run: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~DetailObjectCommandExecutionTests|FullyQualifiedName~DetailCommandAdapterHardeningTests|FullyQualifiedName~DetailContextMenuCompositionTests" -``` - -Expected: all selected menu/adapter tests pass and no `.viewoverride.json` file is created. - -- [ ] Commit: - -```text -feat: share legacy layout command writers -``` - -### Task 5: Retire the JSON override subsystem - -**Files:** - -- Delete all production and test files listed in **Retirement inventory**. -- Modify: `Src/Common/FwAvalonia/Detail/DetailModel.cs` -- Modify: `Src/Common/FwAvalonia/FwAvalonia.csproj` -- Modify: `Src/xWorks/Avalonia/Composer/DetailComposer.cs` -- Modify: `Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs` - -- [ ] Delete the JSON patch store, serializer, model/operations, applier, differ, editor, and migration files. - -- [ ] Delete their dedicated tests and the xWorks migration adapter/tests. - -- [ ] Remove stale XML documentation and comments that claim `DetailField.ClassName`/`LayoutName` key a JSON override store. Retain these properties only if command targeting still needs their layout context; otherwise remove them and their stamping tests. - -- [ ] Remove the `Newtonsoft.Json` package from `FwAvalonia.csproj` only if this command proves the production project no longer uses it: - -```powershell -rg -n "Newtonsoft\.Json" Src/Common/FwAvalonia -g "*.cs" -``` - -Expected: if `ViewDefinitionJsonSerializer` still uses Newtonsoft, keep the package. - -- [ ] Prove no production or test reference remains: - -```powershell -rg -n "ViewDefinitionOverride|ViewOverrideOperation|viewoverride\.json|DetailOverrideMigration" Src -``` - -Expected: no matches. - -- [ ] Prove the project contains no duplicate customization writer: - -```powershell -rg -n "PersistOverrideElement|\.fwlayout|ConfigurationSettings" ` - Src/xWorks/Avalonia Src/Common/FwAvalonia -g "*.cs" -``` - -Expected: Avalonia host references point to `Inventory`/`.fwlayout`; no second extension or serializer appears. - -- [ ] Run: - -```powershell -.\build.ps1 -CommentHygiene -BuildTests -``` - -Expected: build succeeds with deleted SDK-globbed files absent. - -- [ ] Commit: - -```text -refactor: retire Avalonia JSON layout overrides -``` - -### Task 6: Verify persistence parity end to end - -**Files:** - -- Create: `Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs` -- Modify only production files exposed by a failing parity test. - -- [ ] Add an end-to-end test that begins with no project override, executes Avalonia `Normally hidden`, reloads inventories from disk, and verifies both Avalonia composition and legacy `DataTree` see `visibility="never"`. - -- [ ] Add the inverse test: execute legacy `Always visible`, reconstruct Avalonia, and verify it reads the same layout without translation. - -- [ ] Add reorder parity in both directions: Avalonia move affects WinForms order after reload; WinForms move affects Avalonia order after recompose. - -- [ ] Add a backup/synchronization contract test or static assertion that the only new project artifact is `*.fwlayout`. No `.viewoverride.json` should be present or required. - -- [ ] Run: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" ` - -TestFilter "FullyQualifiedName~LayoutPersistenceParityTests" -``` - -Expected: all two-framework round-trip tests pass. - -- [ ] Run the focused FwAvalonia suite after deleting override tests: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/Common/FwAvalonia/FwAvaloniaTests" -``` - -Expected: all FwAvalonia tests pass. - -- [ ] Run the focused xWorks suite: - -```powershell -.\test.ps1 -CommentHygiene ` - -TestProject "Src/xWorks/xWorksTests" -``` - -Expected: all xWorks tests pass, excluding tests already marked `Explicit` by the repository. - -- [ ] Commit: - -```text -test: prove shared layout persistence parity -``` - -### Task 7: Full validation and PR preparation - -**Files:** - -- Modify: this plan only if implementation discoveries require a recorded correction. -- Modify: PR description outside the repository. - -- [ ] Run the required full build: - -```powershell -.\build.ps1 -CommentHygiene -``` - -Expected: exit code 0. - -- [ ] Run the required full test suite: - -```powershell -.\test.ps1 -CommentHygiene -``` - -Expected: exit code 0 and no failed tests. - -- [ ] Verify the retirement and single-store invariants again: - -```powershell -rg -n "ViewDefinitionOverride|ViewOverrideOperation|viewoverride\.json|DetailOverrideMigration" Src -git diff --check origin/main...HEAD -gitlint --ignore body-is-missing --commits origin/main..HEAD -``` - -Expected: `rg` finds nothing; diff and gitlint exit 0. - -- [ ] Manually exercise one lexical-entry field in both modes against the same project: - - 1. In Avalonia, change visibility and move the field. - 2. Switch to Legacy and confirm both changes. - 3. Close/reopen FieldWorks and confirm both changes. - 4. Change the field back in Legacy. - 5. Switch to Avalonia and confirm the reversal. - 6. Inspect `ConfigurationSettings` and confirm only the relevant `.fwlayout` changed. - -- [ ] Update the PR description with: - - - one-store architecture and boundary - - exact retired files - - no-migration rationale for unreleased JSON files - - automated and manual evidence - - explicit sequencing instructions for #1097 and #1108 - -- [ ] Push without force and open the implementation PR against `main`. - -## Landing criteria - -The implementation PR is ready to land only when all are true: - -- Both UI frameworks render project overrides from the same effective `Inventory` XML. -- Avalonia visibility and move commands use legacy persistence handlers. -- A change made in either framework appears in the other after refresh/reload. -- No production reference to `.viewoverride.json` remains. -- No automatic JSON migration or destructive JSON cleanup ships. -- Compiler caches are content- and project-safe. -- Focused parity tests, full build, full test suite, comment hygiene, diff check, and gitlint pass. -- #1097 and #1108 are rebased to use the shared store before either lands. - -## Explicit non-goals - -- Redesigning the `.fwlayout` format or `Inventory` unification rules. -- Moving XCore dependencies into FwAvalonia. -- Adding a third persistence abstraction for hypothetical future Avalonia-only features. -- Migrating unreleased `.viewoverride.json` data. -- Expanding project-level `*Parts.xml` customization. -- Changing backup or Send/Receive filters; using the existing `.fwlayout` artifact removes the need. From eefbcb39b118b0da231f23dceb561cb6b375fcaf Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 27 Aug 2026 06:59:44 -0400 Subject: [PATCH 23/23] docs: clarify layout composition comments --- .../FwAvaloniaTests/LayoutChoiceResolutionTests.cs | 2 -- Src/xWorks/Avalonia/Composer/DetailComposer.cs | 11 +++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs index 1da14eae9e..0dec1dafd6 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs @@ -93,8 +93,6 @@ public void SelectLayoutForChoice_EmptyOrNullVariants_ReturnsNull() Assert.That(LayoutSourceLoader.SelectLayoutForChoice(null, GuidA), Is.Null); } - // Two different choiceGuids on the SAME class must yield two DISTINCT - // layouts (the selector is the cache-discriminator; the composer keys CompiledModels by choiceGuid). [Test] public void TwoChoiceGuids_OnSameKey_SelectDistinctLayouts_NoCollision() { diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 05953fa27b..d7e5b26b36 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -942,14 +942,9 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) WalkUnsupported(node, obj, depth); break; case DetailEditorCategory.EmbeddedView: - // An embedded formatted view (legacy jtview / ViewSlice + XmlView) composes the - // nested layout's fields INLINE for this same object, at depth+1 -- the - // recursive - // sub-view the legacy XmlView renders. WalkEmbeddedView reuses the - // CompileForObjectWithSource/EnterModel/Walk descent (the visited-set - // guards - // cycles); when the nested layout cannot be resolved it degrades to the - // read-only ShortName row rather than vanishing. + // Embedded views inline a nested layout for the same object. + // Missing layouts use a read-only ShortName row. + // Cycles terminate safely. WalkEmbeddedView(node, obj, depth); break; case DetailEditorCategory.Command: