From 8683b8faf7c60b43bb402d370fa9dd6461b68fff Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 1/6] initial plan --- .serena/project.yml | 4 + .../.openspec.yaml | 2 + .../datatree-model-view-separation/design.md | 147 ++++++ .../proposal.md | 38 ++ .../datatree-characterization-tests/spec.md | 73 +++ .../test-plan-datatree.md | 482 ++++++++++++++++++ .../test-plan-harness.md | 302 +++++++++++ .../test-plan-slice.md | 379 ++++++++++++++ .../specs/datatree-model/spec.md | 113 ++++ .../specs/datatree-partial-split/spec.md | 39 ++ .../datatree-model-view-separation/tasks.md | 156 ++++++ 11 files changed, 1735 insertions(+) create mode 100644 openspec/changes/datatree-model-view-separation/.openspec.yaml create mode 100644 openspec/changes/datatree-model-view-separation/design.md create mode 100644 openspec/changes/datatree-model-view-separation/proposal.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/tasks.md diff --git a/.serena/project.yml b/.serena/project.yml index 4d987a65b2..0c1e17a971 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -123,3 +123,7 @@ fixed_tools: [] # the name by which the project can be referenced within Serena project_name: FieldWorks + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: diff --git a/openspec/changes/datatree-model-view-separation/.openspec.yaml b/openspec/changes/datatree-model-view-separation/.openspec.yaml new file mode 100644 index 0000000000..e331c975d9 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-25 diff --git a/openspec/changes/datatree-model-view-separation/design.md b/openspec/changes/datatree-model-view-separation/design.md new file mode 100644 index 0000000000..2130a00853 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/design.md @@ -0,0 +1,147 @@ +## Context + +DataTree.cs is 4,358 lines implementing a WinForms `UserControl` that fuses 8+ responsibilities into one class. It is the core detail-editing control in FieldWorks — every entry, sense, and record is displayed through DataTree slices. The Avalonia migration roadmap requires that both WinForms and Avalonia coexist in the same repo, meaning we need a UI-framework-agnostic model layer that either can consume. + +**Current architecture:** +``` +RecordEditView → DataTree (monolith: XML parsing + WinForms layout + navigation + messaging + persistence) + ↕ + Slice (3,341 lines, also monolithic) + ↕ + SliceFactory, ObjSeqHashMap, SliceFilter +``` + +**Key coupling points:** +- `Slice.ContainingDataTree` — every Slice holds a reference to the parent DataTree +- `SliceFactory.Create()` — returns WinForms `Slice` objects directly +- `StTextDataTree : DataTree` — the only subclass, in InfoPane.cs +- `DTMenuHandler.m_dataEntryForm` — holds a typed `DataTree` reference +- `RecordEditView.m_dataEntryForm` — the primary consumer + +**Existing test coverage:** ~14 tests in DataTreeTests.cs, all focused on `ShowObject` + show-hidden logic. No tests for XML parsing internals, navigation, reuse, or layout. + +## Goals / Non-Goals + +**Goals:** +- Separate "what slices to show" (model) from "how to render them" (view) so the model layer can be shared between WinForms and Avalonia +- Make the XML-to-slice pipeline testable without WinForms infrastructure +- Reduce DataTree.cs to a manageable size where any developer can understand each file's purpose +- Preserve exact runtime behavior throughout — zero functional regressions +- Enable incremental delivery: each phase ships independently and is valuable on its own + +**Non-Goals:** +- Building an Avalonia `DataTreeView` — that is a separate future change +- Refactoring Slice.cs internals beyond partial-class splitting +- Changing the XML layout/part format or Inventory loading +- Modifying DTMenuHandler or RecordEditView beyond minimal adaptation +- Performance optimization (the reuse map is complex enough; preserve it as-is) +- Replacing the XCore Mediator pattern + +## Decisions + +### Decision 1: Four-phase incremental delivery + +**Choice:** Deliver in 4 sequential phases, each independently mergeable. + +**Phases:** +1. **Characterization tests** (Phase 0) — add ~20 tests covering current behavior +2. **Partial-class split** (Phase 1) — mechanical file decomposition, zero logic changes +3. **Extract collaborators** (Phase 2) — `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `SliceCollection` as composition targets inside DataTree +4. **Model/View separation** (Phase 3) — introduce `DataTreeModel`, `SliceSpec`, `IDataTreeView`; DataTree becomes a thin WinForms view + +**Rationale:** Each phase is valuable alone. Phase 0 provides a safety net. Phase 1 improves readability immediately. Phase 2 enables unit testing of the most complex logic. Phase 3 unlocks Avalonia. If the project stalls at any phase, the completed work still has value. + +**Alternative considered:** Big-bang model/view extraction in one PR. Rejected because DataTree touches every detail view in the application — risk of subtle regressions is too high without phased safety nets. + +### Decision 2: SliceSpec as intermediate representation + +**Choice:** Introduce a `SliceSpec` class (plain C# object, no WinForms dependency) that captures everything needed to create a slice: label, abbreviation, indent, editor type, XML config, field ID, object, visibility, weight, tooltip, key path. + +**Rationale:** The current code creates `Slice` WinForms controls directly inside the XML parsing loop (`AddSimpleNode` → `SliceFactory.Create`). This is the fundamental coupling that prevents model/view separation. `SliceSpec` breaks this dependency: the model produces specs, the view materializes them. + +**Alternative considered:** Having the model produce abstract `ISlice` interfaces. Rejected because `ISlice` would still need to carry WinForms `Control` semantics and the interface would grow as large as the concrete class. + +### Decision 3: SliceFactory stays in the view layer (Phase 3) + +**Choice:** `SliceFactory.Create()` remains in the view layer and accepts `SliceSpec` instead of raw XML nodes. The model layer's `SliceLayoutBuilder` produces `SliceSpec` lists; the view layer calls `SliceFactory.Create(spec)` to materialize them. + +**Rationale:** SliceFactory creates ~30 concrete WinForms slice types. Moving it to the model layer would require abstracting all those types. Keeping it in the view layer means only `SliceSpec` crosses the boundary, and each platform (WinForms, Avalonia) has its own factory. + +**Alternative considered:** A fully abstract factory with platform adapters. Deferred to the Avalonia implementation phase since we don't yet know what Avalonia slice equivalents look like. + +### Decision 4: Keep ObjSeqHashMap in the view layer + +**Choice:** The slice reuse map (`ObjSeqHashMap`) stays in the view layer because it maps `SliceSpec.Key` → existing `Slice` instances. The model layer does not concern itself with reuse optimization. + +**Rationale:** Reuse is a performance optimization that depends on having concrete control instances. The model layer simply produces a fresh `SliceSpec` list on each `ShowObject`/`RefreshList` call. The view layer diffs against existing slices using `ObjSeqHashMap`. + +### Decision 5: StTextDataTree adaptation strategy + +**Choice:** `StTextDataTree` currently overrides `ShowObject` to transform the root object (CmBaseAnnotation → owner Text) before calling `base.ShowObject`. In Phase 3, this becomes a model-layer hook: `DataTreeModel` gains a virtual `ResolveRootObject(ICmObject) → ICmObject` method that `StTextDataTree` overrides via a custom `DataTreeModel` subclass or a delegate injection. + +**Rationale:** The override is purely about object resolution (model concern), not rendering. Moving it to the model layer is both natural and simple. + +### Decision 6: Composition over inheritance for DataTreeModel + +**Choice:** `DataTreeModel` uses composition — it holds `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, and a `SliceCollection` (state tracker). These are injected via constructor or property. + +**Rationale:** DataTree's current design suffers from being a single class that inherited too many responsibilities. Composition makes each piece independently testable and replaceable. The only inheritance left is `DataTree : UserControl` (required by WinForms) and the `StTextDataTree` subclass. + +## Risks / Trade-offs + +**[Risk] SliceSpec may not capture all slice creation context** → Mitigation: Phase 2 extracts `SliceLayoutBuilder` *before* introducing `SliceSpec`, so we learn exactly what information flows from XML parsing to slice creation. `SliceSpec` is designed after that extraction, informed by real data. + +**[Risk] ObjSeqHashMap reuse breaks during model/view split** → Mitigation: Phase 0 characterization tests verify reuse behavior. Phase 3 preserves the exact key structure (`Slice.Key` → `SliceSpec.Key`). + +**[Risk] Subtle behavior differences after partial-class split** → Mitigation: This is a zero-logic change; the compiler guarantees identical IL. Characterization tests from Phase 0 provide additional confidence. + +**[Risk] DTMenuHandler and other consumers reference concrete DataTree** → Mitigation: Phase 3 keeps the concrete `DataTree` class; it just becomes thinner. Consumers don't need to change their references. `IDataTreeView` is used only by `DataTreeModel` internally. + +**[Risk] Phase 3 model/view split is large** → Mitigation: Phase 2 has already extracted 60% of the logic into collaborators. Phase 3 is primarily about moving those collaborators under `DataTreeModel` and introducing `SliceSpec` as the boundary type. + +**[Risk] Performance regression from SliceSpec indirection** → Mitigation: `SliceSpec` is a lightweight data object (no allocations beyond the object itself). The expensive work (XML parsing, cache queries) happens exactly once regardless of whether the result is a `Slice` or a `SliceSpec`. + +## Phased Implementation Plan + +### Phase 0: Characterization Tests (1-2 days, low risk) + +- Add ~20 tests to `DataTreeTests.cs` covering: XML→slice mapping, show-hidden toggle, slice reuse, PropChanged routing, navigation, DummyObjectSlice expansion, SliceFilter interaction +- Extend `Test.fwlayout` and `TestParts.xml` with test layouts for sequence properties (>20 items), nested headers, visibility="never" parts +- No production code changes +- **Exit criterion:** All new tests pass; no existing tests broken + +### Phase 1: Partial-Class Split (1 day, very low risk) + +- Split `DataTree.cs` into 7 partial-class files per the spec +- Split `Slice.cs` into 4-5 partial-class files +- Update `.csproj` if needed (SDK-style projects auto-include; verify) +- **Exit criterion:** `build.ps1` succeeds; all tests pass; `git diff --stat` shows only file renames/splits + +### Phase 2: Extract Collaborators (3-5 days, low-medium risk) + +Order of extraction (most valuable first): +1. **`SliceLayoutBuilder`** — extract `CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, `AddAtomicNode`, `EnsureCustomFields`, label/weight helpers. DataTree delegates to it. ~1,000 lines moved. +2. **`ShowHiddenFieldsManager`** — extract `GetShowHiddenFieldsToolName`, `HandleShowHiddenFields`, key resolution. ~100 lines. Already well-tested from LT-22427. +3. **`DataTreeNavigator`** — extract `CurrentSlice` logic, goto methods, focus helpers. ~150 lines. + +Each extraction is a separate commit with its own test run. At this point, `SliceLayoutBuilder` can be unit-tested by providing mock `Inventory`, `LcmCache`, and `IFwMetaDataCache` — no `Form` required. + +- **Exit criterion:** All characterization tests pass; `SliceLayoutBuilder` has dedicated unit tests; DataTree.cs is under 2,000 lines. + +### Phase 3: Model/View Separation (5-8 days, medium risk) + +1. Define `SliceSpec` data class +2. Define `IDataTreeView` interface +3. Create `DataTreeModel` composing `SliceLayoutBuilder` + `ShowHiddenFieldsManager` + navigation state +4. Modify `SliceLayoutBuilder` to produce `SliceSpec[]` instead of calling `SliceFactory.Create` directly +5. Modify `DataTree` to implement `IDataTreeView`: receive `SliceSpec[]` from model, call `SliceFactory.Create(spec)` to materialize, manage `ObjSeqHashMap` for reuse +6. Adapt `StTextDataTree` to override model-layer hook +7. Verify `RecordEditView` and `DTMenuHandler` still function (should require no changes if DataTree facade API is preserved) + +- **Exit criterion:** All characterization tests pass; `DataTreeModel` has no `System.Windows.Forms` reference; existing integration tests pass; manual smoke test of LexEdit entry display + +## Open Questions + +- Should `SliceSpec` be a record type (C# 9+) or a plain class? Depends on .NET Framework 4.8 language version constraints. +- Should the `IDataTreeView` interface live in the DetailControls assembly or a separate abstraction assembly? If Avalonia implementation will be in a different project, a shared abstractions package may be needed. +- How should `DummyObjectSlice` lazy expansion interact with `SliceSpec`? The model could produce placeholder specs, or the view could handle laziness entirely. diff --git a/openspec/changes/datatree-model-view-separation/proposal.md b/openspec/changes/datatree-model-view-separation/proposal.md new file mode 100644 index 0000000000..8d97081a6f --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/proposal.md @@ -0,0 +1,38 @@ +## Why + +DataTree.cs (4,358 lines) is a God Class that fuses XML layout parsing, slice lifecycle management, WinForms layout/paint, focus navigation, mediator messaging, data-change notification, and persistence into a single `UserControl`. This makes it untestable without a running form, impossible to reason about in isolation, and completely blocks reuse when the project migrates from WinForms to Avalonia. The same problem extends to Slice.cs (3,341 lines). With the Avalonia migration on the roadmap, we need a UI-framework-agnostic model layer *now* so both WinForms and Avalonia views can coexist during the transition period. + +## What Changes + +- **Extract `DataTreeModel`** — a new class (no WinForms dependency) owning XML layout interpretation, slice-spec construction, show-hidden-fields resolution, property-change routing, and navigation state. This is the "what to display" layer. +- **Extract `SliceLayoutBuilder`** — moves `CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, `AddAtomicNode`, `EnsureCustomFields` out of DataTree into a focused collaborator consumed by `DataTreeModel`. +- **Introduce `SliceSpec`** — a UI-framework-agnostic descriptor produced by `SliceLayoutBuilder`. Each `SliceSpec` captures label, indent, editor type, XML config, field ID, and object reference — everything needed for a view layer to materialize a concrete control. +- **Introduce `IDataTreeView`** — an interface that WinForms `DataTree` (and later Avalonia) implements, receiving `SliceSpec` lists from `DataTreeModel` and materializing them into platform controls. +- **Slim `DataTree`** to a WinForms `UserControl` that implements `IDataTreeView`: layout, paint, splitter management, control hosting. It delegates all "what" decisions to `DataTreeModel`. +- **Add characterization tests** (Phase 0) covering XML→slice-list mapping, show-hidden toggle, slice reuse, PropChanged→refresh, navigation, and DummyObjectSlice expansion — all *before* any structural changes. +- **Split DataTree.cs into partial-class files** (Phase 1) as a zero-risk mechanical step to isolate responsibilities before extraction. + +These changes affect **managed C# code only** (Src/Common/Controls/DetailControls and Src/xWorks). No native C++ changes. + +## Capabilities + +### New Capabilities + +- `datatree-model`: UI-framework-agnostic model layer that decides which slices to show, in what order, with what configuration — independent of WinForms or Avalonia. +- `datatree-characterization-tests`: Safety-net test suite covering DataTree's current behavior (XML parsing, show-hidden, slice reuse, navigation, lazy expansion) to reduce refactoring risk. +- `datatree-partial-split`: Mechanical decomposition of DataTree.cs and Slice.cs into partial-class files organized by responsibility, enabling targeted navigation and future extraction. + +### Modified Capabilities + +- `architecture/ui-framework/winforms-patterns`: DataTree transitions from monolithic UserControl to a thin view implementing `IDataTreeView`, delegating logic to `DataTreeModel`. +- `architecture/layers/layer-model`: Introduces a "detail-tree model" sublayer between UI shell and data access, formalizing the separation of "what fields to show" from "how to render them." + +## Impact + +- **Src/Common/Controls/DetailControls/**: DataTree.cs, Slice.cs, SliceFactory.cs gain new collaborators; file count increases but per-file complexity drops sharply. +- **Src/xWorks/RecordEditView.cs**: Primary DataTree consumer — must adapt to create `DataTreeModel` + `DataTree` (view). Public API (`ShowObject`, `Reset`, `CurrentSlice`) remains stable through facade methods. +- **Src/xWorks/DTMenuHandler.cs**: References `DataTree`; will need to accept `IDataTreeView` or continue referencing the concrete WinForms class during transition. +- **Src/LexText/Interlinear/InfoPane.cs**: Contains `StTextDataTree : DataTree` — the only subclass. Must be updated to override model-layer behavior rather than view-layer methods. +- **SliceFactory.cs**: Currently creates WinForms `Slice` objects directly. In the model/view split, it either produces `SliceSpec` descriptors (clean) or remains in the view layer (pragmatic). Decision deferred to design phase. +- **Test infrastructure**: New test fixtures in DetailControlsTests/ for characterization tests. Test XML layouts (Test.fwlayout, TestParts.xml) will be extended. +- **No breaking changes to external consumers** — all changes are internal to the DetailControls and xWorks assemblies. diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md new file mode 100644 index 0000000000..84c9c17d3e --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Characterization tests for XML-to-slice mapping + +The test suite SHALL verify that `ShowObject` with a given layout name and root object produces the correct ordered list of slices (by label, indent level, and count). + +#### Scenario: Simple layout produces expected slices +- **WHEN** `ShowObject` is called with a layout containing two `` references (CitationForm, Bibliography) and the object has data in both fields +- **THEN** exactly two slices are created, with labels matching the part labels in order + +#### Scenario: Nested/expanded layout produces header plus children +- **WHEN** `ShowObject` is called with a layout using `` and a header node +- **THEN** the first slice is a header node and subsequent slices have the correct indent level + +### Requirement: Characterization tests for show-hidden-fields toggle + +The test suite SHALL verify that the show-hidden-fields mechanism correctly shows or hides `ifData`-empty and `visibility="never"` slices based on the tool-specific property key. + +#### Scenario: ifData slices hidden when show-hidden is off +- **WHEN** `ShowObject` is called with `ShowHiddenFields` off and a field has no data +- **THEN** the `ifData` slice for that field is excluded from the slice list + +#### Scenario: ifData slices revealed when show-hidden is on +- **WHEN** `ShowObject` is called with `ShowHiddenFields` on for the current tool +- **THEN** the `ifData` slice for an empty field is included in the slice list + +#### Scenario: visibility=never slices revealed when show-hidden is on +- **WHEN** `ShowObject` is called with `ShowHiddenFields` on and a part has `visibility="never"` +- **THEN** the slice for that part is included in the slice list + +#### Scenario: SliceFilter bypassed when show-hidden is on +- **WHEN** a `SliceFilter` is configured to exclude a slice by ID and `ShowHiddenFields` is on +- **THEN** the filtered slice is included in the slice list despite the filter + +### Requirement: Characterization tests for slice reuse during refresh + +The test suite SHALL verify that `RefreshList` reuses existing slice instances when the object and layout have not changed. + +#### Scenario: Same object refresh reuses slices +- **WHEN** `ShowObject` is called, slice references are captured, and `RefreshList(false)` is called +- **THEN** at least the first slice instance in the new list is the same object reference as before + +### Requirement: Characterization tests for PropChanged notification + +The test suite SHALL verify that modifying a monitored property triggers a refresh of the slice list. + +#### Scenario: Monitored property change triggers refresh +- **WHEN** a property registered via `MonitorProp` is changed in the cache +- **THEN** `RefreshListNeeded` becomes true or the slice list is rebuilt + +### Requirement: Characterization tests for focus navigation + +The test suite SHALL verify that `GotoNextSlice` and `GotoPreviousSliceBeforeIndex` navigate correctly through the slice list. + +#### Scenario: GotoNextSlice advances to next focusable slice +- **WHEN** `GotoNextSlice` is called with the first slice focused +- **THEN** `CurrentSlice` moves to the next non-header, focusable slice + +#### Scenario: GotoPreviousSliceBeforeIndex moves backward +- **WHEN** `GotoPreviousSliceBeforeIndex` is called with the last slice's index +- **THEN** `CurrentSlice` moves to the previous focusable slice + +### Requirement: Characterization tests for DummyObjectSlice expansion + +The test suite SHALL verify that sequences with more than `kInstantSliceMax` (20) items use `DummyObjectSlice` placeholders that expand on demand. + +#### Scenario: Large sequence uses dummy slices +- **WHEN** `ShowObject` is called with a sequence property containing 25 items +- **THEN** some slices in the list are `DummyObjectSlice` instances (not real slices) + +#### Scenario: FieldAt expands dummy to real slice +- **WHEN** `FieldAt(i)` is called on an index occupied by a `DummyObjectSlice` +- **THEN** the slice at that index becomes a real slice (`IsRealSlice == true`) diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md new file mode 100644 index 0000000000..1d57ed0d08 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md @@ -0,0 +1,482 @@ +# DataTree Characterization Test Plan + +## Purpose + +This document details every characterization test needed for `DataTree.cs` +before the model/view separation. Tests are organized by subdomain +(matching the partial-class file split). Each entry documents: + +- What behavior to lock down +- Whether a test already exists +- The test name and fixture pattern +- Edge cases to cover + +All tests go in `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs` +unless otherwise noted. + +--- + +## Subdomain 1: XML Layout Parsing (`DataTree.LayoutParsing.cs`) + +These tests cover lines ~1743–2960 of DataTree.cs — the XML-to-slice +pipeline from `CreateSlicesFor` through `AddSimpleNode`. + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `OneStringAttr` | Single `multistring` slice from layout | +| 2 | `TwoStringAttr` | Two slices, correct ordering | +| 3 | `LabelAbbreviations` | 3 abbreviation modes | +| 4 | `IfDataEmpty` | `visibility="ifdata"` hides empty fields | +| 5 | `NestedExpandedPart` | Header + nested children, indent=0 | +| 6 | `RemoveDuplicateCustomFields` | `customFields="here"` dedup | +| 7 | `BadCustomFieldPlaceHoldersAreCorrected` | Missing `ref` attr fixup | +| 8 | `OwnedObjects` | `` expansion across senses + etymology | + +### New Tests Needed + +#### 1.1 `CreateSlicesFor_NullObject_ReturnsEmptySliceList` +- **What:** Call `ShowObject(null, ...)` — verify no slices created +- **Edge case:** Null object after disposal guard + +#### 1.2 `GetTemplateForObjLayout_ClassHierarchyWalk` +- **What:** Create an object whose exact class has no layout, but a + base class does. Verify the base-class layout is used. +- **Fixture:** Add a layout for `CmObject` (generic base) but not for + the specific subclass. Verify slices match the base layout. +- **Edge case:** Class hierarchy walk terminates at `CmObject` (id=0) + +#### 1.3 `GetTemplateForObjLayout_CmCustomItemUsesWsLayout` +- **What:** For `CmCustomItem`, verify the layout selection considers + the containing list's WS (`Names.BestAnalysisAlternative`) to pick + between analysis/vernacular name layouts. +- **Fixture:** Create a `CmCustomItem` in a custom list, verify layout + name resolution. This requires a custom list + custom item in the + test cache. + +#### 1.4 `ApplyLayout_DuplicateCustomFieldPlaceholders` +- **What:** Already partially covered by `RemoveDuplicateCustomFields`. + Extend to verify that custom field parts are actually injected at the + placeholder position and that only the first placeholder survives. +- **Assertions:** Slice count matches expected; custom field slice + appears at the correct index. + +#### 1.5 `ProcessSubpartNode_SequenceWithMoreThanThresholdItems` +- **What:** Create an entry with >20 senses (`kInstantSliceMax`). + Verify that `AddSeqNode` creates some `DummyObjectSlice` instances. +- **Fixture:** New layout `ManySenses` referencing ``. + Populate 25 senses. +- **Assertions:** + - Total slice count < 25 (some are dummies) + - At least one slice has `IsRealSlice == false` + - First `kInstantSliceMax` slices are real + +#### 1.6 `ProcessSubpartNode_ThresholdOverride_ExpandedCaller` +- **What:** When the caller node has `expansion="expanded"`, the + threshold is overridden — all items are instantiated. +- **Fixture:** Layout with ``. + 25 senses. +- **Assertions:** All 25 slices are real (`IsRealSlice == true`). + +#### 1.7 `ProcessSubpartNode_ThresholdOverride_PersistentExpansion` +- **What:** When the user previously expanded a node (stored in + `PropertyTable`), the threshold is overridden. +- **Setup:** Set the expansion key in `PropertyTable` before + `ShowObject`. + +#### 1.8 `ProcessSubpartNode_ThresholdOverride_EmptySequence` +- **What:** An empty `` with no items produces zero child slices + (no dummy, no ghost unless configured). +- **Assertions:** Slice count = 0 for the sequence region. + +#### 1.9 `AddSimpleNode_IfData_MultiString_EmptyAnalysis` +- **What:** `visibility="ifdata"` on a `MultiString` field with an + empty analysis writing system. Verify the slice is hidden. +- **Fixture:** Layout with `editor="multistring" ws="analysis" + visibility="ifdata"`; object has empty analysis WS. + +#### 1.10 `AddSimpleNode_IfData_MultiString_NonEmptyVernacular` +- **What:** Same field with data in vernacular WS, but layout + requests `ws="vernacular"`. Verify the slice is shown. + +#### 1.11 `AddSimpleNode_IfData_StText_EmptyParagraph` +- **What:** `StText` field with a single empty paragraph. Verify the + slice is hidden when `visibility="ifdata"`. +- **Edge case:** The code specifically checks for `StText` with one + empty paragraph and treats it as empty data. + +#### 1.12 `AddSimpleNode_IfData_Summary_SuppressesNode` +- **What:** When a `summary` node attribute is present and the + referenced data is empty, the summary is suppressed. + +#### 1.13 `AddSimpleNode_CustomEditor_ReflectionLookup` +- **What:** When `editor` attribute names a class (e.g., + `SIL.FieldWorks.XWorks.MorphologyEditor.PhonologicalRuleFormulaSlice`), + the factory resolves it via reflection. Verify this path works for + a known editor class. +- **Note:** This is more of an integration test; may belong in + `SliceFactoryTests`. + +#### 1.14 `ProcessSubpartNode_IfNotCondition` +- **What:** `` node checks `XmlVc.ConditionPasses` and includes + children only when the condition is _not_ met. +- **Fixture:** Layout with `...`. Test with objects that do and don't + match. + +#### 1.15 `ProcessSubpartNode_ChoiceWhereOtherwise` +- **What:** `` node evaluates `` clauses in order; + first match wins; `` is the fallback. +- **Fixture:** Layout with `...... + ...`. + +#### 1.16 `AddAtomicNode_GhostSliceCreation` +- **What:** When an atomic property is null and `ghost="fieldName"` is + specified, a ghost slice is created to allow inline object creation. +- **Assertions:** Ghost slice has `IsGhostSlice == true`. + +#### 1.17 `InterpretLabelAttribute_DollarOwnerPrefix` +- **What:** When `label` attribute starts with `$owner.`, the label is + resolved by walking up the ownership chain. +- **Fixture:** Layout with `label="$owner.Form"` on a sense-level + slice. Verify the label comes from the owning entry. + +#### 1.18 `SetNodeWeight_ValidWeights` +- **What:** `weight` XML attribute is parsed and applied to slice. + Test all valid weight values: `field`, `heavy`, `light`. + +#### 1.19 `GetFlidIfPossible_CachingBehavior` +- **What:** `GetFlidIfPossible` uses a static `Dictionary` cache + keyed by `"className-fieldName"`. Verify that repeated calls return + the cached value. Note potential collision risk if two classes share + the same field name with different flids — document this risk even + if we don't fix it. + +--- + +## Subdomain 2: ShowObject & Show-Hidden Fields (`DataTree.Persistence.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `ShowObject_ShowHiddenEnabledForCurrentTool_...` | Tool-keyed show-hidden | +| 2 | `ShowObject_NoCurrentContentControl_...` | Fallback to lexiconEdit | +| 3 | `ShowObject_NonLexiconCurrentContentControl_...` | Override for LexEntry | +| 4 | `ShowObject_ShowHiddenForDifferentTool_...` | Tool isolation | +| 5 | `ShowObject_ShowHiddenEnabled_RevealsNeverVisibility...` | `visibility="never"` reveal | +| 6 | `OnPropertyChanged_ShowHiddenFields_Toggles...` | Toggle via mediator message | +| 7 | `OnDisplayShowHiddenFields_CheckedState_...` | Menu checked state | +| 8 | `ShowObject_ShowHiddenEnabled_BypassesSliceFilter` | Filter bypass | + +### New Tests Needed + +#### 2.1 `ShowObject_SameRootAndDescendant_DoesRefreshList` +- **What:** Calling `ShowObject` with the same root, same layout, same + descendant triggers `RefreshList(false)` not `CreateSlices(true)`. +- **Assertions:** Slice instances survive (reference equality). + +#### 2.2 `ShowObject_DifferentRoot_RecreatesAllSlices` +- **What:** Changing the root object disposes old slices and creates + new ones. +- **Assertions:** Old slice references are disposed; new slices exist. + +#### 2.3 `ShowObject_SameRootDifferentDescendant_SetsCurrentSliceNew` +- **What:** Same root but different descendant → only + `SetCurrentSliceNewFromObject` is called. +- **Assertions:** After idle processing, `CurrentSlice.Object` matches + the descendant. + +#### 2.4 `ShowObject_NoOp_WhenAllParametersUnchanged` +- **What:** Calling `ShowObject` with identical parameters is a no-op. +- **Assertions:** Slice count and references unchanged. + +#### 2.5 `RefreshList_DoNotRefresh_DefersRefresh` +- **What:** Set `DoNotRefresh = true`, call `RefreshList`. Verify slices + are NOT rebuilt. Then set `DoNotRefresh = false` — verify the deferred + refresh fires. +- **Assertions:** Slice count only changes after `DoNotRefresh = false`. + +#### 2.6 `RefreshList_CurrentSliceSurvivesRefresh` +- **What:** If the current slice's identity (type + config + caller + + label + object GUID) matches a slice after refresh, it remains + current. +- **Assertions:** `CurrentSlice` after refresh has same `Key` as before. + +#### 2.7 `RefreshList_InvalidRoot_CallsReset` +- **What:** If `m_root` is invalid (e.g., deleted), `RefreshList` should + call `Reset()` and produce zero slices. + +#### 2.8 `GetShowHiddenFieldsToolName_LexEntry_FallbackToLexiconEdit` +- **What:** Already partially covered. Add explicit unit test that + exercises the 4 branches: + 1. `ILexEntry` + no `currentContentControl` → `"lexiconEdit"` + 2. `ILexEntry` + `currentContentControl = "notebookEdit"` → + `"lexiconEdit"` (override) + 3. `ILexEntry` + `currentContentControl = "lexiconEdit-variant"` → + `"lexiconEdit-variant"` (starts with `"lexiconEdit"`) + 4. Non-`ILexEntry` + `currentContentControl = "notebookEdit"` → + `"notebookEdit"` (pass-through) + +#### 2.9 `SetCurrentSlicePropertyNames_ConstructsCorrectKeys` +- **What:** Verify the property-table keys follow the pattern + `{area}${tool}$CurrentSlicePartName` and + `{area}${tool}$CurrentSliceObjectGuid`. +- **Setup:** Set `areaChoice = "lexicon"`, + `currentContentControl = "lexiconEdit"` in `PropertyTable`. + +--- + +## Subdomain 3: Slice Refresh & Reuse (`DataTree.SliceManagement.cs`) + +### Existing Tests + +None directly test reuse. `OwnedObjects` test exercises +`CreateSlices` indirectly. + +### New Tests Needed + +#### 3.1 `CreateSlices_SliceReuse_SameRootRefresh` +- **What:** After initial `ShowObject`, capture slice references. Call + `RefreshList(false)`. Verify that slices with matching keys are + reused (same .NET object reference). +- **Assertions:** `object.ReferenceEquals(oldSlice, newSlice)` for + matching keys. +- **Fixture:** Simple 2-slice layout. + +#### 3.2 `CreateSlices_DifferentObject_NoReuse` +- **What:** Call `ShowObject` with object A, then with object B. Verify + no reuse (old slices disposed). +- **Assertions:** All old slice references have `IsDisposed == true`. + +#### 3.3 `ObjSeqHashMap_RetrievalByKey` +- **What:** Unit test `ObjSeqHashMap` directly — insert keyed slices, + retrieve by key, verify removed entries are correct. +- **Note:** This is a standalone data-structure test, could go in a + new `ObjSeqHashMapTests.cs`. + +#### 3.4 `ObjSeqHashMap_ClearUnwantedPart_DifferentObject` +- **What:** After `ClearUnwantedPart(true)`, most entries are cleared. + Verify aggressive cleanup. + +#### 3.5 `ObjSeqHashMap_ClearUnwantedPart_SameObject` +- **What:** After `ClearUnwantedPart(false)`, entries are preserved + for reuse. + +#### 3.6 `MonitoredProps_AccumulatesAcrossRefresh` +- **What:** Call `ShowObject`, note `m_monitoredProps` count. Call + `RefreshList`. Verify props are NOT cleared (they accumulate). +- **Risk documentation:** This is by design but could leak memory over + very long sessions — document in test comment. + +--- + +## Subdomain 4: Navigation & Focus (`DataTree.Navigation.cs`) + +### Existing Tests + +None. + +### New Tests Needed + +#### 4.1 `GotoFirstSlice_SetsCurrentSliceToFirstFocusable` +- **What:** With 5 slices (first is a header, rest are data), call + `GotoFirstSlice()`. Verify `CurrentSlice` is the first data slice + (skipping the header). +- **Fixture:** `Nested-Expanded` layout produces a header + children. + +#### 4.2 `GotoNextSlice_AdvancesToNextFocusable` +- **What:** Set `CurrentSlice` to slice[1], call `GotoNextSlice()`. + Verify `CurrentSlice` is slice[2]. + +#### 4.3 `GotoNextSlice_AtEnd_DoesNotChange` +- **What:** Set `CurrentSlice` to the last slice, call + `GotoNextSlice()`. Verify `CurrentSlice` is unchanged. + +#### 4.4 `GotoPreviousSliceBeforeIndex_ReversesNavigation` +- **What:** Set `CurrentSlice` to slice[2], call + `GotoPreviousSliceBeforeIndex(2)`. Verify `CurrentSlice` is + slice[1] (or the nearest focusable before index 2). + +#### 4.5 `FocusFirstPossibleSlice_DescendantSpecific` +- **What:** Set `m_descendant` to a specific owned object. Verify + `FocusFirstPossibleSlice` selects a slice belonging to that + descendant, not the first slice overall. + +#### 4.6 `FocusFirstPossibleSlice_FallsBackToFirstFocusable` +- **What:** Set `m_descendant` to an object without a matching slice. + Verify focus falls back to the first focusable slice. + +#### 4.7 `CurrentSlice_Setter_NullThrows` +- **What:** `CurrentSlice = null` throws `ArgumentException`. +- **Assertions:** `Assert.Throws`. + +#### 4.8 `CurrentSlice_Setter_FiresChangedEvent` +- **What:** Subscribe to `CurrentSliceChanged`, set `CurrentSlice`. + Verify event fires. + +#### 4.9 `CurrentSlice_Setter_SuspendedStoresInNew` +- **What:** During `m_fSuspendSettingCurrentSlice`, setting + `CurrentSlice` only stores in `m_currentSliceNew`. + +#### 4.10 `DescendantForSlice_RootLevelSlice_ReturnsRoot` +- **What:** For a slice with no `ParentSlice`, `DescendantForSlice` + returns `m_root`. + +#### 4.11 `DescendantForSlice_NestedSlice_ReturnsHeaderObject` +- **What:** For a slice under a header, `DescendantForSlice` returns + the header's object. + +#### 4.12 `MakeSliceRealAt_RealizeDummySlice` +- **What:** At an index containing a `DummyObjectSlice`, calling + `MakeSliceRealAt` replaces it with a real slice. +- **Assertions:** After call, `Slices[i].IsRealSlice == true`. + +--- + +## Subdomain 5: Messaging & IxCoreColleague (`DataTree.Messaging.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `OnDisplayShowHiddenFields_CheckedState_...` | Menu display | +| 2 | `OnPropertyChanged_ShowHiddenFields_Toggles...` | Property toggle | + +### New Tests Needed + +#### 5.1 `GetMessageTargets_VisibleWithCurrentSlice` +- **What:** With a visible DataTree and a current slice, verify + `GetMessageTargets()` returns `[currentSlice, this]`. +- **Assertions:** Array length == 2; `[0]` is the slice. + +#### 5.2 `GetMessageTargets_NotVisible_ReturnsSliceOnly` +- **What:** Hide DataTree, verify `GetMessageTargets()` returns + only `[currentSlice]` (or empty if no current slice). + +#### 5.3 `GetMessageTargets_NoCurrentSlice_ReturnsSelfOnly` +- **What:** Visible DataTree, no current slice. Returns `[this]`. + +#### 5.4 `ShouldNotCall_WhenDisposed_ReturnsTrue` +- **What:** After `Dispose()`, `ShouldNotCall` returns `true`. + +#### 5.5 `PropChanged_MonitoredProp_TriggersRefresh` +- **What:** Register `MonitorProp(hvo, tag)`. Fire `PropChanged` with + that pair. Verify `RefreshListAndFocus` is called (or verify the + observable effect: slices are rebuilt). + +#### 5.6 `PropChanged_UnmonitoredProp_NoFullRefresh` +- **What:** Fire `PropChanged` with an unregistered `(hvo, tag)`. + Verify no full refresh occurs. + +#### 5.7 `PropChanged_UnmonitoredDuringUndo_RootVectorChange_FullRefresh` +- **What:** Set up an undo-in-progress state. Fire `PropChanged` on + the root object's owning sequence. Verify `RefreshList(true)`. + +#### 5.8 `DeepSuspendResumeLayout_Counting` +- **What:** Call `DeepSuspendLayout()` twice, then `DeepResumeLayout()` + twice. Verify layout is only resumed on the second resume call. +- **Assertions:** After first resume, layout is still suspended. + +#### 5.9 `OnPropertyChanged_UnknownProperty_NoOp` +- **What:** Call `OnPropertyChanged("someRandomProperty")`. Verify no + side effects (no refresh, no exception). + +#### 5.10 `PostponePropChanged_DefersRefresh` +- **What:** Set `m_postponePropChanged = true` (via the event), fire + `PropChanged` on a monitored prop. Verify `BeginInvoke` is used + rather than a synchronous call. +- **Note:** Hard to test directly; may need to verify via a flag or + mock the control's BeginInvoke. + +--- + +## Subdomain 6: Lifecycle & Persistence (`DataTree.cs` core) + +### Existing Tests + +None directly test lifecycle. Setup/teardown exercises init + dispose. + +### New Tests Needed + +#### 6.1 `Initialize_SetsRequiredFields` +- **What:** After `Initialize(cache, false, layouts, parts)`, verify + `m_cache`, `m_mdc`, `m_sda` are set. + +#### 6.2 `Dispose_UnsubscribesNotifications` +- **What:** After `Dispose()`, verify `m_sda.RemoveNotification` was + called (the DataTree no longer receives property changes). + +#### 6.3 `Dispose_CurrentSlice_GetsDeactivated` +- **What:** Set a current slice, then `Dispose()`. Verify the slice + received `SetCurrentState(false)`. + +#### 6.4 `Dispose_DoubleDispose_IsNoOp` +- **What:** Call `Dispose()` twice. No exception thrown. + +#### 6.5 `CheckDisposed_AfterDispose_Throws` +- **What:** After `Dispose()`, calling `CheckDisposed()` throws + `ObjectDisposedException`. + +--- + +## Subdomain 7: JumpToTool (`DataTree.Messaging.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `GetGuidForJumpToTool_UsesRootObject_WhenNoCurrentSlice` | Null current slice → root GUID | + +### New Tests Needed + +#### 7.1 `GetGuidForJumpToTool_ConcordanceTool_LexEntry` +- **What:** With `tool = "concordance"`, current slice on a `LexEntry`. + Verify the resolved GUID is the entry's GUID. + +#### 7.2 `GetGuidForJumpToTool_ConcordanceTool_LexSense` +- **What:** Current slice on a `LexSense` under the entry. Verify + the GUID is the sense's GUID. + +#### 7.3 `GetGuidForJumpToTool_LexiconEditTool` +- **What:** With `tool = "lexiconEdit"`. Verify the GUID resolution + walks ownership to find the owning `LexEntry`. + +#### 7.4 `GetGuidForJumpToTool_ForEnableOnly_DoesNotCreate` +- **What:** With `forEnableOnly = true` and a tool that might create + an object (e.g., `notebookEdit`). Verify no object creation occurs. + +--- + +## Test Count Summary + +| Subdomain | Existing | New | Total | +|-----------|----------|-----|-------| +| XML Layout Parsing | 8 | 19 | 27 | +| ShowObject & Show-Hidden | 8 | 9 | 17 | +| Slice Refresh & Reuse | 0 | 6 | 6 | +| Navigation & Focus | 0 | 12 | 12 | +| Messaging & IxCoreColleague | 2 | 10 | 12 | +| Lifecycle & Persistence | 0 | 5 | 5 | +| JumpToTool | 1 | 4 | 5 | +| **Total** | **19** | **65** | **84** | + +--- + +## Priority Order for Implementation + +1. **Navigation & Focus** (12 tests) — zero coverage today, critical + for user-facing behavior during Avalonia migration +2. **Slice Refresh & Reuse** (6 tests) — zero coverage, `ObjSeqHashMap` + is a complex data structure with subtle semantics +3. **XML Layout Parsing** (19 new tests) — partial coverage exists + but the threshold logic, `ifData` variants, and ghost slices are + unprotected +4. **Messaging** (10 new tests) — `PropChanged` and + `DeepSuspendLayout` have re-entrancy risks +5. **ShowObject extensions** (9 new tests) — good existing coverage, + but root-change and no-op paths are untested +6. **Lifecycle** (5 tests) — simple but important for dispose safety +7. **JumpToTool** (4 tests) — lower risk, specialized feature diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md new file mode 100644 index 0000000000..8d22c10e91 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md @@ -0,0 +1,302 @@ +# Test Harness & Fixture Plan + +## Purpose + +This document details the test infrastructure enhancements needed to +support the 129 new characterization tests across DataTree and Slice. +It covers: fixture XML additions, helper method needs, base class +choices, and standalone data-structure test files. + +--- + +## 1. Existing Test Infrastructure + +### Base Class +- `MemoryOnlyBackendProviderRestoredForEachTestTestBase` — provides + per-test `LcmCache` with in-memory backend. Both `DataTreeTests` + and `SliceTests` inherit from this. + +### Fixture-Level Setup (DataTreeTests) +- `GenerateLayouts()` — scans `*Tests/*.fwlayout` for layout XML +- `GenerateParts()` — scans `*Tests/*Parts.xml` for part XML +- Both produce `Inventory` objects used to initialize `DataTree` + +### Per-Test Setup (DataTreeTests) +``` +CustomFieldForTest → "testField" on LexEntry +ILexEntry → CitationForm="rubbish", Bibliography="My rubbishy..." +DataTree + Mediator + PropertyTable +Form hosting DataTree +``` + +### Shared Helpers +- `SliceTests.CreateXmlElementFromOuterXmlOf(string)` — parses XML + string to `XmlElement`. Used by `SliceTests` and `SliceFactoryTests`. +- `SliceTests.GenerateSlice(cache, datatree)` — creates a configured + Slice with parent DataTree. +- `SliceTests.GeneratePath()` — returns 7-element ArrayList mimicking + a real slice Key path. + +--- + +## 2. New XML Fixture Additions + +### 2.1 `Test.fwlayout` — New Layouts Needed + +Add the following layouts to the existing fixture file: + +| Layout Name | Class | Purpose | Parts | +|-------------|-------|---------|-------| +| `ManySenses` | `LexEntry` | Test kInstantSliceMax threshold (>20 items) | `` | +| `ManySensesExpanded` | `LexEntry` | Test threshold override with `expansion="expanded"` | `` | +| `EmptySeq` | `LexEntry` | Test empty sequence (no items) | `` | +| `GhostAtomic` | `LexEntry` | Test ghost slice creation for null atomic | `` | +| `IfNotTest` | `LexEntry` | Test `` conditional | `` child parts | +| `ChoiceTest` | varies | Test `//` | Multiple `` clauses | +| `OwnerLabel` | `LexSense` | Test `$owner.` label prefix | `` | +| `WeightTest` | `LexEntry` | Test `weight` attribute parsing | Parts with `weight="heavy"`, `weight="light"` | +| `IfDataMultiString` | `LexEntry` | Test ifdata for multistring fields | Parts with `visibility="ifdata" ws="analysis"` | +| `IfDataStText` | `LexEntry` | Test ifdata for StText empty paragraph | Part referencing a StText field | +| `NavigationTest` | `LexEntry` | 5+ slices for navigation testing | 5 simple `` refs | +| `HeaderWithChildren` | `LexEntry` | Header + 3 children for focus tests | Nested header with children | + +### 2.2 `TestParts.xml` — New Parts Needed + +| Part ID | Purpose | +|---------|---------| +| `LexEntry-Detail-Pronunciation` | Atomic field for ghost slice test | +| `LexEntry-Detail-ManySenses` | Sequence with senses | +| Navigation parts (5x) | Simple string slices for nav tests | +| Header parts | Header + children configuration | + +--- + +## 3. New Helper Methods + +### 3.1 DataTreeTests Helpers + +#### `CreateEntryWithSenses(int count)` +```csharp +private ILexEntry CreateEntryWithSenses(int count) +{ + var entry = Cache.ServiceLocator.GetInstance() + .Create(); + var senseFactory = Cache.ServiceLocator + .GetInstance(); + for (int i = 0; i < count; i++) + senseFactory.Create(entry); + return entry; +} +``` +**Used by:** ManySenses threshold tests, navigation tests. + +#### `ShowObjectAndGetSlices(string layoutName, ILexEntry entry = null)` +```csharp +private List ShowObjectAndGetSlices( + string layoutName, ICmObject obj = null) +{ + obj ??= m_entry; + m_dtree.ShowObject(obj, layoutName, null, obj, false); + return m_dtree.Slices.ToList(); +} +``` +**Used by:** Most characterization tests. + +#### `AssertSliceLabels(params string[] expectedLabels)` +```csharp +private void AssertSliceLabels(params string[] expectedLabels) +{ + var actual = m_dtree.Slices + .Select(s => s.Label).ToArray(); + CollectionAssert.AreEqual(expectedLabels, actual); +} +``` +**Used by:** Layout parsing tests, navigation tests. + +#### `AssertSliceCount(int expected)` +```csharp +private void AssertSliceCount(int expected) +{ + Assert.AreEqual(expected, m_dtree.Controls.Count, + $"Expected {expected} slices but found " + + $"{m_dtree.Controls.Count}"); +} +``` + +#### `SimulateShowHidden(string toolName, bool show)` +```csharp +private void SimulateShowHidden(string toolName, bool show) +{ + m_propertyTable.SetProperty( + $"ShowHiddenFields-{toolName}", show, true); +} +``` + +### 3.2 SliceTests Helpers + +#### `CreateSliceWithConfig(string xmlConfig)` +```csharp +private Slice CreateSliceWithConfig(string xmlConfig) +{ + var slice = new Slice(); + slice.ConfigurationNode = + CreateXmlElementFromOuterXmlOf(xmlConfig); + return slice; +} +``` + +#### `InstallSliceInDataTree(Slice slice, DataTree dt = null)` +```csharp +private void InstallSliceInDataTree(Slice slice, DataTree dt) +{ + dt ??= m_DataTree; + slice.Cache = Cache; + slice.Install(dt); +} +``` + +--- + +## 4. New Test Files + +### 4.1 `ObjSeqHashMapTests.cs` +- **Location:** `DetailControlsTests/ObjSeqHashMapTests.cs` +- **Purpose:** Unit tests for the `ObjSeqHashMap` data structure in + isolation (no DataTree, no Form, no WinForms). +- **Base class:** `NUnit.Framework.TestFixture` (no LCM needed) +- **Tests:** + +| # | Test | What | +|---|------|------| +| 1 | `Add_And_Retrieve` | Add keyed slices, retrieve by key | +| 2 | `Remove_ReturnsCorrectSlice` | Remove returns the first match | +| 3 | `ClearUnwantedPart_True_ClearsAll` | `differentObject=true` clears | +| 4 | `ClearUnwantedPart_False_PreservesEntries` | `differentObject=false` preserves | +| 5 | `DuplicateKeys_FIFO` | Multiple slices with same key → FIFO retrieval | +| 6 | `MissingKey_ReturnsNull` | Retrieving nonexistent key returns null | + +### 4.2 `SliceFactoryTests.cs` (Existing — Extend) +- **Current coverage:** 1 test (`SetConfigurationDisplayPropertyIfNeeded`) +- **New tests:** + +| # | Test | What | +|---|------|------| +| 1 | `Create_MultistringEditor_ReturnsMultiStringSlice` | Factory dispatch for `editor="multistring"` | +| 2 | `Create_StringEditor_ReturnsStringSlice` | Factory dispatch for `editor="string"` | +| 3 | `Create_UnknownEditor_ReflectionFallback` | Custom editor class resolved via reflection | +| 4 | `Create_NullEditor_ReturnsBasicSlice` | No editor attribute → default Slice | + +--- + +## 5. Test Configuration Updates + +### 5.1 `DetailControlsTests.csproj` +The project uses SDK-style format with auto-inclusion. No changes +needed unless new test files are placed outside the project directory. +Verify that `ObjSeqHashMapTests.cs` auto-includes. + +### 5.2 `Test.runsettings` +No changes needed. The existing settings file applies to all managed +tests via `.\test.ps1`. + +--- + +## 6. Test Execution Strategy + +### Phase 0 Implementation Order + +1. **Week 1:** Add XML fixtures + helper methods (foundation) +2. **Week 2:** DataTree Navigation tests (12 tests, zero coverage) +3. **Week 2:** ObjSeqHashMap standalone tests (6 tests) +4. **Week 3:** DataTree Reuse tests (6 tests, depends on ObjSeqHashMap) +5. **Week 3:** DataTree Messaging tests (10 tests) +6. **Week 4:** XML Layout Parsing tests (19 tests) +7. **Week 4:** Slice Core + Lifecycle tests (19 tests) +8. **Week 5:** Slice Menu Commands tests (14 tests) +9. **Week 5:** Remaining Slice tests (31 tests) +10. **Week 6:** ShowObject extensions + JumpToTool (13 tests) + +### Running Tests +```powershell +# All managed tests +.\test.ps1 + +# Just DetailControls tests +.\test.ps1 -Filter "DetailControls" + +# Specific test class +.\test.ps1 -Filter "DataTreeTests" +``` + +--- + +## 7. Risk Areas Identified During Research + +### 7.1 `m_monitoredProps` Never Cleared +The `HashSet<(int, int)>` accumulates across `RefreshList` calls and +is only nulled during `Dispose`. This means entries from previous +slice builds remain. Tests should document this behavior (not fix it +in Phase 0) via a comment: +```csharp +// NOTE: m_monitoredProps accumulates by design. Entries from +// previous ShowObject/RefreshList calls persist until Dispose. +// This could theoretically cause unnecessary refreshes but +// has not been observed as a problem in practice. +``` + +### 7.2 `GetFlidIfPossible` Static Cache Collision +The static `Dictionary` keyed by `"className-fieldName"` +could collide if two classes have identically-named fields with +different flids. Tests should document this: +```csharp +// NOTE: GetFlidIfPossible uses a static cache keyed by +// "className-fieldName". If two classes define a field with +// the same name but different flids, the cache returns the +// first one seen. This is a latent bug but has no known +// manifestation in the current schema. +``` + +### 7.3 `BeginInvoke` in `PropChanged` +When `m_postponePropChanged` is true, `PropChanged` calls +`BeginInvoke(RefreshListAndFocus)`. This is hard to test in NUnit +because `BeginInvoke` requires a Windows message pump. Tests for +this path should either: +- Use a `Form.Show()` + `Application.DoEvents()` pattern, or +- Document the limitation and test only the synchronous path. + +### 7.4 `SelectAt(99999)` Heuristic +`SetDefaultCurrentSlice` calls `SelectAt(99999)` on +`MultiStringSlice`/`StringSlice` to place the cursor at the end. +The magic number is a convention (any large value works). Tests +should verify cursor-at-end behavior without relying on the specific +value. + +--- + +## 8. Cross-Cutting: Tests Needed for Phase 2+ Classes + +These tests are NOT Phase 0 characterization tests but are listed +here for completeness. They will be created when their respective +classes are extracted. + +| Class | File | Test Count | Notes | +|-------|------|------------|-------| +| `SliceLayoutBuilder` | `SliceLayoutBuilderTests.cs` | ~15 | Can test without Form/WinForms | +| `ShowHiddenFieldsManager` | `ShowHiddenFieldsManagerTests.cs` | ~6 | Pure logic, no UI deps | +| `DataTreeNavigator` | `DataTreeNavigatorTests.cs` | ~8 | May need mock Slice list | +| `DataTreeModel` | `DataTreeModelTests.cs` | ~10 | Core Phase 3 tests; no WinForms | +| `SliceSpec` | `SliceSpecTests.cs` | ~5 | Data class, trivial | + +--- + +## Summary + +| Category | File | Tests | +|----------|------|-------| +| DataTree characterization | `DataTreeTests.cs` | 65 new | +| Slice characterization | `SliceTests.cs` | 64 new | +| ObjSeqHashMap unit | `ObjSeqHashMapTests.cs` | 6 new | +| SliceFactory extensions | `SliceFactoryTests.cs` | 4 new | +| **Phase 0 Total** | | **139 new tests** | +| Phase 2+ (future) | Various | ~44 | +| **Grand Total** | | **183** | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md new file mode 100644 index 0000000000..8c51bc1d9a --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md @@ -0,0 +1,379 @@ +# Slice Characterization Test Plan + +## Purpose + +This document details every characterization test needed for `Slice.cs` +(3,341 lines) before the partial-class split. Tests are organized by +proposed partial-class file. Each entry documents existing vs. needed +tests, edge cases, and assertions. + +All tests go in `Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs` +unless otherwise noted. + +--- + +## Existing Test Coverage + +`SliceTests.cs` currently has **6 tests**, all minimal smoke-tests: + +| # | Test | Coverage Level | +|---|------|----------------| +| 1 | `Basic1` | Constructor not null | +| 2 | `Basic2` | Constructor with control | +| 3 | `CreateIndentedNodes_basic` | Smoke (no assertion beyond no-crash) | +| 4 | `Expand` | Smoke | +| 5 | `Collapse` | Smoke | +| 6 | `CreateGhostStringSlice_ParentSliceNotNull` | Ghost slice PropTable propagation | + +**Assessment:** Coverage is minimal. No tests assert actual behaviors +like label rendering, expansion state transitions, delete/merge/split +eligibility, help topic resolution, or field visibility. + +--- + +## Subdomain 1: Core Properties (`Slice.Core.cs`) + +### New Tests Needed + +#### 1.1 `Abbreviation_AutoGeneratedFromLabel` +- **What:** Set `Label = "Citation Form"`, leave `Abbreviation` unset. + Verify `Abbreviation` returns `"Cita"` (first 4 chars). +- **Edge case:** Label shorter than 4 chars → full label is abbreviation. + +#### 1.2 `Abbreviation_ExplicitOverridesAutoGeneration` +- **What:** Set `Label = "Citation Form"`, + `Abbreviation = "CF"`. Verify `Abbreviation` returns `"CF"`. + +#### 1.3 `IsSequenceNode_TrueForOwningSequence` +- **What:** Configure slice with `` where + `Senses` is an `OwningSequence` field. Verify + `IsSequenceNode == true`. + +#### 1.4 `IsSequenceNode_FalseForCollection` +- **What:** Configure slice with `` where the field + type is an unordered collection. Verify `IsSequenceNode == false`. + +#### 1.5 `IsCollectionNode_TrueForNonSequence` +- **What:** Same as 1.4 but verify `IsCollectionNode == true` + (complement of `IsSequenceNode`). + +#### 1.6 `IsHeaderNode_ReadsXmlAttribute` +- **What:** Set `ConfigurationNode` with `header="true"`. Verify + `IsHeaderNode == true`. + +#### 1.7 `ContainingDataTree_NullWhenOrphaned` +- **What:** Create a slice not parented to any DataTree. Verify + `ContainingDataTree` returns null. + +#### 1.8 `ContainingDataTree_ReturnsParent` +- **What:** After `Install(dataTree)`, verify `ContainingDataTree` + returns the DataTree. + +#### 1.9 `WrapsAtomic_ReadsConfigAttribute` +- **What:** Set `ConfigurationNode` with `wrapsAtomic="true"`. Verify + `WrapsAtomic == true`. + +#### 1.10 `CallerNodeEqual_StructuralComparison` +- **What:** Set two `CallerNode` values with identical XML content but + different .NET references. Verify `CallerNodeEqual` returns true. + +--- + +## Subdomain 2: Lifecycle (`Slice.Lifecycle.cs`) + +### New Tests Needed + +#### 2.1 `Constructor_SetsVisibleFalse` +- **What:** After `new Slice()`, verify `Visible == false`. +- **Rationale:** All slices start invisible to prevent reordering + during `Controls.Add`. + +#### 2.2 `Install_SetsHeightToMaxOfControlAndLabel` +- **What:** Install a slice with `Control.Height = 40` and + `LabelHeight = 20`. Verify `Height == 40`. +- **What:** Install a slice with `Control.Height = 10` and + `LabelHeight = 20`. Verify `Height == 20`. + +#### 2.3 `Install_NoLabel_FixesSplitterAtOnePixel` +- **What:** Install a slice with empty label. Verify the splitter is + fixed (Panel1 is collapsed or at minimum width). + +#### 2.4 `Install_SetsPanelMinSizeFromIndent` +- **What:** Set `Indent = 2`. After `Install`, verify + `Panel1MinSize == (20 * (2+1)) + 20 = 80`. + +#### 2.5 `CheckDisposed_AfterDispose_Throws` +- **What:** Dispose slice, call `CheckDisposed()`. Verify + `ObjectDisposedException`. + +#### 2.6 `Dispose_NullsAllFields` +- **What:** After `Dispose(true)`, verify key fields are null + (prevents use-after-dispose). + +#### 2.7 `Dispose_UnsubscribesSplitterEvent` +- **What:** After disposal, the splitter moved event should not fire + (no dangling event handlers). + +#### 2.8 `OnEnter_DuringConstruction_DoesNotSetCurrentSlice` +- **What:** When `ContainingDataTree.ConstructingSlices` is true, + `OnEnter` should not set `CurrentSlice`. + +#### 2.9 `BecomeReal_BaseReturnsSelf` +- **What:** On a non-dummy slice, `BecomeReal(0)` returns `this`. + +--- + +## Subdomain 3: Tree Rendering (`Slice.TreeRendering.cs`) + +### New Tests Needed + +#### 3.1 `DrawLabel_UsesAbbreviationWhenNarrow` +- **What:** With `SplitCont.SplitterDistance <= MaxAbbrevWidth (60)`, + `DrawLabel` should render the abbreviation, not the full label. +- **Note:** Requires a `Graphics` object; may be a visual/smoke test. + +#### 3.2 `LabelIndent_Calculation` +- **What:** Verify `LabelIndent()` returns + `kdxpLeftMargin + (Indent+1) * kdxpIndDist`. For `Indent = 0`, + verify the expected pixel value. + +#### 3.3 `Expand_SetsStateToExpanded` +- **What:** Set `Expansion = ktisCollapsed`. Call `Expand()`. Verify + `Expansion == ktisExpanded`. + +#### 3.4 `Expand_PersistsExpansionState` +- **What:** After `Expand()`, verify the `ExpansionStateKey` is stored + in `PropertyTable` as `"true"`. + +#### 3.5 `Collapse_SetsStateToCollapsed` +- **What:** Set `Expansion = ktisExpanded` with children. Call + `Collapse()`. Verify `Expansion == ktisCollapsed`. + +#### 3.6 `Collapse_PersistsCollapsedState` +- **What:** After `Collapse()`, verify the `ExpansionStateKey` is + removed or set to `"false"` in `PropertyTable`. + +#### 3.7 `Collapse_RemovesDescendantSlices` +- **What:** After `Collapse()`, verify all descendant slices (those + with this slice as an ancestor via `ParentSlice`) are removed from + the DataTree. + +#### 3.8 `ExpansionStateKey_NullForFixedSlices` +- **What:** A slice with `Expansion = ktisFixed` returns null for + `ExpansionStateKey`. + +#### 3.9 `IsObjectNode_TrueForNodeChild` +- **What:** Config with `` child and object != root → + `IsObjectNode == true`. + +#### 3.10 `IsObjectNode_FalseForSeqChild` +- **What:** Config with `` child → `IsObjectNode == false` + (sequence, not object node). + +--- + +## Subdomain 4: Child Generation (`Slice.ChildGeneration.cs`) + +### New Tests Needed + +#### 4.1 `GenerateChildren_NothingResult_SetsFixed` +- **What:** When `NodeTestResult = kntrNothing`, `GenerateChildren` + should set `Expansion = ktisFixed`. + +#### 4.2 `GenerateChildren_PossibleResult_SetsCollapsedEmpty` +- **What:** When `NodeTestResult = kntrPossible` and not previously + expanded, `GenerateChildren` sets + `Expansion = ktisCollapsedEmpty`. + +#### 4.3 `GenerateChildren_PersistentExpansion_RestoresExpanded` +- **What:** Store `ExpansionStateKey = "true"` in PropertyTable before + `GenerateChildren`. Verify expansion is restored to `ktisExpanded`. + +#### 4.4 `ExtraIndent_TrueAttribute_ReturnsOne` +- **What:** `Slice.ExtraIndent(node)` with `indent="true"` returns 1. + +#### 4.5 `ExtraIndent_NoAttribute_ReturnsZero` +- **What:** `Slice.ExtraIndent(node)` with no `indent` attr returns 0. + +--- + +## Subdomain 5: Menu Commands (`Slice.MenuCommands.cs`) + +### New Tests Needed + +#### 5.1 `GetCanDeleteNow_RequiredAtomicField_ReturnsFalse` +- **What:** Object in a required atomic field (CellarPropertyType + check). Verify `GetCanDeleteNow()` returns false. + +#### 5.2 `GetCanDeleteNow_VectorWithMultipleItems_ReturnsTrue` +- **What:** Object in a vector field with >1 items. Verify + `GetCanDeleteNow()` returns true. + +#### 5.3 `GetCanDeleteNow_VectorWithOneItem_Required_ReturnsFalse` +- **What:** Object in a required vector with exactly 1 item. Verify + `GetCanDeleteNow()` returns false. + +#### 5.4 `GetCanMergeNow_SubsensesAlwaysMergeable` +- **What:** A subsense (`LexSense` owned by another `LexSense`) + should always be mergeable. Verify `GetCanMergeNow()` returns true. + +#### 5.5 `GetCanMergeNow_AllomorphsSameClass_Mergeable` +- **What:** Allomorphs can merge with lexeme form if same class. + Verify `GetCanMergeNow()` returns true when classes match. + +#### 5.6 `GetCanMergeNow_NeedsTwoSameClassSiblings` +- **What:** Fewer than 2 items of the same class in the vector → + `GetCanMergeNow()` returns false. + +#### 5.7 `GetCanSplitNow_SameClassOwner_ReturnsTrue` +- **What:** Owner is same class as object → can split. + +#### 5.8 `GetCanSplitNow_VectorLessThanTwo_ReturnsFalse` +- **What:** Vector with <2 items and different-class owner → false. + +#### 5.9 `GetObjectForMenusToOperateOn_WrapsAtomic` +- **What:** When `WrapsAtomic` is true, follows the atomic field to + return the owned object. + +#### 5.10 `GetObjectForMenusToOperateOn_VariantBackRef` +- **What:** When `FromVariantBackRefField` is true, returns the + back-reference object. + +#### 5.11 `GetSeqContext_WalksKeyBackward` +- **What:** Set a Key with 7 elements (the standard path). Verify + `GetSeqContext` returns the correct owning hvo, flid, and position. + +#### 5.12 `GetSeqContext_GhostSlice_HandledCorrectly` +- **What:** Key ends with ghost-slice markers (FWR-556). Verify + `GetSeqContext` still finds the correct sequence context. + +#### 5.13 `StartsWith_BoxedIntEquality` +- **What:** `Slice.StartsWith(key1, key2)` where keys contain boxed + `int` values. Verify correct equality despite boxing. + +#### 5.14 `HandleDeleteCommand_SelectsNearbySlice` +- **What:** After deleting a slice's object, verify a nearby slice + gets focus (not null, not the deleted one). + +--- + +## Subdomain 6: Field Configuration (`Slice.FieldConfiguration.cs`) + +### New Tests Needed + +#### 6.1 `SetFieldVisibility_PersistsOverride` +- **What:** Call `SetFieldVisibility("never")`. Verify the override + is persisted via `Inventory` and the DataTree is refreshed. + +#### 6.2 `SetFieldVisibility_SameValue_NoOp` +- **What:** When the field already has the requested visibility, + `SetFieldVisibility` should not refresh. + +#### 6.3 `GetSibling_Up_SkipsChildren` +- **What:** With a slice at depth 2 under a parent at depth 1, calling + `GetSibling(Direction.Up)` should skip child slices and return the + previous depth-1 sibling. + +#### 6.4 `GetSibling_Down_SkipsChildren` +- **What:** Symmetric test for downward sibling. + +#### 6.5 `GetSibling_AtBoundary_ReturnsNull` +- **What:** For the first/last slice at a given depth, `GetSibling` + returns null. + +#### 6.6 `HelpTopicResolution_FourLevelFallback` +- **What:** `GenerateHelpTopicId` tries 4 patterns: + tool+class+field → tool+SortKey+field → tool+field → + class+field → field. Provide a mock `IHelpTopicProvider` that + validates only the 3rd pattern. Verify the 3rd ID is returned. + +#### 6.7 `HelpTopicResolution_CrossRefVsLexicalRelation` +- **What:** For `Targets` field, help topic varies based on whether + the parent is a cross-reference or lexical relation (uses `parentHvo` + to distinguish). Verify both paths. + +#### 6.8 `ReplacePartWithNewAttribute_PropagatesAcrossSlices` +- **What:** After calling `ReplacePartWithNewAttribute`, verify that + all slices in the DataTree that share the same `part ref` node have + their Key updated to point to the new XML node. + +--- + +## Subdomain 7: IxCoreColleague (`Slice.XCoreColleague.cs`) + +### New Tests Needed + +#### 7.1 `GetMessageTargets_VisibleWithColleagueControl` +- **What:** When Control implements `IxCoreColleague` and the slice + is visible, `GetMessageTargets()` returns `[Control, this]`. + +#### 7.2 `GetMessageTargets_Hidden_ReturnsEmpty` +- **What:** When slice is not visible and DataTree is not visible, + returns empty array. + +#### 7.3 `Init_SetsMediator_InitsColleagueControl` +- **What:** If Control is an `IxCoreColleague`, calling `Init` on + the slice also calls `Init` on the Control. + +#### 7.4 `SetCurrentState_PropagatesActiveToParents` +- **What:** Call `SetCurrentState(true)`. Verify the `Active` flag + propagates up the `ParentSlice` chain to the header. + +#### 7.5 `SetCurrentState_False_DeactivatesChain` +- **What:** Call `SetCurrentState(false)`. Verify the chain is + deactivated. + +--- + +## Subdomain 8: Splitter & Layout (`Slice.SplitterLayout.cs`) + +### New Tests Needed + +#### 8.1 `SetSplitPosition_UsesBaseAndIndent` +- **What:** Set `DataTree.SliceSplitPositionBase = 100`, + `Indent = 2`. Verify `SplitCont.SplitterDistance == + 100 + LabelIndent()`. + +#### 8.2 `OnSizeChanged_PreservesScrollPosition` +- **What:** Simulate `OnSizeChanged` and verify scroll position is + preserved (LT-18750 regression test). + +#### 8.3 `SetWidthForDataTreeLayout_MarksFlag` +- **What:** After calling `SetWidthForDataTreeLayout(500)`, verify + `m_widthHasBeenSetByDataTree` is true and the width is 500. + +--- + +## Test Count Summary + +| Subdomain | Existing | New | Total | +|-----------|----------|-----|-------| +| Core Properties | 2 | 10 | 12 | +| Lifecycle | 0 | 9 | 9 | +| Tree Rendering | 0 | 10 | 10 | +| Child Generation | 2 | 5 | 7 | +| Menu Commands | 0 | 14 | 14 | +| Field Configuration | 0 | 8 | 8 | +| IxCoreColleague | 0 | 5 | 5 | +| Splitter & Layout | 0 | 3 | 3 | +| **Total** | **6** (incl. ghost) | **64** | **70** | + +--- + +## Priority Order for Implementation + +1. **Menu Commands** (14 tests) — Delete/Merge/Split eligibility is + user-facing CRUD behavior with complex class-hierarchy logic; + zero coverage today +2. **Core Properties** (10 tests) — Property semantics (abbreviation, + node types, containment) underpin all other subdomains +3. **Lifecycle** (9 tests) — Dispose safety and construction invariants + prevent use-after-dispose bugs during migration +4. **Tree Rendering** (10 tests) — Expand/Collapse + persistence + protects user state across refresh +5. **Child Generation** (5 tests) — Expansion state machine transitions + are subtle and layout-dependent +6. **Field Configuration** (8 tests) — Visibility persistence and + sibling navigation used by field-reordering UI +7. **IxCoreColleague** (5 tests) — Message routing correctness +8. **Splitter & Layout** (3 tests) — Lower risk, mostly cosmetic diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md new file mode 100644 index 0000000000..bbb4094384 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: DataTreeModel owns slice-specification logic + +A new class `DataTreeModel` SHALL exist with no dependency on `System.Windows.Forms`. It SHALL own the decision of which slices to display, in what order, with what configuration — given a root `ICmObject`, a layout name, and a layout-choice field. + +#### Scenario: DataTreeModel produces slice specifications without WinForms +- **WHEN** `DataTreeModel.BuildSliceSpecs(root, layoutName, layoutChoiceField)` is called +- **THEN** it returns an ordered list of `SliceSpec` descriptors without creating any WinForms controls + +#### Scenario: DataTreeModel is testable without a Form +- **WHEN** a unit test constructs a `DataTreeModel` with cache, layout inventory, and part inventory +- **THEN** no `Form`, `UserControl`, or WinForms host is required for the test to run + +### Requirement: SliceSpec descriptor captures all slice metadata + +A new class `SliceSpec` SHALL capture the information needed to materialize a slice in any UI framework: label, abbreviation, indent level, editor type, XML configuration node, field ID, object reference, visibility, weight, tooltip, and key path. + +#### Scenario: SliceSpec contains editor type +- **WHEN** a `SliceSpec` is produced from a `` with `editor="multistring"` +- **THEN** `SliceSpec.EditorType` equals `"multistring"` + +#### Scenario: SliceSpec contains label and indent +- **WHEN** a `SliceSpec` is produced from an indented part with `label="Citation Form"` +- **THEN** `SliceSpec.Label` equals `"Citation Form"` and `SliceSpec.Indent` reflects the nesting depth + +### Requirement: SliceLayoutBuilder performs XML layout interpretation + +A new class `SliceLayoutBuilder` SHALL encapsulate the XML layout parsing logic currently in `DataTree.CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, and `AddAtomicNode`. It SHALL produce `SliceSpec` lists rather than concrete `Slice` WinForms controls. + +#### Scenario: SliceLayoutBuilder interprets sequence nodes +- **WHEN** a layout contains `` and the entry has 3 senses +- **THEN** `SliceLayoutBuilder` produces `SliceSpec` entries for each sense's sub-layout + +#### Scenario: SliceLayoutBuilder handles ifData visibility +- **WHEN** a part has `visibility="ifdata"` and the field is empty +- **THEN** `SliceLayoutBuilder` excludes that `SliceSpec` from the output (unless show-hidden is on) + +### Requirement: ShowHiddenFieldsManager encapsulates show-hidden key resolution + +A new class `ShowHiddenFieldsManager` SHALL own the logic for resolving the tool-specific property key used for the "Show Hidden Fields" toggle. It SHALL be consumed by both `DataTreeModel` and `IDataTreeView` implementations. + +#### Scenario: Resolves key from currentContentControl +- **WHEN** `currentContentControl` is `"lexiconEdit"` and the root is `ILexEntry` +- **THEN** `ShowHiddenFieldsManager.GetKey()` returns `"ShowHiddenFields-lexiconEdit"` + +#### Scenario: Falls back to lexiconEdit for LexEntry roots +- **WHEN** `currentContentControl` is not set and the root is `ILexEntry` +- **THEN** `ShowHiddenFieldsManager.GetKey()` returns `"ShowHiddenFields-lexiconEdit"` + +### Requirement: IDataTreeView interface for platform-specific rendering + +An interface `IDataTreeView` SHALL define the contract between `DataTreeModel` and platform-specific view implementations. It SHALL include methods for materializing `SliceSpec` lists into visible controls, managing focus, and reporting user interactions. + +#### Scenario: WinForms DataTree implements IDataTreeView +- **WHEN** the existing `DataTree : UserControl` is adapted +- **THEN** it implements `IDataTreeView` and delegates "what to show" decisions to `DataTreeModel` + +#### Scenario: IDataTreeView does not expose WinForms types +- **WHEN** `IDataTreeView` is defined +- **THEN** it references only framework-agnostic types (`SliceSpec`, `ICmObject`, etc.) — no `Control`, `Form`, or `UserControl` in the interface + +### Requirement: DataTree delegates to DataTreeModel + +The existing `DataTree` WinForms class SHALL delegate `ShowObject`, show-hidden resolution, XML layout interpretation, and navigation state to `DataTreeModel`. It SHALL retain only WinForms-specific responsibilities: `OnLayout`, `OnPaint`, splitter management, `Control` hosting, and `SplitContainer` configuration. + +#### Scenario: ShowObject delegates to model +- **WHEN** `DataTree.ShowObject(root, layout, ...)` is called +- **THEN** it calls `DataTreeModel.BuildSliceSpecs(...)` and materializes the resulting `SliceSpec` list into WinForms `Slice` controls + +#### Scenario: DataTree retains WinForms layout +- **WHEN** the WinForms layout engine calls `OnLayout` +- **THEN** `DataTree` positions its child `Slice` controls using WinForms-specific APIs without involving `DataTreeModel` + +### Requirement: StTextDataTree subclass compatibility + +The `StTextDataTree` subclass in `InfoPane.cs` SHALL continue to function. Its overrides of `ShowObject` and `SetDefaultCurrentSlice` SHALL be adapted to work with the model/view split. + +#### Scenario: StTextDataTree overrides model behavior +- **WHEN** `StTextDataTree` needs to transform the root object before display +- **THEN** it overrides a model-layer method (or hook) rather than a view-layer `ShowObject` + +### Requirement: Slice reuse remains functional + +The `ObjSeqHashMap`-based slice reuse mechanism SHALL continue to work during `RefreshList`. `SliceSpec` keys SHALL be compatible with the existing `Slice.Key` array structure. + +#### Scenario: Refresh reuses existing slices +- **WHEN** `RefreshList` is called on the same object +- **THEN** slices whose `SliceSpec.Key` matches an existing slice are reused, not recreated + +## MODIFIED Requirements + +### Requirement: WinForms patterns — DataTree composition + +*(Modified from `architecture/ui-framework/winforms-patterns`)* + +DataTree SHALL follow a model/view composition pattern: `DataTreeModel` (UI-agnostic) decides what to display; `DataTree` (WinForms `UserControl`) renders it. This replaces the current monolithic pattern where a single `UserControl` owns both logic and rendering. + +#### Scenario: DataTree is a thin view +- **WHEN** a developer reads `DataTree.cs` (the WinForms view) +- **THEN** they see only WinForms layout, paint, and control hosting — no XML parsing or show-hidden logic + +## MODIFIED Requirements + +### Requirement: Layer model — detail-tree model sublayer + +*(Modified from `architecture/layers/layer-model`)* + +A "detail-tree model" sublayer SHALL exist between the UI shell and data access layers. `DataTreeModel` and `SliceLayoutBuilder` reside in this sublayer. They depend downward on `LcmCache` and `Inventory` (data access / configuration) and are consumed upward by `IDataTreeView` implementations (UI shell). + +#### Scenario: Model layer has no UI dependency +- **WHEN** the `DataTreeModel` class is compiled +- **THEN** it does not reference `System.Windows.Forms`, `Avalonia`, or any UI framework assembly diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md new file mode 100644 index 0000000000..df47cdc0aa --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Partial-class file decomposition of DataTree + +DataTree.cs SHALL be split into partial-class files organized by responsibility. Each file SHALL contain one logical concern. The runtime behavior SHALL remain identical — no method signatures, visibility, or logic changes. + +#### Scenario: File split preserves compilation +- **WHEN** DataTree.cs is split into partial-class files +- **THEN** the project compiles with zero errors and zero new warnings + +#### Scenario: File split preserves test results +- **WHEN** all existing DataTreeTests pass before the split +- **THEN** all existing DataTreeTests pass after the split with no modifications + +### Requirement: Partial-class file organization + +The following files SHALL be created, each containing the indicated `#region` or logical grouping: + +| File | Content | +|------|---------| +| `DataTree.cs` | Data members, constructor, `Initialize`, `Dispose`, `CheckDisposed`, core properties (`Root`, `Cache`, `StyleSheet`, `Mediator`, etc.) | +| `DataTree.SliceManagement.cs` | `InsertSlice`, `RemoveSlice`, `RawSetSlice`, `InstallSlice`, `ForceSliceIndex`, `ResetTabIndices`, `InsertSliceRange`, tooltip management | +| `DataTree.LayoutParsing.cs` | `CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode`, `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice`, `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight resolution methods | +| `DataTree.WinFormsLayout.cs` | `OnLayout`, `HandleLayout1`, `MakeSliceRealAt`, `MakeSliceVisible`, `OnPaint`, `HandlePaintLinesBetweenSlices`, `OnSizeChanged`, `IndexOfSliceAtY`, `HeightOfSliceOrNullAt`, `FieldAt`, `FieldOrDummyAt`, `AboutToCreateField` | +| `DataTree.Navigation.cs` | `CurrentSlice` property, `DescendantForSlice`, `GotoFirstSlice`, `GotoNextSlice`, `GotoNextSliceAfterIndex`, `GotoPreviousSliceBeforeIndex`, `LastSlice`, `FocusFirstPossibleSlice`, `SelectFirstPossibleSlice`, `ScrollCurrentAndIfPossibleSectionIntoView`, `SetDefaultCurrentSlice`, `ActiveControl` | +| `DataTree.Messaging.cs` | All `IxCoreColleague` implementation (`Init`, `GetMessageTargets`, `ShouldNotCall`, `Priority`) and all `On*` message handlers | +| `DataTree.Persistence.cs` | `PrepareToGoAway`, `PersistPreferences`, `RestorePreferences`, `SetCurrentSlicePropertyNames`, `ShowObject` entry point, `RefreshList`, `CreateSlices`, show-hidden fields logic | + +#### Scenario: Each file contains exactly one concern +- **WHEN** a developer opens `DataTree.LayoutParsing.cs` +- **THEN** they see only XML layout interpretation methods, not WinForms layout or messaging code + +### Requirement: Partial-class decomposition of Slice + +Slice.cs SHALL be similarly split into partial-class files. The exact file list SHALL be determined during implementation but MUST separate at minimum: core properties, installation/lifecycle, tree-node rendering, and child generation. + +#### Scenario: Slice file split preserves compilation +- **WHEN** Slice.cs is split into partial-class files +- **THEN** the project compiles with zero errors and all SliceTests pass unchanged diff --git a/openspec/changes/datatree-model-view-separation/tasks.md b/openspec/changes/datatree-model-view-separation/tasks.md new file mode 100644 index 0000000000..0a1086b3d6 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/tasks.md @@ -0,0 +1,156 @@ +## 1. Phase 0: Characterization Tests + +_Full test inventory in:_ +- `specs/datatree-characterization-tests/test-plan-datatree.md` (84 tests) +- `specs/datatree-characterization-tests/test-plan-slice.md` (70 tests) +- `specs/datatree-characterization-tests/test-plan-harness.md` (fixtures + helpers) + +### 1A. Test Infrastructure (Week 1) + +- [ ] 1.1 Add 12 new layouts to `Test.fwlayout` (ManySenses, ManySensesExpanded, EmptySeq, GhostAtomic, IfNotTest, ChoiceTest, OwnerLabel, WeightTest, IfDataMultiString, IfDataStText, NavigationTest, HeaderWithChildren) +- [ ] 1.2 Add corresponding part definitions to `TestParts.xml` for the new layouts +- [ ] 1.3 Add DataTreeTests helpers: `CreateEntryWithSenses(int)`, `ShowObjectAndGetSlices(string, ICmObject)`, `AssertSliceLabels(params string[])`, `AssertSliceCount(int)`, `SimulateShowHidden(string, bool)` +- [ ] 1.4 Add SliceTests helpers: `CreateSliceWithConfig(string)`, `InstallSliceInDataTree(Slice, DataTree)` +- [ ] 1.5 Create `DetailControlsTests/ObjSeqHashMapTests.cs` — 6 standalone data-structure tests (Add_And_Retrieve, Remove, ClearUnwantedPart true/false, DuplicateKeys, MissingKey) + +### 1B. DataTree Navigation Tests (Week 2, 12 tests — zero coverage today) + +- [ ] 1.6 `GotoFirstSlice_SetsCurrentSliceToFirstFocusable` — skips headers +- [ ] 1.7 `GotoNextSlice_AdvancesToNextFocusable` + `GotoNextSlice_AtEnd_DoesNotChange` +- [ ] 1.8 `GotoPreviousSliceBeforeIndex_ReversesNavigation` +- [ ] 1.9 `FocusFirstPossibleSlice_DescendantSpecific` + `FocusFirstPossibleSlice_FallsBackToFirstFocusable` +- [ ] 1.10 `CurrentSlice_Setter_NullThrows` + `CurrentSlice_Setter_FiresChangedEvent` + `CurrentSlice_Setter_SuspendedStoresInNew` +- [ ] 1.11 `DescendantForSlice_RootLevelSlice_ReturnsRoot` + `DescendantForSlice_NestedSlice_ReturnsHeaderObject` +- [ ] 1.12 `MakeSliceRealAt_RealizeDummySlice` + +### 1C. DataTree Reuse & Refresh Tests (Week 3, 6 tests) + +- [ ] 1.13 `CreateSlices_SliceReuse_SameRootRefresh` — verify same .NET object reference +- [ ] 1.14 `CreateSlices_DifferentObject_NoReuse` — old slices disposed +- [ ] 1.15 `MonitoredProps_AccumulatesAcrossRefresh` — document non-clearing behavior + +### 1D. DataTree Messaging Tests (Week 3, 10 tests) + +- [ ] 1.16 `GetMessageTargets_VisibleWithCurrentSlice` / `NotVisible` / `NoCurrentSlice` +- [ ] 1.17 `ShouldNotCall_WhenDisposed_ReturnsTrue` +- [ ] 1.18 `PropChanged_MonitoredProp_TriggersRefresh` + `PropChanged_UnmonitoredProp_NoFullRefresh` +- [ ] 1.19 `PropChanged_UnmonitoredDuringUndo_RootVectorChange_FullRefresh` +- [ ] 1.20 `DeepSuspendResumeLayout_Counting` — nested counting +- [ ] 1.21 `OnPropertyChanged_UnknownProperty_NoOp` +- [ ] 1.22 `PostponePropChanged_DefersRefresh` + +### 1E. DataTree XML Layout Parsing Tests (Week 4, 19 tests) + +- [ ] 1.23 `CreateSlicesFor_NullObject_ReturnsEmptySliceList` +- [ ] 1.24 `GetTemplateForObjLayout_ClassHierarchyWalk` + `GetTemplateForObjLayout_CmCustomItemUsesWsLayout` +- [ ] 1.25 `ProcessSubpartNode_SequenceWithMoreThanThresholdItems` (>20 senses → DummyObjectSlice) +- [ ] 1.26 `ProcessSubpartNode_ThresholdOverride_ExpandedCaller` + `_PersistentExpansion` + `_EmptySequence` +- [ ] 1.27 `AddSimpleNode_IfData_MultiString_EmptyAnalysis` + `_NonEmptyVernacular` +- [ ] 1.28 `AddSimpleNode_IfData_StText_EmptyParagraph` +- [ ] 1.29 `AddSimpleNode_IfData_Summary_SuppressesNode` +- [ ] 1.30 `ProcessSubpartNode_IfNotCondition` + `ProcessSubpartNode_ChoiceWhereOtherwise` +- [ ] 1.31 `AddAtomicNode_GhostSliceCreation` +- [ ] 1.32 `InterpretLabelAttribute_DollarOwnerPrefix` +- [ ] 1.33 `SetNodeWeight_ValidWeights` + `GetFlidIfPossible_CachingBehavior` + +### 1F. DataTree ShowObject & Lifecycle Tests (Week 4, 14 tests) + +- [ ] 1.34 `ShowObject_SameRootAndDescendant_DoesRefreshList` + `ShowObject_DifferentRoot_RecreatesAllSlices` +- [ ] 1.35 `ShowObject_SameRootDifferentDescendant_SetsCurrentSliceNew` +- [ ] 1.36 `ShowObject_NoOp_WhenAllParametersUnchanged` +- [ ] 1.37 `RefreshList_DoNotRefresh_DefersRefresh` + `RefreshList_CurrentSliceSurvivesRefresh` + `RefreshList_InvalidRoot_CallsReset` +- [ ] 1.38 `GetShowHiddenFieldsToolName_LexEntry_FallbackToLexiconEdit` (4 branches) +- [ ] 1.39 `SetCurrentSlicePropertyNames_ConstructsCorrectKeys` +- [ ] 1.40 `Initialize_SetsRequiredFields` + `Dispose_UnsubscribesNotifications` + `Dispose_CurrentSlice_GetsDeactivated` + `Dispose_DoubleDispose_IsNoOp` + `CheckDisposed_AfterDispose_Throws` + +### 1G. DataTree JumpToTool Tests (Week 5, 4 tests) + +- [ ] 1.41 `GetGuidForJumpToTool_ConcordanceTool_LexEntry` + `_LexSense` +- [ ] 1.42 `GetGuidForJumpToTool_LexiconEditTool` + `_ForEnableOnly_DoesNotCreate` + +### 1H. Slice Core & Lifecycle Tests (Week 5, 19 tests) + +- [ ] 1.43 `Abbreviation_AutoGeneratedFromLabel` + `Abbreviation_ExplicitOverridesAutoGeneration` +- [ ] 1.44 `IsSequenceNode_TrueForOwningSequence` + `IsCollectionNode_TrueForNonSequence` + `IsHeaderNode_ReadsXmlAttribute` +- [ ] 1.45 `ContainingDataTree_NullWhenOrphaned` + `ContainingDataTree_ReturnsParent` +- [ ] 1.46 `WrapsAtomic_ReadsConfigAttribute` + `CallerNodeEqual_StructuralComparison` +- [ ] 1.47 `Constructor_SetsVisibleFalse` + `Install_SetsHeightToMaxOfControlAndLabel` + `Install_NoLabel_FixesSplitterAtOnePixel` + `Install_SetsPanelMinSizeFromIndent` +- [ ] 1.48 `CheckDisposed_AfterDispose_Throws_Slice` + `Dispose_NullsAllFields` + `Dispose_UnsubscribesSplitterEvent` +- [ ] 1.49 `OnEnter_DuringConstruction_DoesNotSetCurrentSlice` + `BecomeReal_BaseReturnsSelf` + +### 1I. Slice Menu Command Tests (Week 5, 14 tests) + +- [ ] 1.50 `GetCanDeleteNow_RequiredAtomicField_ReturnsFalse` + `GetCanDeleteNow_VectorWithMultipleItems_ReturnsTrue` + `GetCanDeleteNow_VectorWithOneItem_Required_ReturnsFalse` +- [ ] 1.51 `GetCanMergeNow_SubsensesAlwaysMergeable` + `GetCanMergeNow_AllomorphsSameClass_Mergeable` + `GetCanMergeNow_NeedsTwoSameClassSiblings` +- [ ] 1.52 `GetCanSplitNow_SameClassOwner_ReturnsTrue` + `GetCanSplitNow_VectorLessThanTwo_ReturnsFalse` +- [ ] 1.53 `GetObjectForMenusToOperateOn_WrapsAtomic` + `GetObjectForMenusToOperateOn_VariantBackRef` +- [ ] 1.54 `GetSeqContext_WalksKeyBackward` + `GetSeqContext_GhostSlice_HandledCorrectly` +- [ ] 1.55 `StartsWith_BoxedIntEquality` + `HandleDeleteCommand_SelectsNearbySlice` + +### 1J. Slice Rendering, Child Gen, Field Config, XCore (Week 6, 31 tests) + +- [ ] 1.56 Rendering: `LabelIndent_Calculation` + `Expand_SetsStateToExpanded` + `Expand_PersistsExpansionState` + `Collapse_SetsStateToCollapsed` + `Collapse_PersistsCollapsedState` + `Collapse_RemovesDescendantSlices` + `ExpansionStateKey_NullForFixedSlices` + `IsObjectNode` tests +- [ ] 1.57 Child Gen: `GenerateChildren_NothingResult_SetsFixed` + `_PossibleResult_SetsCollapsedEmpty` + `_PersistentExpansion_RestoresExpanded` + `ExtraIndent` tests +- [ ] 1.58 Field Config: `SetFieldVisibility_PersistsOverride` + `_SameValue_NoOp` + `GetSibling_Up_SkipsChildren` + `_Down_SkipsChildren` + `_AtBoundary_ReturnsNull` +- [ ] 1.59 Field Config: `HelpTopicResolution_FourLevelFallback` + `_CrossRefVsLexicalRelation` + `ReplacePartWithNewAttribute_PropagatesAcrossSlices` +- [ ] 1.60 XCoreColleague: `GetMessageTargets_VisibleWithColleagueControl` + `_Hidden_ReturnsEmpty` + `Init_SetsMediator_InitsColleagueControl` + `SetCurrentState_PropagatesActiveToParents` + `_False_DeactivatesChain` +- [ ] 1.61 Splitter: `SetSplitPosition_UsesBaseAndIndent` + `OnSizeChanged_PreservesScrollPosition` + `SetWidthForDataTreeLayout_MarksFlag` + +### 1K. SliceFactory Extensions (Week 6, 4 tests) + +- [ ] 1.62 `Create_MultistringEditor_ReturnsMultiStringSlice` + `Create_StringEditor_ReturnsStringSlice` +- [ ] 1.63 `Create_UnknownEditor_ReflectionFallback` + `Create_NullEditor_ReturnsBasicSlice` + +### 1L. Final Verification + +- [ ] 1.64 Run `.\test.ps1` and verify all 139 new + ~25 existing tests pass +- [ ] 1.65 Review test coverage against test-plan-datatree.md and test-plan-slice.md — confirm no gaps + +## 2. Phase 1: Partial-Class File Split + +- [ ] 2.1 Split `Src/Common/Controls/DetailControls/DataTree.cs` into `DataTree.cs` (core: data members, constructor, `Initialize`, `Dispose`, core properties) — ~500 lines +- [ ] 2.2 Create `DataTree.SliceManagement.cs` — move slice collection methods (`InsertSlice`, `RemoveSlice`, `RawSetSlice`, `InstallSlice`, `ForceSliceIndex`, `ResetTabIndices`, `InsertSliceRange`, tooltip management) — ~280 lines +- [ ] 2.3 Create `DataTree.LayoutParsing.cs` — move XML layout methods (`CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode`, `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice`, `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight helpers) — ~1,000 lines +- [ ] 2.4 Create `DataTree.WinFormsLayout.cs` — move WinForms layout/paint methods (`OnLayout`, `HandleLayout1`, `MakeSliceRealAt`, `MakeSliceVisible`, `OnPaint`, `HandlePaintLinesBetweenSlices`, `OnSizeChanged`, hit-test, `FieldAt`, `FieldOrDummyAt`, `AboutToCreateField`) — ~400 lines +- [ ] 2.5 Create `DataTree.Navigation.cs` — move focus/navigation methods (`CurrentSlice` property, `DescendantForSlice`, `Goto*` methods, `FocusFirstPossibleSlice`, `SelectFirstPossibleSlice`, `ScrollCurrentAndIfPossibleSectionIntoView`, `SetDefaultCurrentSlice`, `ActiveControl`) — ~250 lines +- [ ] 2.6 Create `DataTree.Messaging.cs` — move `IxCoreColleague` implementation and all `On*` message handlers — ~650 lines +- [ ] 2.7 Create `DataTree.Persistence.cs` — move `ShowObject`, `RefreshList`, `CreateSlices`, show-hidden logic, `PrepareToGoAway`, `PersistPreferences`, `RestorePreferences`, `SetCurrentSlicePropertyNames` — ~350 lines +- [ ] 2.8 Split `Src/Common/Controls/DetailControls/Slice.cs` into partial-class files: `Slice.cs` (core properties), `Slice.Lifecycle.cs` (Install, Dispose, BecomeReal), `Slice.TreeNode.cs` (SliceTreeNode rendering), `Slice.Children.cs` (GenerateChildren, CreateIndentedNodes) +- [ ] 2.9 Verify `.csproj` auto-includes new files (SDK-style), run `.\build.ps1`, run `.\test.ps1` + +## 3. Phase 2: Extract Collaborators + +- [ ] 3.1 Create `SliceLayoutBuilder` class in `Src/Common/Controls/DetailControls/SliceLayoutBuilder.cs` — constructor takes `LcmCache`, `Inventory` (layouts), `Inventory` (parts), `IFwMetaDataCache`, `SliceFilter` +- [ ] 3.2 Move `CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode` from `DataTree.LayoutParsing.cs` to `SliceLayoutBuilder` — replace `this` references with injected dependencies +- [ ] 3.3 Move `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice` to `SliceLayoutBuilder` +- [ ] 3.4 Move `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight resolution methods to `SliceLayoutBuilder` +- [ ] 3.5 Update `DataTree` to hold a `SliceLayoutBuilder` instance and delegate all XML-parsing calls to it +- [ ] 3.6 Add unit tests for `SliceLayoutBuilder` in `DetailControlsTests/SliceLayoutBuilderTests.cs` — test `CreateSlicesFor` with mock inventories, no `Form` required +- [ ] 3.7 Create `ShowHiddenFieldsManager` class in `Src/Common/Controls/DetailControls/ShowHiddenFieldsManager.cs` — extract `GetShowHiddenFieldsToolName`, key-resolution logic, `HandleShowHiddenFields` +- [ ] 3.8 Update `DataTree` to delegate show-hidden logic to `ShowHiddenFieldsManager` +- [ ] 3.9 Add unit tests for `ShowHiddenFieldsManager` — test key resolution for LexEntry, non-LexEntry, missing `currentContentControl` +- [ ] 3.10 Create `DataTreeNavigator` class in `Src/Common/Controls/DetailControls/DataTreeNavigator.cs` — extract `CurrentSlice` state management, `Goto*` methods, `FocusFirstPossibleSlice` +- [ ] 3.11 Update `DataTree` to delegate navigation to `DataTreeNavigator` +- [ ] 3.12 Run `.\build.ps1`, run `.\test.ps1`, verify all characterization tests pass + +## 4. Phase 3: Model/View Separation + +- [ ] 4.1 Define `SliceSpec` data class in `Src/Common/Controls/DetailControls/SliceSpec.cs` — properties: `Label`, `Abbreviation`, `Indent`, `EditorType`, `ConfigNode` (XmlNode), `FieldId`, `Object` (ICmObject), `Visibility`, `Weight`, `Tooltip`, `Key` (object[]) +- [ ] 4.2 Define `IDataTreeView` interface in `Src/Common/Controls/DetailControls/IDataTreeView.cs` — methods: `MaterializeSlices(IReadOnlyList)`, `GetCurrentSliceIndex()`, `SetCurrentSlice(int)`, `Refresh()` +- [ ] 4.3 Create `DataTreeModel` class in `Src/Common/Controls/DetailControls/DataTreeModel.cs` — constructor takes `LcmCache`, `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `PropertyTable` +- [ ] 4.4 Add `DataTreeModel.BuildSliceSpecs(ICmObject root, string layoutName, string layoutChoiceField)` — produces `List` by calling `SliceLayoutBuilder` (modify builder to return specs instead of creating Slice controls) +- [ ] 4.5 Add virtual `DataTreeModel.ResolveRootObject(ICmObject) → ICmObject` for `StTextDataTree` override hook +- [ ] 4.6 Modify `SliceLayoutBuilder` to produce `SliceSpec` list instead of directly calling `SliceFactory.Create` — this is the core boundary change +- [ ] 4.7 Update `DataTree` to implement `IDataTreeView` — receive `SliceSpec[]` from model, call `SliceFactory.Create(spec)` to materialize, manage `ObjSeqHashMap` for reuse +- [ ] 4.8 Update `DataTree.ShowObject` to delegate to `DataTreeModel.BuildSliceSpecs` then `MaterializeSlices` +- [ ] 4.9 Adapt `StTextDataTree` in `Src/LexText/Interlinear/InfoPane.cs` to override model-layer `ResolveRootObject` instead of view-layer `ShowObject` +- [ ] 4.10 Verify `DataTreeModel` has no reference to `System.Windows.Forms` — check `.csproj` references and `using` statements +- [ ] 4.11 Add unit tests for `DataTreeModel.BuildSliceSpecs` — verify spec list matches expected layout, no WinForms infrastructure needed +- [ ] 4.12 Run `.\build.ps1`, run `.\test.ps1`, verify all tests pass +- [ ] 4.13 Manual smoke test: open FieldWorks, navigate Lexicon Edit entry display, verify all slices render correctly, toggle Show Hidden Fields, navigate between entries + +## 5. Documentation + +- [ ] 5.1 Update `Src/Common/Controls/DetailControls/AGENTS.md` — document new classes (`DataTreeModel`, `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `SliceSpec`, `IDataTreeView`) and the model/view pattern +- [ ] 5.2 Update `openspec/specs/architecture/ui-framework/winforms-patterns.md` — document DataTree model/view composition pattern +- [ ] 5.3 Update `openspec/specs/architecture/layers/layer-model.md` — add detail-tree model sublayer From 653ba6f3bfa928ad3592565ca1343927b08a8b89 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 2/6] Add DataTree/Slice characterization tests and pre-refactor red tests --- .../DetailControlsTests/DataTreeTests.cs | 520 ++++++++++++++++++ .../DetailControlsTests/ObjSeqHashMapTests.cs | 248 +++++++++ .../DetailControlsTests/SliceTests.cs | 343 ++++++++++++ .../DetailControlsTests/Test.fwlayout | 22 + .../changes-from-test-before-refactor/spec.md | 110 ++++ 5 files changed, 1243 insertions(+) create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs index ac83210b6d..0382658e96 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Reflection; using System.Windows.Forms; using System.Xml; using NUnit.Framework; @@ -355,5 +357,523 @@ public void OwnedObjects() Assert.That((m_dtree.Controls[3] as Slice).Label, Is.EqualTo("Form")); Assert.That((m_dtree.Controls[4] as Slice).Label, Is.EqualTo("Source Language Notes")); } + + #region Characterization Tests — ShowObject & Show-Hidden Fields + + /// + /// Calling ShowObject with identical parameters to the previous call is a no-op. + /// + [Test] + public void ShowObject_IdenticalParameters_IsNoOp() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + int sliceCount = m_dtree.Slices.Count; + var firstSlice = m_dtree.Slices[0]; + + // Call again with identical parameters. + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + Assert.That(m_dtree.Slices.Count, Is.EqualTo(sliceCount), "Slice count should not change on identical ShowObject call"); + Assert.That(m_dtree.Slices[0], Is.SameAs(firstSlice), "Slice instances should be unchanged"); + } + + /// + /// ShowObject with a different root object disposes old slices and creates new ones. + /// + [Test] + public void ShowObject_DifferentRoot_RecreatesAllSlices() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1)); + var oldSlice = m_dtree.Slices[0]; + + // Create a different root object. + var entry2 = Cache.ServiceLocator.GetInstance().Create(); + entry2.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("other", Cache.DefaultVernWs); + + m_dtree.ShowObject(entry2, "CfOnly", null, entry2, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1)); + Assert.That(m_dtree.Slices[0], Is.Not.SameAs(oldSlice), "Old slice should have been replaced"); + Assert.That(m_dtree.Root, Is.EqualTo(entry2)); + } + + /// + /// Same root, same layout → RefreshList path. Slices should be reused. + /// + [Test] + public void ShowObject_SameRootSameLayout_RefreshesAndReusesSlices() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + var sliceBefore = m_dtree.Slices[0]; + + // Force a different descendant to trigger the else-if branch (same root, different descendant). + ILexSense sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + // Same root and layout means RefreshList(false) was called. The first slice should survive. + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2)); + Assert.That(m_dtree.Slices[0], Is.SameAs(sliceBefore), "RefreshList should reuse matching slices"); + } + + /// + /// Show-hidden-fields ON reveals visibility="ifdata" slices even when the field is empty. + /// + [Test] + public void ShowObject_ShowHiddenEnabled_RevealsIfDataEmpty() + { + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(""); + m_entry.Bibliography.SetVernacularDefaultWritingSystem(""); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + // Set show-hidden for lexiconEdit tool. + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, + PropertyTable.SettingsGroup.LocalSettings, true); + m_propertyTable.SetDefault("ShowHiddenFields", true, + PropertyTable.SettingsGroup.LocalSettings, false); + + m_dtree.ShowObject(m_entry, "ShowHiddenTest", null, m_entry, false); + + // With show-hidden ON: CitationForm + Bibliography (empty but revealed) + NeverField (revealed). + Assert.That(m_dtree.Slices.Count, Is.EqualTo(3), + "Show-hidden should reveal ifdata-empty and visibility=never slices"); + } + + /// + /// Show-hidden-fields OFF hides visibility="ifdata" slices when the field is empty. + /// + [Test] + public void ShowObject_ShowHiddenDisabled_HidesIfDataEmpty() + { + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(""); + m_entry.Bibliography.SetVernacularDefaultWritingSystem(""); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", false, + PropertyTable.SettingsGroup.LocalSettings, true); + + m_dtree.ShowObject(m_entry, "ShowHiddenTest", null, m_entry, false); + + // With show-hidden OFF: only CitationForm (Bibliography empty, NeverField hidden). + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1), + "With show-hidden off, ifdata-empty and visibility=never should be hidden"); + Assert.That(m_dtree.Slices[0].Label, Is.EqualTo("CitationForm")); + } + + /// + /// Show-hidden-fields ON reveals visibility="never" slices. + /// + [Test] + public void ShowObject_ShowHiddenEnabled_RevealsNeverVisibility() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, + PropertyTable.SettingsGroup.LocalSettings, true); + m_propertyTable.SetDefault("ShowHiddenFields", true, + PropertyTable.SettingsGroup.LocalSettings, false); + + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + // CfOnly has CitationForm + Bibliography(visibility="never"). + // With show-hidden ON, Bibliography should appear. + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2), + "Show-hidden should reveal visibility=never slices"); + } + + /// + /// Show-hidden OFF hides visibility="never" slices. + /// + [Test] + public void ShowObject_ShowHiddenDisabled_HidesNeverVisibility() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", false, + PropertyTable.SettingsGroup.LocalSettings, true); + + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + // CfOnly has CitationForm + Bibliography(visibility="never"). + // With show-hidden OFF, only CitationForm should appear. + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1)); + Assert.That(m_dtree.Slices[0].Label, Is.EqualTo("CitationForm")); + } + + /// + /// The tool-specific ShowHiddenFields property is isolated per tool. + /// Setting it for one tool does not affect another. + /// + [Test] + public void ShowObject_ShowHiddenForDifferentTool_DoesNotAffect() + { + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(""); + m_entry.Bibliography.SetVernacularDefaultWritingSystem(""); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + // Enable show-hidden for a DIFFERENT tool. + m_propertyTable.SetProperty("ShowHiddenFields-notebookEdit", true, + PropertyTable.SettingsGroup.LocalSettings, true); + // lexiconEdit (default for LexEntry) is not set. + + m_dtree.ShowObject(m_entry, "ShowHiddenTest", null, m_entry, false); + + // lexiconEdit show-hidden is off, so ifdata-empty and never should be hidden. + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1)); + } + + /// + /// Toggling ShowHiddenFields via OnPropertyChanged causes refresh. + /// + [Test] + public void OnPropertyChanged_ShowHiddenFields_TogglesVisibility() + { + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(""); + m_entry.Bibliography.SetVernacularDefaultWritingSystem(""); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_propertyTable.SetProperty("currentContentControl", "lexiconEdit", true); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", false, + PropertyTable.SettingsGroup.LocalSettings, true); + + m_dtree.ShowObject(m_entry, "ShowHiddenTest", null, m_entry, false); + int countBefore = m_dtree.Slices.Count; + Assert.That(countBefore, Is.EqualTo(1), "Initially only non-hidden slices"); + + // Toggle show-hidden ON. + m_dtree.OnPropertyChanged("ShowHiddenFields"); + + Assert.That(m_dtree.Slices.Count, Is.GreaterThan(countBefore), + "Toggling show-hidden should reveal more slices"); + } + + #endregion + + #region Characterization Tests — Slice Reuse & Refresh + + /// + /// RefreshList(false) reuses existing slice instances when the object and layout haven't changed. + /// + [Test] + public void RefreshList_SameObject_ReusesSlices() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2)); + var slice0 = m_dtree.Slices[0]; + var slice1 = m_dtree.Slices[1]; + + m_dtree.RefreshList(false); + + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2)); + Assert.That(m_dtree.Slices[0], Is.SameAs(slice0), "First slice should be reused"); + Assert.That(m_dtree.Slices[1], Is.SameAs(slice1), "Second slice should be reused"); + } + + /// + /// RefreshList(true) (different object) does not reuse slices by strict key match. + /// + [Test] + public void RefreshList_DifferentObject_DoesNotReuseByKey() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + var oldSlice0 = m_dtree.Slices[0]; + + m_dtree.RefreshList(true); + + // After differentObject=true, slices may have been recreated. + // The important thing is the tree still has the right content. + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2)); + } + + /// + /// DoNotRefresh=true suppresses RefreshList; setting it back to false triggers deferred refresh. + /// + [Test] + public void DoNotRefresh_SuppressesThenTriggersRefresh() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(2)); + + m_dtree.DoNotRefresh = true; + + // Request a refresh while suppressed. + m_dtree.RefreshList(false); + Assert.That(m_dtree.RefreshListNeeded, Is.True, "Refresh should be deferred"); + + // Unsuppress — deferred refresh should fire. + m_dtree.DoNotRefresh = false; + Assert.That(m_dtree.RefreshListNeeded, Is.False, "Deferred refresh should have cleared the flag"); + } + + /// + /// RefreshList with an invalid (deleted) root object calls Reset and produces zero slices. + /// + [Test] + public void RefreshList_InvalidRoot_ProducesZeroSlices() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(1)); + + // Delete the root object (we are already inside a UOW from the base class). + m_entry.Delete(); + + m_dtree.RefreshList(true); + + Assert.That(m_dtree.Slices.Count, Is.EqualTo(0), + "After deleting root, RefreshList should produce zero slices"); + } + + [Test] + public void MonitoredProps_AccumulatesAcrossRefresh_CurrentBehavior() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + int hvo = m_entry.Hvo; + int flid = (int)LexEntryTags.kflidCitationForm; + m_dtree.MonitorProp(hvo, flid); + int countBefore = GetMonitoredPropsCount(); + + m_dtree.RefreshList(false); + int countAfter = GetMonitoredPropsCount(); + + Assert.That(countAfter, Is.GreaterThanOrEqualTo(countBefore), + "Current behavior: monitored props are retained across RefreshList"); + } + + [Test] + [Explicit("Expected to fail until m_monitoredProps is cleared on refresh.")] + public void MonitoredProps_ClearedOnRefresh_ExpectedAfterFix() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + m_dtree.MonitorProp(m_entry.Hvo, (int)LexEntryTags.kflidCitationForm); + Assert.That(GetMonitoredPropsCount(), Is.GreaterThan(0), + "Sanity check: at least one monitored prop should be present before refresh"); + + m_dtree.RefreshList(false); + + Assert.That(GetMonitoredPropsCount(), Is.EqualTo(0), + "Expected future behavior: refresh clears stale monitored props"); + } + + #endregion + + #region Characterization Tests — Navigation + + /// + /// NavigationTest layout produces 5 slices for navigation testing. + /// + [Test] + public void NavigationTest_ProducesExpectedSlices() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.EqualTo(5)); + Assert.That(m_dtree.Slices[0].Label, Is.EqualTo("CitationForm")); + Assert.That(m_dtree.Slices[1].Label, Is.EqualTo("Bibliography")); + Assert.That(m_dtree.Slices[2].Label, Is.EqualTo("CF2")); + Assert.That(m_dtree.Slices[3].Label, Is.EqualTo("Bib2")); + Assert.That(m_dtree.Slices[4].Label, Is.EqualTo("CF3")); + } + + #endregion + + #region Characterization Tests — ManySenses / DummyObjectSlice + + /// + /// Helper: creates an entry with the given number of senses. + /// + private ILexEntry CreateEntryWithSenses(int count) + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("sensetest", Cache.DefaultVernWs); + for (int i = 0; i < count; i++) + { + var sense = Cache.ServiceLocator.GetInstance().Create(); + entry.SensesOS.Add(sense); + sense.Gloss.AnalysisDefaultWritingSystem = + TsStringUtils.MakeString("gloss" + i, Cache.DefaultAnalWs); + } + return entry; + } + + /// + /// Sequences with more than kInstantSliceMax (20) items may use DummyObjectSlice placeholders. + /// + [Test] + public void ManySenses_LargeSequence_CreatesSomeSlices() + { + var entry = CreateEntryWithSenses(25); + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + + m_parent.Close(); + m_parent.Dispose(); + m_mediator.Dispose(); + m_mediator = new Mediator(); + m_propertyTable.Dispose(); + m_propertyTable = new PropertyTable(m_mediator); + m_dtree = new DataTree(); + m_dtree.Init(m_mediator, m_propertyTable, null); + m_parent = new Form(); + m_parent.Controls.Add(m_dtree); + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(entry, "ManySenses", null, entry, false); + + // With 25 senses, we expect slices to be created. + // kInstantSliceMax is 20, so some may be DummyObjectSlice (not real). + Assert.That(m_dtree.Slices.Count, Is.GreaterThan(0), + "ManySenses layout should create slices for the 25 senses"); + + // Characterize: count real vs dummy slices. + int dummyCount = 0; + int realCount = 0; + foreach (var slice in m_dtree.Slices) + { + if (slice.IsRealSlice) + realCount++; + else + dummyCount++; + } + // Document actual behavior: with the test harness, ALL slices are dummies. + // This is expected since 25 > kInstantSliceMax (20). + Assert.That(m_dtree.Slices.Count, Is.GreaterThan(0), + "With 25 senses, DataTree should create some slices (real or dummy)"); + } + + /// + /// FieldAt expands a dummy slice to a real one. + /// + [Test] + public void FieldAt_ExpandsDummyToReal() + { + var entry = CreateEntryWithSenses(25); + + m_parent.Close(); + m_parent.Dispose(); + m_mediator.Dispose(); + m_mediator = new Mediator(); + m_propertyTable.Dispose(); + m_propertyTable = new PropertyTable(m_mediator); + m_dtree = new DataTree(); + m_dtree.Init(m_mediator, m_propertyTable, null); + m_parent = new Form(); + m_parent.Controls.Add(m_dtree); + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(entry, "ManySenses", null, entry, false); + + // Find a non-real slice, if any. + int dummyIndex = -1; + for (int i = 0; i < m_dtree.Slices.Count; i++) + { + if (!m_dtree.Slices[i].IsRealSlice) + { + dummyIndex = i; + break; + } + } + + if (dummyIndex >= 0) + { + // FieldAt should expand it. + var realSlice = m_dtree.FieldAt(dummyIndex); + Assert.That(realSlice, Is.Not.Null); + Assert.That(realSlice.IsRealSlice, Is.True, + "FieldAt should expand a DummyObjectSlice to a real slice"); + } + else + { + // All slices are real — document this behavior. + Assert.Pass("All 25 sense slices were created as real slices (no dummies used)."); + } + } + + #endregion + + #region Characterization Tests — SetCurrentSlicePropertyNames + + /// + /// Verify the property-table keys follow the expected pattern. + /// + [Test] + public void SetCurrentSlicePropertyNames_ConstructsCorrectKeys() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_propertyTable.SetProperty("areaChoice", "lexicon", true); + m_propertyTable.SetProperty("currentContentControl", "lexiconEdit", true); + + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + // Verify the property keys are constructed. + // The keys should follow the pattern: {area}${tool}$CurrentSlicePartName + // We verify indirectly by checking the properties exist. + string partNameKey = "lexicon$lexiconEdit$CurrentSlicePartName"; + string objGuidKey = "lexicon$lexiconEdit$CurrentSliceObjectGuid"; + + // These should have been set (possibly to null/empty) during ShowObject. + Assert.That(m_propertyTable.PropertyExists(partNameKey, PropertyTable.SettingsGroup.LocalSettings), + Is.True, "CurrentSlicePartName property should exist in PropertyTable"); + Assert.That(m_propertyTable.PropertyExists(objGuidKey, PropertyTable.SettingsGroup.LocalSettings), + Is.True, "CurrentSliceObjectGuid property should exist in PropertyTable"); + } + + #endregion + + #region Characterization Tests — Nested/Expanded Layout + + /// + /// Nested expanded layout creates header plus children with correct indent levels. + /// + [Test] + public void NestedExpandedLayout_HeaderPlusChildren() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "Nested-Expanded", null, m_entry, false); + + Assert.That(m_dtree.Slices.Count, Is.EqualTo(3)); + Assert.That(m_dtree.Slices[0].Label, Is.EqualTo("Header")); + Assert.That(m_dtree.Slices[1].Label, Is.EqualTo("Citation form")); + Assert.That(m_dtree.Slices[2].Label, Is.EqualTo("Bibliography")); + + // Document the indent levels for characterization. + // Note: indent is currently 0 for nested children (documented in existing test). + Assert.That(m_dtree.Slices[0].Indent, Is.EqualTo(0), "Header at root indent"); + } + + #endregion + + #region Helper Methods + + /// + /// Helper: shows object and returns the slice list. + /// + private List ShowObjectAndGetSlices(string layoutName, ICmObject obj = null) + { + obj = obj ?? m_entry; + m_dtree.ShowObject(obj, layoutName, null, obj, false); + return m_dtree.Slices.ToList(); + } + + private int GetMonitoredPropsCount() + { + var field = typeof(DataTree).GetField("m_monitoredProps", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_monitoredProps field"); + var value = field.GetValue(m_dtree); + Assert.That(value, Is.Not.Null, "m_monitoredProps should be non-null"); + var countProperty = value.GetType().GetProperty("Count"); + Assert.That(countProperty, Is.Not.Null, "m_monitoredProps should expose Count"); + return (int)countProperty.GetValue(value, null); + } + + #endregion } } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs new file mode 100644 index 0000000000..adc1c6b0ff --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) 2025 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; +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + [TestFixture] + public class ObjSeqHashMapTests + { + private List m_slices; + + [SetUp] + public void SetUp() + { + m_slices = new List(); + } + + [TearDown] + public void TearDown() + { + foreach (var slice in m_slices) + slice.Dispose(); + m_slices.Clear(); + } + + private Slice MakeSlice(params object[] key) + { + var slice = new Slice(); + slice.Key = key; + m_slices.Add(slice); + return slice; + } + + [Test] + public void Add_And_Retrieve() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 1, 2, 3 }; + var slice = MakeSlice(1, 2, 3); + + map.Add(key, slice); + + var result = map[key]; + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result[0], Is.SameAs(slice)); + } + + [Test] + public void Remove_ReturnsCorrectSlice() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 10, 20 }; + var slice = MakeSlice(10, 20); + + map.Add(key, slice); + map.Remove(key, slice); + + var result = map[key]; + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void ClearUnwantedPart_DifferentObjectTrue_ClearsTable() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 5 }; + var slice = MakeSlice(5); + + map.Add(key, slice); + map.ClearUnwantedPart(true); + + var result = map[key]; + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void ClearUnwantedPart_DifferentObjectFalse_ClearsReuse() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 5 }; + var slice = MakeSlice(5); + + map.Add(key, slice); + map.ClearUnwantedPart(false); + + var reused = map.GetSliceToReuse(nameof(Slice)); + Assert.That(reused, Is.Null); + } + + [Test] + public void DuplicateKeys_FIFO() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 42 }; + var slice1 = MakeSlice(42); + var slice2 = MakeSlice(42); + var slice3 = MakeSlice(42); + + map.Add(key, slice1); + map.Add(key, slice2); + map.Add(key, slice3); + + var result = map[key]; + Assert.That(result.Count, Is.EqualTo(3)); + Assert.That(result[0], Is.SameAs(slice1)); + Assert.That(result[1], Is.SameAs(slice2)); + Assert.That(result[2], Is.SameAs(slice3)); + } + + [Test] + public void MissingKey_ReturnsEmptyList() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 99, 100 }; + + var result = map[key]; + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void GetSliceToReuse_ReturnsAndRemoves() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 1 }; + var slice1 = MakeSlice(1); + var slice2 = MakeSlice(1); + + map.Add(key, slice1); + map.Add(key, slice2); + + var reused = map.GetSliceToReuse(nameof(Slice)); + Assert.That(reused, Is.SameAs(slice1)); + + // After retrieving, the slice should be removed from both the key list and reuse list + var remaining = map[key]; + Assert.That(remaining.Count, Is.EqualTo(1)); + Assert.That(remaining[0], Is.SameAs(slice2)); + } + + [Test] + public void GetSliceToReuse_MissingType_ReturnsNull() + { + var map = new ObjSeqHashMap(); + + var result = map.GetSliceToReuse("NonExistentSliceType"); + Assert.That(result, Is.Null); + } + + [Test] + public void Values_ReturnsAllSlices() + { + var map = new ObjSeqHashMap(); + var key1 = new ArrayList { 1 }; + var key2 = new ArrayList { 2 }; + var slice1 = MakeSlice(1); + var slice2 = MakeSlice(2); + var slice3 = MakeSlice(1); + + map.Add(key1, slice1); + map.Add(key2, slice2); + map.Add(key1, slice3); + + var values = map.Values.ToList(); + // Values iterates both m_table and m_slicesToReuse, so each slice appears twice + Assert.That(values, Has.Member(slice1)); + Assert.That(values, Has.Member(slice2)); + Assert.That(values, Has.Member(slice3)); + } + + [Test] + public void Values_DuplicatesSliceAfterReuse_CurrentBehavior() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 7 }; + var slice = MakeSlice(7); + + map.Add(key, slice); + + var values = map.Values.ToList(); + Assert.That(values.Count(v => ReferenceEquals(v, slice)), Is.GreaterThan(1), + "Current behavior: Values can include duplicate references from table + reuse map"); + } + + [Test] + [Explicit("Expected to fail until ObjSeqHashMap.Values is deduplicated.")] + public void Values_AreDeduplicated_ExpectedAfterFix() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 8 }; + var slice = MakeSlice(8); + + map.Add(key, slice); + + var values = map.Values.ToList(); + Assert.That(values.Count(v => ReferenceEquals(v, slice)), Is.EqualTo(1), + "Expected future behavior: each slice appears once in Values"); + } + } + + [TestFixture] + public class ListHashCodeProviderTests + { + [Test] + public void ListHashCodeProvider_BoxedIntEquality() + { + IEqualityComparer comparer = new ListHashCodeProvider(); + var list1 = new ArrayList { 1, 2, 3 }; + var list2 = new ArrayList { 1, 2, 3 }; + + Assert.That(comparer.Equals(list1, list2), Is.True); + Assert.That(comparer.GetHashCode(list1), Is.EqualTo(comparer.GetHashCode(list2))); + } + + [Test] + public void ListHashCodeProvider_DifferentLengths_NotEqual() + { + IEqualityComparer comparer = new ListHashCodeProvider(); + var list1 = new ArrayList { 1, 2 }; + var list2 = new ArrayList { 1, 2, 3 }; + + Assert.That(comparer.Equals(list1, list2), Is.False); + } + + [Test] + public void ListHashCodeProvider_XmlNodeEquality() + { + IEqualityComparer comparer = new ListHashCodeProvider(); + var doc = new XmlDocument(); + var node = doc.CreateElement("test"); + + // Same reference → equal + var list1 = new ArrayList { node }; + var list2 = new ArrayList { node }; + Assert.That(comparer.Equals(list1, list2), Is.True); + + // Different node instances with same content → not equal (reference equality) + var node2 = doc.CreateElement("test"); + var list3 = new ArrayList { node2 }; + Assert.That(comparer.Equals(list1, list3), Is.False); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs index 805cca6c4a..de53922ff6 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) // // Original author: MarkS 2010-08-03 SliceTests.cs +using System; using System.Collections; using System.Windows.Forms; using System.Xml; @@ -195,5 +196,347 @@ public void CreateGhostStringSlice_ParentSliceNotNull() Assert.That(ghostSlice, Is.Not.Null); Assert.That(m_Slice.PropTable, Is.EqualTo(ghostSlice.PropTable)); } + + #region Characterization Tests — Core Properties + + /// + /// Abbreviation auto-generates from Label (first 4 chars) when not explicitly set. + /// + [Test] + public void Abbreviation_AutoGeneratedFromLabel() + { + using (var slice = new Slice()) + { + slice.Label = "Citation Form"; + // Setting Label also auto-sets Abbreviation via the setter logic. + // But Abbreviation is set via its own property — verify when set to null/empty. + slice.Abbreviation = null; + Assert.That(slice.Abbreviation, Is.EqualTo("Cita"), + "Abbreviation should auto-generate to first 4 chars of label"); + } + } + + /// + /// Label shorter than 4 chars → abbreviation is the full label. + /// + [Test] + public void Abbreviation_ShortLabel_UsesFullLabel() + { + using (var slice = new Slice()) + { + slice.Label = "Go"; + slice.Abbreviation = null; + Assert.That(slice.Abbreviation, Is.EqualTo("Go"), + "Abbreviation for short label should be the full label"); + } + } + + /// + /// Explicit abbreviation overrides auto-generation. + /// + [Test] + public void Abbreviation_ExplicitOverridesAutoGeneration() + { + using (var slice = new Slice()) + { + slice.Label = "Citation Form"; + slice.Abbreviation = "CF"; + Assert.That(slice.Abbreviation, Is.EqualTo("CF"), + "Explicit abbreviation should override auto-generation"); + } + } + + /// + /// IsHeaderNode reads the header XML attribute. + /// + [Test] + public void IsHeaderNode_ReadsXmlAttribute() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.IsHeaderNode, Is.True); + } + } + + /// + /// IsHeaderNode is false when header attribute is absent. + /// + [Test] + public void IsHeaderNode_FalseWhenAbsent() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.IsHeaderNode, Is.False); + } + } + + /// + /// ContainingDataTree returns null when the slice is not parented. + /// + [Test] + public void ContainingDataTree_NullWhenOrphaned() + { + using (var slice = new Slice()) + { + Assert.That(slice.ContainingDataTree, Is.Null, + "Orphaned slice should have null ContainingDataTree"); + } + } + + /// + /// ContainingDataTree returns the parent DataTree after Install. + /// + [Test] + public void ContainingDataTree_ReturnsParent() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + Assert.That(m_Slice.ContainingDataTree, Is.SameAs(m_DataTree), + "Installed slice should return its parent DataTree"); + } + + /// + /// WrapsAtomic reads the wrapsAtomic XML attribute. + /// + [Test] + public void WrapsAtomic_ReadsConfigAttribute() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.WrapsAtomic, Is.True); + } + } + + /// + /// WrapsAtomic defaults to false. + /// + [Test] + public void WrapsAtomic_DefaultsFalse() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.WrapsAtomic, Is.False); + } + } + + /// + /// IsRealSlice returns true for regular slices. + /// + [Test] + public void IsRealSlice_TrueForRegularSlice() + { + using (var slice = new Slice()) + { + Assert.That(slice.IsRealSlice, Is.True, + "Regular Slice should be real"); + } + } + + /// + /// BecomeReal on a non-dummy slice returns itself. + /// + [Test] + public void BecomeReal_BaseReturnsSelf() + { + using (var slice = new Slice()) + { + var result = slice.BecomeReal(0); + Assert.That(result, Is.SameAs(slice), + "BecomeReal on a real slice should return itself"); + } + } + + /// + /// CallerNodeEqual compares OuterXml of two nodes. + /// + [Test] + public void CallerNodeEqual_StructuralComparison() + { + using (var slice = new Slice()) + { + // Set CallerNode. + var node1 = CreateXmlElementFromOuterXmlOf(""); + slice.CallerNode = node1; + + // Create structurally identical but different .NET reference. + var node2 = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(slice.CallerNodeEqual(node2), Is.True, + "CallerNodeEqual should compare by OuterXml"); + + var node3 = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.CallerNodeEqual(node3), Is.False, + "Different XML content should not be equal"); + } + } + + #endregion + + #region Characterization Tests — Lifecycle + + /// + /// Constructor sets Visible to false. + /// + [Test] + public void Constructor_SetsVisibleFalse() + { + using (var slice = new Slice()) + { + Assert.That(slice.Visible, Is.False, + "New slices should start invisible"); + } + } + + /// + /// After Dispose, CheckDisposed throws ObjectDisposedException. + /// + [Test] + public void CheckDisposed_AfterDispose_Throws() + { + var slice = new Slice(); + slice.Dispose(); + + Assert.Throws(() => slice.CheckDisposed()); + } + + #endregion + + #region Characterization Tests — Expansion + + /// + /// Default expansion state is Fixed. + /// + [Test] + public void Expansion_DefaultIsFixed() + { + using (var slice = new Slice()) + { + Assert.That(slice.Expansion, Is.EqualTo(DataTree.TreeItemState.ktisFixed), + "Default expansion should be Fixed"); + } + } + + /// + /// ExpansionStateKey is null for Fixed slices. + /// + [Test] + public void ExpansionStateKey_NullForFixedSlices() + { + using (var slice = new Slice()) + { + // Default expansion is Fixed. + Assert.That(slice.ExpansionStateKey, Is.Null, + "Fixed slices should have null ExpansionStateKey"); + } + } + + /// + /// ExpansionStateKey is non-null when expansion is Expanded and object is set. + /// + [Test] + public void ExpansionStateKey_NonNullForExpandedWithObject() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + var obj = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.Object = obj; + m_Slice.Expansion = DataTree.TreeItemState.ktisExpanded; + + Assert.That(m_Slice.ExpansionStateKey, Is.Not.Null, + "Expanded slice with an object should have a non-null ExpansionStateKey"); + Assert.That(m_Slice.ExpansionStateKey, Does.StartWith("expand"), + "ExpansionStateKey should start with 'expand'"); + } + + #endregion + + #region Characterization Tests — Static Utilities + + /// + /// StartsWith correctly handles boxed int equality. + /// + [Test] + public void StartsWith_BoxedIntEquality() + { + // Two arrays with boxed ints that have the same value. + var target = new object[] { 1, 2, 3, "extra" }; + var match = new object[] { 1, 2, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.True, + "StartsWith should handle boxed int equality"); + } + + /// + /// StartsWith returns false when match is longer than target. + /// + [Test] + public void StartsWith_MatchLongerThanTarget_ReturnsFalse() + { + var target = new object[] { 1, 2 }; + var match = new object[] { 1, 2, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.False); + } + + /// + /// StartsWith returns false for mismatched elements. + /// + [Test] + public void StartsWith_MismatchedElements_ReturnsFalse() + { + var target = new object[] { 1, 2, 3 }; + var match = new object[] { 1, 99, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.False); + } + + /// + /// ExtraIndent returns 1 when indent="true". + /// + [Test] + public void ExtraIndent_TrueAttribute_ReturnsOne() + { + var node = CreateXmlElementFromOuterXmlOf(""); + Assert.That(Slice.ExtraIndent(node), Is.EqualTo(1)); + } + + /// + /// ExtraIndent returns 0 when indent attribute is absent. + /// + [Test] + public void ExtraIndent_NoAttribute_ReturnsZero() + { + var node = CreateXmlElementFromOuterXmlOf(""); + Assert.That(Slice.ExtraIndent(node), Is.EqualTo(0)); + } + + #endregion + + #region Characterization Tests — Weight + + /// + /// Weight property can be set and retrieved. + /// + [Test] + public void Weight_SetAndGet() + { + using (var slice = new Slice()) + { + slice.Weight = ObjectWeight.heavy; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.heavy)); + + slice.Weight = ObjectWeight.light; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.light)); + + slice.Weight = ObjectWeight.field; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.field)); + } + } + + #endregion } } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout b/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout index 3684e911d3..85765d2e4e 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/Test.fwlayout @@ -46,4 +46,26 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md new file mode 100644 index 0000000000..2e3a29d368 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md @@ -0,0 +1,110 @@ +# Changes From Test Before Refactor + +## Purpose + +During Phase 0 (characterization testing), several code-level issues were discovered in `DataTree.cs`, `Slice.cs`, `ObjSeqHashMap.cs`, and the test infrastructure that should be addressed **before** the Phase 1 partial-class split begins. Fixing these first reduces noise during the structural refactor and prevents propagating known defects into the new architecture. + +## Issues Discovered + +### 1. `m_monitoredProps` Never Clears (DataTree.cs) + +**Location**: `DataTree.MonitorProp()` (~line 560) and `DataTree.RefreshList()` (~line 1413) + +**Problem**: `m_monitoredProps` is a `HashSet>` that accumulates entries across successive `ShowObject` / `RefreshList` calls. It is never cleared, even when the root object changes. Over a long editing session, this can cause unnecessary refresh triggers from stale monitored properties that no longer correspond to visible slices. + +**Suggested Fix**: Clear `m_monitoredProps` at the start of `CreateSlices()` or `RefreshList()` when building a new slice tree. The characterization test `MonitoredProps_AccumulatesAcrossRefresh` (when written) should verify this new behavior. + +**Risk**: Low — monitored props are used only for deciding whether `PropChanged` triggers a full refresh. Clearing when rebuilding the slice tree aligns intent with implementation. + +### 2. `GetFlidIfPossible` Static Cache Collision Risk (DataTree.cs) + +**Location**: `DataTree.GetFlidIfPossible()` (~line 3050) + +**Problem**: Uses a `static Dictionary` cache keyed by field name. If two different classes have fields with the same name but different flids, the first call wins and subsequent calls return the wrong flid. This is a latent bug that could produce incorrect slice generation. + +**Suggested Fix**: Change the cache key to include the class ID: `$"{classId}:{fieldName}"`. Add a characterization test that verifies correct flid resolution for same-named fields on different classes. + +**Risk**: Very low — adding class ID to the key is strictly more correct. + +### 3. `ObjSeqHashMap.Values` May Double-Count Slices (ObjSeqHashMap.cs) + +**Location**: `ObjSeqHashMap.Values` property + +**Problem**: The `Values` property iterates all lists inside `m_table` and all values in `m_slicesToReuse`, but a slice can exist in both collections simultaneously (added to `m_table` during `Setup`, then also moved to `m_slicesToReuse` during `GetSliceToReuse`). This could return duplicate references. + +**Suggested Fix**: Return a deduplicated set (e.g., `HashSet`) or document the intentional duplication. Add a test that verifies whether Values contains duplicates after typical Setup/GetSliceToReuse usage. + +**Risk**: Low — `Values` is used only during refresh cleanup to dispose leftover slices. Disposing twice is handled, but the double iteration is wasteful. + +### 4. `SelectAt(99999)` Magic Number in Navigation (DataTree.cs) + +**Location**: `DataTree.FocusFirstPossibleSlice()` and related navigation methods + +**Problem**: Uses the literal `99999` as a "select all text" constant when calling `SelectAt()`. This is fragile and undocumented. + +**Suggested Fix**: Extract to a named constant: `private const int SelectAllText = 99999;` (or ideally use `int.MaxValue` if the downstream API supports it). This is a mechanical cleanup with no behavioral change. + +**Risk**: None — rename only. + +### 5. `PropChanged` Uses `BeginInvoke` Without Message Pump (DataTree.cs) + +**Location**: `DataTree.PropChanged()` (~line 578) + +**Problem**: `PropChanged` calls `BeginInvoke()` to defer `PostponedPropChanged()`, which requires a Windows message pump. In NUnit tests (which run without a message pump), `BeginInvoke` callbacks never execute, making it impossible to test the full `PropChanged → RefreshList` chain in unit tests. + +**Impact on Testing**: This is a fundamental testability barrier. The characterization test for `PropChanged` can only verify that `m_fOutOfDate` is set (via `DoNotRefresh`), but cannot verify that `RefreshList` is actually called. + +**Suggested Fix (Pre-Refactor)**: No code change needed yet — this is a design constraint to document. During Phase 2 (Extract Collaborators), the model layer should use a synchronous notification pattern (e.g., `Action` callback or event) instead of `BeginInvoke`, enabling full unit test coverage. + +**Risk**: N/A — documentation only for now. + +### 6. UOW Nesting in Test Harness + +**Location**: `DataTreeTests.cs` test setup and tests that modify data + +**Problem**: The test base class `MemoryOnlyBackendProviderRestoredForEachTestTestBase` already provides an active UOW (unit of work). Tests that call `NonUndoableUnitOfWorkHelper.Do()` fail with `InvalidOperationException: Nested tasks are not supported`. Not all tests need to modify data, but those that do (e.g., deleting an object for RefreshList testing) must work within the existing UOW. + +**Suggested Fix**: Use `Cache.ActionHandlerAccessor` directly (objects can be deleted/modified within the existing UOW without wrapping in `NonUndoableUnitOfWorkHelper`). Document this pattern for future test authors. + +**Risk**: None — the fix is already applied in the current test code. + +### 7. `ManySenses` Layout Resolution in Test Harness + +**Location**: `Test.fwlayout` / `TestParts.xml` and `DataTreeTests.ManySenses_LargeSequence_CreatesSomeSlices` + +**Problem**: The `ManySenses` layout uses `` with a `seq` element that expands to child items. In the test harness, all 25 senses produce slices, but none are "real" (`IsRealSlice` returns false for all). This suggests that when the child count exceeds `kInstantSliceMax` (20), the DataTree wraps entire sequences in DummyObjectSlice, not individual items. + +**Impact**: The characterization test documents this behavior but cannot assert the mix of real vs dummy slices. This limitation should be understood before refactoring the DummyObjectSlice pathway. + +**Suggested Fix**: No code change needed — add a detailed comment in the test documenting the observed behavior and the threshold mechanism. + +**Risk**: None — documentation only. + +### 8. Missing Test Coverage for Key Behaviors + +The following behaviors from the test plans (`test-plan-datatree.md`, `test-plan-slice.md`) are partially or fully uncovered in the current characterization test batch. These should be completed before Phase 1 begins: + +| Area | Gap | Priority | +|------|-----|----------| +| Slice.Expand/Collapse | Persistence of expansion state via PropertyTable | Medium | +| Slice.GenerateChildren | NothingResult, PossibleResult, PersistentExpansion | Medium | +| DataTree.FieldAt | DummyObjectSlice expansion on access | High | +| DataTree.GetMessageTargets | Visible vs hidden vs no current slice | Low | +| DataTree.PostponePropChanged | Deferred refresh chain | Medium | +| SliceFactory.Create | Editor dispatch: multistring, string, unknown, null | Low | +| Slice.GetCanDeleteNow | Required/optional field logic | Medium | +| Slice.GetCanMergeNow | Same-class sibling check | Medium | + +## Recommended Order of Changes + +1. **Items 4, 6** (zero-risk renames and pattern documentation) — immediate +2. **Item 8** (complete missing test coverage) — before Phase 1 +3. **Items 1, 2, 3** (functional fixes) — during or just before Phase 1, with characterization tests verifying both old and new behavior +4. **Items 5, 7** (design constraints) — addressed during Phase 2 architecture changes + +## Relationship to Other Phases + +- **Phase 0** (characterization tests): This spec captures discoveries made during Phase 0. +- **Phase 1** (partial-class split): Items 1–4 should be resolved before splitting, so the split starts from a cleaner baseline. +- **Phase 2** (extract collaborators): Item 5 directly informs the notification pattern for `DataTreeModel`. +- **Phase 3** (model/view separation): Item 7 informs testing strategy for `SliceSpec` generation. From 673855264d5ccde4b03a1b132b7a0452f8b85a1c Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 3/6] Add managed coverage workflow, skill, and DetailControls gap tests --- .github/instructions/terminal.instructions.md | 1 + .github/instructions/testing.instructions.md | 18 ++ .../managed-test-coverage-assessment/SKILL.md | 65 ++++++ .../scripts/Assess-CoverageGaps.ps1 | 161 +++++++++++++ .../scripts/Run-ManagedCoverageAssessment.ps1 | 38 +++ .gitignore | 1 + .vscode/tasks.json | 34 +++ Build/Agent/Run-TestCoverage.ps1 | 216 ++++++++++++++++++ .../DetailControlsTests/DataTreeTests.cs | 107 +++++++++ .../DetailControlsTests/ObjSeqHashMapTests.cs | 10 + .../DetailControlsTests/SliceTests.cs | 55 +++++ .../changes-from-test-before-refactor/spec.md | 49 ++++ .../tests to fix coverage gaps.md | 70 ++++++ 13 files changed, 825 insertions(+) create mode 100644 .github/skills/managed-test-coverage-assessment/SKILL.md create mode 100644 .github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1 create mode 100644 .github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 create mode 100644 Build/Agent/Run-TestCoverage.ps1 create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md diff --git a/.github/instructions/terminal.instructions.md b/.github/instructions/terminal.instructions.md index 1226d90c3d..c83f513b84 100644 --- a/.github/instructions/terminal.instructions.md +++ b/.github/instructions/terminal.instructions.md @@ -29,6 +29,7 @@ Placement policy for wrappers: |--------|---------| | `Git-Search.ps1` | git show/diff/log/grep/blame | | `Read-FileContent.ps1` | File reading with filtering | +| `Run-TestCoverage.ps1` | Collect local coverage (cobertura/xml/html summaries) | **Build/test**: Run `.\build.ps1` or `.\test.ps1` directly—they're auto-approvable. ## Beads CLI (auto-approvable patterns) diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 6cfc77a5e6..f8932333cd 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -31,6 +31,24 @@ Use `.\test.ps1` for all managed (C#) tests. .\test.ps1 -NoBuild -TestProject "FwUtilsTests" ``` +## Local Coverage Reports (Managed) + +Use the repo wrapper to collect local, machine-readable coverage that agents can parse. + +```powershell +# Default DetailControls coverage (cobertura+xml+html summaries) +.\Build\Agent\Run-TestCoverage.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DetailControls" + +# Custom test slice (example) +.\Build\Agent\Run-TestCoverage.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DataTreeTests" +``` + +Artifacts are written under `Output//Coverage/`: +- `coverage.cobertura.xml` (machine-readable) +- `coverage-summary.json` (agent-friendly aggregate + lowest-coverage classes) +- `coverage-summary.md` (human summary) +- `html/index.html` (navigable report) + ## Running Tests (Native C++) Use `.\test.ps1 -Native` for native (C++) tests. This wraps `Build/scripts/Invoke-CppTest.ps1`. diff --git a/.github/skills/managed-test-coverage-assessment/SKILL.md b/.github/skills/managed-test-coverage-assessment/SKILL.md new file mode 100644 index 0000000000..ce01337020 --- /dev/null +++ b/.github/skills/managed-test-coverage-assessment/SKILL.md @@ -0,0 +1,65 @@ +````skill +--- +name: managed-test-coverage-assessment +description: Run managed tests with local coverage, analyze coverage gaps, and propose pragmatic remediations (tests, simplification, dead-code candidates). +model: haiku +--- + + +You are a coverage assessment agent for managed (.NET) code. You run tests, collect coverage, classify gaps, and propose concrete fixes. + + + +You may receive: +- Test filter (for focused slice, e.g. DetailControls) +- Configuration (Debug/Release) +- Focus path/class prefix +- Existing coverage artifacts (optional) + + + +1. **Run coverage collection** + - Use repo wrapper (auto-approvable): + ```powershell + .\Build\Agent\Run-TestCoverage.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DetailControls" -NoBuild + ``` +2. **Generate method/class gap inventory** + - Use helper parser: + ```powershell + .\.github\skills\managed-test-coverage-assessment\scripts\Assess-CoverageGaps.ps1 -Configuration Debug -FocusPath "Src\\Common\\Controls\\DetailControls\\" + ``` +3. **Classify remediation strategy** + - For each gap: choose one of + - Add tests + - Simplify architecture / increase testability + - Dead code candidate (validate and remove) +4. **Produce plan artifact** + - Emit markdown with prioritized actions and proposed tests. +5. **Implement selected tests** + - Add tests for high-value, low-risk, deterministic paths first. +6. **Re-run tests + coverage** + - Confirm gap movement and update summary. + + + +- Use `build.ps1` / `test.ps1` / repo wrappers only. +- Keep red/future-fix tests gated (`[Explicit]`) unless user asks otherwise. +- Prefer deterministic unit tests over UI-event/message-pump dependent tests. +- Do not claim dead code without evidence and owner confirmation. + + + +Primary inputs: +- `Output//Coverage/coverage.cobertura.xml` +- `Output//Coverage/coverage-summary.json` + +Primary outputs: +- `Output//Coverage/coverage-gap-assessment.md` +- `Output//Coverage/coverage-gap-assessment.json` + + + +- This skill complements `verify-test` (execution) and `review` (risk triage). +- Use this skill when user asks for “coverage gaps”, “what tests are missing”, or “coverage-driven cleanup”. + +```` diff --git a/.github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1 b/.github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1 new file mode 100644 index 0000000000..4217e8f461 --- /dev/null +++ b/.github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1 @@ -0,0 +1,161 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [string]$CoverageXmlPath = "", + [string]$FocusPath = "Src\\Common\\Controls\\DetailControls\\", + [string]$FocusClassRegex = "SIL\.FieldWorks\.Common\.Framework\.DetailControls\.(DataTree|Slice|ObjSeqHashMap)$", + [string]$OutputMarkdownPath = "", + [string]$OutputJsonPath = "" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../../../..") +if ([string]::IsNullOrWhiteSpace($CoverageXmlPath)) { + $CoverageXmlPath = Join-Path $repoRoot "Output/$Configuration/Coverage/coverage.cobertura.xml" +} +if ([string]::IsNullOrWhiteSpace($OutputMarkdownPath)) { + $OutputMarkdownPath = Join-Path $repoRoot "Output/$Configuration/Coverage/coverage-gap-assessment.md" +} +if ([string]::IsNullOrWhiteSpace($OutputJsonPath)) { + $OutputJsonPath = Join-Path $repoRoot "Output/$Configuration/Coverage/coverage-gap-assessment.json" +} + +if (-not (Test-Path -LiteralPath $CoverageXmlPath)) { + throw "Coverage XML not found: $CoverageXmlPath" +} + +function Classify-Resolution { + param( + [string]$MethodName, + [double]$LineRate, + [string]$ClassName + ) + + $method = $MethodName.ToLowerInvariant() + if ($method -match 'trace|debug|report') { + return 'dead-code-or-debug-path-review' + } + if ($method -match 'onpaint|onlayout|onsizechanged|mouse|contextmenu|focus|splitter|begininvoke') { + return 'simplify-architecture-or-add-ui-harness' + } + if ($method -match '^get_|^set_|checkdisposed|startswith|extraindent|callernodeequal|getflidifpossible|monitored|dorefresh') { + return 'add-unit-tests' + } + if ($method -match 'handle.*command|getcan.*|insertobject|merge|split|delete') { + return 'add-functional-tests' + } + if ($LineRate -eq 0) { + return 'add-tests-or-evaluate-relevance' + } + return 'add-targeted-tests' +} + +function ToPercent([double]$v) { + return [math]::Round($v * 100, 2) +} + +[xml]$coverage = Get-Content -LiteralPath $CoverageXmlPath + +$classes = @() +$methods = @() + +foreach ($pkg in $coverage.coverage.packages.package) { + if ($null -eq $pkg.classes) { continue } + foreach ($cls in $pkg.classes.class) { + $file = [string]$cls.filename + $className = [string]$cls.name + $lineRate = ToPercent([double]$cls.'line-rate') + $branchRate = ToPercent([double]$cls.'branch-rate') + + if ($file -like "*$FocusPath*" -or $className -match $FocusClassRegex) { + $classes += [PSCustomObject]@{ + File = $file + Class = $className + LineRate = $lineRate + BranchRate = $branchRate + } + + if ($null -ne $cls.methods) { + foreach ($m in $cls.methods.method) { + $ml = ToPercent([double]$m.'line-rate') + $mb = ToPercent([double]$m.'branch-rate') + if ($ml -lt 100) { + $recommendation = Classify-Resolution -MethodName ([string]$m.name) -LineRate $ml -ClassName $className + $methods += [PSCustomObject]@{ + Class = $className + Method = [string]$m.name + LineRate = $ml + BranchRate = $mb + Resolution = $recommendation + } + } + } + } + } + } +} + +$classesSorted = $classes | Sort-Object LineRate, File, Class +$methodsSorted = $methods | Sort-Object LineRate, Class, Method + +$resolutionSummary = $methodsSorted | + Group-Object Resolution | + Sort-Object Count -Descending | + ForEach-Object { + [PSCustomObject]@{ Resolution = $_.Name; Count = $_.Count } + } + +$topGaps = $methodsSorted | Select-Object -First 60 + +$result = [PSCustomObject]@{ + GeneratedAt = (Get-Date).ToString('o') + Configuration = $Configuration + CoverageXml = $CoverageXmlPath + FocusPath = $FocusPath + FocusClassRegex = $FocusClassRegex + ClassCoverage = $classesSorted + MethodGaps = $topGaps + ResolutionSummary = $resolutionSummary +} + +New-Item -ItemType Directory -Path (Split-Path -Parent $OutputJsonPath) -Force | Out-Null +$result | ConvertTo-Json -Depth 8 | Out-File -LiteralPath $OutputJsonPath -Encoding utf8 + +$md = @() +$md += "# Coverage Gap Assessment" +$md += "" +$md += "- Generated: $($result.GeneratedAt)" +$md += "- Configuration: $Configuration" +$md += "- FocusPath: $FocusPath" +$md += "" +$md += "## Resolution Summary" +$md += "" +$md += "| Resolution | Count |" +$md += "|-----------|------:|" ++($resolutionSummary | ForEach-Object { $md += "| $($_.Resolution) | $($_.Count) |" }) +$md += "" +$md += "## Focused Class Coverage" +$md += "" +$md += "| Line % | Branch % | File | Class |" +$md += "|-------:|---------:|------|-------|" ++($classesSorted | Select-Object -First 40 | ForEach-Object { $md += "| $($_.LineRate) | $($_.BranchRate) | $($_.File) | $($_.Class) |" }) +$md += "" +$md += "## Top Method Gaps" +$md += "" +$md += "| Line % | Branch % | Class | Method | Suggested Resolution |" +$md += "|-------:|---------:|-------|--------|----------------------|" ++($topGaps | ForEach-Object { $md += "| $($_.LineRate) | $($_.BranchRate) | $($_.Class) | $($_.Method) | $($_.Resolution) |" }) +$md += "" +$md += "## Suggested Next Actions" +$md += "" +$md += '1. Add deterministic unit tests for low-cost `add-unit-tests` gaps.' +$md += "2. For `simplify-architecture-or-add-ui-harness`, prefer extracting pure logic before adding fragile UI harnesses." +$md += "3. Review `dead-code-or-debug-path-review` items with maintainers before removing any code." + +$md -join [Environment]::NewLine | Out-File -LiteralPath $OutputMarkdownPath -Encoding utf8 + +Write-Host "[OK] Coverage gap assessment written" -ForegroundColor Green +Write-Host " Markdown: $OutputMarkdownPath" -ForegroundColor Gray +Write-Host " JSON: $OutputJsonPath" -ForegroundColor Gray diff --git a/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 b/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 new file mode 100644 index 0000000000..897d8ea6df --- /dev/null +++ b/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [string]$TestFilter = "FullyQualifiedName~DetailControls", + [switch]$NoBuild, + [string]$FocusPath = "Src\\Common\\Controls\\DetailControls\\" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../../../..") +$coverageRunner = Join-Path $repoRoot "Build/Agent/Run-TestCoverage.ps1" +$assessmentRunner = Join-Path $PSScriptRoot "Assess-CoverageGaps.ps1" + +if (-not (Test-Path -LiteralPath $coverageRunner)) { + throw "Coverage runner not found: $coverageRunner" +} +if (-not (Test-Path -LiteralPath $assessmentRunner)) { + throw "Assessment runner not found: $assessmentRunner" +} + +if ($NoBuild) { + & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -NoBuild -FocusPath $FocusPath +} +else { + & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -FocusPath $FocusPath +} +if ($LASTEXITCODE -ne 0) { + throw "Coverage collection failed" +} + +& $assessmentRunner -Configuration $Configuration -FocusPath $FocusPath +if ($LASTEXITCODE -ne 0) { + throw "Coverage gap assessment failed" +} + +Write-Host "[OK] Managed coverage assessment flow complete" -ForegroundColor Green diff --git a/.gitignore b/.gitignore index 1d10128593..a343968d70 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,4 @@ FLExInstaller/wix6/cabcache/* .env .beads/* .beads/metadata.json +.tools/* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 669c9df8c7..0a3565b472 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -47,6 +47,12 @@ "description": "Path to test project (e.g., Src/Common/FwUtils/FwUtilsTests)", "default": "" }, + { + "id": "coverageFilter", + "type": "promptString", + "description": "Coverage test filter (e.g., 'FullyQualifiedName~DetailControls')", + "default": "FullyQualifiedName~DetailControls" + }, { "id": "worktreeBranch", "type": "promptString", @@ -360,6 +366,34 @@ }, "problemMatcher": "$msCompile" }, + { + "label": "Test Coverage (DetailControls)", + "type": "shell", + "command": "./Build/Agent/Run-TestCoverage.ps1 -Configuration ${input:testConfiguration} -TestFilter 'FullyQualifiedName~DetailControls'", + "group": "test", + "detail": "Collect local cobertura + HTML coverage report for DetailControls tests", + "options": { + "shell": { + "executable": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] + } + }, + "problemMatcher": [] + }, + { + "label": "Test Coverage (with filter)", + "type": "shell", + "command": "./Build/Agent/Run-TestCoverage.ps1 -Configuration ${input:testConfiguration} -TestFilter '${input:coverageFilter}'", + "group": "test", + "detail": "Collect local cobertura + HTML coverage report using a custom test filter", + "options": { + "shell": { + "executable": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] + } + }, + "problemMatcher": [] + }, // ==================== CI Parity Checks ==================== { diff --git a/Build/Agent/Run-TestCoverage.ps1 b/Build/Agent/Run-TestCoverage.ps1 new file mode 100644 index 0000000000..98aea3d9a9 --- /dev/null +++ b/Build/Agent/Run-TestCoverage.ps1 @@ -0,0 +1,216 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [string]$TestFilter = "", + [string]$TestProject = "", + [switch]$NoBuild, + [string]$OutputSubDir = "Coverage", + [string]$FocusPath = "Src\\Common\\Controls\\DetailControls\\" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..") +$testScript = Join-Path $repoRoot "test.ps1" +if (-not (Test-Path -LiteralPath $testScript)) { + Write-Host "[ERROR] Could not find test.ps1 at $testScript" -ForegroundColor Red + exit 1 +} + +$outputRoot = Join-Path $repoRoot "Output/$Configuration/$OutputSubDir" +$htmlDir = Join-Path $outputRoot "html" +$coverageXml = Join-Path $outputRoot "coverage.cobertura.xml" +$summaryJson = Join-Path $outputRoot "coverage-summary.json" +$summaryMd = Join-Path $outputRoot "coverage-summary.md" + +New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null + +$toolsDir = Join-Path $repoRoot ".tools" +New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null + +function Ensure-Tool { + param( + [string]$ExeName, + [string]$PackageId + ) + + $exePath = Join-Path $toolsDir "$ExeName.exe" + if (-not (Test-Path -LiteralPath $exePath)) { + Write-Host "Installing $PackageId to $toolsDir..." -ForegroundColor Cyan + $null = & dotnet tool install --tool-path $toolsDir $PackageId + if ($LASTEXITCODE -ne 0) { + throw "Failed to install tool '$PackageId'" + } + } + + if (-not (Test-Path -LiteralPath $exePath)) { + throw "Tool executable not found after install: $exePath" + } + + return $exePath +} + +function To-Percent { + param([double]$Value) + if ($Value -le 1.0) { + return [math]::Round($Value * 100, 2) + } + return [math]::Round($Value, 2) +} + +$dotnetCoverageExe = Ensure-Tool -ExeName "dotnet-coverage" -PackageId "dotnet-coverage" +$reportGeneratorExe = Ensure-Tool -ExeName "reportgenerator" -PackageId "dotnet-reportgenerator-globaltool" + +Write-Host "Collecting coverage..." -ForegroundColor Cyan +$testArgs = @( + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $testScript, + "-Configuration", + $Configuration +) + +if ($NoBuild) { + $testArgs += "-NoBuild" +} +if (-not [string]::IsNullOrWhiteSpace($TestFilter)) { + $testArgs += "-TestFilter" + $testArgs += $TestFilter +} +if (-not [string]::IsNullOrWhiteSpace($TestProject)) { + $testArgs += "-TestProject" + $testArgs += $TestProject +} + +$collectArgs = @( + "collect", + "--output-format", + "cobertura", + "--output", + $coverageXml, + "--" +) + $testArgs + +& $dotnetCoverageExe $collectArgs +if ($LASTEXITCODE -ne 0) { + throw "Coverage collection failed" +} + +if (-not (Test-Path -LiteralPath $coverageXml)) { + throw "Coverage file was not produced: $coverageXml" +} + +Write-Host "Generating report artifacts..." -ForegroundColor Cyan +& $reportGeneratorExe "-reports:$coverageXml" "-targetdir:$htmlDir" "-reporttypes:Html;MarkdownSummary;JsonSummary" +if ($LASTEXITCODE -ne 0) { + throw "ReportGenerator failed" +} + +[xml]$coverage = Get-Content -LiteralPath $coverageXml + +$lineRate = To-Percent([double]$coverage.coverage.'line-rate') +$branchRate = To-Percent([double]$coverage.coverage.'branch-rate') +$linesCovered = [int]$coverage.coverage.'lines-covered' +$linesValid = [int]$coverage.coverage.'lines-valid' +$branchesCovered = [int]$coverage.coverage.'branches-covered' +$branchesValid = [int]$coverage.coverage.'branches-valid' + +$classRows = @() +foreach ($pkg in $coverage.coverage.packages.package) { + if (-not $pkg.classes) { + continue + } + foreach ($cls in $pkg.classes.class) { + $classRows += [PSCustomObject]@{ + Package = [string]$pkg.name + Class = [string]$cls.name + File = [string]$cls.filename + LineRate = To-Percent([double]$cls.'line-rate') + BranchRate = To-Percent([double]$cls.'branch-rate') + } + } +} + +$lowestCoverageClasses = $classRows | + Where-Object { $_.LineRate -lt 100 } | + Sort-Object LineRate, File, Class | + Select-Object -First 25 + +$focusedCoverageClasses = $classRows | + Where-Object { $_.File -like "*$FocusPath*" -and $_.LineRate -lt 100 } | + Sort-Object LineRate, File, Class | + Select-Object -First 25 + +$summaryObject = [PSCustomObject]@{ + GeneratedAt = (Get-Date).ToString("o") + Configuration = $Configuration + TestFilter = $TestFilter + TestProject = $TestProject + Coverage = [PSCustomObject]@{ + LineRate = $lineRate + BranchRate = $branchRate + LinesCovered = $linesCovered + LinesValid = $linesValid + BranchesCovered = $branchesCovered + BranchesValid = $branchesValid + } + LowestCoverageClasses = $lowestCoverageClasses + FocusedCoverageClasses = $focusedCoverageClasses + Artifacts = [PSCustomObject]@{ + CoberturaXml = $coverageXml + HtmlIndex = (Join-Path $htmlDir "index.html") + MarkdownSummary = (Join-Path $htmlDir "Summary.md") + JsonSummary = (Join-Path $htmlDir "Summary.json") + } +} + +$summaryObject | ConvertTo-Json -Depth 8 | Out-File -LiteralPath $summaryJson -Encoding utf8 + +$md = @() +$md += "# Coverage Summary" +$md += "" +$md += "- Generated: $($summaryObject.GeneratedAt)" +$md += "- Configuration: $Configuration" +$md += "- TestFilter: $TestFilter" +$md += "- TestProject: $TestProject" +$md += "" +$md += "## Overall" +$md += "" +$md += "- Line coverage: $lineRate% ($linesCovered / $linesValid)" +$md += "- Branch coverage: $branchRate% ($branchesCovered / $branchesValid)" +$md += "" +$md += "## Lowest Coverage Classes (Top 25)" +$md += "" +$md += "| File | Class | Line % | Branch % |" +$md += "|------|-------|--------|----------|" +foreach ($row in $lowestCoverageClasses) { + $md += "| $($row.File) | $($row.Class) | $($row.LineRate) | $($row.BranchRate) |" +} +$md += "" +$md += "## Artifacts" +$md += "" +$md += "- Cobertura XML: $coverageXml" +$md += "- HTML report: $(Join-Path $htmlDir 'index.html')" +$md += "- ReportGenerator markdown: $(Join-Path $htmlDir 'Summary.md')" +$md += "- ReportGenerator json: $(Join-Path $htmlDir 'Summary.json')" +$md += "" +$md += "## Focused Coverage Classes (Top 25)" +$md += "" +$md += "- FocusPath: $FocusPath" +$md += "" +$md += "| File | Class | Line % | Branch % |" +$md += "|------|-------|--------|----------|" +foreach ($row in $focusedCoverageClasses) { + $md += "| $($row.File) | $($row.Class) | $($row.LineRate) | $($row.BranchRate) |" +} + +$md -join [Environment]::NewLine | Out-File -LiteralPath $summaryMd -Encoding utf8 + +Write-Host "[OK] Coverage collection complete" -ForegroundColor Green +Write-Host " Summary markdown: $summaryMd" -ForegroundColor Gray +Write-Host " Summary json: $summaryJson" -ForegroundColor Gray +Write-Host " HTML report: $(Join-Path $htmlDir 'index.html')" -ForegroundColor Gray \ No newline at end of file diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs index 0382658e96..0762665549 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs @@ -79,6 +79,101 @@ public override void FixtureSetup() } #endregion + #region Characterization Tests — Coverage Gap Closures + + [Test] + public void DoNotRefresh_GetterReflectsSetter() + { + Assert.That(m_dtree.DoNotRefresh, Is.False); + m_dtree.DoNotRefresh = true; + Assert.That(m_dtree.DoNotRefresh, Is.True); + m_dtree.DoNotRefresh = false; + Assert.That(m_dtree.DoNotRefresh, Is.False); + } + + [Test] + public void Init_AssignsMediatorAndPropertyTable() + { + Assert.That(m_dtree.Mediator, Is.SameAs(m_mediator)); + Assert.That(m_dtree.PropTable, Is.SameAs(m_propertyTable)); + } + + [Test] + public void RootLayoutName_DefaultAndAfterShowObject() + { + Assert.That(m_dtree.RootLayoutName, Is.EqualTo("default")); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Assert.That(m_dtree.RootLayoutName, Is.EqualTo("CfOnly")); + } + + [Test] + public void SliceFilter_GetterReflectsSetter() + { + Assert.That(m_dtree.SliceFilter, Is.Null); + + var filter = new SliceFilter(); + m_dtree.SliceFilter = filter; + + Assert.That(m_dtree.SliceFilter, Is.SameAs(filter)); + } + + [Test] + public void ShowingAllFields_ReadsShowHiddenSettingForTool() + { + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, true, PropertyTable.SettingsGroup.LocalSettings); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Assert.That(m_dtree.ShowingAllFields, Is.True); + } + + [Test] + public void GetFlidIfPossible_ValidField_ReturnsFlid() + { + var mdc = Cache.DomainDataByFlid.MetaDataCache as IFwMetaDataCacheManaged; + Assert.That(mdc, Is.Not.Null); + + int flid = m_dtree.GetFlidIfPossible(LexEntryTags.kClassId, "CitationForm", mdc); + + Assert.That(flid, Is.GreaterThan(0), "CitationForm should resolve to a valid flid"); + } + + [Test] + public void GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey() + { + var mdc = Cache.DomainDataByFlid.MetaDataCache as IFwMetaDataCacheManaged; + Assert.That(mdc, Is.Not.Null); + int countBefore = GetInvalidFieldCacheCount(); + + int flid = m_dtree.GetFlidIfPossible(LexEntryTags.kClassId, "DefinitelyNotARealField", mdc); + + Assert.That(flid, Is.EqualTo(0)); + Assert.That(GetInvalidFieldCacheCount(), Is.EqualTo(countBefore + 1), + "Invalid field should be cached after first failed lookup"); + } + + [Test] + public void GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache() + { + var mdc = Cache.DomainDataByFlid.MetaDataCache as IFwMetaDataCacheManaged; + Assert.That(mdc, Is.Not.Null); + + m_dtree.GetFlidIfPossible(LexEntryTags.kClassId, "StillNotARealField", mdc); + int countAfterFirst = GetInvalidFieldCacheCount(); + + m_dtree.GetFlidIfPossible(LexEntryTags.kClassId, "StillNotARealField", mdc); + int countAfterSecond = GetInvalidFieldCacheCount(); + + Assert.That(countAfterSecond, Is.EqualTo(countAfterFirst), + "Same invalid field should not be added twice to invalid-field cache"); + } + + #endregion + #region Test setup and teardown /// ------------------------------------------------------------------------------------ /// @@ -874,6 +969,18 @@ private int GetMonitoredPropsCount() return (int)countProperty.GetValue(value, null); } + private int GetInvalidFieldCacheCount() + { + var field = typeof(DataTree).GetField("m_setInvalidFields", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_setInvalidFields field"); + var value = field.GetValue(m_dtree); + Assert.That(value, Is.Not.Null, "m_setInvalidFields should be non-null"); + var countProperty = value.GetType().GetProperty("Count"); + Assert.That(countProperty, Is.Not.Null, "m_setInvalidFields should expose Count"); + return (int)countProperty.GetValue(value, null); + } + #endregion } } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs index adc1c6b0ff..3076fd90fa 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/ObjSeqHashMapTests.cs @@ -152,6 +152,16 @@ public void GetSliceToReuse_MissingType_ReturnsNull() Assert.That(result, Is.Null); } + [Test] + public void Report_DoesNotThrow_WhenMapContainsEntries() + { + var map = new ObjSeqHashMap(); + var key = new ArrayList { 101 }; + map.Add(key, MakeSlice(101)); + + Assert.DoesNotThrow(() => map.Report()); + } + [Test] public void Values_ReturnsAllSlices() { diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs index de53922ff6..198ba41a3c 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs @@ -272,6 +272,61 @@ public void IsHeaderNode_FalseWhenAbsent() } } + [Test] + public void IsSequenceNode_TrueForOwningSequence() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsSequenceNode, Is.True, + "LexEntry.Senses should be treated as an owning sequence node"); + Assert.That(m_Slice.IsCollectionNode, Is.False); + } + + [Test] + public void IsCollectionNode_TrueForNonOwningSequenceField() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsSequenceNode, Is.False, + "CitationForm is not an owning sequence field"); + Assert.That(m_Slice.IsCollectionNode, Is.True, + "Current behavior: any node that is not owning-sequence is treated as collection"); + } + + [Test] + public void Object_SetAndGet() + { + using (var slice = new Slice()) + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + + slice.Object = entry; + + Assert.That(slice.Object, Is.SameAs(entry)); + } + } + + [Test] + public void Key_SetAndGet() + { + using (var slice = new Slice()) + { + var key = new object[] { 1, "abc" }; + + slice.Key = key; + + Assert.That(slice.Key, Is.SameAs(key)); + } + } + /// /// ContainingDataTree returns null when the slice is not parented. /// diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md index 2e3a29d368..f59a98ba50 100644 --- a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md @@ -108,3 +108,52 @@ The following behaviors from the test plans (`test-plan-datatree.md`, `test-plan - **Phase 1** (partial-class split): Items 1–4 should be resolved before splitting, so the split starts from a cleaner baseline. - **Phase 2** (extract collaborators): Item 5 directly informs the notification pattern for `DataTreeModel`. - **Phase 3** (model/view separation): Item 7 informs testing strategy for `SliceSpec` generation. + +## Coverage Findings (2026-02-25) + +Coverage was re-run locally using `Build/Agent/Run-TestCoverage.ps1` and assessed via +`.github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1`. + +### Focused Class Coverage Snapshot + +| Class | Line % | Branch % | +|------|-------:|---------:| +| `DataTree` | 40.59 | 28.03 | +| `Slice` | 30.27 | 19.40 | +| `ObjSeqHashMap` | 98.39 | 94.44 | + +### Gap Classification Summary (Top Focused Methods) + +| Suggested Resolution | Count | +|----------------------|------:| +| `add-tests-or-evaluate-relevance` | 109 | +| `add-targeted-tests` | 43 | +| `add-unit-tests` | 41 | +| `simplify-architecture-or-add-ui-harness` | 23 | +| `add-functional-tests` | 16 | +| `dead-code-or-debug-path-review` | 3 | + +### Implemented Coverage-Reduction Tests (This Batch) + +The following tests were implemented to reduce deterministic unit-test gaps: + +- `DataTreeTests.DoNotRefresh_GetterReflectsSetter` +- `DataTreeTests.GetFlidIfPossible_ValidField_ReturnsFlid` +- `DataTreeTests.GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` +- `DataTreeTests.GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` +- `SliceTests.IsSequenceNode_TrueForOwningSequence` +- `SliceTests.IsCollectionNode_TrueForNonOwningSequenceField` +- `ObjSeqHashMapTests.Report_DoesNotThrow_WhenMapContainsEntries` + +### Artifacts + +- `Output/Debug/Coverage/coverage-summary.md` +- `Output/Debug/Coverage/coverage-summary.json` +- `Output/Debug/Coverage/coverage-gap-assessment.md` +- `Output/Debug/Coverage/coverage-gap-assessment.json` + +### Current Prioritization After This Run + +1. Continue with deterministic `add-unit-tests` in `DataTree` and `Slice` (non-UI paths). +2. Defer `simplify-architecture-or-add-ui-harness` methods unless extracted into pure collaborators. +3. Review `dead-code-or-debug-path-review` candidates with maintainers before any removal. diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md new file mode 100644 index 0000000000..ebd2c7eedf --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md @@ -0,0 +1,70 @@ +# Tests To Fix Coverage Gaps + +## Scope + +This plan targets the classes affected by the current refactor-prep work and test additions: + +- `DataTree.cs` +- `Slice.cs` +- `ObjSeqHashMap.cs` + +Coverage input source: `Output/Debug/Coverage/detailcontrols-method-gaps.txt` generated by `Build/Agent/Run-TestCoverage.ps1`. + +## Functionality Gaps + +1. **DataTree field metadata lookup** + - **Gap:** `DataTree.GetFlidIfPossible` had no direct coverage. + - **Tests:** + - `GetFlidIfPossible_ValidField_ReturnsFlid` + - **Intent:** confirm valid metadata lookup path returns non-zero flid. + +2. **Slice sequence/collection classification** + - **Gap:** `Slice.IsSequenceNode` and `Slice.IsCollectionNode` had no direct coverage. + - **Tests:** + - `IsSequenceNode_TrueForOwningSequence` + - `IsCollectionNode_TrueForNonOwningSequenceField` + - **Intent:** lock current behavior for XML `` interpretation. + +## Edge-Case Gaps + +1. **Invalid field cache behavior in DataTree** + - **Gap:** repeated invalid lookups and cache behavior untested. + - **Tests:** + - `GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` + - `GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` + - **Intent:** verify failed lookups return 0 and are cached once. + +2. **DoNotRefresh getter symmetry** + - **Gap:** setter path covered, getter path still weak. + - **Test:** + - `DoNotRefresh_GetterReflectsSetter` + - **Intent:** lock down state visibility for deferred refresh behavior. + +## Error-Handling / Robustness Gaps + +1. **ObjSeqHashMap diagnostic path** + - **Gap:** `ObjSeqHashMap.Report()` uncovered. + - **Test:** + - `Report_DoesNotThrow_WhenMapContainsEntries` + - **Intent:** ensure debug/report path is safe under non-empty state. + +## Additional Planned (Not Implemented In This Batch) + +These remain high-value but need larger fixture setup or message-pump infrastructure: + +- `DataTree.PropChanged`/`PostponePropChanged` full async refresh chain +- `Slice.GetCanDeleteNow` and `Slice.GetCanMergeNow` branch matrix +- `DataTree.GetMessageTargets` visibility matrix +- `Slice.GenerateHelpTopicId` fallback chain + +## Implementation Status + +Implemented in this batch: + +- ✅ `GetFlidIfPossible_ValidField_ReturnsFlid` +- ✅ `GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` +- ✅ `GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` +- ✅ `DoNotRefresh_GetterReflectsSetter` +- ✅ `IsSequenceNode_TrueForOwningSequence` +- ✅ `IsCollectionNode_TrueForNonOwningSequenceField` +- ✅ `Report_DoesNotThrow_WhenMapContainsEntries` From 2ec4feff23d7b07638746925a8288e84e406c1bb Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 4/6] Test cov scripts, datatree tests, skill updates --- .../skills/atlassian-readonly-skills/SKILL.md | 14 +- .../scripts/jira_cli.py | 39 ++ .../scripts/jira_workflow.py | 29 +- .github/skills/atlassian-skills/SKILL.md | 22 +- .../atlassian-skills/scripts/jira_cli.py | 88 +++ .github/skills/beads/CLAUDE.md | 2 +- .../skills/beads/resources/TROUBLESHOOTING.md | 16 +- .../managed-test-coverage-assessment/SKILL.md | 5 +- .../scripts/Run-ManagedCoverageAssessment.ps1 | 23 +- .github/skills/openspec-onboard/SKILL.md | 8 +- .github/skills/powershell/SKILL.md | 5 +- .vscode/tasks.json | 28 + Build/Agent/Run-ManagedCoverageAssessment.ps1 | 38 ++ .../DataTreeTests.Wave3.CommandsAndProps.cs | 579 ++++++++++++++++++ .../DataTreeTests.Wave3.Navigation.cs | 62 ++ .../DetailControlsTests/DataTreeTests.cs | 518 +++++++++++++++- .../coverage-wave2-test-matrix.md | 194 ++++++ 17 files changed, 1602 insertions(+), 68 deletions(-) create mode 100644 .github/skills/atlassian-readonly-skills/scripts/jira_cli.py create mode 100644 .github/skills/atlassian-skills/scripts/jira_cli.py create mode 100644 Build/Agent/Run-ManagedCoverageAssessment.ps1 create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.Navigation.cs create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md diff --git a/.github/skills/atlassian-readonly-skills/SKILL.md b/.github/skills/atlassian-readonly-skills/SKILL.md index 875dd60ed1..511bb879e5 100644 --- a/.github/skills/atlassian-readonly-skills/SKILL.md +++ b/.github/skills/atlassian-readonly-skills/SKILL.md @@ -29,17 +29,17 @@ Read-only Python utilities for Jira, Confluence, and Bitbucket integration, supp - Use `fetch_webpage` or similar tools on JIRA URLs - Use GitHub issue tools for LT-* tickets -**ALWAYS** use these Python modules. The scripts are Python modules (not CLI tools), so use them via inline Python or import: +**ALWAYS** use these Python scripts via CLI entry points: ```powershell -# Get a single issue (inline Python one-liner) -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_issues import jira_get_issue; print(jira_get_issue('LT-22382'))" +# Get a single issue +python .github/skills/atlassian-readonly-skills/scripts/jira_cli.py get-issue --issue-key LT-22382 # Search for issues (JQL query) -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_search import jira_search; print(jira_search('project = LT AND status = Open'))" +python .github/skills/atlassian-readonly-skills/scripts/jira_cli.py search --jql "project = LT AND status = Open" # Get issue workflow transitions -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_workflow import jira_get_transitions; print(jira_get_transitions('LT-22382'))" +python .github/skills/atlassian-readonly-skills/scripts/jira_cli.py get-transitions --issue-key LT-22382 ``` Alternatively, use the CLI helper in `jira-to-beads` skill: @@ -114,12 +114,12 @@ credentials = AtlassianCredentials( jira_url="https://your-company.atlassian.net", jira_username="your.email@company.com", jira_api_token="your_api_token", - + # Confluence configuration (optional) confluence_url="https://your-company.atlassian.net/wiki", confluence_username="your.email@company.com", confluence_api_token="your_api_token", - + # Bitbucket configuration (optional) # bitbucket_url="https://bitbucket.your-company.com", # bitbucket_pat_token="your_pat_token" diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_cli.py b/.github/skills/atlassian-readonly-skills/scripts/jira_cli.py new file mode 100644 index 0000000000..da2d496c55 --- /dev/null +++ b/.github/skills/atlassian-readonly-skills/scripts/jira_cli.py @@ -0,0 +1,39 @@ +"""CLI wrapper for atlassian-readonly-skills Jira operations.""" + +import argparse + +from jira_issues import jira_get_issue +from jira_search import jira_search +from jira_workflow import jira_get_transitions + + +def main(): + parser = argparse.ArgumentParser(description="Readonly Jira CLI") + subparsers = parser.add_subparsers(dest="action", required=True) + + get_issue = subparsers.add_parser("get-issue", help="Get a Jira issue") + get_issue.add_argument("--issue-key", required=True) + get_issue.add_argument("--fields", default=None) + get_issue.add_argument("--expand", default=None) + + search = subparsers.add_parser("search", help="Search Jira issues") + search.add_argument("--jql", required=True) + search.add_argument("--fields", default=None) + search.add_argument("--limit", type=int, default=10) + search.add_argument("--start-at", type=int, default=0) + + transitions = subparsers.add_parser("get-transitions", help="Get issue transitions") + transitions.add_argument("--issue-key", required=True) + + args = parser.parse_args() + + if args.action == "get-issue": + print(jira_get_issue(args.issue_key, fields=args.fields, expand=args.expand)) + elif args.action == "search": + print(jira_search(args.jql, fields=args.fields, limit=args.limit, start_at=args.start_at)) + elif args.action == "get-transitions": + print(jira_get_transitions(args.issue_key)) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py b/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py index 4ba1570a93..8ac093ba05 100644 --- a/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py +++ b/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py @@ -8,9 +8,10 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from typing import Any, Dict +from typing import Any, Dict, Optional from _common import ( + AtlassianCredentials, get_jira_client, format_json_response, format_error_response, @@ -29,15 +30,15 @@ def _simplify_transition(transition_data: Dict[str, Any]) -> Dict[str, Any]: 'id': transition_data.get('id', ''), 'name': transition_data.get('name', ''), } - + to_status = transition_data.get('to', {}) if to_status and isinstance(to_status, dict): simplified['to_status'] = to_status.get('name', '') simplified['to_status_id'] = to_status.get('id', '') - + has_screen = transition_data.get('hasScreen', False) simplified['has_screen'] = has_screen - + fields = transition_data.get('fields', {}) if fields: required_fields = [] @@ -53,47 +54,47 @@ def _simplify_transition(transition_data: Dict[str, Any]) -> Dict[str, Any]: required_fields.append(field_data) else: optional_fields.append(field_data) - + if required_fields: simplified['required_fields'] = required_fields if optional_fields: simplified['optional_fields'] = optional_fields - + return simplified def jira_get_transitions(issue_key: str, credentials: Optional[AtlassianCredentials] = None) -> str: """Get available status transitions for a Jira issue. - + Args: issue_key: Issue key (e.g., 'PROJ-123') - + Returns: JSON string with list of available transitions or error information """ try: client = get_jira_client(credentials) - + if not issue_key: raise ValidationError('issue_key is required') - + params = {'expand': 'transitions.fields'} response = client.get( client.api_path(f'issue/{issue_key}/transitions'), params=params ) - + transitions = response.get('transitions', []) simplified_transitions = [_simplify_transition(t) for t in transitions] - + result = { 'issue_key': issue_key, 'transitions': simplified_transitions, 'count': len(simplified_transitions) } - + return format_json_response(result) - + except ConfigurationError as e: return format_error_response('ConfigurationError', str(e)) except AuthenticationError as e: diff --git a/.github/skills/atlassian-skills/SKILL.md b/.github/skills/atlassian-skills/SKILL.md index f5efec7fd8..721cbbd2b6 100644 --- a/.github/skills/atlassian-skills/SKILL.md +++ b/.github/skills/atlassian-skills/SKILL.md @@ -29,20 +29,20 @@ Python utilities for Jira, Confluence, and Bitbucket integration, supporting bot - Use `fetch_webpage` or similar tools on JIRA URLs - Use GitHub issue tools for LT-* tickets -**ALWAYS** use these Python modules. The scripts are Python modules (not CLI tools), so use them via inline Python or import: +**ALWAYS** use these Python scripts via CLI entry points: ```powershell # Create a new issue -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_create_issue; print(jira_create_issue('LT', 'Issue title', 'Bug'))" +python .github/skills/atlassian-skills/scripts/jira_cli.py create-issue --project-key LT --summary "Issue title" --issue-type Bug # Update an existing issue -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_update_issue; print(jira_update_issue('LT-22382', summary='Updated title'))" +python .github/skills/atlassian-skills/scripts/jira_cli.py update-issue --issue-key LT-22382 --summary "Updated title" # Add a comment -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_add_comment; print(jira_add_comment('LT-22382', 'Comment text'))" +python .github/skills/atlassian-skills/scripts/jira_cli.py add-comment --issue-key LT-22382 --comment "Comment text" # Transition issue status -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_workflow import jira_transition_issue; print(jira_transition_issue('LT-22382', 'In Progress'))" +python .github/skills/atlassian-skills/scripts/jira_cli.py transition --issue-key LT-22382 --transition-id 31 ``` For read-only operations (get issue, search, get comments), use `atlassian-readonly-skills` instead. @@ -112,12 +112,12 @@ credentials = AtlassianCredentials( jira_url="https://your-company.atlassian.net", jira_username="your.email@company.com", jira_api_token="your_api_token", - + # Confluence configuration (optional) confluence_url="https://your-company.atlassian.net/wiki", confluence_username="your.email@company.com", confluence_api_token="your_api_token", - + # Bitbucket configuration (optional) # bitbucket_url="https://bitbucket.your-company.com", # bitbucket_pat_token="your_pat_token" @@ -167,7 +167,7 @@ AtlassianCredentials( jira_pat_token: Optional[str] = None, jira_api_version: Optional[str] = None, # '2' or '3', auto-detected if not set jira_ssl_verify: bool = False, - + # Confluence confluence_url: Optional[str] = None, confluence_username: Optional[str] = None, @@ -175,7 +175,7 @@ AtlassianCredentials( confluence_pat_token: Optional[str] = None, confluence_api_version: Optional[str] = None, confluence_ssl_verify: bool = False, - + # Bitbucket bitbucket_url: Optional[str] = None, bitbucket_username: Optional[str] = None, @@ -614,14 +614,14 @@ def skill_function( credentials: Optional[AtlassianCredentials] = None # Always last parameter ) -> str: """Function description. - + Args: required_param1: Description required_param2: Description optional_param: Description (optional) credentials: Optional AtlassianCredentials for Agent environments. If not provided, uses environment variables. - + Returns: JSON string with result or error information """ diff --git a/.github/skills/atlassian-skills/scripts/jira_cli.py b/.github/skills/atlassian-skills/scripts/jira_cli.py new file mode 100644 index 0000000000..2ae98f3ba9 --- /dev/null +++ b/.github/skills/atlassian-skills/scripts/jira_cli.py @@ -0,0 +1,88 @@ +"""CLI wrapper for atlassian-skills Jira operations.""" + +import argparse + +from jira_issues import jira_add_comment, jira_create_issue, jira_update_issue +from jira_workflow import jira_get_transitions, jira_transition_issue + + +def _parse_labels(labels_value): + if labels_value is None or labels_value == "": + return None + return [label.strip() for label in labels_value.split(",") if label.strip()] + + +def main(): + parser = argparse.ArgumentParser(description="Jira CLI") + subparsers = parser.add_subparsers(dest="action", required=True) + + create_issue = subparsers.add_parser("create-issue", help="Create a Jira issue") + create_issue.add_argument("--project-key", required=True) + create_issue.add_argument("--summary", required=True) + create_issue.add_argument("--issue-type", required=True) + create_issue.add_argument("--description", default=None) + create_issue.add_argument("--assignee", default=None) + create_issue.add_argument("--priority", default=None) + create_issue.add_argument("--labels", default=None) + + update_issue = subparsers.add_parser("update-issue", help="Update a Jira issue") + update_issue.add_argument("--issue-key", required=True) + update_issue.add_argument("--summary", default=None) + update_issue.add_argument("--description", default=None) + update_issue.add_argument("--assignee", default=None) + update_issue.add_argument("--priority", default=None) + update_issue.add_argument("--labels", default=None) + + add_comment = subparsers.add_parser("add-comment", help="Add issue comment") + add_comment.add_argument("--issue-key", required=True) + add_comment.add_argument("--comment", required=True) + + transitions = subparsers.add_parser("get-transitions", help="Get issue transitions") + transitions.add_argument("--issue-key", required=True) + + transition_issue = subparsers.add_parser("transition", help="Transition issue status") + transition_issue.add_argument("--issue-key", required=True) + transition_issue.add_argument("--transition-id", required=True) + transition_issue.add_argument("--comment", default=None) + + args = parser.parse_args() + + if args.action == "create-issue": + print( + jira_create_issue( + project_key=args.project_key, + summary=args.summary, + issue_type=args.issue_type, + description=args.description, + assignee=args.assignee, + priority=args.priority, + labels=_parse_labels(args.labels), + ) + ) + elif args.action == "update-issue": + print( + jira_update_issue( + issue_key=args.issue_key, + summary=args.summary, + description=args.description, + assignee=args.assignee, + priority=args.priority, + labels=_parse_labels(args.labels), + ) + ) + elif args.action == "add-comment": + print(jira_add_comment(args.issue_key, args.comment)) + elif args.action == "get-transitions": + print(jira_get_transitions(args.issue_key)) + elif args.action == "transition": + print( + jira_transition_issue( + issue_key=args.issue_key, + transition_id=args.transition_id, + comment=args.comment, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/beads/CLAUDE.md b/.github/skills/beads/CLAUDE.md index f734508f39..c916e18a7f 100644 --- a/.github/skills/beads/CLAUDE.md +++ b/.github/skills/beads/CLAUDE.md @@ -72,7 +72,7 @@ wc -w claude-plugin/skills/beads/SKILL.md # Target: 400-600 words # (Manual check: ensure all resource links in SKILL.md exist) # Verify bd prime still works -bd prime | head -20 +bd prime ``` ## Attribution diff --git a/.github/skills/beads/resources/TROUBLESHOOTING.md b/.github/skills/beads/resources/TROUBLESHOOTING.md index 03af6dc0a0..3968c2ed5a 100644 --- a/.github/skills/beads/resources/TROUBLESHOOTING.md +++ b/.github/skills/beads/resources/TROUBLESHOOTING.md @@ -81,8 +81,8 @@ bd show If dependencies still don't persist after updating: 1. **Check daemon is running:** - ```bash - ps aux | grep "bd daemon" + ```powershell + bd daemon status ``` 2. **Try without --no-daemon flag:** @@ -92,9 +92,9 @@ If dependencies still don't persist after updating: ``` 3. **Check JSONL file:** - ```bash - cat .beads/issues.jsonl | jq '.dependencies' - # Should show dependency array + ```powershell + .\scripts\Agent\Read-FileContent.ps1 -Path ".beads/issues.jsonl" -Pattern '"dependencies"' + # Should show dependency entries ``` 4. **Report to beads GitHub** with: @@ -150,7 +150,7 @@ bd show issue-1 ```bash bd --no-daemon update issue-1 --status in_progress # Trigger sync by exporting/importing -bd export > /dev/null 2>&1 # Forces sync +bd export bd show issue-1 ``` @@ -428,7 +428,7 @@ Before reporting issues, collect this information: bd version # 2. Daemon status -ps aux | grep "bd daemon" +bd daemon status # 3. Database location echo $PWD/.beads/*.db @@ -439,7 +439,7 @@ git status git log --oneline -1 # 5. JSONL contents (for dependency issues) -cat .beads/issues.jsonl | jq '.' | head -50 +.\scripts\Agent\Read-FileContent.ps1 -Path ".beads/issues.jsonl" -HeadLines 50 ``` ### Report to beads GitHub diff --git a/.github/skills/managed-test-coverage-assessment/SKILL.md b/.github/skills/managed-test-coverage-assessment/SKILL.md index ce01337020..b52060c76d 100644 --- a/.github/skills/managed-test-coverage-assessment/SKILL.md +++ b/.github/skills/managed-test-coverage-assessment/SKILL.md @@ -21,12 +21,12 @@ You may receive: 1. **Run coverage collection** - Use repo wrapper (auto-approvable): ```powershell - .\Build\Agent\Run-TestCoverage.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DetailControls" -NoBuild + .\Build\Agent\Run-ManagedCoverageAssessment.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DetailControls" -NoBuild ``` 2. **Generate method/class gap inventory** - Use helper parser: ```powershell - .\.github\skills\managed-test-coverage-assessment\scripts\Assess-CoverageGaps.ps1 -Configuration Debug -FocusPath "Src\\Common\\Controls\\DetailControls\\" + .\Build\Agent\Run-ManagedCoverageAssessment.ps1 -Configuration Debug -TestFilter "FullyQualifiedName~DetailControls" -NoBuild -FocusPath "Src\\Common\\Controls\\DetailControls\\" ``` 3. **Classify remediation strategy** - For each gap: choose one of @@ -43,6 +43,7 @@ You may receive: - Use `build.ps1` / `test.ps1` / repo wrappers only. +- Do not run scripts under `.github/skills/*/scripts` directly from terminal; use `Build/Agent` wrappers for auto-approval. - Keep red/future-fix tests gated (`[Explicit]`) unless user asks otherwise. - Prefer deterministic unit tests over UI-event/message-pump dependent tests. - Do not claim dead code without evidence and owner confirmation. diff --git a/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 b/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 index 897d8ea6df..fed6100935 100644 --- a/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 +++ b/.github/skills/managed-test-coverage-assessment/scripts/Run-ManagedCoverageAssessment.ps1 @@ -10,29 +10,22 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../../../..") -$coverageRunner = Join-Path $repoRoot "Build/Agent/Run-TestCoverage.ps1" -$assessmentRunner = Join-Path $PSScriptRoot "Assess-CoverageGaps.ps1" +$wrapperRunner = Join-Path $repoRoot "Build/Agent/Run-ManagedCoverageAssessment.ps1" -if (-not (Test-Path -LiteralPath $coverageRunner)) { - throw "Coverage runner not found: $coverageRunner" -} -if (-not (Test-Path -LiteralPath $assessmentRunner)) { - throw "Assessment runner not found: $assessmentRunner" +if (-not (Test-Path -LiteralPath $wrapperRunner)) { + throw "Managed coverage wrapper not found: $wrapperRunner" } +Write-Host "[WARN] Running skill-local coverage script; prefer Build/Agent/Run-ManagedCoverageAssessment.ps1 for terminal use." -ForegroundColor Yellow + if ($NoBuild) { - & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -NoBuild -FocusPath $FocusPath + & $wrapperRunner -Configuration $Configuration -TestFilter $TestFilter -NoBuild -FocusPath $FocusPath } else { - & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -FocusPath $FocusPath + & $wrapperRunner -Configuration $Configuration -TestFilter $TestFilter -FocusPath $FocusPath } if ($LASTEXITCODE -ne 0) { - throw "Coverage collection failed" -} - -& $assessmentRunner -Configuration $Configuration -FocusPath $FocusPath -if ($LASTEXITCODE -ne 0) { - throw "Coverage gap assessment failed" + throw "Managed coverage assessment failed" } Write-Host "[OK] Managed coverage assessment flow complete" -ForegroundColor Green diff --git a/.github/skills/openspec-onboard/SKILL.md b/.github/skills/openspec-onboard/SKILL.md index 0f018dbdfd..ae79c73925 100644 --- a/.github/skills/openspec-onboard/SKILL.md +++ b/.github/skills/openspec-onboard/SKILL.md @@ -17,8 +17,8 @@ Guide the user through their first complete OpenSpec workflow cycle. This is a t Before starting, check if OpenSpec is initialized: -```bash -openspec status --json 2>&1 || echo "NOT_INITIALIZED" +```powershell +openspec status --json ``` **If not initialized:** @@ -66,8 +66,8 @@ Scan the codebase for small improvement opportunities. Look for: 6. **Missing validation** - User input handlers without validation Also check recent git activity: -```bash -git log --oneline -10 2>/dev/null || echo "No git history" +```powershell +.\scripts\Agent\Git-Search.ps1 -Action log -HeadLines 10 ``` ### Present Suggestions diff --git a/.github/skills/powershell/SKILL.md b/.github/skills/powershell/SKILL.md index 480e5d83ae..7c0c53d421 100644 --- a/.github/skills/powershell/SKILL.md +++ b/.github/skills/powershell/SKILL.md @@ -36,6 +36,7 @@ Conventions and safety patterns for PowerShell scripts in `scripts/` and CI. - Redirection (`2>&1`) **ALWAYS use `scripts/Agent/` wrapper scripts for these operations.** Do not attempt raw commands. +For repo workflows, prefer `Build/Agent/*.ps1` or `scripts/Agent/*.ps1`; avoid invoking `.github/skills/*/scripts/*.ps1` directly from terminal. See [terminal.instructions.md](../../instructions/terminal.instructions.md) for the complete transformation table. @@ -50,8 +51,4 @@ git status .\scripts\Agent\Git-Search.ps1 -Action show -Ref "release/9.3" -Path "file.h" -HeadLines 20 .\scripts\Agent\Git-Search.ps1 -Action log -HeadLines 20 .\scripts\Agent\Read-FileContent.ps1 -Path "file.cs" -HeadLines 50 -LineNumbers - -# BAD: these require manual approval - NEVER USE -# git log --oneline | head -20 -# Get-Content file.cs | Select-Object -First 50 ``` diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0a3565b472..fb98bf80b9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -394,6 +394,34 @@ }, "problemMatcher": [] }, + { + "label": "Test Coverage Assessment (DetailControls)", + "type": "shell", + "command": "./Build/Agent/Run-ManagedCoverageAssessment.ps1 -Configuration ${input:testConfiguration} -TestFilter 'FullyQualifiedName~DetailControls' -NoBuild", + "group": "test", + "detail": "Collect coverage and generate gap assessment artifacts for DetailControls tests", + "options": { + "shell": { + "executable": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] + } + }, + "problemMatcher": [] + }, + { + "label": "Test Coverage Assessment (with filter)", + "type": "shell", + "command": "./Build/Agent/Run-ManagedCoverageAssessment.ps1 -Configuration ${input:testConfiguration} -TestFilter '${input:coverageFilter}' -NoBuild", + "group": "test", + "detail": "Collect coverage and generate gap assessment artifacts using a custom filter", + "options": { + "shell": { + "executable": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] + } + }, + "problemMatcher": [] + }, // ==================== CI Parity Checks ==================== { diff --git a/Build/Agent/Run-ManagedCoverageAssessment.ps1 b/Build/Agent/Run-ManagedCoverageAssessment.ps1 new file mode 100644 index 0000000000..13af82f82e --- /dev/null +++ b/Build/Agent/Run-ManagedCoverageAssessment.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [string]$Configuration = "Debug", + [string]$TestFilter = "FullyQualifiedName~DetailControls", + [switch]$NoBuild, + [string]$FocusPath = "Src\\Common\\Controls\\DetailControls\\" +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..") +$coverageRunner = Join-Path $repoRoot "Build/Agent/Run-TestCoverage.ps1" +$assessmentRunner = Join-Path $repoRoot ".github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1" + +if (-not (Test-Path -LiteralPath $coverageRunner)) { + throw "Coverage runner not found: $coverageRunner" +} +if (-not (Test-Path -LiteralPath $assessmentRunner)) { + throw "Assessment runner not found: $assessmentRunner" +} + +if ($NoBuild) { + & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -NoBuild -FocusPath $FocusPath +} +else { + & $coverageRunner -Configuration $Configuration -TestFilter $TestFilter -FocusPath $FocusPath +} +if ($LASTEXITCODE -ne 0) { + throw "Coverage collection failed" +} + +& $assessmentRunner -Configuration $Configuration -FocusPath $FocusPath +if ($LASTEXITCODE -ne 0) { + throw "Coverage gap assessment failed" +} + +Write-Host "[OK] Managed coverage assessment flow complete" -ForegroundColor Green diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs new file mode 100644 index 0000000000..0ef5f7de3a --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs @@ -0,0 +1,579 @@ +// Copyright (c) 2016 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.Windows.Forms; +using NUnit.Framework; +using SIL.LCModel; +using XCore; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + [TestFixture] + public partial class DataTreeTests + { + #region Wave 3 — Command Handlers & Low-cost Properties + + [Test] + public void GetMessageTargets_NotVisibleWithCurrentSlice_ReturnsSliceOnly() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + var currentSlice = m_dtree.Slices[0]; + SetCurrentSliceFieldForTest(currentSlice); + + var targets = m_dtree.GetMessageTargets(); + + Assert.That(targets.Length, Is.EqualTo(1)); + Assert.That(targets[0], Is.SameAs(currentSlice)); + } + + [Test] + public void OnDisplayJumpToLexiconEditFilterAnthroItems_NonAnthroField_Disables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + SetCurrentSliceFieldForTest(m_dtree.Slices[0]); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "JumpToLexiconEditFilterAnthroItems", true, null, true); + bool handled = m_dtree.OnDisplayJumpToLexiconEditFilterAnthroItems(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + Assert.That(display.Visible, Is.False); + } + } + + [Test] + public void OnDisplayJumpToNotebookEditFilterAnthroItems_NonAnthroField_Disables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + SetCurrentSliceFieldForTest(m_dtree.Slices[0]); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "JumpToNotebookEditFilterAnthroItems", true, null, true); + bool handled = m_dtree.OnDisplayJumpToNotebookEditFilterAnthroItems(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + Assert.That(display.Visible, Is.False); + } + } + + [Test] + public void OnDisplayJumpToLexiconEditFilterAnthroItems_AnthroFieldAndMatchingCommand_Enables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var slice = m_dtree.Slices[0]; + var originalNode = slice.ConfigurationNode; + + try + { + slice.ConfigurationNode = CreateXmlNode(""); + SetCurrentSliceFieldForTest(slice); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "JumpToLexiconEditFilterAnthroItems", true, null, true); + bool handled = m_dtree.OnDisplayJumpToLexiconEditFilterAnthroItems(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.True); + Assert.That(display.Visible, Is.True); + } + } + finally + { + slice.ConfigurationNode = originalNode; + SetCurrentSliceFieldForTest(null); + } + } + + [Test] + public void OnDisplayJumpToLexiconEditFilterAnthroItems_AnthroFieldDifferentCommand_Disables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var slice = m_dtree.Slices[0]; + var originalNode = slice.ConfigurationNode; + + try + { + slice.ConfigurationNode = CreateXmlNode(""); + SetCurrentSliceFieldForTest(slice); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "JumpToLexiconEditFilterAnthroItems", true, null, true); + bool handled = m_dtree.OnDisplayJumpToLexiconEditFilterAnthroItems(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + Assert.That(display.Visible, Is.False); + } + } + finally + { + slice.ConfigurationNode = originalNode; + SetCurrentSliceFieldForTest(null); + } + } + + [Test] + public void OnJumpToTool_InvalidCommand_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + bool result = m_dtree.OnJumpToTool(cmd); + Assert.That(result, Is.False); + } + } + + [Test] + public void OnJumpToTool_ValidConcordanceCommand_ReturnsTrue() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + cmd.TargetId = Guid.NewGuid(); + bool result = m_dtree.OnJumpToTool(cmd); + + Assert.That(result, Is.True); + Assert.That(cmd.TargetId, Is.EqualTo(Guid.Empty), "Handled jump should clear TargetId for future use"); + } + } + + [Test] + public void OnJumpToLexiconEditFilterAnthroItems_WithoutCurrentSlice_ThrowsNullReferenceException() + { + Assert.Throws(() => m_dtree.OnJumpToLexiconEditFilterAnthroItems(null)); + } + + [Test] + public void OnJumpToNotebookEditFilterAnthroItems_WithoutCurrentSlice_ThrowsNullReferenceException() + { + Assert.Throws(() => m_dtree.OnJumpToNotebookEditFilterAnthroItems(null)); + } + + [Test] + public void OnReadyToSetCurrentSlice_WhenActive_ReturnsTrue() + { + bool result = m_dtree.OnReadyToSetCurrentSlice(false); + Assert.That(result, Is.True); + } + + [Test] + public void OnFocusFirstPossibleSlice_DoesNotThrow() + { + Assert.DoesNotThrow(() => m_dtree.OnFocusFirstPossibleSlice(null)); + } + + [Test] + public void Priority_ReturnsMediumColleaguePriority() + { + Assert.That(m_dtree.Priority, Is.EqualTo((int)ColleaguePriority.Medium)); + } + + [Test] + public void ShouldNotCall_FalseByDefault() + { + Assert.That(m_dtree.ShouldNotCall, Is.False); + } + + [Test] + public void SliceControlContainer_ReturnsSelf() + { + Assert.That(m_dtree.SliceControlContainer, Is.SameAs(m_dtree)); + } + + [Test] + public void LabelWidth_ReturnsExpectedConstant() + { + Assert.That(m_dtree.LabelWidth, Is.EqualTo(40)); + } + + [Test] + public void LastSlice_WhenNoSlices_ReturnsNull() + { + Assert.That(m_dtree.LastSlice, Is.Null); + } + + [Test] + public void LastSlice_WhenSlicesExist_ReturnsLast() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + Assert.That(m_dtree.LastSlice, Is.SameAs(m_dtree.Slices[m_dtree.Slices.Count - 1])); + } + + [Test] + public void ConstructingSlices_IsFalseAfterShowObject() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Assert.That(m_dtree.ConstructingSlices, Is.False); + } + + [Test] + public void HasSubPossibilitiesSlice_CfOnlyLayout_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Assert.That(m_dtree.HasSubPossibilitiesSlice, Is.False); + } + + [Test] + public void Descendant_GetterIsAccessible_AfterShowObject() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Assert.DoesNotThrow(() => + { + var descendant = m_dtree.Descendant; + if (descendant != null) + Assert.That(descendant.IsValidObject, Is.True); + }); + } + + [Test] + public void CurrentSlice_SetterAssignsSliceFromTree() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + + var slice = m_dtree.Slices[0]; + Assert.DoesNotThrow(() => m_dtree.CurrentSlice = slice); + + if (m_dtree.CurrentSlice != null) + Assert.That(m_dtree.CurrentSlice, Is.SameAs(slice)); + } + + [Test] + public void ActiveControl_SetterToSliceControl_UpdatesCurrentSlice() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + + var targetSlice = m_dtree.Slices[1]; + Assert.That(targetSlice.Control, Is.Not.Null); + + Assert.DoesNotThrow(() => m_dtree.ActiveControl = targetSlice.Control); + + if (m_dtree.CurrentSlice != null) + Assert.That(m_dtree.CurrentSlice, Is.SameAs(targetSlice)); + } + + [Test] + public void SliceSplitPositionBase_SetterUpdatesValue() + { + int original = m_dtree.SliceSplitPositionBase; + int updated = original + 7; + + m_dtree.SliceSplitPositionBase = updated; + + Assert.That(m_dtree.SliceSplitPositionBase, Is.EqualTo(updated)); + } + + [Test] + public void SmallImages_SetterAndGetterRoundTrip() + { + var images = new ImageCollection(false); + m_dtree.SmallImages = images; + + Assert.That(m_dtree.SmallImages, Is.SameAs(images)); + } + + [Test] + public void StyleSheet_SetterAllowsNullRoundTrip() + { + m_dtree.StyleSheet = null; + + Assert.That(m_dtree.StyleSheet, Is.Null); + } + + [Test] + public void PersistenceProvider_SetterAndGetterRoundTrip() + { + var provider = new PersistenceProvider(m_mediator, m_propertyTable, "DataTreeTests"); + m_dtree.PersistenceProvder = provider; + + Assert.That(m_dtree.PersistenceProvder, Is.SameAs(provider)); + } + + [Test] + public void RefreshDisplay_ReturnsTrue() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + bool result = m_dtree.RefreshDisplay(); + + Assert.That(result, Is.True); + } + + [Test] + public void SetAndClearCurrentObjectFlids_TracksAndClearsPath() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + m_dtree.SetCurrentObjectFlids(m_entry.Hvo, 123456); + + var field = typeof(DataTree).GetField("m_currentObjectFlids", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_currentObjectFlids field"); + var value = field.GetValue(m_dtree) as System.Collections.IList; + Assert.That(value, Is.Not.Null); + Assert.That(value.Count, Is.GreaterThan(0)); + Assert.That(value.Contains(123456), Is.True); + + m_dtree.ClearCurrentObjectFlids(); + Assert.That(value.Count, Is.EqualTo(0)); + } + + [Test] + public void OnInsertItemViaBackrefVector_WrongClass_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + bool result = m_dtree.OnInsertItemViaBackrefVector(cmd); + Assert.That(result, Is.False); + } + } + + [Test] + public void OnInsertItemViaBackrefVector_MissingFieldName_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + bool result = m_dtree.OnInsertItemViaBackrefVector(cmd); + Assert.That(result, Is.False); + } + } + + [Test] + public void OnDemoteItemInVector_WhenRootIsNull_ReturnsFalse() + { + Assert.That(m_dtree.Root, Is.Null); + bool result = m_dtree.OnDemoteItemInVector(null); + Assert.That(result, Is.False); + } + + [Test] + public void OnDemoteItemInVector_WhenRootIsNotNotebookRecord_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + bool result = m_dtree.OnDemoteItemInVector(null); + Assert.That(result, Is.False); + } + + [Test] + public void PostponePropChanged_TrueThenFalse_TogglesInternalFlag() + { + var method = typeof(DataTree).GetMethod("PostponePropChanged", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.PostponePropChanged method"); + + var field = typeof(DataTree).GetField("m_postponePropChanged", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_postponePropChanged field"); + + method.Invoke(m_dtree, new object[] { true }); + Assert.That((bool)field.GetValue(m_dtree), Is.True); + + method.Invoke(m_dtree, new object[] { false }); + Assert.That((bool)field.GetValue(m_dtree), Is.False); + } + + [Test] + public void PrepareToGoAway_WithoutCurrentSlice_ReturnsTrue() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + bool result = m_dtree.PrepareToGoAway(); + + Assert.That(result, Is.True); + } + + [Test] + public void PropChanged_Unmonitored_WhenRefreshSuppressed_DoesNotQueueRefresh() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var postponeField = typeof(DataTree).GetField("m_postponePropChanged", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(postponeField, Is.Not.Null, "Could not reflect DataTree.m_postponePropChanged field"); + postponeField.SetValue(m_dtree, false); + m_dtree.DoNotRefresh = true; + + Assert.That(m_dtree.RefreshListNeeded, Is.False); + + m_dtree.PropChanged(m_entry.Hvo, (int)LexEntryTags.kflidSummaryDefinition, 0, 1, 0); + + Assert.That(m_dtree.RefreshListNeeded, Is.False); + } + + [Test] + public void PropChanged_Monitored_WhenRefreshSuppressed_QueuesRefresh() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var postponeField = typeof(DataTree).GetField("m_postponePropChanged", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(postponeField, Is.Not.Null, "Could not reflect DataTree.m_postponePropChanged field"); + postponeField.SetValue(m_dtree, false); + m_dtree.DoNotRefresh = true; + m_dtree.MonitorProp(m_entry.Hvo, (int)LexEntryTags.kflidCitationForm); + + Assert.That(m_dtree.RefreshListNeeded, Is.False); + + m_dtree.PropChanged(m_entry.Hvo, (int)LexEntryTags.kflidCitationForm, 0, 1, 0); + + Assert.That(m_dtree.RefreshListNeeded, Is.True); + } + + [Test] + public void ResetRecordListUpdater_WithListNameAndNoWindowOwner_LeavesUpdaterNull() + { + var listNameField = typeof(DataTree).GetField("m_listName", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(listNameField, Is.Not.Null, "Could not reflect DataTree.m_listName field"); + + var rluField = typeof(DataTree).GetField("m_rlu", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(rluField, Is.Not.Null, "Could not reflect DataTree.m_rlu field"); + + listNameField.SetValue(m_dtree, "AnyListName"); + rluField.SetValue(m_dtree, null); + + var method = typeof(DataTree).GetMethod("ResetRecordListUpdater", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.ResetRecordListUpdater method"); + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, null)); + Assert.That(rluField.GetValue(m_dtree), Is.Null); + } + + [Test] + public void NotebookRecordRefersToThisText_Null_ThrowsArgumentException() + { + IRnGenericRec ignored; + Assert.Throws(() => DataTree.NotebookRecordRefersToThisText(null, out ignored)); + } + + [Test] + public void NotebookRecordRefersToThisText_TextWithoutAssociation_ReturnsFalse() + { + var text = Cache.ServiceLocator.GetInstance().Create(); + + IRnGenericRec referringRecord; + bool result = DataTree.NotebookRecordRefersToThisText(text, out referringRecord); + + Assert.That(result, Is.False); + Assert.That(referringRecord, Is.Null); + } + + [Test] + public void GetSliceContextMenu_UsesRegisteredHandlerAndForwardsArgs() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Slice capturedSlice = null; + bool capturedHotLinksOnly = false; + var expectedMenu = new ContextMenu(); + + m_dtree.SetContextMenuHandler((sender, args) => + { + capturedSlice = args.Slice; + capturedHotLinksOnly = args.HotLinksOnly; + return expectedMenu; + }); + + var slice = m_dtree.Slices[0]; + ContextMenu actualMenu = m_dtree.GetSliceContextMenu(slice, true); + + Assert.That(actualMenu, Is.SameAs(expectedMenu)); + Assert.That(capturedSlice, Is.SameAs(slice)); + Assert.That(capturedHotLinksOnly, Is.True); + } + + [Test] + public void SetContextMenuHandler_ReplacesPreviousHandler() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + var firstMenu = new ContextMenu(); + var secondMenu = new ContextMenu(); + + m_dtree.SetContextMenuHandler((sender, args) => firstMenu); + m_dtree.SetContextMenuHandler((sender, args) => secondMenu); + + ContextMenu actual = m_dtree.GetSliceContextMenu(m_dtree.Slices[0], false); + Assert.That(actual, Is.SameAs(secondMenu)); + } + + [Test] + public void OnShowContextMenu_InvokesHandlerForNonPopupForm() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + Slice capturedSlice = null; + int callCount = 0; + m_dtree.SetContextMenuHandler((sender, args) => + { + callCount++; + capturedSlice = args.Slice; + return new ContextMenu(); + }); + + var slice = m_dtree.Slices[0]; + var eventArgs = new TreeNodeEventArgs(m_dtree, slice, new System.Drawing.Point(0, 0)); + Assert.DoesNotThrow(() => m_dtree.OnShowContextMenu(m_dtree, eventArgs)); + + Assert.That(callCount, Is.EqualTo(1)); + Assert.That(capturedSlice, Is.SameAs(slice)); + } + + #endregion + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.Navigation.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.Navigation.cs new file mode 100644 index 0000000000..df6f1fd1fd --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.Navigation.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2016 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + [TestFixture] + public partial class DataTreeTests + { + #region Wave 3 — Navigation Additions + + [Test] + public void GotoFirstSlice_WithNoSlices_DoesNotThrow() + { + Assert.DoesNotThrow(() => m_dtree.GotoFirstSlice()); + Assert.That(m_dtree.CurrentSlice, Is.Null); + } + + [Test] + public void GotoNextSlice_WithNoCurrentSlice_LeavesCurrentNull() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + Assert.That(m_dtree.CurrentSlice, Is.Null); + m_dtree.GotoNextSlice(); + Assert.That(m_dtree.CurrentSlice, Is.Null); + } + + [Test] + public void GotoNextSlice_WithCurrentAtLast_DoesNotAdvance() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + var onlySlice = m_dtree.Slices[0]; + SetCurrentSliceFieldForTest(onlySlice); + + m_dtree.GotoNextSlice(); + + Assert.That(m_dtree.CurrentSlice, Is.SameAs(onlySlice)); + } + + [Test] + public void IndexOfSliceAtY_WithNoSlices_ReturnsMinusOne() + { + Assert.That(m_dtree.IndexOfSliceAtY(0), Is.EqualTo(-1)); + Assert.That(m_dtree.IndexOfSliceAtY(42), Is.EqualTo(-1)); + } + + [Test] + public void GotoPreviousSliceBeforeIndex_WithNoSlices_ReturnsFalse() + { + bool moved = m_dtree.GotoPreviousSliceBeforeIndex(0); + Assert.That(moved, Is.False); + } + + #endregion + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs index 0762665549..f42567e928 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs @@ -22,7 +22,7 @@ namespace SIL.FieldWorks.Common.Framework.DetailControls { /// [TestFixture] - public class DataTreeTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + public partial class DataTreeTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase { private Inventory m_parts; private Inventory m_layouts; @@ -123,7 +123,8 @@ public void SliceFilter_GetterReflectsSetter() [Test] public void ShowingAllFields_ReadsShowHiddenSettingForTool() { - m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, true, PropertyTable.SettingsGroup.LocalSettings); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, + PropertyTable.SettingsGroup.LocalSettings, true); m_dtree.Initialize(Cache, false, m_layouts, m_parts); m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); @@ -945,6 +946,442 @@ public void NestedExpandedLayout_HeaderPlusChildren() #endregion + #region Package A Matrix Tests + + [Test] + public void EquivalentKeys_LengthMismatch_ReturnsFalse() + { + bool result = InvokeEquivalentKeys(new object[] { "a" }, new object[] { "a", "b" }, true); + + Assert.That(result, Is.False); + } + + [Test] + public void EquivalentKeys_XmlNodesWithSameNameInnerAndAttributes_ReturnsTrue() + { + XmlNode first = CreateXmlNode("v"); + XmlNode second = CreateXmlNode("v"); + + bool result = InvokeEquivalentKeys(new object[] { first }, new object[] { second }, true); + + Assert.That(result, Is.True); + } + + [Test] + public void EquivalentKeys_XmlNodesWithAttributeMismatch_ReturnsFalse() + { + XmlNode first = CreateXmlNode(""); + XmlNode second = CreateXmlNode(""); + + bool result = InvokeEquivalentKeys(new object[] { first }, new object[] { second }, true); + + Assert.That(result, Is.False); + } + + [Test] + public void EquivalentKeys_IntComparisonHonorsCheckFlag() + { + bool strict = InvokeEquivalentKeys(new object[] { 1 }, new object[] { 2 }, true); + bool loose = InvokeEquivalentKeys(new object[] { 1 }, new object[] { 2 }, false); + + Assert.That(strict, Is.False); + Assert.That(loose, Is.True); + } + + [Test] + public void EquivalentKeys_DifferentNonComparableTypes_ReturnsFalse() + { + bool result = InvokeEquivalentKeys(new object[] { "1" }, new object[] { 1 }, true); + + Assert.That(result, Is.False); + } + + [Test] + public void FindMatchingSlices_FindsSliceForObjectAndKey() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + Slice target = m_dtree.Slices[0]; + Slice newCopy; + Slice found = m_dtree.FindMatchingSlices(target.Object, null, target.Key, target.GetType(), out newCopy); + + Assert.That(found, Is.SameAs(target)); + Assert.That(newCopy, Is.Null); + } + + [Test] + public void FindMatchingSlices_NoMatch_ReturnsNulls() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + Slice target = m_dtree.Slices[0]; + Slice newCopy; + Slice found = m_dtree.FindMatchingSlices(target.Object, null, + new object[] { "definitely-not-a-real-key" }, target.GetType(), out newCopy); + + Assert.That(found, Is.Null); + Assert.That(newCopy, Is.Null); + } + + [Test] + public void IsChildSlice_MatchingPrefix_ReturnsTrue() + { + Slice first = CreateSliceWithKey(1, "root"); + Slice second = CreateSliceWithKey(1, "root", "child"); + bool result = InvokeIsChildSlice(first, second); + + Assert.That(result, Is.True); + } + + [Test] + public void IsChildSlice_ShortOrNullSecondKey_ReturnsFalse() + { + Slice first = CreateSliceWithKey(1, "root"); + + Slice secondWithNull = CreateSliceWithKey((object[])null); + bool nullResult = InvokeIsChildSlice(first, secondWithNull); + + Slice secondWithShortKey = CreateSliceWithKey(1); + bool shortResult = InvokeIsChildSlice(first, secondWithShortKey); + + Assert.That(nullResult, Is.False); + Assert.That(shortResult, Is.False); + } + + [Test] + public void IsChildSlice_MismatchedPrefix_ReturnsFalse() + { + Slice first = CreateSliceWithKey(1, "root"); + Slice second = CreateSliceWithKey(2, "root", "child"); + bool result = InvokeIsChildSlice(first, second); + + Assert.That(result, Is.False); + } + + [Test] + public void GetClassId_DelegatesToMetadataCache() + { + var mdc = Cache.DomainDataByFlid.MetaDataCache; + int expected = mdc.GetClassId("LexEntry"); + int actual = DataTree.GetClassId(mdc, "LexEntry"); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #endregion + + #region Package B Matrix Tests + + [Test] + public void GetMessageTargets_NotVisible_ReturnsEmpty() + { + Assert.That(m_dtree.Visible, Is.False); + + IxCoreColleague[] targets = m_dtree.GetMessageTargets(); + + Assert.That(targets, Is.Empty); + } + + [Test] + public void GetMessageTargets_VisibleWithoutCurrentSlice_ReturnsTreeOnly() + { + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + Assert.That(m_dtree.Visible, Is.True); + + IxCoreColleague[] targets = m_dtree.GetMessageTargets(); + + Assert.That(targets.Length, Is.EqualTo(1)); + Assert.That(targets[0], Is.SameAs(m_dtree)); + } + + [Test] + public void GetMessageTargets_VisibleWithCurrentSlice_ReturnsSliceAndTree() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + Assert.That(m_dtree.Visible, Is.True); + + var currentSlice = m_dtree.Slices[0]; + SetCurrentSliceFieldForTest(currentSlice); + + IxCoreColleague[] targets = m_dtree.GetMessageTargets(); + + Assert.That(targets.Length, Is.EqualTo(2)); + Assert.That(targets[0], Is.SameAs(currentSlice)); + Assert.That(targets[1], Is.SameAs(m_dtree)); + + SetCurrentSliceFieldForTest(null); + } + + [Test] + public void OnDisplayShowHiddenFields_AllowedAndSet_ShowsChecked() + { + m_propertyTable.SetProperty("AllowShowNormalFields", true, true); + m_propertyTable.SetProperty("currentContentControl", "lexiconEdit", true); + m_propertyTable.SetProperty("ShowHiddenFields-lexiconEdit", true, + PropertyTable.SettingsGroup.LocalSettings, true); + + var display = new UIItemDisplayProperties(null, "ShowHiddenFields", true, null, true); + + bool handled = m_dtree.OnDisplayShowHiddenFields(null, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.True); + Assert.That(display.Visible, Is.True); + Assert.That(display.Checked, Is.True); + } + + [Test] + public void OnDisplayShowHiddenFields_NotAllowed_Disables() + { + m_propertyTable.SetProperty("AllowShowNormalFields", false, true); + m_propertyTable.SetProperty("currentContentControl", "lexiconEdit", true); + + var display = new UIItemDisplayProperties(null, "ShowHiddenFields", true, null, true) + { + Enabled = true, + Visible = true + }; + + bool handled = m_dtree.OnDisplayShowHiddenFields(null, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + Assert.That(display.Visible, Is.False); + } + + [Test] + public void OnDelayedRefreshList_ArgumentTogglesDoNotRefresh() + { + Assert.That(m_dtree.DoNotRefresh, Is.False); + + m_dtree.OnDelayedRefreshList(true); + Assert.That(m_dtree.DoNotRefresh, Is.True); + + m_dtree.OnDelayedRefreshList(false); + Assert.That(m_dtree.DoNotRefresh, Is.False); + } + + [Test] + public void OnDisplayInsertItemViaBackrefVector_MatchingClass_Enabled() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "InsertItemViaBackrefVector", true, null, true); + + bool handled = m_dtree.OnDisplayInsertItemViaBackrefVector(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.True); + } + } + + [Test] + public void OnDisplayInsertItemViaBackrefVector_WrongClass_Disabled() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "InsertItemViaBackrefVector", true, null, true) + { + Enabled = true + }; + + bool handled = m_dtree.OnDisplayInsertItemViaBackrefVector(cmd, ref display); + + Assert.That(handled, Is.False); + Assert.That(display.Enabled, Is.False); + } + } + + [Test] + public void OnDisplayDemoteItemInVector_NonRnRoot_Disables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "DemoteItemInVector", true, null, true) + { + Enabled = true + }; + + bool handled = m_dtree.OnDisplayDemoteItemInVector(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + } + } + + [Test] + public void OnDisplayJumpToTool_ValidCommand_Enables() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + using (var cmd = CreateCommandFromXml( + "")) + { + var display = new UIItemDisplayProperties(null, "JumpToTool", true, null, true); + + bool handled = m_dtree.OnDisplayJumpToTool(cmd, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.True); + Assert.That(display.Visible, Is.True); + } + } + + #endregion + + #region Package C Matrix Tests + + [Test] + public void NextFieldAtIndent_FindsNextAtSameIndent() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + SetSliceIndents(0, 1, 1, 2, 0); + + int next = m_dtree.NextFieldAtIndent(1, 1); + + Assert.That(next, Is.EqualTo(2)); + } + + [Test] + public void NextFieldAtIndent_StopsWhenIndentDecreases() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + SetSliceIndents(0, 1, 2, 0, 1); + + int next = m_dtree.NextFieldAtIndent(1, 1); + + Assert.That(next, Is.EqualTo(0)); + } + + [Test] + public void PrevFieldAtIndent_FindsPreviousAtSameIndent() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + SetSliceIndents(0, 1, 2, 1, 0); + + int previous = m_dtree.PrevFieldAtIndent(1, 3); + + Assert.That(previous, Is.EqualTo(1)); + } + + [Test] + public void PrevFieldAtIndent_StopsWhenIndentDecreases() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + SetSliceIndents(1, 0, 2, 2, 1); + + int previous = m_dtree.PrevFieldAtIndent(1, 4); + + Assert.That(previous, Is.EqualTo(0)); + } + + [Test] + public void IndexOfSliceAtY_ReturnsExpectedIndexAndMinusOneAfterLast() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + foreach (Slice slice in m_dtree.Slices) + slice.Height = 20; + + Assert.That(m_dtree.IndexOfSliceAtY(0), Is.EqualTo(0)); + Assert.That(m_dtree.IndexOfSliceAtY(19), Is.EqualTo(0)); + Assert.That(m_dtree.IndexOfSliceAtY(20), Is.EqualTo(1)); + Assert.That(m_dtree.IndexOfSliceAtY(99), Is.EqualTo(4)); + Assert.That(m_dtree.IndexOfSliceAtY(100), Is.EqualTo(-1)); + } + + [Test] + public void GotoNextSliceAfterIndex_AtEnd_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + bool moved = InvokeGotoNextSliceAfterIndex(m_dtree.Slices.Count - 1); + + Assert.That(moved, Is.False); + } + + [Test] + public void GotoPreviousSliceBeforeIndex_AtStart_ReturnsFalse() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + bool moved = m_dtree.GotoPreviousSliceBeforeIndex(0); + + Assert.That(moved, Is.False); + } + + [Test] + public void MakeSliceVisible_TargetSliceAndPriorSlicesBecomeVisible() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "NavigationTest", null, m_entry, false); + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + + for (int i = 0; i < m_dtree.Slices.Count; i++) + m_dtree.Slices[i].Visible = false; + + InvokeMakeSliceVisible(m_dtree.Slices[3]); + + Assert.That(m_dtree.Slices[0].Visible, Is.True); + Assert.That(m_dtree.Slices[1].Visible, Is.True); + Assert.That(m_dtree.Slices[2].Visible, Is.True); + Assert.That(m_dtree.Slices[3].Visible, Is.True); + Assert.That(m_dtree.Slices[4].Visible, Is.False); + } + + [Test] + public void GotoFirstSlice_SetsCurrentSlice_WhenFocusable() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + SetControlVisibleForTest(m_parent, true); + SetControlVisibleForTest(m_dtree, true); + + Slice first = m_dtree.Slices[0]; + Assert.That(first.Control, Is.Not.Null); + Assume.That(first.Control.TabStop && first.Control.CanFocus, + "Current harness did not produce a focusable first slice control."); + + SetCurrentSliceFieldForTest(first); + bool movedToNext = InvokeGotoNextSliceAfterIndex(0); + Assert.That(movedToNext, Is.False); + SetCurrentSliceFieldForTest(first); + + m_dtree.GotoFirstSlice(); + + Assert.That(m_dtree.CurrentSlice, Is.SameAs(first)); + } + + #endregion + #region Helper Methods /// @@ -981,6 +1418,83 @@ private int GetInvalidFieldCacheCount() return (int)countProperty.GetValue(value, null); } + private bool InvokeEquivalentKeys(object[] newKey, object[] oldKey, bool fCheckInts) + { + var method = typeof(DataTree).GetMethod("EquivalentKeys", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.EquivalentKeys method"); + return (bool)method.Invoke(m_dtree, new object[] { newKey, oldKey, fCheckInts }); + } + + private bool InvokeIsChildSlice(Slice first, Slice second) + { + var method = typeof(DataTree).GetMethod("IsChildSlice", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.IsChildSlice method"); + return (bool)method.Invoke(null, new object[] { first, second }); + } + + private bool InvokeGotoNextSliceAfterIndex(int index) + { + var method = typeof(DataTree).GetMethod("GotoNextSliceAfterIndex", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.GotoNextSliceAfterIndex method"); + return (bool)method.Invoke(m_dtree, new object[] { index }); + } + + private static void InvokeMakeSliceVisible(Slice slice) + { + var method = typeof(DataTree).GetMethod("MakeSliceVisible", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.MakeSliceVisible method"); + method.Invoke(null, new object[] { slice }); + } + + private void SetSliceIndents(params int[] indents) + { + Assert.That(m_dtree.Slices.Count, Is.EqualTo(indents.Length), + "Expected one indent value per existing slice"); + for (int i = 0; i < indents.Length; i++) + m_dtree.Slices[i].Indent = indents[i]; + } + + private static Slice CreateSliceWithKey(params object[] key) + { + var slice = new Slice(); + slice.Key = key; + return slice; + } + + private static XmlNode CreateXmlNode(string xml) + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + return doc.DocumentElement; + } + + private void SetCurrentSliceFieldForTest(Slice slice) + { + var field = typeof(DataTree).GetField("m_currentSlice", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_currentSlice field"); + field.SetValue(m_dtree, slice); + } + + private static void SetControlVisibleForTest(Control control, bool visible) + { + var method = typeof(Control).GetMethod("SetVisibleCore", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect Control.SetVisibleCore method"); + method.Invoke(control, new object[] { visible }); + } + + private static Command CreateCommandFromXml(string commandXml) + { + var doc = new XmlDocument(); + doc.LoadXml(commandXml); + return new Command(null, doc.DocumentElement); + } + #endregion } } diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md new file mode 100644 index 0000000000..4b641e75cb --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md @@ -0,0 +1,194 @@ +# Coverage Wave 2 Test Matrix (2026-02-25) + +## Goal + +Increase characterization coverage before refactor by adding deterministic tests in three areas: + +- `DataTree` pure logic and key matching +- `DataTree` command/message handlers +- `DataTree` navigation + UI-adjacent helper logic + +Current baseline from latest coverage artifacts: + +- `DataTree`: 40.59% line / 28.03% branch +- `Slice`: 30.27% line / 19.4% branch +- `ObjSeqHashMap`: 98.39% line / 94.44% branch + +## Rerun Status (2026-02-25, refreshed) + +Reran managed coverage assessment via: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DetailControls"` + +Latest focused class coverage after Wave 2: + +- `DataTree`: **51.91%** line / **38.83%** branch +- `Slice`: **30.88%** line / **19.95%** branch +- `ObjSeqHashMap`: **98.39%** line / **94.44%** branch + +## Rerun Status (2026-02-25, post-Wave 3) + +After Wave 3 test additions and test-file regrouping, coverage was rerun with: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DetailControls"` + +Latest focused class coverage: + +- `DataTree`: **57.96%** line / **43.36%** branch +- `Slice`: **32.55%** line / **21.72%** branch +- `ObjSeqHashMap`: **98.39%** line / **94.44%** branch + +Net effect: post-Wave 3 work significantly improved DataTree and modestly improved Slice (from additional exercised shared paths). + +## Rerun Status (2026-02-25, continued incremental additions) + +After adding another deterministic batch and rerunning: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **57.46%** line / **42.87%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Note: this latest increment kept tests green but did **not** move focused class percentages in the managed assessment output, so the next batch should target methods not currently represented in the focused class method map. + +### Correction: build-backed reruns required + +Subsequent verification found that earlier reruns using `-NoBuild` were not compiling the newest test edits, so coverage appeared artificially flat. The current baseline below is from build-backed runs: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage after additional deterministic tests: + +- `DataTree`: **61.09%** line / **45.34%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the build-backed continuation pass: + +- `DataTree`: **+1.26 line** / **+1.40 branch** (from 59.83 / 43.94) + +Wave 2 desired target: + +- `DataTree` line coverage toward ~46-50% +- `DataTree` branch coverage toward ~34-38% + +## Harness Types + +- **H1 — Existing DataTree fixture**: `DataTreeTests` with in-memory backend, `Mediator`, `PropertyTable`, `Form` host. +- **H2 — Reflection helper harness**: invoke private methods (`BindingFlags.NonPublic`) for pure logic methods. +- **H3 — Command XML harness**: create `Command` from inline XML + `UIItemDisplayProperties` for `OnDisplay*` handlers. +- **H4 — Navigation host harness**: use existing `Form` host and `ShowObject` layouts (`NavigationTest`, `CfOnly`, `CfAndBib`) to drive slice focus/visibility. +- **H5 — Bitmap paint harness (planned, not in initial implementation pass)**: `Bitmap` + `Graphics.FromImage` + `PaintEventArgs`. + +## Planned Tests (Detailed) + +## Package A — Pure Logic and Key Matching (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `EquivalentKeys_LengthMismatch_ReturnsFalse` | `EquivalentKeys` | +2 lines, branch false path | H2 | +| `EquivalentKeys_XmlNodesWithSameNameInnerAndAttributes_ReturnsTrue` | `EquivalentKeys` | +7 lines, xml/attr loop true path | H2 | +| `EquivalentKeys_XmlNodesWithAttributeMismatch_ReturnsFalse` | `EquivalentKeys` | +5 lines, xml attr mismatch branch | H2 | +| `EquivalentKeys_IntComparisonHonorsCheckFlag` | `EquivalentKeys` | +4 lines, int/fCheckInts paths | H2 | +| `EquivalentKeys_DifferentNonComparableTypes_ReturnsFalse` | `EquivalentKeys` | +2 lines, terminal false branch | H2 | +| `FindMatchingSlices_FindsSliceForObjectAndKey` | `FindMatchingSlices`, `EquivalentKeys` | +10 lines, match path | H1+H2 | +| `FindMatchingSlices_NoMatch_ReturnsNulls` | `FindMatchingSlices` | +6 lines, no-match loop path | H1+H2 | +| `IsChildSlice_MatchingPrefix_ReturnsTrue` | `IsChildSlice` | +6 lines, positive path | H2 | +| `IsChildSlice_ShortOrNullSecondKey_ReturnsFalse` | `IsChildSlice` | +4 lines, null/len guard | H2 | +| `IsChildSlice_MismatchedPrefix_ReturnsFalse` | `IsChildSlice` | +4 lines, mismatch loop branch | H2 | +| `GetClassId_DelegatesToMetadataCache` | `GetClassId` | +2 lines | H1 | + +**Package A expected gain (DataTree):** ~45-55 lines + +## Package B — Command/Message Handlers (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `GetMessageTargets_NotVisible_ReturnsEmpty` | `GetMessageTargets` | +4 lines, visibility false path | H1 | +| `GetMessageTargets_VisibleWithoutCurrentSlice_ReturnsTreeOnly` | `GetMessageTargets` | +5 lines, visible/default path | H1 | +| `GetMessageTargets_VisibleWithCurrentSlice_ReturnsSliceAndTree` | `GetMessageTargets` | +5 lines, current-slice path | H1 | +| `OnDisplayShowHiddenFields_AllowedAndSet_ShowsChecked` | `OnDisplayShowHiddenFields` | +8 lines, allowed/checked path | H1+H3 | +| `OnDisplayShowHiddenFields_NotAllowed_Disables` | `OnDisplayShowHiddenFields` | +6 lines, disallowed path | H1+H3 | +| `OnDelayedRefreshList_ArgumentTogglesDoNotRefresh` | `OnDelayedRefreshList` | +3 lines | H1 | +| `OnDisplayInsertItemViaBackrefVector_MatchingClass_Enabled` | `OnDisplayInsertItemViaBackrefVector` | +8 lines, enabled path | H1+H3 | +| `OnDisplayInsertItemViaBackrefVector_WrongClass_Disabled` | `OnDisplayInsertItemViaBackrefVector` | +7 lines, disabled path | H1+H3 | +| `OnDisplayDemoteItemInVector_NonRnRoot_Disables` | `OnDisplayDemoteItemInVector` | +7 lines, guard branch | H1+H3 | +| `OnDisplayJumpToTool_ValidCommand_Enables` | `OnDisplayJumpToTool` | +8 lines, happy path | H1+H3 | + +**Package B expected gain (DataTree):** ~60-75 lines + +## Package C — Navigation and Utility Paths (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `NextFieldAtIndent_FindsNextAtSameIndent` | `NextFieldAtIndent` | +6 lines | H1+H4 | +| `NextFieldAtIndent_StopsWhenIndentDecreases` | `NextFieldAtIndent` | +4 lines | H1+H4 | +| `PrevFieldAtIndent_FindsPreviousAtSameIndent` | `PrevFieldAtIndent` | +6 lines | H1+H4 | +| `PrevFieldAtIndent_StopsWhenIndentDecreases` | `PrevFieldAtIndent` | +4 lines | H1+H4 | +| `IndexOfSliceAtY_ReturnsExpectedIndexAndMinusOneAfterLast` | `IndexOfSliceAtY`, `HeightOfSliceOrNullAt` | +10 lines | H1+H4 | +| `GotoNextSliceAfterIndex_AtEnd_ReturnsFalse` | `GotoNextSliceAfterIndex` | +6 lines, fail path | H1+H4 | +| `GotoPreviousSliceBeforeIndex_AtStart_ReturnsFalse` | `GotoPreviousSliceBeforeIndex` | +6 lines, fail path | H1+H4 | +| `MakeSliceVisible_TargetSliceAndPriorSlicesBecomeVisible` | `MakeSliceVisible` | +10 lines | H1+H4 | +| `GotoFirstSlice_SetsCurrentSlice_WhenFocusable` | `GotoFirstSlice`, `GotoNextSliceAfterIndex` | +8 lines | H1+H4 | + +**Package C expected gain (DataTree):** ~50-65 lines + +## Planned but deferred to next pass (higher harness complexity) + +| Planned test area | Method(s) | Why deferred | Harness | +|---|---|---|---| +| Paint state machine tests | `OnPaint`, `HandlePaintLinesBetweenSlices` | Need controlled paint/layout surface and robustness checks | H5 | +| Context menu popup behavior | `OnShowContextMenu`, `GetSliceContextMenu` | Requires reliable context menu handler/event plumbing in tests | H1+H3 | +| Focus idle-queue behavior | `OnFocusFirstPossibleSlice`, `DoPostponedFocusSlice`, `FocusFirstPossibleSlice` | Message-pump sensitivity in CI | H4 + pump surrogate | +| Deep slice expansion matrix | `Slice.GenerateChildren`, `CreateIndentedNodes` | Needs richer layout fixture and expansion-state matrix | New Slice lifecycle harness | + +## Subagent Implementation Plan + +- **Subagent A (Pure Logic):** implement Package A tests + minimal reflection helpers. +- **Subagent B (Command Handlers):** implement Package B tests + command helper method(s). +- **Subagent C (Navigation):** implement Package C tests using existing layouts and form host. + +All code changes are expected in: + +- `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs` + +Validation after all subagents complete: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DetailControls"` +- Coverage rerun via `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DetailControls"` + +## Wave 3 Follow-on Work (current) + +To keep test growth manageable, DataTree tests are now being split into logical partial-class files: + +- `DataTreeTests.cs` (core fixture/setup + existing suites) +- `DataTreeTests.Wave3.CommandsAndProps.cs` (command handlers + low-cost property coverage) +- `DataTreeTests.Wave3.Navigation.cs` (additional navigation edge/path coverage) + +Wave 3 focus is on additional deterministic methods still listed as 0% in the gap report, especially: + +- `OnDisplayJumpToLexiconEditFilterAnthroItems` +- `OnDisplayJumpToNotebookEditFilterAnthroItems` +- `OnJumpToTool` +- `OnReadyToSetCurrentSlice` +- `OnFocusFirstPossibleSlice` +- `get_LastSlice`, `get_LabelWidth`, `get_Priority`, `get_ShouldNotCall`, `get_SliceControlContainer` + +Wave 3 implementation status: + +- ✅ Added grouped files: + - `DataTreeTests.Wave3.CommandsAndProps.cs` + - `DataTreeTests.Wave3.Navigation.cs` +- ✅ Added deterministic tests for: + - Jump-to-tool display/action handlers (`OnDisplayJumpToLexicon...`, `OnDisplayJumpToNotebook...`, `OnJumpToTool`) + - Message-target path where tree is hidden but `CurrentSlice` exists + - Property/unit gaps (`Priority`, `ShouldNotCall`, `SliceControlContainer`, `LabelWidth`, `LastSlice`, `SliceSplitPositionBase`, `SmallImages`, `StyleSheet`, `PersistenceProvder`, `ConstructingSlices`, `HasSubPossibilitiesSlice`) + - Additional navigation edge paths (`GotoFirstSlice` with no slices, `GotoNextSlice` with null/last current, `IndexOfSliceAtY` empty tree, `GotoPreviousSliceBeforeIndex` empty tree) + - Context menu plumbing (`GetSliceContextMenu`, `SetContextMenuHandler`, non-popup `OnShowContextMenu`) + - Additional low-risk action/utility probes (`RefreshDisplay`, `NotebookRecordRefersToThisText`, `SetCurrentObjectFlids/ClearCurrentObjectFlids`, `PostponePropChanged`, `PrepareToGoAway`, `OnInsertItemViaBackrefVector` wrong-class guard, `OnDemoteItemInVector` null-root guard) + - Additional deterministic gap reducers (`PropChanged` monitored/unmonitored with refresh suppression, `ResetRecordListUpdater` no-owner path, `OnInsertItemViaBackrefVector` missing-field guard, `OnDemoteItemInVector` non-notebook-root guard) From 341f33a8d36f7ee399ed8a4fcf407522181bf843 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 5/6] test(detailcontrols): expand Wave 4 offscreen/layout coverage and record gains - add IDataTreePainter seam and offscreen paint test harness (RecordingPainter, OffscreenGraphicsContext) - add DataTree Wave 4 tests for OnPaint/paint branches and HandleLayout1 branch paths - add Slice branch tests for IsObjectNode, LabelIndent, and GetBranchHeight - fix Slice IsObjectNode test setup to avoid NRE by setting DataTree root directly - update OpenSpec coverage matrix with 2026-02-26 checkpoint (DataTree 67.51/52.02, Slice 34.5/22.4) --- .../Controls/DetailControls/DataTree.cs | 31 +- .../DataTreeTests.Wave3.CommandsAndProps.cs | 537 +++++++++++++ .../DataTreeTests.Wave4.OffscreenUI.cs | 754 ++++++++++++++++++ .../OffscreenGraphicsContext.cs | 90 +++ .../DetailControlsTests/RecordingPainter.cs | 52 ++ .../DetailControlsTests/SliceTests.cs | 63 ++ .../DetailControls/IDataTreePainter.cs | 29 + .../coverage-wave2-test-matrix.md | 155 ++++ .../test-plan-forms-future.md | 241 ++++++ .../test-plan-forms.md | 239 ++++++ .../testing-approach-2.md | 339 ++++++++ 11 files changed, 2521 insertions(+), 9 deletions(-) create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave4.OffscreenUI.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/OffscreenGraphicsContext.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/RecordingPainter.cs create mode 100644 Src/Common/Controls/DetailControls/IDataTreePainter.cs create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 65adc8f8c6..84a845e954 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -54,7 +54,7 @@ namespace SIL.FieldWorks.Common.Framework.DetailControls /// System.Windows.Forms.Panel /// System.Windows.Forms.ContainerControl /// System.Windows.Forms.UserControl - public class DataTree : UserControl, IVwNotifyChange, IxCoreColleague, IRefreshableRoot + public class DataTree : UserControl, IVwNotifyChange, IxCoreColleague, IRefreshableRoot, IDataTreePainter { /// /// Part refs that don't represent actual data slices @@ -161,6 +161,14 @@ public class DataTree : UserControl, IVwNotifyChange, IxCoreColleague, IRefresha public List Slices { get; private set; } + /// + /// The painter used to draw separator lines between slices. + /// Defaults to this (DataTree's own implementation). + /// Tests can substitute a recording implementation to verify + /// rendering without a visible window. + /// + public IDataTreePainter Painter { get; set; } + #endregion Data members #region constants @@ -474,6 +482,7 @@ public DataTree() // string objName = ToString() + GetHashCode().ToString(); // Debug.WriteLine("Creating object:" + objName); Slices = new List(); + Painter = this; m_autoCustomFieldNodesDocument = new XmlDocument(); m_autoCustomFieldNodesDocRoot = m_autoCustomFieldNodesDocument.CreateElement("root"); m_autoCustomFieldNodesDocument.AppendChild(m_autoCustomFieldNodesDocRoot); @@ -1627,7 +1636,7 @@ public virtual bool RefreshDisplay() /// Answer true if the two slices are displaying fields of the same object. /// Review: should we require more strictly, that the full path of objects in their keys are the same? /// - private static bool SameSourceObject(Slice first, Slice second) + internal static bool SameSourceObject(Slice first, Slice second) { return first.Object.Hvo == second.Object.Hvo; } @@ -1635,7 +1644,7 @@ private static bool SameSourceObject(Slice first, Slice second) /// /// Answer true if the second slice is a 'child' of the first (common key) /// - private static bool IsChildSlice(Slice first, Slice second) + internal static bool IsChildSlice(Slice first, Slice second) { if (second.Key == null || second.Key.Length <= first.Key.Length) return false; @@ -1655,12 +1664,16 @@ private static bool IsChildSlice(Slice first, Slice second) /// This actually handles Paint for the contained control that has the slice controls in it. /// /// The instance containing the event data. - void HandlePaintLinesBetweenSlices(PaintEventArgs pea) + internal void HandlePaintLinesBetweenSlices(PaintEventArgs pea) + { + PaintLinesBetweenSlices(pea.Graphics, Width); + } + + /// + /// IDataTreePainter implementation: draw separator lines between slices. + /// + public void PaintLinesBetweenSlices(Graphics gr, int width) { - Graphics gr = pea.Graphics; - UserControl uc = this; - // Where we're drawing. - int width = uc.Width; using (var thinPen = new Pen(Color.LightGray, 1)) using (var thickPen = new Pen(Color.LightGray, 1 + HeavyweightRuleThickness)) { @@ -3593,7 +3606,7 @@ protected override void OnPaint(PaintEventArgs e) else { base.OnPaint(e); - HandlePaintLinesBetweenSlices(e); + Painter.PaintLinesBetweenSlices(e.Graphics, Width); } } finally diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs index 0ef5f7de3a..7a14b69a7e 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs @@ -326,6 +326,166 @@ public void PersistenceProvider_SetterAndGetterRoundTrip() Assert.That(m_dtree.PersistenceProvder, Is.SameAs(provider)); } + [Test] + public void RestorePreferences_WithPersistedSplitterDistance_UpdatesSliceSplitPositionBase() + { + var provider = new PersistenceProvider(m_mediator, m_propertyTable, "DataTreeTests"); + provider.SetInfoObject("SliceSplitterBaseDistance", 123); + m_dtree.PersistenceProvder = provider; + + var method = typeof(DataTree).GetMethod("RestorePreferences", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.RestorePreferences method"); + + method.Invoke(m_dtree, null); + + Assert.That(m_dtree.SliceSplitPositionBase, Is.EqualTo(123)); + } + + [Test] + public void ApplyChildren_WithOnlyChangeRecordHandler_ReturnsUnchangedInsertPosition() + { + var template = CreateXmlNode(""); + int result = m_dtree.ApplyChildren(m_entry, null, template, 0, 9, + new System.Collections.ArrayList(), new ObjSeqHashMap()); + + Assert.That(result, Is.EqualTo(9)); + } + + [Test] + public void MakeEditorAt_DefaultImplementation_ReturnsNull() + { + Assert.That(m_dtree.MakeEditorAt(0), Is.Null); + } + + [Test] + public void AddAtomicNode_WhenFlidIsZero_ThrowsApplicationException() + { + int insertPosition = 0; + var ex = Assert.Throws(() => InvokeAddAtomicNode( + new System.Collections.ArrayList(), + CreateXmlNode(""), + new ObjSeqHashMap(), + 0, + m_entry, + null, + 0, + ref insertPosition, + true, + "default", + false, + null)); + + Assert.That(ex.InnerException, Is.TypeOf()); + } + + [Test] + public void AddAtomicNode_TestOnlyWithoutDataAndNoGhost_ReturnsPossible() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + int flid = LexEntryTags.kflidLexemeForm; + int insertPosition = 0; + var result = InvokeAddAtomicNode( + new System.Collections.ArrayList(), + CreateXmlNode(""), + new ObjSeqHashMap(), + flid, + m_entry, + null, + 0, + ref insertPosition, + true, + "default", + false, + null); + + Assert.That(result, Is.EqualTo(DataTree.NodeTestResult.kntrPossible)); + } + + [Test] + public void AddAtomicNode_TestOnlyWithGhost_ReturnsSomething() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + int flid = LexEntryTags.kflidLexemeForm; + int insertPosition = 0; + var result = InvokeAddAtomicNode( + new System.Collections.ArrayList(), + CreateXmlNode(""), + new ObjSeqHashMap(), + flid, + m_entry, + null, + 0, + ref insertPosition, + true, + "default", + false, + null); + + Assert.That(result, Is.EqualTo(DataTree.NodeTestResult.kntrSomething)); + } + + [Test] + public void AddAtomicNode_VisIfDataWithoutData_ReturnsNothing() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + int flid = LexEntryTags.kflidLexemeForm; + int insertPosition = 0; + var result = InvokeAddAtomicNode( + new System.Collections.ArrayList(), + CreateXmlNode(""), + new ObjSeqHashMap(), + flid, + m_entry, + null, + 0, + ref insertPosition, + false, + "default", + true, + null); + + Assert.That(result, Is.EqualTo(DataTree.NodeTestResult.kntrNothing)); + } + + [Test] + public void RefreshListByHvoTag_NoPendingRefresh_DoesNotThrow() + { + var method = typeof(DataTree).GetMethod("RefreshList", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + new[] { typeof(int), typeof(int) }, + null); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.RefreshList(int, int) method"); + + m_dtree.RefreshListNeeded = false; + Assert.DoesNotThrow(() => method.Invoke(m_dtree, new object[] { m_entry.Hvo, LexEntryTags.kflidCitationForm })); + Assert.That(m_dtree.RefreshListNeeded, Is.False); + } + + [Test] + public void RefreshListByHvoTag_WithPendingRefreshAndSuppressedRefresh_LeavesRefreshNeededTrue() + { + var method = typeof(DataTree).GetMethod("RefreshList", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + new[] { typeof(int), typeof(int) }, + null); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.RefreshList(int, int) method"); + + m_dtree.DoNotRefresh = true; + m_dtree.RefreshListNeeded = true; + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, new object[] { m_entry.Hvo, LexEntryTags.kflidCitationForm })); + Assert.That(m_dtree.RefreshListNeeded, Is.True); + } + [Test] public void RefreshDisplay_ReturnsTrue() { @@ -491,6 +651,142 @@ public void ResetRecordListUpdater_WithListNameAndNoWindowOwner_LeavesUpdaterNul Assert.That(rluField.GetValue(m_dtree), Is.Null); } + [Test] + public void SetCurrentSliceNewFromObject_MatchingSlice_SetsCurrentSliceNewField() + { + var first = new Slice { Object = m_entry }; + var secondEntry = Cache.ServiceLocator.GetInstance().Create(); + var second = new Slice { Object = secondEntry }; + + m_dtree.Slices.Clear(); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + var method = typeof(DataTree).GetMethod("SetCurrentSliceNewFromObject", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.SetCurrentSliceNewFromObject method"); + method.Invoke(m_dtree, new object[] { secondEntry }); + + var currentSliceNewField = typeof(DataTree).GetField("m_currentSliceNew", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(currentSliceNewField, Is.Not.Null, "Could not reflect DataTree.m_currentSliceNew field"); + Assert.That(currentSliceNewField.GetValue(m_dtree), Is.SameAs(second)); + } + + [Test] + public void SetCurrentSliceNewFromObject_NoMatch_LeavesCurrentSliceNewNull() + { + var first = new Slice { Object = m_entry }; + m_dtree.Slices.Clear(); + m_dtree.Slices.Add(first); + + var currentSliceNewField = typeof(DataTree).GetField("m_currentSliceNew", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(currentSliceNewField, Is.Not.Null, "Could not reflect DataTree.m_currentSliceNew field"); + currentSliceNewField.SetValue(m_dtree, null); + + var method = typeof(DataTree).GetMethod("SetCurrentSliceNewFromObject", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.SetCurrentSliceNewFromObject method"); + method.Invoke(m_dtree, new object[] { Cache.ServiceLocator.GetInstance().Create() }); + + Assert.That(currentSliceNewField.GetValue(m_dtree), Is.Null); + } + + [Test] + public void CreateAndAssociateNotebookRecord_WhenCurrentSliceObjectIsNotText_ThrowsArgumentException() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + SetCurrentSliceFieldForTest(m_dtree.Slices[0]); + + var method = typeof(DataTree).GetMethod("CreateAndAssociateNotebookRecord", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.CreateAndAssociateNotebookRecord method"); + + var ex = Assert.Throws(() => method.Invoke(m_dtree, null)); + Assert.That(ex.InnerException, Is.TypeOf()); + } + + [Test] + public void FixRecordList_WithNullHandlers_DoesNotThrow() + { + Assert.DoesNotThrow(() => m_dtree.FixRecordList()); + } + + [Test] + public void DescendantForSlice_ParentNull_ReturnsRoot() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + var method = typeof(DataTree).GetMethod("DescendantForSlice", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.DescendantForSlice method"); + + var slice = m_dtree.Slices[0]; + var result = method.Invoke(m_dtree, new object[] { slice }) as ICmObject; + + Assert.That(result, Is.SameAs(m_entry)); + } + + [Test] + public void DescendantForSlice_HeaderAncestor_ReturnsHeaderObject() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + + var headerObject = Cache.ServiceLocator.GetInstance().Create(); + var parentSlice = new Slice + { + Object = m_entry, + ConfigurationNode = CreateXmlNode("") + }; + var headerSlice = new Slice + { + Object = headerObject, + ParentSlice = parentSlice, + ConfigurationNode = CreateXmlNode("") + }; + var childSlice = new Slice + { + Object = m_entry, + ParentSlice = headerSlice, + ConfigurationNode = CreateXmlNode("") + }; + + var method = typeof(DataTree).GetMethod("DescendantForSlice", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.DescendantForSlice method"); + + var result = method.Invoke(m_dtree, new object[] { childSlice }) as ICmObject; + + Assert.That(result, Is.SameAs(headerObject)); + } + + [Test] + public void RawSetSlice_ReplacesSliceAtIndex() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + var replacement = new Slice { Object = m_entry, ConfigurationNode = CreateXmlNode("") }; + m_dtree.RawSetSlice(0, replacement); + + Assert.That(m_dtree.Slices[0], Is.SameAs(replacement)); + } + + [Test] + public void RemoveDisposedSlice_RemovesMatchingSliceFromCollection() + { + var slice = new Slice(); + m_dtree.Slices.Add(slice); + + m_dtree.RemoveDisposedSlice(slice); + + Assert.That(m_dtree.Slices.Contains(slice), Is.False); + } + [Test] public void NotebookRecordRefersToThisText_Null_ThrowsArgumentException() { @@ -574,6 +870,247 @@ public void OnShowContextMenu_InvokesHandlerForNonPopupForm() Assert.That(capturedSlice, Is.SameAs(slice)); } + [Test] + public void SelectFirstPossibleSlice_EmptyList_DoesNotThrow() + { + Assert.DoesNotThrow(() => m_dtree.SelectFirstPossibleSlice(new System.Collections.Generic.List())); + } + + [Test] + public void SelectFirstPossibleSlice_SliceFromDifferentTree_DoesNotThrow() + { + var otherTree = new DataTree(); + try + { + var slice = new Slice(); + otherTree.Slices.Add(slice); + + Assert.DoesNotThrow(() => + m_dtree.SelectFirstPossibleSlice(new System.Collections.Generic.List { slice })); + } + finally + { + otherTree.Dispose(); + } + } + + [Test] + public void SelectFirstPossibleSlice_DisposedSlice_DoesNotThrow() + { + var slice = new Slice(); + slice.Dispose(); + + Assert.DoesNotThrow(() => + m_dtree.SelectFirstPossibleSlice(new System.Collections.Generic.List { slice })); + } + + [Test] + public void TraceMethods_WhenVerboseEnabled_DoNotThrow() + { + var tree = new TraceTestDataTree(); + try + { + tree.SetTraceLevel(System.Diagnostics.TraceLevel.Verbose); + Assert.DoesNotThrow(() => tree.CallTraceVerbose("v")); + Assert.DoesNotThrow(() => tree.CallTraceVerboseLine("vl")); + Assert.DoesNotThrow(() => tree.CallTraceInfoLine("il")); + } + finally + { + tree.Dispose(); + } + } + + [Test] + public void RecordChangeHandlerDisposed_WhenSenderDiffers_DoesNotClearField() + { + var fakeRch = new FakeRecordChangeHandler(); + var rchField = typeof(DataTree).GetField("m_rch", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(rchField, Is.Not.Null, "Could not reflect DataTree.m_rch field"); + rchField.SetValue(m_dtree, fakeRch); + + var method = typeof(DataTree).GetMethod("m_rch_Disposed", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.m_rch_Disposed method"); + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, new object[] { new object(), EventArgs.Empty })); + Assert.That(rchField.GetValue(m_dtree), Is.SameAs(fakeRch)); + } + + [Test] + public void RecordChangeHandlerDisposed_WhenSenderMatches_ClearsField() + { + var fakeRch = new FakeRecordChangeHandler(); + var rchField = typeof(DataTree).GetField("m_rch", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(rchField, Is.Not.Null, "Could not reflect DataTree.m_rch field"); + rchField.SetValue(m_dtree, fakeRch); + + var method = typeof(DataTree).GetMethod("m_rch_Disposed", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.m_rch_Disposed method"); + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, new object[] { fakeRch, EventArgs.Empty })); + Assert.That(rchField.GetValue(m_dtree), Is.Null); + } + + [Test] + public void InsertSliceRange_WithTwoSlices_InsertsBoth() + { + var tree = new InsertSliceRangeTestDataTree(); + try + { + var first = new Slice(); + var second = new Slice(); + var range = new System.Collections.Generic.HashSet { first, second }; + + Assert.DoesNotThrow(() => tree.CallInsertSliceRange(0, range)); + Assert.That(tree.Slices.Count, Is.EqualTo(2)); + Assert.That(tree.Slices.Contains(first), Is.True); + Assert.That(tree.Slices.Contains(second), Is.True); + } + finally + { + tree.Dispose(); + } + } + + [Test] + public void SliceSplitterMoved_WhenCurrentSliceNull_DoesNotThrow() + { + var method = typeof(DataTree).GetMethod("slice_SplitterMoved", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.slice_SplitterMoved method"); + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, + new object[] { null, new System.Windows.Forms.SplitterEventArgs(0, 0, 0, 0) })); + } + + [Test] + public void ObjSeqHashMap_GetSliceToReuse_ReturnsAndRemovesSlice() + { + var map = new ObjSeqHashMap(); + var key = new System.Collections.ArrayList { 1, 2, 3 }; + var slice = new Slice(); + try + { + slice.Key = new object[] { 1, 2, 3 }; + map.Add(key, slice); + + var reused = map.GetSliceToReuse(nameof(Slice)); + + Assert.That(reused, Is.SameAs(slice)); + Assert.That(map[key].Count, Is.EqualTo(0)); + } + finally + { + slice.Dispose(); + } + } + + [Test] + public void ObjSeqHashMap_Report_DoesNotThrow() + { + var map = new ObjSeqHashMap(); + Assert.DoesNotThrow(() => map.Report()); + } + + private sealed class TraceTestDataTree : DataTree + { + public void SetTraceLevel(System.Diagnostics.TraceLevel level) + { + m_traceSwitch.Level = level; + } + + public void CallTraceVerbose(string message) + { + TraceVerbose(message); + } + + public void CallTraceVerboseLine(string message) + { + TraceVerboseLine(message); + } + + public void CallTraceInfoLine(string message) + { + TraceInfoLine(message); + } + } + + private sealed class InsertSliceRangeTestDataTree : DataTree + { + public void CallInsertSliceRange(int insertPosition, System.Collections.Generic.ISet slices) + { + InsertSliceRange(insertPosition, slices); + } + } + + private DataTree.NodeTestResult InvokeAddAtomicNode( + System.Collections.ArrayList path, + System.Xml.XmlNode node, + ObjSeqHashMap reuseMap, + int flid, + ICmObject obj, + Slice parentSlice, + int indent, + ref int insertPosition, + bool fTestOnly, + string layoutName, + bool fVisIfData, + System.Xml.XmlNode caller) + { + var method = typeof(DataTree).GetMethod("AddAtomicNode", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.AddAtomicNode method"); + + object[] args = + { + path, + node, + reuseMap, + flid, + obj, + parentSlice, + indent, + insertPosition, + fTestOnly, + layoutName, + fVisIfData, + caller + }; + + var result = (DataTree.NodeTestResult)method.Invoke(m_dtree, args); + insertPosition = (int)args[7]; + return result; + } + + private sealed class FakeRecordChangeHandler : SIL.FieldWorks.Common.Framework.IRecordChangeHandler + { + public event EventHandler Disposed; + + public bool HasRecordListUpdater + { + get { return false; } + } + + public void Setup(object record, SIL.FieldWorks.Common.Framework.IRecordListUpdater rlu, LcmCache cache) + { + } + + public void Fixup(bool fRefreshList) + { + } + + public void Dispose() + { + var handler = Disposed; + if (handler != null) + handler(this, EventArgs.Empty); + } + } + #endregion } } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave4.OffscreenUI.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave4.OffscreenUI.cs new file mode 100644 index 0000000000..51de44312d --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave4.OffscreenUI.cs @@ -0,0 +1,754 @@ +// Copyright (c) 2016 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.Drawing; +using System.Linq; +using System.Reflection; +using System.Windows.Forms; +using NUnit.Framework; +using SIL.LCModel; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + [TestFixture] + public partial class DataTreeTests + { + #region Wave 4 — Offscreen UI & Painter Interface + + #region IDataTreePainter contract + + /// + /// The default Painter property should point to the DataTree itself. + /// Category: SurvivesRefactoring — tests the seam contract. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void Painter_DefaultIsSelf() + { + Assert.That(m_dtree.Painter, Is.SameAs(m_dtree), + "DataTree.Painter should default to 'this'"); + } + + /// + /// The Painter property can be replaced with a test double. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void Painter_CanBeReplacedWithRecorder() + { + var recorder = new RecordingPainter(); + m_dtree.Painter = recorder; + + Assert.That(m_dtree.Painter, Is.SameAs(recorder)); + } + + /// + /// When a RecordingPainter is injected and PaintLinesBetweenSlices is called, + /// the recorder captures the call. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void RecordingPainter_RecordsPaintCalls() + { + var recorder = new RecordingPainter(); + using (var ctx = new OffscreenGraphicsContext()) + { + recorder.PaintLinesBetweenSlices(ctx.Graphics, 800); + + Assert.That(recorder.PaintCallCount, Is.EqualTo(1)); + Assert.That(recorder.Calls[0].Width, Is.EqualTo(800)); + Assert.That(recorder.Calls[0].Graphics, Is.SameAs(ctx.Graphics)); + } + } + + /// + /// RecordingPainter's Delegate property forwards calls to another painter. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void RecordingPainter_DelegateForwardsCall() + { + var inner = new RecordingPainter(); + var outer = new RecordingPainter { Delegate = inner }; + using (var ctx = new OffscreenGraphicsContext()) + { + outer.PaintLinesBetweenSlices(ctx.Graphics, 400); + + Assert.That(outer.PaintCallCount, Is.EqualTo(1), + "Outer recorder should record the call"); + Assert.That(inner.PaintCallCount, Is.EqualTo(1), + "Inner delegate should also receive the call"); + } + } + + #endregion + + #region PaintLinesBetweenSlices — offscreen bitmap tests + + /// + /// Calling PaintLinesBetweenSlices with no slices loaded must not throw. + /// Category: SurvivesRefactoring — empty-state safety. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_NoSlices_DoesNotThrow() + { + // m_dtree has empty Slices list — no ShowObject called. + using (var ctx = new OffscreenGraphicsContext()) + { + Assert.DoesNotThrow( + () => m_dtree.PaintLinesBetweenSlices(ctx.Graphics, 800)); + } + } + + /// + /// PaintLinesBetweenSlices with loaded slices executes the drawing path + /// without throwing, even though slices have default layout positions. + /// + [Test] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_WithSlices_DrawsWithoutError() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.GreaterThanOrEqualTo(2), + "Need at least 2 slices for inter-slice line drawing"); + + using (var ctx = new OffscreenGraphicsContext()) + { + Assert.DoesNotThrow( + () => m_dtree.PaintLinesBetweenSlices(ctx.Graphics, ctx.Bitmap.Width)); + } + } + + /// + /// PaintLinesBetweenSlices draws separator lines onto the bitmap when slices + /// have been positioned with explicit locations and sizes. + /// + [Test] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_AfterLayout_DrawsPixels() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.GreaterThanOrEqualTo(2)); + + // Give slices explicit positions so the paint method has real + // geometry to draw between. In headless tests PerformLayout may + // not position children, so we set Location/Size directly. + var slice0 = m_dtree.Slices[0] as Slice; + var slice1 = m_dtree.Slices[1] as Slice; + slice0.SetBounds(0, 0, 800, 30); + slice1.SetBounds(0, 30, 800, 30); + + using (var ctx = new OffscreenGraphicsContext(800, 100)) + { + ctx.Graphics.Clear(Color.White); + m_dtree.PaintLinesBetweenSlices(ctx.Graphics, ctx.Bitmap.Width); + + // The separator line should appear near y=30 (bottom of first slice). + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.True, + "Expected at least one gray separator line to be drawn between slices"); + } + } + + /// + /// HandlePaintLinesBetweenSlices delegates correctly to PaintLinesBetweenSlices. + /// We verify by injecting a RecordingPainter — note that HandlePaintLinesBetweenSlices + /// does NOT go through the Painter property (it calls PaintLinesBetweenSlices directly + /// on 'this'). But we can verify the internal call path works. + /// + [Test] + [Category("OffscreenUI")] + public void HandlePaintLinesBetweenSlices_CallsPaintMethod() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + using (var ctx = new OffscreenGraphicsContext()) + using (var pea = ctx.CreatePaintEventArgs()) + { + // HandlePaintLinesBetweenSlices is internal and calls + // this.PaintLinesBetweenSlices(pea.Graphics, Width). + Assert.DoesNotThrow( + () => m_dtree.HandlePaintLinesBetweenSlices(pea)); + } + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OnPaint_UsesInjectedPainter_WhenLayoutStateNormal() + { + var recorder = new RecordingPainter(); + m_dtree.Painter = recorder; + + using (var ctx = new OffscreenGraphicsContext(300, 120)) + using (var pea = ctx.CreatePaintEventArgs()) + { + InvokeOnPaintForTest(pea); + } + + Assert.That(recorder.PaintCallCount, Is.EqualTo(1), + "OnPaint should delegate to the injected Painter when layout state is normal"); + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OnPaint_ReentrantState_DoesNotInvokePainter() + { + var recorder = new RecordingPainter(); + m_dtree.Painter = recorder; + SetLayoutStateForTest(DataTree.LayoutStates.klsChecking); + + try + { + using (var ctx = new OffscreenGraphicsContext(300, 120)) + using (var pea = ctx.CreatePaintEventArgs()) + { + InvokeOnPaintForTest(pea); + } + } + finally + { + SetLayoutStateForTest(DataTree.LayoutStates.klsNormal); + } + + Assert.That(recorder.PaintCallCount, Is.EqualTo(0), + "OnPaint should early-return during re-entrant layout states"); + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_HeaderChildPair_SkipsLine() + { + m_dtree.Slices.Clear(); + var first = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field, + Key = new object[] { 1 } + }; + var second = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field, + Key = new object[] { 1, 2 } + }; + first.SetBounds(0, 0, 220, 20); + second.SetBounds(0, 20, 220, 20); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + using (var ctx = new OffscreenGraphicsContext(220, 60)) + { + ctx.Graphics.Clear(Color.White); + m_dtree.PaintLinesBetweenSlices(ctx.Graphics, 220); + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.False, + "Header->child transition should suppress spacer line"); + } + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_SameObjectAttr_UsesSplitPositionBaseX() + { + m_dtree.Slices.Clear(); + m_dtree.SliceSplitPositionBase = 150; + var first = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + var second = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + first.Indent = 0; + second.Indent = 0; + first.SetBounds(0, 0, 220, 20); + second.SetBounds(0, 20, 220, 20); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + using (var ctx = new OffscreenGraphicsContext(220, 60)) + { + ctx.Graphics.Clear(Color.White); + m_dtree.PaintLinesBetweenSlices(ctx.Graphics, 220); + + Assert.That( + ctx.HasNonBackgroundPixelsInRegion(new Rectangle(0, 20, 120, 1), Color.White), + Is.False, + "sameObject branch should start the line near split-base, not at far-left label indent"); + Assert.That( + ctx.HasNonBackgroundPixelsInRegion(new Rectangle(150, 20, 60, 1), Color.White), + Is.True, + "sameObject branch should draw from split-base towards the right edge"); + } + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_HeavyNextSlice_AppliesVerticalOffset() + { + m_dtree.Slices.Clear(); + var first = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + var second = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.heavy + }; + first.SetBounds(0, 0, 220, 20); + second.SetBounds(0, 20, 220, 20); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + using (var ctx = new OffscreenGraphicsContext(220, 80)) + { + ctx.Graphics.Clear(Color.White); + m_dtree.PaintLinesBetweenSlices(ctx.Graphics, 220); + + int expectedY = 20 + (DataTree.HeavyweightRuleThickness / 2) + DataTree.HeavyweightRuleAboveMargin; + Assert.That( + ctx.HasNonBackgroundPixelsInRegion(new Rectangle(0, expectedY, 220, 1), Color.White), + Is.True, + "Heavy next slice should shift line downward by heavy-rule offsets"); + } + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void PaintLinesBetweenSlices_SummarySkipSpacerLine_SkipsLine() + { + m_dtree.Slices.Clear(); + var first = new SummarySlice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + var second = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + first.SetBounds(0, 0, 220, 20); + second.SetBounds(0, 20, 220, 20); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + using (var ctx = new OffscreenGraphicsContext(220, 60)) + { + ctx.Graphics.Clear(Color.White); + m_dtree.PaintLinesBetweenSlices(ctx.Graphics, 220); + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.False, + "Summary slices with skipSpacerLine=true should suppress spacer line"); + } + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void HandleLayout1_HeavySlice_IncludesHeavyMargins() + { + m_dtree.Slices.Clear(); + var first = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + var second = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.heavy + }; + first.SetBounds(0, 0, 200, 20); + second.SetBounds(0, 0, 200, 20); + m_dtree.Slices.Add(first); + m_dtree.Slices.Add(second); + + int yBottom = m_dtree.HandleLayout1(true, new Rectangle(0, 0, 200, 200)); + + int expectedSecondTop = (first.Height + 1) + DataTree.HeavyweightRuleThickness + DataTree.HeavyweightRuleAboveMargin; + Assert.That(second.Top, Is.EqualTo(expectedSecondTop), + "Heavy slices should be pushed down by heavy-rule thickness + margin"); + Assert.That(yBottom, Is.GreaterThan(second.Top), "Layout should advance past second slice"); + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void HandleLayout1_WhenClipBelowTop_ReturnsEarly() + { + m_dtree.Slices.Clear(); + var first = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Weight = ObjectWeight.field + }; + first.SetBounds(0, 0, 200, 20); + m_dtree.Slices.Add(first); + + int result = m_dtree.HandleLayout1(false, new Rectangle(0, -10, 200, 0)); + + Assert.That(result, Is.EqualTo(0), + "Partial layout should return early when current yTop is already below clip bottom"); + } + + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void HandleLayout1_WhenDisposing_ReturnsClipBottom() + { + SetDisposingStateForTest(true); + try + { + var clip = new Rectangle(0, 0, 200, 123); + int result = m_dtree.HandleLayout1(true, clip); + Assert.That(result, Is.EqualTo(clip.Bottom)); + } + finally + { + SetDisposingStateForTest(false); + } + } + + #endregion + + #region DrawLabel — offscreen bitmap tests + + /// + /// Slice.DrawLabel draws the label text onto a bitmap-backed Graphics. + /// We verify non-blank pixels appear after the call. + /// + [Test] + [Category("OffscreenUI")] + public void DrawLabel_WithSlice_DrawsTextToBitmap() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.GreaterThan(0)); + + var slice = m_dtree.Slices[0]; + Assert.That(slice.Label, Is.Not.Null.And.Not.Empty, + "Slice should have a label for us to draw"); + + using (var ctx = new OffscreenGraphicsContext(400, 40)) + { + ctx.Graphics.Clear(Color.White); + slice.DrawLabel(0, 0, ctx.Graphics, 400); + + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.True, + "Expected DrawLabel to render visible text onto the bitmap"); + } + } + + /// + /// Slice.DrawLabel with null SmallImages does not throw; it simply skips + /// image drawing and renders text only. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void DrawLabel_NullSmallImages_SkipsImagesAndDoesNotThrow() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var slice = m_dtree.Slices[0]; + + Assert.That(slice.SmallImages, Is.Null, + "SmallImages should default to null in test environment"); + + using (var ctx = new OffscreenGraphicsContext(400, 40)) + { + Assert.DoesNotThrow( + () => slice.DrawLabel(0, 0, ctx.Graphics, 400)); + } + } + + /// + /// Both overloads of DrawLabel (4-param and 3-param) work correctly. + /// The 3-param version uses LabelIndent() for the x position. + /// + [Test] + [Category("OffscreenUI")] + public void DrawLabel_ThreeParamOverload_DrawsTextToBitmap() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfOnly", null, m_entry, false); + var slice = m_dtree.Slices[0]; + + using (var ctx = new OffscreenGraphicsContext(400, 40)) + { + ctx.Graphics.Clear(Color.White); + // 3-param overload: DrawLabel(int y, Graphics gr, int clipWidth) + slice.DrawLabel(0, ctx.Graphics, 400); + + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.True, + "3-param DrawLabel should also render visible text"); + } + } + + #endregion + + #region Static helper tests (SameSourceObject, IsChildSlice) + + /// + /// SameSourceObject returns true when both slices have the same Key array contents. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void SameSourceObject_IdenticalKeys_ReturnsTrue() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Slices.Count, Is.GreaterThanOrEqualTo(2)); + + var slice1 = m_dtree.Slices[0]; + var slice2 = m_dtree.Slices[1]; + + // Both should belong to the same root object (m_entry). + bool result = DataTree.SameSourceObject(slice1, slice2); + Assert.That(result, Is.True, + "Two slices from the same root entry should share the source object"); + } + + /// + /// SameSourceObject returns false when slices display different objects. + /// We compare a slice from a sense sub-object against a top-level entry slice. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void SameSourceObject_DifferentKeys_ReturnsFalse() + { + // Give m_entry a sense so we get slices at different object levels. + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "OptSensesEty", null, m_entry, false); + + // With one sense we get Gloss slices. Find two slices where + // Object differs (e.g., the entry-level vs sense-level). + // If the layout only produces sense-level slices (same Object), + // we fall back to verifying that SameSourceObject correctly + // returns true for those. + if (m_dtree.Slices.Count >= 2) + { + var s1 = m_dtree.Slices[0]; + // Look for a slice whose Object differs from s1's Object. + var s2 = m_dtree.Slices.Cast() + .FirstOrDefault(s => s.Object != null && s.Object.Hvo != s1.Object.Hvo); + + if (s2 != null) + { + Assert.That(DataTree.SameSourceObject(s1, s2), Is.False, + "Slices viewing different objects should not be same-source"); + } + else + { + // All slices happen to show the same object — just verify they agree. + Assert.That(DataTree.SameSourceObject(s1, m_dtree.Slices[1] as Slice), Is.True, + "Slices from the same object should be same-source"); + } + } + else + { + Assert.Inconclusive("Not enough slices generated for SameSourceObject negative test"); + } + } + + /// + /// IsChildSlice returns true when the second slice's Key is a prefix extension + /// of the first (i.e. same initial elements plus at least one more). + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void IsChildSlice_ChildKey_ReturnsTrue() + { + using (var parent = new Slice()) + using (var child = new Slice()) + { + parent.Key = new object[] { 1, "field" }; + child.Key = new object[] { 1, "field", 2 }; + + Assert.That(DataTree.IsChildSlice(parent, child), Is.True, + "Slice with extended key should be recognized as child"); + } + } + + /// + /// IsChildSlice returns false when both slices have the same key length. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void IsChildSlice_SameLength_ReturnsFalse() + { + using (var a = new Slice()) + using (var b = new Slice()) + { + a.Key = new object[] { 1, "field" }; + b.Key = new object[] { 1, "field" }; + + Assert.That(DataTree.IsChildSlice(a, b), Is.False, + "Same-length keys should not be parent-child"); + } + } + + /// + /// IsChildSlice returns false when the second slice has a shorter key. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void IsChildSlice_ShorterSecond_ReturnsFalse() + { + using (var a = new Slice()) + using (var b = new Slice()) + { + a.Key = new object[] { 1, "field", 2 }; + b.Key = new object[] { 1 }; + + Assert.That(DataTree.IsChildSlice(a, b), Is.False, + "Shorter second key should not be child"); + } + } + + #endregion + + #region OffscreenGraphicsContext self-tests + + /// + /// OffscreenGraphicsContext creates a usable bitmap and Graphics surface. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OffscreenGraphicsContext_CreatesValidSurface() + { + using (var ctx = new OffscreenGraphicsContext(200, 100)) + { + Assert.That(ctx.Bitmap, Is.Not.Null); + Assert.That(ctx.Graphics, Is.Not.Null); + Assert.That(ctx.Bitmap.Width, Is.EqualTo(200)); + Assert.That(ctx.Bitmap.Height, Is.EqualTo(100)); + } + } + + /// + /// HasNonBackgroundPixels returns false for a freshly-cleared bitmap. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OffscreenGraphicsContext_FreshBitmap_HasNoNonBackgroundPixels() + { + using (var ctx = new OffscreenGraphicsContext(50, 50)) + { + ctx.Graphics.Clear(Color.White); + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.False); + } + } + + /// + /// HasNonBackgroundPixels returns true after drawing on the bitmap. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OffscreenGraphicsContext_AfterDraw_HasNonBackgroundPixels() + { + using (var ctx = new OffscreenGraphicsContext(50, 50)) + { + ctx.Graphics.Clear(Color.White); + ctx.Graphics.DrawLine(Pens.Black, 0, 25, 50, 25); + Assert.That(ctx.HasNonBackgroundPixels(Color.White), Is.True); + } + } + + /// + /// CreatePaintEventArgs creates a valid PaintEventArgs with full bitmap clip. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OffscreenGraphicsContext_CreatePaintEventArgs_ValidClip() + { + using (var ctx = new OffscreenGraphicsContext(300, 200)) + using (var pea = ctx.CreatePaintEventArgs()) + { + Assert.That(pea.Graphics, Is.SameAs(ctx.Graphics)); + Assert.That(pea.ClipRectangle.Width, Is.EqualTo(300)); + Assert.That(pea.ClipRectangle.Height, Is.EqualTo(200)); + } + } + + /// + /// HasNonBackgroundPixelsInRegion correctly scopes to the given region. + /// + [Test] + [Category("SurvivesRefactoring")] + [Category("OffscreenUI")] + public void OffscreenGraphicsContext_RegionCheck_ScopedCorrectly() + { + using (var ctx = new OffscreenGraphicsContext(100, 100)) + { + ctx.Graphics.Clear(Color.White); + // Draw a line only in the bottom half. + ctx.Graphics.DrawLine(Pens.Black, 0, 75, 100, 75); + + Assert.That( + ctx.HasNonBackgroundPixelsInRegion(new Rectangle(0, 0, 100, 50), Color.White), + Is.False, "Top half should be blank"); + Assert.That( + ctx.HasNonBackgroundPixelsInRegion(new Rectangle(0, 50, 100, 50), Color.White), + Is.True, "Bottom half should have the drawn line"); + } + } + + private void InvokeOnPaintForTest(PaintEventArgs pea) + { + var onPaint = typeof(DataTree).GetMethod("OnPaint", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(onPaint, Is.Not.Null, "Could not reflect DataTree.OnPaint"); + onPaint.Invoke(m_dtree, new object[] { pea }); + } + + private void SetLayoutStateForTest(DataTree.LayoutStates state) + { + var field = typeof(DataTree).GetField("m_layoutState", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_layoutState"); + field.SetValue(m_dtree, state); + } + + private void SetDisposingStateForTest(bool disposing) + { + var field = typeof(DataTree).GetField("m_fDisposing", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Could not reflect DataTree.m_fDisposing"); + field.SetValue(m_dtree, disposing); + } + + #endregion + + #endregion + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/OffscreenGraphicsContext.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/OffscreenGraphicsContext.cs new file mode 100644 index 0000000000..0d7357e7cd --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/OffscreenGraphicsContext.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2016 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.Drawing; +using System.Windows.Forms; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Provides a bitmap-backed Graphics context for exercising painting + /// and layout logic without a visible window. + /// + /// + /// Pattern follows VwGraphicsTests.GraphicsObjectFromImage which + /// uses Bitmap + Graphics.FromImage for offscreen IVwGraphics testing. + /// + internal sealed class OffscreenGraphicsContext : IDisposable + { + public Bitmap Bitmap { get; } + public Graphics Graphics { get; } + + public OffscreenGraphicsContext(int width = 800, int height = 600) + { + Bitmap = new Bitmap(width, height); + Graphics = Graphics.FromImage(Bitmap); + } + + /// + /// Create a PaintEventArgs whose clip rectangle covers the entire bitmap. + /// + public PaintEventArgs CreatePaintEventArgs() + { + return new PaintEventArgs(Graphics, + new Rectangle(0, 0, Bitmap.Width, Bitmap.Height)); + } + + /// + /// Create a PaintEventArgs with a specific clip rectangle. + /// + public PaintEventArgs CreatePaintEventArgs(Rectangle clip) + { + return new PaintEventArgs(Graphics, clip); + } + + /// + /// Check whether any pixel in the bitmap differs from a given background color. + /// Useful as a simple "did anything get drawn?" assertion. + /// + public bool HasNonBackgroundPixels(Color background) + { + int bgArgb = background.ToArgb(); + for (int y = 0; y < Bitmap.Height; y++) + { + for (int x = 0; x < Bitmap.Width; x++) + { + if (Bitmap.GetPixel(x, y).ToArgb() != bgArgb) + return true; + } + } + return false; + } + + /// + /// Check whether any pixel in a sub-region differs from a given background color. + /// + public bool HasNonBackgroundPixelsInRegion(Rectangle region, Color background) + { + int bgArgb = background.ToArgb(); + int maxX = Math.Min(region.Right, Bitmap.Width); + int maxY = Math.Min(region.Bottom, Bitmap.Height); + for (int y = region.Top; y < maxY; y++) + { + for (int x = region.Left; x < maxX; x++) + { + if (Bitmap.GetPixel(x, y).ToArgb() != bgArgb) + return true; + } + } + return false; + } + + public void Dispose() + { + Graphics?.Dispose(); + Bitmap?.Dispose(); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/RecordingPainter.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/RecordingPainter.cs new file mode 100644 index 0000000000..e277c49647 --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/RecordingPainter.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2016 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.Drawing; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Test double that implements and records + /// every paint invocation rather than drawing anything on screen. + /// + /// + /// Inject via to intercept painting. + /// Useful for asserting that painting was triggered, how many times, + /// and with what parameters. + /// + internal sealed class RecordingPainter : IDataTreePainter + { + /// + /// Record of a single call to . + /// + public struct PaintCall + { + public Graphics Graphics; + public int Width; + } + + /// + /// All recorded paint calls, in order. + /// + public List Calls { get; } = new List(); + + /// + /// Convenience: number of times painting was invoked. + /// + public int PaintCallCount => Calls.Count; + + /// + /// When true, forward the paint call to an optional delegate + /// after recording it (useful for partial mocking / spy pattern). + /// + public IDataTreePainter Delegate { get; set; } + + public void PaintLinesBetweenSlices(Graphics gr, int width) + { + Calls.Add(new PaintCall { Graphics = gr, Width = width }); + Delegate?.PaintLinesBetweenSlices(gr, width); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs index 198ba41a3c..ae2464d523 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs @@ -5,6 +5,7 @@ // Original author: MarkS 2010-08-03 SliceTests.cs using System; using System.Collections; +using System.Reflection; using System.Windows.Forms; using System.Xml; using NUnit.Framework; @@ -429,6 +430,68 @@ public void CallerNodeEqual_StructuralComparison() } } + [Test] + public void IsObjectNode_TrueWhenNodeElementPresent_AndObjectIsNotRoot() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var childObject = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null, "Could not reflect DataTree.m_root"); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = childObject; + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsObjectNode, Is.True, + "A slice for a non-root object should be treated as object node"); + } + + [Test] + public void IsObjectNode_FalseWhenSeqElementPresent() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var childObject = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null, "Could not reflect DataTree.m_root"); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = childObject; + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsObjectNode, Is.False, + "Presence of should force non-object-node behavior"); + } + + [Test] + public void LabelIndent_IncreasesWithIndent() + { + using (var slice = new Slice()) + { + slice.Indent = 0; + int indent0 = slice.LabelIndent(); + slice.Indent = 2; + int indent2 = slice.LabelIndent(); + + Assert.That(indent2 - indent0, Is.EqualTo(2 * SliceTreeNode.kdxpIndDist)); + } + } + + [Test] + public void GetBranchHeight_ReturnsPositiveValue() + { + using (var slice = new Slice()) + { + int branchHeight = slice.GetBranchHeight(); + Assert.That(branchHeight, Is.GreaterThan(0)); + } + } + #endregion #region Characterization Tests — Lifecycle diff --git a/Src/Common/Controls/DetailControls/IDataTreePainter.cs b/Src/Common/Controls/DetailControls/IDataTreePainter.cs new file mode 100644 index 0000000000..2bf26853d3 --- /dev/null +++ b/Src/Common/Controls/DetailControls/IDataTreePainter.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2016 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.Drawing; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Abstracts the painting operations that DataTree performs between + /// slices, allowing tests to intercept/record draw calls without a + /// real screen. + /// + /// + /// DataTree implements this interface as its default behavior. Tests + /// can substitute a recording implementation via the + /// property to verify rendering + /// without a visible window. + /// + public interface IDataTreePainter + { + /// + /// Paint separator lines between slices onto the given Graphics context. + /// + /// The Graphics surface to draw on (may be bitmap-backed for tests). + /// The width of the drawing area in pixels. + void PaintLinesBetweenSlices(Graphics gr, int width); + } +} diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md index 4b641e75cb..6d63d6cb02 100644 --- a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md @@ -72,6 +72,161 @@ Incremental gain from the build-backed continuation pass: - `DataTree`: **+1.26 line** / **+1.40 branch** (from 59.83 / 43.94) +## Rerun Status (2026-02-25, latest targeted reassessment) + +After the most recent deterministic Wave 3 additions, managed coverage was rerun with: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **62.82%** line / **47.16%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the prior build-backed checkpoint (61.09 / 45.34): + +- `DataTree`: **+1.73 line** / **+1.82 branch** + +## Rerun Status (2026-02-25, post-`RestorePreferences`/`ApplyChildren`/`MakeEditorAt` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.50%** line / **47.57%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous targeted reassessment (62.82 / 47.16): + +- `DataTree`: **+0.68 line** / **+0.41 branch** + +## Rerun Status (2026-02-26, post-`SelectFirstPossibleSlice` deterministic paths) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.70%** line / **47.98%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.50 / 47.57): + +- `DataTree`: **+0.20 line** / **+0.41 branch** + +## Rerun Status (2026-02-26, post-`RefreshList(int,int)` deterministic probes) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.96%** line / **48.23%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.70 / 47.98): + +- `DataTree`: **+0.26 line** / **+0.25 branch** + +## Rerun Status (2026-02-26, post-trace + `m_rch_Disposed` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **64.84%** line / **49.22%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.96 / 48.23): + +- `DataTree`: **+0.88 line** / **+0.99 branch** + +## Rerun Status (2026-02-26, post-`AddAtomicNode` guard/test-only branches) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.34%** line / **50.04%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (64.84 / 49.22): + +- `DataTree`: **+0.50 line** / **+0.82 branch** + +## Rerun Status (2026-02-26, post-`InsertSliceRange`/`slice_SplitterMoved` guard path) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.80%** line / **50.29%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (65.34 / 50.04): + +- `DataTree`: **+0.46 line** / **+0.25 branch** + +## Rerun Status (2026-02-26, post-`ObjSeqHashMap` reuse/report tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.38%** line / **50.04%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Net effect: + +- `ObjSeqHashMap` gaps were substantially reduced (no longer in top-gap list for `GetSliceToReuse`/`Report`). +- `DataTree` remained near the previous high-water mark, with branch coverage still at ~50%. + +## Rerun Status (2026-02-26, post-Wave 4 offscreen + `HandleLayout1` + Slice branch tests) + +Validated with: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **67.51%** line / **52.02%** branch +- `Slice`: **34.5%** line / **22.4%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (65.38 / 50.04 for `DataTree`, 26.88 / 17.9 for `Slice`): + +- `DataTree`: **+2.13 line** / **+1.98 branch** +- `Slice`: **+7.62 line** / **+4.50 branch** +- `ObjSeqHashMap`: **no change** + Wave 2 desired target: - `DataTree` line coverage toward ~46-50% diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md new file mode 100644 index 0000000000..0837cfbcb1 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md @@ -0,0 +1,241 @@ +# Test Plan: Headless DataTree Model (Approach 3 — Future) + +## Goal + +Fully separate the model layer from the view layer so that all +layout-engine logic, slice metadata computation, visibility rules, +navigation, and property-change handling can be tested as pure C# +classes with **zero WinForms dependency**. + +This is the end-state architecture for the `datatree-model-view` +branch. It builds on Approach 2 (the `IDataTreePainter` seam) and +replaces the current god-class `DataTree : UserControl` with a +model+view pair. + +--- + +## 1. Architecture + +### 1.1 Target Decomposition + +``` +┌──────────────────────────────────────────────┐ +│ DataTreeView : UserControl │ +│ - Hosts WinForms controls (SplitContainers) │ +│ - Painting (OnPaint, DrawLabel) │ +│ - Focus management │ +│ - Scroll position │ +│ - Delegates to DataTreeModel for all logic │ +└──────────┬───────────────────────────────────┘ + │ uses +┌──────────▼───────────────────────────────────┐ +│ DataTreeModel (plain C#, no UI dependency) │ +│ - Layout engine: XML → List │ +│ - Visibility rules (ifdata, never, hidden) │ +│ - Navigation (current index, next, prev) │ +│ - ObjSeqHashMap (slice reuse) │ +│ - Property change → refresh decisions │ +│ - Expand/collapse state │ +│ - MonitoredProps tracking │ +└──────────────────────────────────────────────┘ +``` + +### 1.2 SliceSpec Data Object + +The core output of the layout engine, replacing the current Slice +class for model-level concerns: + +```csharp +public class SliceSpec +{ + public string Label { get; set; } + public string Abbreviation { get; set; } + public int Indent { get; set; } + public int Flid { get; set; } + public string FieldName { get; set; } + public string EditorType { get; set; } + public SliceVisibility Visibility { get; set; } + public ObjectWeight Weight { get; set; } + public int ObjectHvo { get; set; } + public Guid ObjectGuid { get; set; } + public XmlNode ConfigurationNode { get; set; } + public ArrayList Key { get; set; } + public Type SliceType { get; set; } // e.g. typeof(StringSlice) +} +``` + +### 1.3 New Project + +``` +SIL.FieldWorks.Common.Controls.DetailControls.Model +├── DataTreeModel.cs +├── SliceSpec.cs +├── SliceVisibility.cs +├── LayoutEngine.cs // XML → SliceSpec list +├── NavigationModel.cs // current, next, prev +├── SliceReuseMap.cs // extracted from ObjSeqHashMap +└── VisibilityRules.cs // ifdata, never, show-hidden +``` + +**Dependencies:** `SIL.LCModel`, `System.Xml` — no `System.Windows.Forms`. + +--- + +## 2. Extraction Strategy + +### Phase 1: Extract Layout Engine + +The core pure function buried in DataTree: + +``` +(XML layouts, XML parts, LCM cache, object, layoutName) + → ordered List +``` + +This is currently spread across: +- `DataTree.CreateSlicesFor` (line ~1800) +- `DataTree.ProcessPartChildren` (line ~2000) +- `DataTree.AddAtomicNode` (line ~2420) +- `DataTree.AddSeqNode` (line ~2500) +- `DataTree.MakeGhostSlice` (line ~2458) +- `SliceFactory.Create` (external) + +**Extraction approach:** Create `LayoutEngine.ComputeSliceSpecs(...)` that +returns `List`. DataTree calls this and then creates actual +WinForms Slice controls from the specs. + +### Phase 2: Extract Navigation Model + +Currently: `CurrentSlice`, `GotoNextSlice`, `GotoPreviousSliceBeforeIndex`, +`FocusFirstPossibleSlice` are all on DataTree and mix model state +(which slice is current) with view behavior (focus, scrolling). + +**Extraction:** `NavigationModel` holds the current index and provides +`MoveNext()`, `MovePrevious()`, `MoveToFirst()` operating on +`List`. The view layer translates model index changes into +focus/scroll actions. + +### Phase 3: Extract Visibility Rules + +Currently: visibility logic is scattered across `ShowObject`, +`HandleShowHiddenFields`, `ProcessPartChildren`, and individual slice +checks. + +**Extraction:** `VisibilityRules.ShouldShow(SliceSpec, bool showHidden)` +is a pure function. The view layer calls it during layout. + +### Phase 4: Extract Slice Reuse + +`ObjSeqHashMap` already exists as a separate class. Rename to +`SliceReuseMap`, clean up its API, and make it part of the model +project. Already 90% covered by existing tests. + +--- + +## 3. Test Strategy + +### 3.1 Golden-File / Snapshot Tests + +For each real layout in `DistFiles/Language Explorer/Configuration/`: + +1. Run `LayoutEngine.ComputeSliceSpecs(cache, layout, testObject)` +2. Serialize the result to JSON: `[{label, indent, editorType, flid, objectClass}]` +3. Compare against a golden file checked into the repo + +**Benefits:** +- Tests the real combinatorial complexity (``, ``, + ``, ``, multi-level ownership) +- No WinForms, no Form, no Graphics +- Regressions are immediately visible as golden-file diffs +- Covers thousands of lines of XML parsing logic + +### 3.2 Unit Tests for Pure Model Classes + +```csharp +[Test] +public void LayoutEngine_CfOnly_ProducesOneSliceSpec() +{ + var engine = new LayoutEngine(layouts, parts); + var specs = engine.ComputeSliceSpecs(cache, entry, "CfOnly"); + + Assert.That(specs.Count, Is.EqualTo(1)); + Assert.That(specs[0].Label, Is.EqualTo("Citation Form")); + Assert.That(specs[0].EditorType, Is.EqualTo("multistring")); +} + +[Test] +public void NavigationModel_MoveNext_AdvancesIndex() +{ + var nav = new NavigationModel(specs); + nav.MoveTo(0); + nav.MoveNext(); + Assert.That(nav.CurrentIndex, Is.EqualTo(1)); +} + +[Test] +public void VisibilityRules_IfData_EmptyField_ReturnsFalse() +{ + var spec = new SliceSpec { Visibility = SliceVisibility.IfData }; + // ... setup empty field + Assert.That(VisibilityRules.ShouldShow(spec, cache, showHidden: false), + Is.False); +} +``` + +### 3.3 Integration Tests (from testing-approach-2.md §2.5) + +Small number of end-to-end tests that exercise real workflows: + +1. **Show → Edit → Refresh**: ShowObject, modify citation form via + LCM, trigger PropChanged, verify slice list is refreshed correctly +2. **Large list + scroll**: 30+ senses, verify DummyObjectSlice → + BecomeReal at correct indices +3. **Toggle show-hidden**: Flip property, verify correct slices appear/disappear +4. **Switch objects**: Show entry A, then show entry B, verify clean + slate with correct slices + +These tests use `DataTreeModel` directly — no WinForms needed. + +--- + +## 4. Migration Path from Approach 2 + +| Approach 2 artifact | Evolves into | +|---------------------|-------------| +| `IDataTreePainter` interface | View-layer contract | +| `RecordingPainter` test double | View-layer test infrastructure | +| `OffscreenGraphicsContext` | View-layer test infrastructure | +| `HandlePaintLinesBetweenSlices` (internal) | `DataTreeView.PaintLines(...)` | +| `HandleLayout1` (protected internal) | `DataTreeView.PerformSliceLayout(...)` | +| Behavioral contract tests | Move to model test project | +| Static helpers (IsChildSlice, SameSourceObject) | `LayoutEngine` or `NavigationModel` | + +Tests written under Approach 2 with `[Category("SurvivesRefactoring")]` +are expected to survive by moving to the model test project with +minimal changes. + +--- + +## 5. Timeline and Prerequisites + +| Step | Prerequisite | Effort | +|------|-------------|--------| +| Approach 2 (current plan) | None | Small (current sprint) | +| Phase 1: Layout Engine extraction | Approach 2 done, high test coverage | Large (dedicated sprint) | +| Phase 2: Navigation Model | Phase 1 | Medium | +| Phase 3: Visibility Rules | Phase 1 | Medium | +| Phase 4: Slice Reuse cleanup | Phase 1 | Small | +| Golden-file test suite | Phase 1 | Medium | +| DataTreeView refactoring | Phases 1–4 | Large | + +--- + +## 6. Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Layout engine extraction is complex (4700-line class) | High | Incremental: extract one method at a time, verify coverage after each | +| XML config nodes are used by both model and view | High | SliceSpec captures all config data needed by view; config nodes stay in model | +| SliceFactory creates actual WinForms controls | High | Split into SpecFactory (model) + ControlFactory (view) | +| Real layouts may have undocumented edge cases | Medium | Golden-file tests catch regressions from day one | +| Performance regression from extra allocation | Low | SliceSpec is a simple POCO; profile if needed | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md new file mode 100644 index 0000000000..aeabb3441f --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md @@ -0,0 +1,239 @@ +# Test Plan: Offscreen UI & Rendering Tests (Approach 2) + +## Goal + +Exercise DataTree and Slice painting, layout, visibility, and control +behavior without showing windows on screen. Uses bitmap-backed +`Graphics`, `SetVisibleCore` reflection, and a small interface +extraction to create a testable seam for rendering. + +This plan also incorporates actionable, non-breaking improvements from +`testing-approach-2.md` §2.1–§2.6. + +--- + +## 1. Production Code Changes (Non-Breaking) + +### 1.1 Extract `IDataTreePainter` Interface + +A small interface that abstracts the line-drawing logic between slices. +DataTree implements it as its default behavior. Tests can substitute a +recording implementation. + +```csharp +// New file: IDataTreePainter.cs +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Abstracts the painting operations that DataTree performs between + /// slices, allowing tests to intercept/record draw calls without a + /// real screen. + /// + public interface IDataTreePainter + { + /// + /// Paint separator lines between slices. + /// + void PaintLinesBetweenSlices(Graphics gr, int width); + } +} +``` + +**DataTree changes:** +- Add `public IDataTreePainter Painter { get; set; }` property, + defaulting to `this` in the constructor. +- DataTree implements `IDataTreePainter` explicitly. +- `OnPaint` delegates to `Painter.PaintLinesBetweenSlices(...)` instead + of calling `HandlePaintLinesBetweenSlices` directly. +- `HandlePaintLinesBetweenSlices` becomes `internal` (was `private`). + +This is backward-compatible: existing code sees no difference because +the Painter defaults to the DataTree itself. + +### 1.2 Make Private Methods Internal + +The following methods change from `private` to `internal` to allow +direct testing via the existing `InternalsVisibleTo("DetailControlsTests")`: + +| Method | Current | New | Rationale | +|--------|---------|-----|-----------| +| `HandlePaintLinesBetweenSlices` | private | internal | Direct bitmap test | +| `SameSourceObject` | private static | internal static | Unit test paint logic | +| `IsChildSlice` | private static | internal static | Unit test paint logic | + +### 1.3 Test Category Attributes + +Add `[Category]` attributes to existing and new tests for lifespan +tracking during the refactoring (from testing-approach-2.md §2.6): + +| Category | Meaning | +|----------|---------| +| `SurvivesRefactoring` | Tests behavioral contracts preserved across model/view split | +| `PreRefactoring` | Tests documenting current internals; expected to be rewritten | +| `KnownBug` | Tests that document bugs as current behavior | +| `OffscreenUI` | Tests exercising painting/layout/visibility without a screen | + +--- + +## 2. Test Infrastructure + +### 2.1 `OffscreenGraphicsContext` (test helper) + +Disposable helper that creates a `Bitmap` + `Graphics` + synthetic +`PaintEventArgs` for offscreen rendering: + +```csharp +internal class OffscreenGraphicsContext : IDisposable +{ + public Bitmap Bitmap { get; } + public Graphics Graphics { get; } + + public OffscreenGraphicsContext(int width = 800, int height = 600) + { + Bitmap = new Bitmap(width, height); + Graphics = Graphics.FromImage(Bitmap); + } + + public PaintEventArgs CreatePaintEventArgs() + { + return new PaintEventArgs(Graphics, + new Rectangle(0, 0, Bitmap.Width, Bitmap.Height)); + } + + public PaintEventArgs CreatePaintEventArgs(Rectangle clip) + { + return new PaintEventArgs(Graphics, clip); + } + + public void Dispose() + { + Graphics?.Dispose(); + Bitmap?.Dispose(); + } +} +``` + +Pattern inspired by `VwGraphicsTests.GraphicsObjectFromImage` which +already uses `Bitmap(1000,1000)` + `Graphics.FromImage` for offscreen +VwGraphics testing. + +### 2.2 `RecordingPainter` (test double) + +Records all paint operations for assertion without drawing: + +```csharp +internal class RecordingPainter : IDataTreePainter +{ + public List<(Point From, Point To, float PenWidth)> DrawnLines { get; } = new(); + public int PaintCallCount { get; private set; } + + public void PaintLinesBetweenSlices(Graphics gr, int width) + { + PaintCallCount++; + // Optionally record using a recording Graphics wrapper + } +} +``` + +### 2.3 Visibility Helper (existing) + +Already implemented in `DataTreeTests`: +```csharp +SetControlVisibleForTest(Control control, bool visible) +// Uses reflection to call Control.SetVisibleCore(bool) +``` + +This makes controls report `Visible=true` without `Form.Show()`. + +--- + +## 3. Test Plan + +### 3.1 Slice.DrawLabel — Bitmap Rendering + +| # | Test | Asserts | +|---|------|---------| +| 1 | `DrawLabel_WithLabel_DrawsToGraphics` | Call `DrawLabel(0, 0, gr, 800)` on a Slice with `Label="Test"`. Verify no exception and that the bitmap has non-white pixels. | +| 2 | `DrawLabel_WithAbbreviation_UsesAbbrevWhenNarrow` | Set `SplitCont.SplitterDistance` ≤ `MaxAbbrevWidth`. Verify `DrawLabel` uses `Abbreviation` text. | +| 3 | `DrawLabel_WithSmallImages_DrawsIconBeforeText` | Set `SmallImages` to a real `ImageCollection`. Verify icon is drawn at (x,y) and text starts after icon width. | +| 4 | `DrawLabel_NullLabel_DoesNotThrow` | `Label = null`, call `DrawLabel`. Document current behavior (likely NRE — characterize it). | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.2 DataTree.HandlePaintLinesBetweenSlices — Offscreen Lines + +| # | Test | Asserts | +|---|------|---------| +| 5 | `HandlePaintLinesBetweenSlices_TwoSlices_DrawsLine` | Initialize DataTree + ShowObject with "CfAndBib" layout (2 slices). Force visibility. Create bitmap Graphics + PaintEventArgs. Call `HandlePaintLinesBetweenSlices`. Verify bitmap has drawn pixels between slice positions. | +| 6 | `HandlePaintLinesBetweenSlices_SingleSlice_DrawsNothing` | ShowObject with "CfOnly" (1 slice). Verify no lines drawn. | +| 7 | `HandlePaintLinesBetweenSlices_HeaderSlice_SkipsLine` | Configure a slice with `header="true"` attribute. Verify line is skipped per the existing logic. | +| 8 | `PaintLinesBetweenSlices_ViaInterface_DelegatesToPainter` | Set `Painter` to `RecordingPainter`. Trigger paint. Assert `PaintCallCount == 1`. | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.3 DataTree Layout — HandleLayout1 with Forced Visibility + +| # | Test | Asserts | +|---|------|---------| +| 9 | `HandleLayout1_PositionsSlicesVertically` | Initialize + ShowObject. `SetVisibleCore(true)` on parent and DataTree. Call `HandleLayout1(true, ClientRectangle)`. Assert each slice's `Top` is greater than previous slice's `Top + Height`. | +| 10 | `HandleLayout1_HeavyWeightSlice_AddsMargin` | Create a slice with `Weight = ObjectWeight.heavy`. Verify the gap includes `HeavyweightRuleThickness + HeavyweightRuleAboveMargin`. | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.4 Behavioral Contract Tests (from testing-approach-2.md §2.1) + +These test invariants that survive the model/view split: + +| # | Test | Contract | +|---|------|----------| +| 11 | `ShowObject_ProducesCorrectSliceOrder` | Given layout XML + object → correct ordered list of labels | +| 12 | `VisibilityIfData_HidesWhenEmpty` | `visibility="ifdata"` hides when data is empty | +| 13 | `ShowHiddenFields_TogglesVisibility` | Setting ShowHiddenFields property shows/hides "never" fields | +| 14 | `Expand_CreatesChildSlices` | Expanding a collapsed node creates child slices in correct order | +| 15 | `Collapse_RemovesChildSlices` | Collapsing removes descendant slices from the tree | + +**Category:** `SurvivesRefactoring` + +### 3.5 Static Helper Tests (IsChildSlice, SameSourceObject) + +| # | Test | Asserts | +|---|------|---------| +| 16 | `IsChildSlice_MatchingPrefix_ReturnsTrue` | Two slices where second's key extends first's | +| 17 | `IsChildSlice_DifferentKeys_ReturnsFalse` | Non-matching key prefixes | +| 18 | `SameSourceObject_SameHvo_ReturnsTrue` | Same Object.Hvo | +| 19 | `SameSourceObject_DifferentHvo_ReturnsFalse` | Different Object.Hvo | + +**Category:** `SurvivesRefactoring` + +--- + +## 4. File Layout + +| File | Contents | +|------|----------| +| `IDataTreePainter.cs` | Interface (production, DetailControls project) | +| `DataTree.cs` | Implement interface, add Painter property, widen visibility | +| `DataTreeTests.Wave4.OffscreenUI.cs` | Tests §3.1–3.5 above | +| `OffscreenGraphicsContext.cs` | Test helper (DetailControlsTests project) | +| `RecordingPainter.cs` | Test double (DetailControlsTests project) | + +--- + +## 5. Implementation Order + +1. Create `IDataTreePainter.cs` interface +2. DataTree: implement interface, add `Painter` property, widen method visibility +3. Create `OffscreenGraphicsContext.cs` test helper +4. Create `RecordingPainter.cs` test double +5. Create `DataTreeTests.Wave4.OffscreenUI.cs` with initial tests +6. Build + verify all existing tests still pass +7. Run coverage assessment to measure improvement + +## 6. Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| `SetVisibleCore` may trigger unintended side effects | Already proven safe in existing Wave3 tests | +| Bitmap-based Graphics may differ from screen Graphics | We test structure (pixels drawn vs. not drawn), not exact rendering | +| Interface extraction could break callers | Default implementation is DataTree itself — fully backward compatible | +| Tests may be fragile on CI (headless Windows) | WinForms handle creation works without a desktop session on Windows | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md new file mode 100644 index 0000000000..0977639178 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md @@ -0,0 +1,339 @@ +# DataTree Testing Approach — Critical Analysis + +## Context + +DataTree is a ~4,700-line god class that combines XML layout parsing, slice +lifecycle management, WinForms control hosting, navigation, mediator messaging, +property change notifications, persistence, and domain-specific jump-to-tool +commands. The current testing effort is writing "characterization tests" before +a planned model/view separation refactoring. + +This document asks two questions: + +1. **Devil's advocate:** Why might these tests fail to provide meaningful safety + during the refactoring? +2. **How could the testing be made more effective?** + +--- + +## Part 1: Devil's Advocate — Why These Tests May Not Measure What Matters + +### 1.1 Reflection-heavy tests are testing the lock, not the door + +A significant number of Wave 3 tests reach into private fields and methods via +`typeof(DataTree).GetField(...)` / `.GetMethod(...)`: + +- `m_currentSlice`, `m_currentSliceNew`, `m_postponePropChanged`, + `m_rch`, `m_listName`, `m_rlu`, `m_currentObjectFlids`, `m_monitoredProps` +- Private methods like `PostponePropChanged`, `RestorePreferences`, + `SetCurrentSliceNewFromObject`, `CreateAndAssociateNotebookRecord`, + `DescendantForSlice`, `m_rch_Disposed`, `RefreshList(int, int)` + +**The problem:** The whole point of the refactoring is to _change the internals_. +Tests that are coupled to field names and method signatures will break the moment +you rename, extract, or restructure — which is exactly what a model/view split +does. These tests don't protect behavior; they protect implementation topology. +They'll tell you "you moved a field" not "you broke a user-visible behavior." + +When the refactoring starts, you'll face a choice: delete most of these tests +(eliminating the safety net) or port them (spending effort adapting tests that +weren't testing outcomes in the first place). + +### 1.2 Assert.DoesNotThrow is a code smell, not a specification + +Multiple tests follow this pattern: + +```csharp +Assert.DoesNotThrow(() => m_dtree.OnFocusFirstPossibleSlice(null)); +Assert.DoesNotThrow(() => m_dtree.FixRecordList()); +Assert.DoesNotThrow(() => m_dtree.GotoFirstSlice()); +``` + +"It doesn't crash" is the weakest possible assertion. It means: +- You don't know what the method is _supposed_ to do +- You can't detect silent regressions (wrong state, lost data, no-op where + action was expected) +- The test will pass even after a refactoring that guts the method body to + `return;` + +These tests create an illusion of safety while catching almost nothing that +matters. + +### 1.3 Property getter/setter round-trips test the C# language, not the class + +Tests like: + +```csharp +public void SmallImages_SetterAndGetterRoundTrip() { ... } +public void StyleSheet_SetterAllowsNullRoundTrip() { ... } +public void PersistenceProvider_SetterAndGetterRoundTrip() { ... } +public void SliceSplitPositionBase_SetterUpdatesValue() { ... } +public void DoNotRefresh_GetterReflectsSetter() { ... } +``` + +These verify that `{ get; set; }` works. The C# compiler already guarantees +this. Unless the property has side effects (like DoNotRefresh triggering a +deferred refresh), testing plain auto-properties adds coverage percentage +without adding confidence. + +### 1.4 "Characterization" without behavioral contracts is just archaeology + +A characterization test is supposed to document _what the system actually does_ +so regressions are detectable. But many of these tests document trivia: + +- `Priority_ReturnsMediumColleaguePriority` — will this constant ever change + during a model/view split? No. Does it matter? Only if the mediator cares. +- `ShouldNotCall_FalseByDefault` — this is a one-liner property. +- `SliceControlContainer_ReturnsSelf` — trivially true for any class that + implements the interface by returning `this`. +- `LabelWidth_ReturnsExpectedConstant` — magic number `40` hardcoded in both + production code and test. + +The real danger in the refactoring is in the _interactions_: what happens when +ShowObject triggers RefreshList which reuses slices via ObjSeqHashMap which +depends on key equivalence which uses EquivalentKeys with XML node comparison. +That chain is barely tested as an integrated flow. + +### 1.5 The test harness itself is a mock of reality + +The tests use `MemoryOnlyBackendProviderRestoredForEachTestTestBase` with +a minimal `Inventory` of parts and layouts (loaded from test XML files in the +test directory). This is appropriate for unit tests, but it means: + +- **Layout complexity is synthetic.** The real layouts in `DistFiles/` have + ``, ``, ``, `` nesting, custom editors, + and multi-level ownership chains. The test layouts are 2–3 part configs. +- **Slice types are limited.** The test setup creates generic `Slice` objects. + In production, DataTree creates `ReferenceVectorSlice`, `PhonologicalRuleFormulaSlice`, + `StTextSlice`, and dozens of others via editor reflection. None of these + appear in the tests. +- **No real WinForms hosting.** Tests use `m_parent = new Form()` without + `.Show()`, so layout, painting, focus, and visibility checks never fire + properly. Navigation tests (`GotoNextSlice`, `GotoPreviousSliceBeforeIndex`) + are testing navigation logic in a context where no slice is actually visible + or focusable. + +The refactoring risk isn't "does DataTree work with 2-slice test layouts?" +It's "does DataTree work with the 47 real layouts that FieldWorks ships?" + +### 1.6 Coverage percentage ≠ confidence + +The test plan targets 84 tests across 7 subdomains and claims priority based +on "zero coverage today." But coverage metrics count lines hit, not behaviors +verified. A test that calls `ShowObject` and asserts `Slices.Count > 0` "covers" +thousands of lines of XML parsing, slice creation, and reuse logic — without +actually verifying any of it works correctly. + +The most dangerous code paths are: +- Re-entrant `PropChanged` → `RefreshListAndFocus` → `CreateSlices` while + layout is suspended +- `ObjSeqHashMap` reuse across refresh with stale keys +- `DummyObjectSlice.BecomeReal` mid-scroll +- Thread safety of `m_fSuspendSettingCurrentSlice` + +None of these are addressed by the current tests in a way that would catch +a regression. + +### 1.7 Tests document bugs as "expected behavior" + +Several tests explicitly lock down questionable behavior: + +- `OnJumpToLexiconEditFilterAnthroItems_WithoutCurrentSlice_ThrowsNullReferenceException` + — This test asserts that a NullReferenceException is thrown. That's a bug, + not a feature. Characterizing it means you'll need to keep the NRE after + refactoring, or remember to delete the test. +- `MonitoredProps_AccumulatesAcrossRefresh_CurrentBehavior` — Documents a + potential memory leak. The companion `[Explicit]` test acknowledges this + should be fixed, but the characterization test will _prevent_ fixing it + during refactoring because it asserts the leak persists. +- `AddAtomicNode_WhenFlidIsZero_ThrowsApplicationException` — Documents an + internal validation check that's only reachable through reflection. + +Characterizing bugs creates a maintenance drag: every bug you document is a +test you'll later need to update or delete when the bug is fixed. + +### 1.8 The refactoring will change the public API surface + +The model/view separation means DataTree will be split into (at least): +- A model layer (data, business rules, slice metadata) +- A view layer (WinForms controls, painting, focus) + +Tests that call `m_dtree.ShowObject()` and then check `m_dtree.Slices[0].Label` +are testing the _combined_ model+view. After the split, there won't be a single +object that does both. Every test that touches both "what slices exist" and +"what controls are visible" will need rewriting. + +If that's the case, these tests are a temporary scaffold — which is fine, but +the test plan doesn't acknowledge this. It presents them as long-lived safety +nets, which they won't be. + +--- + +## Part 2: How to Test More Effectively + +### 2.1 Identify the behavioral contracts that survive the refactoring + +Before writing more tests, answer: **what invariants must be preserved after +the model/view split?** These are the tests worth writing: + +| Contract | Survives refactoring? | +|----------|----------------------| +| Given layout XML + object → correct ordered list of (label, type, indent, object) | Yes — this is the model | +| `visibility="ifdata"` hides when data is empty | Yes | +| `visibility="never"` hides unless show-hidden is on | Yes | +| Refreshing same object reuses matching slice metadata | Maybe — depends on design | +| MonitorProp + PropChanged → refresh | Yes — notification contract | +| Navigation order matches slice order | Yes — but implementation changes | +| JumpToTool resolves correct GUID | Yes — business logic | +| Context menu handler dispatch | Probably yes | + +Tests for the "Yes" contracts are worth investing in. Tests for internal +mechanics (ObjSeqHashMap, EquivalentKeys, DummyObjectSlice) will break. + +### 2.2 Test at the right boundary: layout → slice metadata + +Instead of testing `DataTree` as a god object, extract the testable core: + +**The layout engine is a pure function:** +``` +(XML layouts, XML parts, LCM object, layout name) → ordered list of SliceSpec +``` + +Where `SliceSpec` is a data object: `{ Label, Indent, FieldName, Flid, EditorType, +Visibility, ObjectHvo, ConfigurationNode }`. + +If you extract this function (even before the full refactoring), you can: +- Test it without WinForms +- Test it without a parent Form +- Test with real production layouts from `DistFiles/` +- Assert on structured data instead of control tree state +- Keep the tests across the refactoring because the function signature is stable + +This is the single most impactful change: **separate "what slices should exist" +from "how are they rendered."** + +### 2.3 Use production layouts as golden-file tests + +The test plan uses synthetic 2-part layouts. This misses the combinatorial +complexity of real layouts. A more effective approach: + +1. Run DataTree with each real layout (from `DistFiles/Language Explorer/Configuration/`) + and a representative LCM object +2. Serialize the resulting slice list to a golden file: + `{ label, indent, type, object class, flid }` +3. Diff against the golden file after each code change + +This gives you whole-system regression coverage without writing individual +assertions. It's the classic characterization test pattern — snapshot testing — +and it's far more robust than hand-written assertions for each branch. + +### 2.4 Replace reflection-based access with testable seams + +Instead of: +```csharp +var field = typeof(DataTree).GetField("m_currentSlice", BindingFlags.Instance | BindingFlags.NonPublic); +field.SetValue(m_dtree, slice); +``` + +Create `internal` methods or use `[InternalsVisibleTo]` (already available +since the tests are in the same assembly area). Better yet, extract the logic +into a separate class where the state is part of the public contract: + +```csharp +// Instead of testing DataTree's private field directly: +var nav = new SliceNavigator(slices); +nav.MoveTo(slice); +Assert.That(nav.Current, Is.SameAs(slice)); +``` + +This way, the tests survive extraction because they're testing the extracted +class directly. + +### 2.5 Write integration tests that exercise real user workflows + +The most dangerous regressions in a refactoring are the ones where "each piece +works but the whole doesn't." Consider a small number of end-to-end tests: + +1. **Show a LexEntry, edit citation form, verify refresh** — exercises + ShowObject → slice creation → PropChanged → RefreshList → slice reuse +2. **Show an entry with 30 senses, scroll to sense 25** — exercises + DummyObjectSlice → BecomeReal → layout +3. **Toggle show-hidden-fields on/off** — exercises property change → + HandleShowHiddenFields → slice visibility +4. **Switch from one entry to another** — exercises slice disposal → + new slice creation → focus management + +These four tests cover more real-world risk than 50 getter/setter checks. + +### 2.6 Explicitly tag tests by lifespan + +Not all characterization tests are equal. Tag them: + +- **`[Category("SurvivesRefactoring")]`** — Tests behavioral contracts that + should pass before _and_ after the model/view split. +- **`[Category("PreRefactoring")]`** — Tests that document current internals + and are expected to be deleted/rewritten during the split. +- **`[Category("KnownBug")]`** — Tests that document bugs (NRE on null current + slice, etc.) which should become `Assert.DoesNotThrow` or be deleted after fix. + +This makes the test suite actionable during the refactoring instead of a wall +of red that requires triage. + +### 2.7 Don't test constants and trivial properties + +Remove or don't write tests for: +- `Priority_ReturnsMediumColleaguePriority` (constant) +- `ShouldNotCall_FalseByDefault` (trivial) +- `SliceControlContainer_ReturnsSelf` (identity) +- `LabelWidth_ReturnsExpectedConstant` (constant) +- `SmallImages_SetterAndGetterRoundTrip` (auto-property) +- `StyleSheet_SetterAllowsNullRoundTrip` (auto-property) + +These inflate test count and coverage without detecting regressions. The time +spent writing and maintaining them is better spent on the behavioral contracts +listed in §2.1. + +### 2.8 Address the ObjSeqHashMap gap directly + +`ObjSeqHashMap` is the most critical data structure for the refactoring (it +controls slice reuse during refresh), yet it has zero direct tests. The test +plan mentions this (§3.3–3.5) but no tests have been written. + +This should be the highest priority: extract `ObjSeqHashMap` into its own file +with its own test fixture, and test: +- Insert → retrieve by key +- ClearUnwantedPart(true) vs ClearUnwantedPart(false) +- Key collision behavior +- Behavior with disposed slices in the map + +These tests are cheap, fast, and directly relevant to the refactoring. + +--- + +## Summary + +| Issue | Severity | Recommendation | +|-------|----------|----------------| +| Reflection-coupled tests break on refactoring | High | Extract testable seams; use internal visibility | +| Assert.DoesNotThrow tests catch nothing | High | Replace with specific outcome assertions | +| Getter/setter round-trips are wasteful | Medium | Delete; focus on behavioral contracts | +| Synthetic layouts miss real complexity | High | Add golden-file tests with production layouts | +| Tests document bugs as expected behavior | Medium | Tag as `[Category("KnownBug")]`; plan for fixes | +| ObjSeqHashMap untested | High | Standalone test fixture, highest priority | +| No test lifespan tagging | Medium | Add categories for refactoring lifecycle | +| No integrated workflow tests | High | Write 4–5 end-to-end scenarios | + +### Bottom line + +The current testing effort is building _coverage_ but not _confidence_. Many +tests are tightly coupled to internals that will change, assert trivial +properties, or document bugs as specifications. The test plan optimizes for +"number of tests" when it should optimize for "number of behavioral contracts +preserved across the refactoring." + +The most effective changes are: +1. Extract the layout-to-slice-metadata function and test it in isolation +2. Add golden-file tests with real production layouts +3. Test `ObjSeqHashMap` directly +4. Tag tests by expected lifespan +5. Stop writing getter/setter and `DoesNotThrow` tests From d4e82e1c4097ff7ccd6ee71ab0ab0792e266ddc0 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Sat, 28 Feb 2026 20:11:18 -0500 Subject: [PATCH 6/6] test(detailcontrols): split SliceTests and extend deterministic coverage --- .../DataTreeTests.Wave3.CommandsAndProps.cs | 28 + .../SliceTests.CoreProperties.cs | 901 ++++++++++++++++++ .../SliceTests.LifecycleAndUtilities.cs | 142 +++ .../DetailControlsTests/SliceTests.cs | 493 ++-------- .../coverage-wave2-test-matrix.md | 114 +++ 5 files changed, 1247 insertions(+), 431 deletions(-) create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.CoreProperties.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.LifecycleAndUtilities.cs diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs index 7a14b69a7e..9152f45285 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.Wave3.CommandsAndProps.cs @@ -1016,6 +1016,34 @@ public void ObjSeqHashMap_Report_DoesNotThrow() Assert.DoesNotThrow(() => map.Report()); } + [Test] + public void MakeSliceRealAt_WithExistingSlice_MakesSliceVisible() + { + var slice = new Slice + { + ConfigurationNode = CreateXmlNode(""), + Object = m_entry + }; + slice.SetBounds(0, 0, 200, 20); + + m_dtree.Width = 220; + m_dtree.Controls.Add(slice); + m_dtree.Slices.Add(slice); + + var method = typeof(DataTree).GetMethod("MakeSliceRealAt", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "Could not reflect DataTree.MakeSliceRealAt"); + + Assert.DoesNotThrow(() => method.Invoke(m_dtree, new object[] { 0 })); + Assert.That(slice.Width, Is.EqualTo(m_dtree.ClientRectangle.Width)); + } + + [Test] + public void ChooseNewOwner_NullRecords_ThrowsNullReferenceException() + { + Assert.Throws(() => m_dtree.ChooseNewOwner(null, "choose")); + } + private sealed class TraceTestDataTree : DataTree { public void SetTraceLevel(System.Diagnostics.TraceLevel level) diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.CoreProperties.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.CoreProperties.cs new file mode 100644 index 0000000000..4ec3a7c17f --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.CoreProperties.cs @@ -0,0 +1,901 @@ +// Copyright (c) 2025 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.Reflection; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.RootSites; +using SIL.FieldWorks.XWorks; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.DomainServices; +using SIL.LCModel.Utils; +using XCore; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + public partial class SliceTests + { + #region Characterization Tests — Core Properties + + [Test] + public void Abbreviation_AutoGeneratedFromLabel() + { + using (var slice = new Slice()) + { + slice.Label = "Citation Form"; + slice.Abbreviation = null; + Assert.That(slice.Abbreviation, Is.EqualTo("Cita")); + } + } + + [Test] + public void Abbreviation_ShortLabel_UsesFullLabel() + { + using (var slice = new Slice()) + { + slice.Label = "Go"; + slice.Abbreviation = null; + Assert.That(slice.Abbreviation, Is.EqualTo("Go")); + } + } + + [Test] + public void Abbreviation_ExplicitOverridesAutoGeneration() + { + using (var slice = new Slice()) + { + slice.Label = "Citation Form"; + slice.Abbreviation = "CF"; + Assert.That(slice.Abbreviation, Is.EqualTo("CF")); + } + } + + [Test] + public void IsHeaderNode_ReadsXmlAttribute() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.IsHeaderNode, Is.True); + } + } + + [Test] + public void IsHeaderNode_FalseWhenAbsent() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.IsHeaderNode, Is.False); + } + } + + [Test] + public void IsSequenceNode_TrueForOwningSequence() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsSequenceNode, Is.True); + Assert.That(m_Slice.IsCollectionNode, Is.False); + } + + [Test] + public void IsCollectionNode_TrueForNonOwningSequenceField() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsSequenceNode, Is.False); + Assert.That(m_Slice.IsCollectionNode, Is.True); + } + + [Test] + public void Object_SetAndGet() + { + using (var slice = new Slice()) + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + + slice.Object = entry; + + Assert.That(slice.Object, Is.SameAs(entry)); + } + } + + [Test] + public void Key_SetAndGet() + { + using (var slice = new Slice()) + { + var key = new object[] { 1, "abc" }; + + slice.Key = key; + + Assert.That(slice.Key, Is.SameAs(key)); + } + } + + [Test] + public void ContainingDataTree_NullWhenOrphaned() + { + using (var slice = new Slice()) + { + Assert.That(slice.ContainingDataTree, Is.Null); + } + } + + [Test] + public void ContainingDataTree_ReturnsParent() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + Assert.That(m_Slice.ContainingDataTree, Is.SameAs(m_DataTree)); + } + + [Test] + public void WrapsAtomic_ReadsConfigAttribute() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.WrapsAtomic, Is.True); + } + } + + [Test] + public void WrapsAtomic_DefaultsFalse() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.WrapsAtomic, Is.False); + } + } + + [Test] + public void IsRealSlice_TrueForRegularSlice() + { + using (var slice = new Slice()) + { + Assert.That(slice.IsRealSlice, Is.True); + } + } + + [Test] + public void BecomeReal_BaseReturnsSelf() + { + using (var slice = new Slice()) + { + var result = slice.BecomeReal(0); + Assert.That(result, Is.SameAs(slice)); + } + } + + [Test] + public void CallerNodeEqual_StructuralComparison() + { + using (var slice = new Slice()) + { + var node1 = CreateXmlElementFromOuterXmlOf(""); + slice.CallerNode = node1; + + var node2 = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.CallerNodeEqual(node2), Is.True); + + var node3 = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.CallerNodeEqual(node3), Is.False); + } + } + + [Test] + public void IsObjectNode_TrueWhenNodeElementPresent_AndObjectIsNotRoot() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var childObject = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = childObject; + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsObjectNode, Is.True); + } + + [Test] + public void IsObjectNode_FalseWhenSeqElementPresent() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var childObject = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = childObject; + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.IsObjectNode, Is.False); + } + + [Test] + public void LabelIndent_IncreasesWithIndent() + { + using (var slice = new Slice()) + { + slice.Indent = 0; + int indent0 = slice.LabelIndent(); + slice.Indent = 2; + int indent2 = slice.LabelIndent(); + + Assert.That(indent2 - indent0, Is.EqualTo(2 * SliceTreeNode.kdxpIndDist)); + } + } + + [Test] + public void GetBranchHeight_ReturnsPositiveValue() + { + using (var slice = new Slice()) + { + int branchHeight = slice.GetBranchHeight(); + Assert.That(branchHeight, Is.GreaterThan(0)); + } + } + + [Test] + public void Active_DefaultFalse_SetterDoesNotActivateBaseSlice() + { + using (var slice = new Slice()) + { + Assert.That(slice.Active, Is.False); + slice.Active = true; + Assert.That(slice.Active, Is.False); + } + } + + [Test] + public void IsGhostSlice_BaseReturnsFalse() + { + using (var slice = new Slice()) + { + Assert.That(slice.IsGhostSlice, Is.False); + } + } + + [Test] + public void ShouldNotCall_FalseUntilDisposed_ThenTrue() + { + var slice = new Slice(); + Assert.That(slice.ShouldNotCall, Is.False); + + slice.Dispose(); + + Assert.That(slice.ShouldNotCall, Is.True); + } + + [Test] + public void Priority_ReturnsMediumColleaguePriority() + { + using (var slice = new Slice()) + { + Assert.That(slice.Priority, Is.EqualTo((int)ColleaguePriority.Medium)); + } + } + + [Test] + public void HelpTopicProperties_ReadConfiguredAttributes() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf( + ""); + + Assert.That(slice.HelpTopicID, Is.EqualTo("khtpField-Entry")); + Assert.That(slice.ChooserDlgHelpTopicID, Is.EqualTo("khtpChoose-Entry")); + } + } + + [Test] + public void GetHelpTopicHelpers_UseConfiguredValues() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf( + ""); + + Assert.That(slice.GetSliceHelpTopicID(), Is.EqualTo("khtpField-Entry")); + Assert.That(slice.GetChooserHelpTopicID(), Is.EqualTo("khtpChoose-Entry")); + Assert.That(slice.GetChooserHelpTopicID("khtpChoose-Explicit"), Is.EqualTo("khtpChoose-Explicit")); + } + } + + [Test] + public void GetSliceHelpTopicID_WithoutProvider_FallsBackToNoHelpTopic() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_Mediator); + m_Slice.Mediator = m_Mediator; + m_Slice.PropTable = m_propertyTable; + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_propertyTable.SetProperty("areaChoice", "lexicon", false); + m_propertyTable.SetProperty("currentContentControl", "Entries", false); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.GetSliceHelpTopicID(), Is.EqualTo("khtpNoHelpTopic")); + } + + [Test] + public void GetChooserHelpTopicID_WithoutProvider_UsesChooseFallback() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_Mediator); + m_Slice.Mediator = m_Mediator; + m_Slice.PropTable = m_propertyTable; + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_propertyTable.SetProperty("areaChoice", "lexicon", false); + m_propertyTable.SetProperty("currentContentControl", "Entries", false); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.GetChooserHelpTopicID(), Is.EqualTo("khtpChoose-CmPossibility")); + } + + [Test] + public void GetSliceHelpTopicID_ForListsArea_UsesCustomListFallback() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_Mediator); + m_Slice.Mediator = m_Mediator; + m_Slice.PropTable = m_propertyTable; + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_propertyTable.SetProperty("areaChoice", "lists", false); + m_propertyTable.SetProperty("currentContentControl", "Entries", false); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.GetSliceHelpTopicID(), Is.EqualTo("khtp-CustomListField")); + } + + [Test] + public void HelpId_UsesIdThenFallsBackToField() + { + using (var withId = new ExposedSlice()) + { + withId.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(withId.ExposedHelpId, Is.EqualTo("khtpCustom")); + } + + using (var withFieldOnly = new ExposedSlice()) + { + withFieldOnly.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(withFieldOnly.ExposedHelpId, Is.EqualTo("CitationForm")); + } + } + + [Test] + public void Flid_WithoutObjectOrField_ReturnsZero() + { + using (var slice = new Slice()) + { + slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + Assert.That(slice.Flid, Is.EqualTo(0)); + } + } + + [Test] + public void Flid_WithConfiguredFieldAndObject_ReturnsMetadataFlid() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Cache = Cache; + m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); + + Assert.That(m_Slice.Flid, Is.EqualTo(LexEntryTags.kflidCitationForm)); + } + + [Test] + public void NextSlice_PrivateGetter_ReturnsFollowingSlice_AndNullAtEnd() + { + m_DataTree = new DataTree(); + var first = GenerateSlice(Cache, m_DataTree); + var second = GenerateSlice(Cache, m_DataTree); + m_DataTree.Slices.Add(first); + m_DataTree.Slices.Add(second); + + var nextSliceProperty = typeof(Slice).GetProperty("NextSlice", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(nextSliceProperty, Is.Not.Null); + + var last = second; + + var nextFromFirst = nextSliceProperty.GetValue(first, null) as Slice; + var nextFromLast = nextSliceProperty.GetValue(last, null) as Slice; + + Assert.That(nextFromFirst, Is.SameAs(second)); + Assert.That(nextFromLast, Is.Null); + } + + [Test] + public void CanDeleteReferenceNow_DefaultSlicePath_ReturnsFalse() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + var root = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = root; + Assert.That(m_Slice.CanDeleteReferenceNow(null), Is.False); + } + + [Test] + public void AboutToDiscard_SetsBeingDiscardedPrivateFlag() + { + using (var slice = new Slice()) + { + var property = typeof(Slice).GetProperty("BeingDiscarded", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(property, Is.Not.Null); + + Assert.That((bool)property.GetValue(slice, null), Is.False); + slice.AboutToDiscard(); + Assert.That((bool)property.GetValue(slice, null), Is.True); + } + } + + [Test] + public void DisplayVisibilityCommands_CheckStateMatchesPartVisibility() + { + using (var slice = new Slice()) + { + slice.Key = new object[] + { + CreateXmlElementFromOuterXmlOf(""), + CreateXmlElementFromOuterXmlOf("") + }; + + var displayAlways = new UIItemDisplayProperties(null, "always", true, null, true); + var displayIfData = new UIItemDisplayProperties(null, "ifdata", true, null, true); + var displayNever = new UIItemDisplayProperties(null, "never", true, null, true); + + Assert.That(slice.OnDisplayShowFieldAlwaysVisible(null, ref displayAlways), Is.True); + Assert.That(slice.OnDisplayShowFieldIfData(null, ref displayIfData), Is.True); + Assert.That(slice.OnDisplayShowFieldNormallyHidden(null, ref displayNever), Is.True); + + Assert.That(displayAlways.Checked, Is.False); + Assert.That(displayIfData.Checked, Is.True); + Assert.That(displayNever.Checked, Is.False); + } + } + + [Test] + public void OnDisplayMoveFieldUp_FirstSlice_DisablesCommand() + { + Slice first; + Slice middle; + Slice last; + CreateMoveSlices(out first, out middle, out last); + + var display = new UIItemDisplayProperties(null, "MoveFieldUp", true, null, true); + bool handled = first.OnDisplayMoveFieldUp(null, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + } + + [Test] + public void OnDisplayMoveFieldUp_MiddleSlice_EnablesCommand() + { + Slice first; + Slice middle; + Slice last; + CreateMoveSlices(out first, out middle, out last); + + var display = new UIItemDisplayProperties(null, "MoveFieldUp", true, null, true); + bool handled = middle.OnDisplayMoveFieldUp(null, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.True); + } + + [Test] + public void OnDisplayMoveFieldDown_LastSlice_DisablesCommand() + { + Slice first; + Slice middle; + Slice last; + CreateMoveSlices(out first, out middle, out last); + + var display = new UIItemDisplayProperties(null, "MoveFieldDown", true, null, true); + bool handled = last.OnDisplayMoveFieldDown(null, ref display); + + Assert.That(handled, Is.True); + Assert.That(display.Enabled, Is.False); + } + + [Test] + public void EmbeddedSlice_ReturnsTrue_WhenChildContainsParentRefNodes() + { + Slice root; + Slice child; + Slice outsider; + CreateEmbeddedSliceHarness(out root, out child, out outsider); + + var method = typeof(Slice).GetMethod("EmbeddedSlice", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + + bool result = (bool)method.Invoke(root, new object[] { child }); + + Assert.That(result, Is.True); + } + + [Test] + public void EmbeddedSlice_ReturnsFalse_WhenChildMissingParentRefNode() + { + Slice root; + Slice child; + Slice outsider; + CreateEmbeddedSliceHarness(out root, out child, out outsider); + + var method = typeof(Slice).GetMethod("EmbeddedSlice", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + + bool result = (bool)method.Invoke(root, new object[] { outsider }); + + Assert.That(result, Is.False); + } + + [Test] + public void ExpandSubItem_ReturnsMatchingEmbeddedSlice() + { + Slice root; + Slice child; + Slice outsider; + CreateEmbeddedSliceHarness(out root, out child, out outsider); + + var matched = root.ExpandSubItem(child.Object.Hvo); + + Assert.That(matched, Is.SameAs(child)); + } + + [Test] + public void ExpandSubItem_StopsAtFirstNonEmbeddedSlice() + { + Slice root; + Slice child; + Slice outsider; + CreateEmbeddedSliceHarness(out root, out child, out outsider); + + var lateMatch = new Slice + { + Object = child.Object, + Key = child.Key, + Expansion = DataTree.TreeItemState.ktisFixed + }; + lateMatch.Parent = m_DataTree; + m_DataTree.Controls.Add(lateMatch); + m_DataTree.Slices.Add(lateMatch); + + var matched = root.ExpandSubItem(child.Object.Hvo); + + Assert.That(matched, Is.SameAs(child)); + } + + [Test] + public void FocusSliceOrChild_ReturnsNull_WhenNoCandidateTakesFocus() + { + m_DataTree = new DataTree(); + var doc = new XmlDocument(); + doc.LoadXml(""); + var layout = doc.DocumentElement; + var partA = layout.SelectSingleNode("part[@ref='A']"); + var partB = layout.SelectSingleNode("part[@ref='B']"); + var partC = layout.SelectSingleNode("part[@ref='C']"); + + var root = new Slice { Key = new object[] { layout, partA } }; + var child = new Slice { Key = new object[] { layout, partA, partB } }; + var outsider = new Slice { Key = new object[] { layout, partC } }; + + root.Parent = m_DataTree; + child.Parent = m_DataTree; + outsider.Parent = m_DataTree; + m_DataTree.Controls.Add(root); + m_DataTree.Controls.Add(child); + m_DataTree.Controls.Add(outsider); + m_DataTree.Slices.Add(root); + m_DataTree.Slices.Add(child); + m_DataTree.Slices.Add(outsider); + + var focused = root.FocusSliceOrChild(); + + Assert.That(focused, Is.Null); + } + + [Test] + public void GetObjectForMenusToOperateOn_VariantBackRefSlice_ReturnsBackRefOwner() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var owner = Cache.ServiceLocator.GetInstance().Create(); + var entryRef = Cache.ServiceLocator.GetInstance().Create(); + owner.EntryRefsOS.Add(entryRef); + + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = entryRef; + + var fromVariantProperty = typeof(Slice).GetProperty("FromVariantBackRefField", BindingFlags.Instance | BindingFlags.NonPublic); + var backRefProperty = typeof(Slice).GetProperty("BackRefObject", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(fromVariantProperty, Is.Not.Null); + Assert.That(backRefProperty, Is.Not.Null); + + Assert.That((bool)fromVariantProperty.GetValue(m_Slice, null), Is.True); + Assert.That(backRefProperty.GetValue(m_Slice, null), Is.SameAs(owner)); + Assert.That(m_Slice.GetObjectForMenusToOperateOn(), Is.SameAs(owner)); + } + + [Test] + public void BackRefObject_ReturnsNull_WhenObjectIsRoot() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = root; + + var fromVariantProperty = typeof(Slice).GetProperty("FromVariantBackRefField", BindingFlags.Instance | BindingFlags.NonPublic); + var backRefProperty = typeof(Slice).GetProperty("BackRefObject", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(fromVariantProperty, Is.Not.Null); + Assert.That(backRefProperty, Is.Not.Null); + + Assert.That((bool)fromVariantProperty.GetValue(m_Slice, null), Is.False); + Assert.That(backRefProperty.GetValue(m_Slice, null), Is.Null); + } + + [Test] + public void GetCloseSlices_MiddleIndex_ReturnsNearestAlternatingOrder() + { + Slice[] slices; + CreateSequentialSlices(5, out slices); + + var target = slices[2]; + var close = target.GetCloseSlices(); + + Assert.That(close.Count, Is.EqualTo(5)); + Assert.That(close[0], Is.SameAs(slices[2])); + Assert.That(close[1], Is.SameAs(slices[1])); + Assert.That(close[2], Is.SameAs(slices[3])); + Assert.That(close[3], Is.SameAs(slices[0])); + Assert.That(close[4], Is.SameAs(slices[4])); + } + + [Test] + public void GetCloseSlices_FirstIndex_ReturnsSelfThenFollowingSlices() + { + Slice[] slices; + CreateSequentialSlices(5, out slices); + + var close = slices[0].GetCloseSlices(); + + Assert.That(close.Count, Is.EqualTo(5)); + Assert.That(close[0], Is.SameAs(slices[0])); + Assert.That(close[1], Is.SameAs(slices[1])); + Assert.That(close[2], Is.SameAs(slices[2])); + Assert.That(close[3], Is.SameAs(slices[3])); + Assert.That(close[4], Is.SameAs(slices[4])); + } + + [Test] + public void GetCanDeleteNow_ReturnsFalse_WhenObjectIsNull() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = null; + + Assert.That(m_Slice.GetCanDeleteNow(), Is.False); + } + + [Test] + public void GetCanDeleteNow_UnownedObject_ReturnsTrue() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var root = Cache.ServiceLocator.GetInstance().Create(); + var freeObject = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + + m_Slice.Object = freeObject; + + Assert.That(m_Slice.GetCanDeleteNow(), Is.True); + } + + [Test] + public void GetCanEditNow_BaseReturnsTrue() + { + using (var slice = new Slice()) + { + Assert.That(slice.GetCanEditNow(), Is.True); + } + } + + [Test] + public void PersistenceProvider_SetAndGet_RoundTrips() + { + using (var slice = new Slice()) + { + var mediator = new Mediator(); + var propertyTable = new PropertyTable(mediator); + try + { + var provider = new PersistenceProvider(mediator, propertyTable, "SliceTests"); + slice.PersistenceProvider = provider; + + Assert.That(slice.PersistenceProvider, Is.SameAs(provider)); + } + finally + { + propertyTable.Dispose(); + mediator.Dispose(); + } + } + } + + [Test] + public void ShouldHide_BaseSlice_DefaultsTrue() + { + using (var slice = new ExposedSlice()) + { + Assert.That(slice.ExposedShouldHide, Is.True); + } + } + + [Test] + public void GetCanMergeNow_WithNullObject_ReturnsFalse() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + var root = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + m_Slice.Object = null; + + Assert.That(m_Slice.GetCanMergeNow(), Is.False); + } + + [Test] + public void GetCanSplitNow_WithNullObject_ReturnsFalse() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + var root = Cache.ServiceLocator.GetInstance().Create(); + var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.That(rootField, Is.Not.Null); + rootField.SetValue(m_DataTree, root); + m_Slice.Object = null; + + Assert.That(m_Slice.GetCanSplitNow(), Is.False); + } + + [Test] + public void GetSeqContext_WithNullKey_ReturnsFalse() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + m_Slice.Key = null; + + int hvoOwner; + int flid; + int ihvo; + bool result = m_Slice.GetSeqContext(out hvoOwner, out flid, out ihvo); + + Assert.That(result, Is.False); + } + + [Test] + public void GetSeqContext_WithSenseInEntrySenses_ReturnsOwnerAndIndex() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var entry = Cache.ServiceLocator.GetInstance().Create(); + var senseA = Cache.ServiceLocator.GetInstance().Create(); + var senseB = Cache.ServiceLocator.GetInstance().Create(); + entry.SensesOS.Add(senseA); + entry.SensesOS.Add(senseB); + + m_Slice.Key = new object[] + { + CreateXmlElementFromOuterXmlOf(""), + senseB.Hvo + }; + + int hvoOwner; + int flid; + int ihvo; + bool result = m_Slice.GetSeqContext(out hvoOwner, out flid, out ihvo); + + Assert.That(result, Is.True); + Assert.That(hvoOwner, Is.EqualTo(entry.Hvo)); + Assert.That(flid, Is.EqualTo(LexEntryTags.kflidSenses)); + Assert.That(ihvo, Is.EqualTo(1)); + } + + [Test] + public void GetAtomicContext_WithAtomicNode_ReturnsTrueAndFlid() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + + var entry = Cache.ServiceLocator.GetInstance().Create(); + var allomorph = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = allomorph; + + m_Slice.Key = new object[] + { + CreateXmlElementFromOuterXmlOf(""), + allomorph.Hvo + }; + + int hvoOwner; + int flid; + bool result = m_Slice.GetAtomicContext(out hvoOwner, out flid); + + Assert.That(result, Is.True); + Assert.That(flid, Is.EqualTo(LexEntryTags.kflidLexemeForm)); + } + + [Test] + public void GetMessageTargets_WhenSliceNotVisible_ReturnsEmpty() + { + using (var slice = new Slice()) + { + var targets = slice.GetMessageTargets(); + Assert.That(targets, Is.Empty); + } + } + + #endregion + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.LifecycleAndUtilities.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.LifecycleAndUtilities.cs new file mode 100644 index 0000000000..7e7177863e --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.LifecycleAndUtilities.cs @@ -0,0 +1,142 @@ +// Copyright (c) 2025 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 NUnit.Framework; +using SIL.LCModel; +using SIL.LCModel.DomainServices; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + public partial class SliceTests + { + #region Characterization Tests — Lifecycle + + [Test] + public void Constructor_SetsVisibleFalse() + { + using (var slice = new Slice()) + { + Assert.That(slice.Visible, Is.False, + "New slices should start invisible"); + } + } + + [Test] + public void CheckDisposed_AfterDispose_Throws() + { + var slice = new Slice(); + slice.Dispose(); + + Assert.Throws(() => slice.CheckDisposed()); + } + + #endregion + + #region Characterization Tests — Expansion + + [Test] + public void Expansion_DefaultIsFixed() + { + using (var slice = new Slice()) + { + Assert.That(slice.Expansion, Is.EqualTo(DataTree.TreeItemState.ktisFixed), + "Default expansion should be Fixed"); + } + } + + [Test] + public void ExpansionStateKey_NullForFixedSlices() + { + using (var slice = new Slice()) + { + Assert.That(slice.ExpansionStateKey, Is.Null, + "Fixed slices should have null ExpansionStateKey"); + } + } + + [Test] + public void ExpansionStateKey_NonNullForExpandedWithObject() + { + m_DataTree = new DataTree(); + m_Slice = GenerateSlice(Cache, m_DataTree); + var obj = Cache.ServiceLocator.GetInstance().Create(); + m_Slice.Object = obj; + m_Slice.Expansion = DataTree.TreeItemState.ktisExpanded; + + Assert.That(m_Slice.ExpansionStateKey, Is.Not.Null, + "Expanded slice with an object should have a non-null ExpansionStateKey"); + Assert.That(m_Slice.ExpansionStateKey, Does.StartWith("expand"), + "ExpansionStateKey should start with 'expand'"); + } + + #endregion + + #region Characterization Tests — Static Utilities + + [Test] + public void StartsWith_BoxedIntEquality() + { + var target = new object[] { 1, 2, 3, "extra" }; + var match = new object[] { 1, 2, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.True, + "StartsWith should handle boxed int equality"); + } + + [Test] + public void StartsWith_MatchLongerThanTarget_ReturnsFalse() + { + var target = new object[] { 1, 2 }; + var match = new object[] { 1, 2, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.False); + } + + [Test] + public void StartsWith_MismatchedElements_ReturnsFalse() + { + var target = new object[] { 1, 2, 3 }; + var match = new object[] { 1, 99, 3 }; + + Assert.That(Slice.StartsWith(target, match), Is.False); + } + + [Test] + public void ExtraIndent_TrueAttribute_ReturnsOne() + { + var node = CreateXmlElementFromOuterXmlOf(""); + Assert.That(Slice.ExtraIndent(node), Is.EqualTo(1)); + } + + [Test] + public void ExtraIndent_NoAttribute_ReturnsZero() + { + var node = CreateXmlElementFromOuterXmlOf(""); + Assert.That(Slice.ExtraIndent(node), Is.EqualTo(0)); + } + + #endregion + + #region Characterization Tests — Weight + + [Test] + public void Weight_SetAndGet() + { + using (var slice = new Slice()) + { + slice.Weight = ObjectWeight.heavy; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.heavy)); + + slice.Weight = ObjectWeight.light; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.light)); + + slice.Weight = ObjectWeight.field; + Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.field)); + } + } + + #endregion + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs index ae2464d523..e091085e3b 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs @@ -16,7 +16,7 @@ namespace SIL.FieldWorks.Common.Framework.DetailControls { /// [TestFixture] - public class SliceTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + public partial class SliceTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase { private DataTree m_DataTree; private Slice m_Slice; @@ -198,463 +198,94 @@ public void CreateGhostStringSlice_ParentSliceNotNull() Assert.That(m_Slice.PropTable, Is.EqualTo(ghostSlice.PropTable)); } - #region Characterization Tests — Core Properties - /// - /// Abbreviation auto-generates from Label (first 4 chars) when not explicitly set. - /// - [Test] - public void Abbreviation_AutoGeneratedFromLabel() - { - using (var slice = new Slice()) - { - slice.Label = "Citation Form"; - // Setting Label also auto-sets Abbreviation via the setter logic. - // But Abbreviation is set via its own property — verify when set to null/empty. - slice.Abbreviation = null; - Assert.That(slice.Abbreviation, Is.EqualTo("Cita"), - "Abbreviation should auto-generate to first 4 chars of label"); - } - } - /// - /// Label shorter than 4 chars → abbreviation is the full label. - /// - [Test] - public void Abbreviation_ShortLabel_UsesFullLabel() + private sealed class ExposedSlice : Slice { - using (var slice = new Slice()) - { - slice.Label = "Go"; - slice.Abbreviation = null; - Assert.That(slice.Abbreviation, Is.EqualTo("Go"), - "Abbreviation for short label should be the full label"); - } + public string ExposedHelpId => HelpId; + public bool ExposedShouldHide => ShouldHide; } - /// - /// Explicit abbreviation overrides auto-generation. - /// - [Test] - public void Abbreviation_ExplicitOverridesAutoGeneration() - { - using (var slice = new Slice()) - { - slice.Label = "Citation Form"; - slice.Abbreviation = "CF"; - Assert.That(slice.Abbreviation, Is.EqualTo("CF"), - "Explicit abbreviation should override auto-generation"); - } - } - - /// - /// IsHeaderNode reads the header XML attribute. - /// - [Test] - public void IsHeaderNode_ReadsXmlAttribute() - { - using (var slice = new Slice()) - { - slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - Assert.That(slice.IsHeaderNode, Is.True); - } - } - - /// - /// IsHeaderNode is false when header attribute is absent. - /// - [Test] - public void IsHeaderNode_FalseWhenAbsent() - { - using (var slice = new Slice()) - { - slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - Assert.That(slice.IsHeaderNode, Is.False); - } - } - - [Test] - public void IsSequenceNode_TrueForOwningSequence() - { - m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - m_Slice.Cache = Cache; - m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); - m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - - Assert.That(m_Slice.IsSequenceNode, Is.True, - "LexEntry.Senses should be treated as an owning sequence node"); - Assert.That(m_Slice.IsCollectionNode, Is.False); - } - - [Test] - public void IsCollectionNode_TrueForNonOwningSequenceField() + private void CreateMoveSlices(out Slice first, out Slice middle, out Slice last) { m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - m_Slice.Cache = Cache; - m_Slice.Object = Cache.ServiceLocator.GetInstance().Create(); - m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - - Assert.That(m_Slice.IsSequenceNode, Is.False, - "CitationForm is not an owning sequence field"); - Assert.That(m_Slice.IsCollectionNode, Is.True, - "Current behavior: any node that is not owning-sequence is treated as collection"); - } - - [Test] - public void Object_SetAndGet() - { - using (var slice = new Slice()) - { - var entry = Cache.ServiceLocator.GetInstance().Create(); - - slice.Object = entry; - - Assert.That(slice.Object, Is.SameAs(entry)); - } - } - - [Test] - public void Key_SetAndGet() - { - using (var slice = new Slice()) - { - var key = new object[] { 1, "abc" }; + var doc = new XmlDocument(); + doc.LoadXml(""); + var layout = doc.DocumentElement; + var partA = layout.SelectSingleNode("part[@ref='A']"); + var partB = layout.SelectSingleNode("part[@ref='B']"); + var partC = layout.SelectSingleNode("part[@ref='C']"); - slice.Key = key; + first = new Slice { Key = new object[] { layout, partA } }; + middle = new Slice { Key = new object[] { layout, partB } }; + last = new Slice { Key = new object[] { layout, partC } }; - Assert.That(slice.Key, Is.SameAs(key)); - } + m_DataTree.Controls.Add(first); + m_DataTree.Controls.Add(middle); + m_DataTree.Controls.Add(last); + m_DataTree.Slices.Add(first); + m_DataTree.Slices.Add(middle); + m_DataTree.Slices.Add(last); } - /// - /// ContainingDataTree returns null when the slice is not parented. - /// - [Test] - public void ContainingDataTree_NullWhenOrphaned() - { - using (var slice = new Slice()) - { - Assert.That(slice.ContainingDataTree, Is.Null, - "Orphaned slice should have null ContainingDataTree"); - } - } - - /// - /// ContainingDataTree returns the parent DataTree after Install. - /// - [Test] - public void ContainingDataTree_ReturnsParent() + private void CreateEmbeddedSliceHarness(out Slice root, out Slice child, out Slice outsider) { m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - Assert.That(m_Slice.ContainingDataTree, Is.SameAs(m_DataTree), - "Installed slice should return its parent DataTree"); - } + m_DataTree.Initialize(Cache, false, DataTreeTests.GenerateLayouts(), DataTreeTests.GenerateParts()); - /// - /// WrapsAtomic reads the wrapsAtomic XML attribute. - /// - [Test] - public void WrapsAtomic_ReadsConfigAttribute() - { - using (var slice = new Slice()) - { - slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - Assert.That(slice.WrapsAtomic, Is.True); - } - } + var doc = new XmlDocument(); + doc.LoadXml(""); + var layout = doc.DocumentElement; + var partA = layout.SelectSingleNode("part[@ref='A']"); + var partB = layout.SelectSingleNode("part[@ref='B']"); - /// - /// WrapsAtomic defaults to false. - /// - [Test] - public void WrapsAtomic_DefaultsFalse() - { - using (var slice = new Slice()) + root = new Slice { - slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - Assert.That(slice.WrapsAtomic, Is.False); - } - } + Object = Cache.ServiceLocator.GetInstance().Create(), + Key = new object[] { layout, partA }, + Expansion = DataTree.TreeItemState.ktisFixed + }; - /// - /// IsRealSlice returns true for regular slices. - /// - [Test] - public void IsRealSlice_TrueForRegularSlice() - { - using (var slice = new Slice()) - { - Assert.That(slice.IsRealSlice, Is.True, - "Regular Slice should be real"); - } - } - - /// - /// BecomeReal on a non-dummy slice returns itself. - /// - [Test] - public void BecomeReal_BaseReturnsSelf() - { - using (var slice = new Slice()) + child = new Slice { - var result = slice.BecomeReal(0); - Assert.That(result, Is.SameAs(slice), - "BecomeReal on a real slice should return itself"); - } - } + Object = Cache.ServiceLocator.GetInstance().Create(), + Key = new object[] { layout, partA, partB }, + Expansion = DataTree.TreeItemState.ktisFixed + }; - /// - /// CallerNodeEqual compares OuterXml of two nodes. - /// - [Test] - public void CallerNodeEqual_StructuralComparison() - { - using (var slice = new Slice()) + outsider = new Slice { - // Set CallerNode. - var node1 = CreateXmlElementFromOuterXmlOf(""); - slice.CallerNode = node1; - - // Create structurally identical but different .NET reference. - var node2 = CreateXmlElementFromOuterXmlOf(""); + Object = Cache.ServiceLocator.GetInstance().Create(), + Key = new object[] { layout, partB }, + Expansion = DataTree.TreeItemState.ktisFixed + }; - Assert.That(slice.CallerNodeEqual(node2), Is.True, - "CallerNodeEqual should compare by OuterXml"); - - var node3 = CreateXmlElementFromOuterXmlOf(""); - Assert.That(slice.CallerNodeEqual(node3), Is.False, - "Different XML content should not be equal"); - } + root.Parent = m_DataTree; + child.Parent = m_DataTree; + outsider.Parent = m_DataTree; + m_DataTree.Controls.Add(root); + m_DataTree.Controls.Add(child); + m_DataTree.Controls.Add(outsider); + m_DataTree.Slices.Add(root); + m_DataTree.Slices.Add(child); + m_DataTree.Slices.Add(outsider); } - [Test] - public void IsObjectNode_TrueWhenNodeElementPresent_AndObjectIsNotRoot() + private void CreateSequentialSlices(int count, out Slice[] slices) { m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - - var root = Cache.ServiceLocator.GetInstance().Create(); - var childObject = Cache.ServiceLocator.GetInstance().Create(); - var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - Assert.That(rootField, Is.Not.Null, "Could not reflect DataTree.m_root"); - rootField.SetValue(m_DataTree, root); - - m_Slice.Object = childObject; - m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - - Assert.That(m_Slice.IsObjectNode, Is.True, - "A slice for a non-root object should be treated as object node"); - } - - [Test] - public void IsObjectNode_FalseWhenSeqElementPresent() - { - m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - - var root = Cache.ServiceLocator.GetInstance().Create(); - var childObject = Cache.ServiceLocator.GetInstance().Create(); - var rootField = typeof(DataTree).GetField("m_root", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - Assert.That(rootField, Is.Not.Null, "Could not reflect DataTree.m_root"); - rootField.SetValue(m_DataTree, root); - - m_Slice.Object = childObject; - m_Slice.ConfigurationNode = CreateXmlElementFromOuterXmlOf(""); - - Assert.That(m_Slice.IsObjectNode, Is.False, - "Presence of should force non-object-node behavior"); - } - - [Test] - public void LabelIndent_IncreasesWithIndent() - { - using (var slice = new Slice()) + slices = new Slice[count]; + for (int i = 0; i < count; i++) { - slice.Indent = 0; - int indent0 = slice.LabelIndent(); - slice.Indent = 2; - int indent2 = slice.LabelIndent(); - - Assert.That(indent2 - indent0, Is.EqualTo(2 * SliceTreeNode.kdxpIndDist)); - } - } - - [Test] - public void GetBranchHeight_ReturnsPositiveValue() - { - using (var slice = new Slice()) - { - int branchHeight = slice.GetBranchHeight(); - Assert.That(branchHeight, Is.GreaterThan(0)); - } - } - - #endregion - - #region Characterization Tests — Lifecycle - - /// - /// Constructor sets Visible to false. - /// - [Test] - public void Constructor_SetsVisibleFalse() - { - using (var slice = new Slice()) - { - Assert.That(slice.Visible, Is.False, - "New slices should start invisible"); - } - } - - /// - /// After Dispose, CheckDisposed throws ObjectDisposedException. - /// - [Test] - public void CheckDisposed_AfterDispose_Throws() - { - var slice = new Slice(); - slice.Dispose(); - - Assert.Throws(() => slice.CheckDisposed()); - } - - #endregion - - #region Characterization Tests — Expansion - - /// - /// Default expansion state is Fixed. - /// - [Test] - public void Expansion_DefaultIsFixed() - { - using (var slice = new Slice()) - { - Assert.That(slice.Expansion, Is.EqualTo(DataTree.TreeItemState.ktisFixed), - "Default expansion should be Fixed"); - } - } - - /// - /// ExpansionStateKey is null for Fixed slices. - /// - [Test] - public void ExpansionStateKey_NullForFixedSlices() - { - using (var slice = new Slice()) - { - // Default expansion is Fixed. - Assert.That(slice.ExpansionStateKey, Is.Null, - "Fixed slices should have null ExpansionStateKey"); - } - } - - /// - /// ExpansionStateKey is non-null when expansion is Expanded and object is set. - /// - [Test] - public void ExpansionStateKey_NonNullForExpandedWithObject() - { - m_DataTree = new DataTree(); - m_Slice = GenerateSlice(Cache, m_DataTree); - var obj = Cache.ServiceLocator.GetInstance().Create(); - m_Slice.Object = obj; - m_Slice.Expansion = DataTree.TreeItemState.ktisExpanded; - - Assert.That(m_Slice.ExpansionStateKey, Is.Not.Null, - "Expanded slice with an object should have a non-null ExpansionStateKey"); - Assert.That(m_Slice.ExpansionStateKey, Does.StartWith("expand"), - "ExpansionStateKey should start with 'expand'"); - } - - #endregion - - #region Characterization Tests — Static Utilities - - /// - /// StartsWith correctly handles boxed int equality. - /// - [Test] - public void StartsWith_BoxedIntEquality() - { - // Two arrays with boxed ints that have the same value. - var target = new object[] { 1, 2, 3, "extra" }; - var match = new object[] { 1, 2, 3 }; - - Assert.That(Slice.StartsWith(target, match), Is.True, - "StartsWith should handle boxed int equality"); - } - - /// - /// StartsWith returns false when match is longer than target. - /// - [Test] - public void StartsWith_MatchLongerThanTarget_ReturnsFalse() - { - var target = new object[] { 1, 2 }; - var match = new object[] { 1, 2, 3 }; - - Assert.That(Slice.StartsWith(target, match), Is.False); - } - - /// - /// StartsWith returns false for mismatched elements. - /// - [Test] - public void StartsWith_MismatchedElements_ReturnsFalse() - { - var target = new object[] { 1, 2, 3 }; - var match = new object[] { 1, 99, 3 }; - - Assert.That(Slice.StartsWith(target, match), Is.False); - } - - /// - /// ExtraIndent returns 1 when indent="true". - /// - [Test] - public void ExtraIndent_TrueAttribute_ReturnsOne() - { - var node = CreateXmlElementFromOuterXmlOf(""); - Assert.That(Slice.ExtraIndent(node), Is.EqualTo(1)); - } - - /// - /// ExtraIndent returns 0 when indent attribute is absent. - /// - [Test] - public void ExtraIndent_NoAttribute_ReturnsZero() - { - var node = CreateXmlElementFromOuterXmlOf(""); - Assert.That(Slice.ExtraIndent(node), Is.EqualTo(0)); - } - - #endregion - - #region Characterization Tests — Weight - - /// - /// Weight property can be set and retrieved. - /// - [Test] - public void Weight_SetAndGet() - { - using (var slice = new Slice()) - { - slice.Weight = ObjectWeight.heavy; - Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.heavy)); - - slice.Weight = ObjectWeight.light; - Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.light)); - - slice.Weight = ObjectWeight.field; - Assert.That(slice.Weight, Is.EqualTo(ObjectWeight.field)); + var slice = new Slice + { + Key = new object[] { "k", i } + }; + slice.Parent = m_DataTree; + m_DataTree.Controls.Add(slice); + m_DataTree.Slices.Add(slice); + slices[i] = slice; } } - - #endregion } } diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md index 6d63d6cb02..34a2732322 100644 --- a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md @@ -227,6 +227,120 @@ Incremental gain from the immediately previous checkpoint (65.38 / 50.04 for `Da - `Slice`: **+7.62 line** / **+4.50 branch** - `ObjSeqHashMap`: **no change** +## Rerun Status (2026-02-26, post-Slice low-cost method/property batch) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **67.51%** line / **52.02%** branch +- `Slice`: **40.51%** line / **25.68%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (67.51 / 52.02 for `DataTree`, 34.5 / 22.4 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+6.01 line** / **+3.28 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-Slice move paths + DataTree `MakeSliceRealAt`/`ChooseNewOwner` probes) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.68%** branch +- `Slice`: **46.47%** line / **32.1%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (67.51 / 52.02 for `DataTree`, 40.51 / 25.68 for `Slice`): + +- `DataTree`: **+0.76 line** / **+0.66 branch** +- `Slice`: **+5.96 line** / **+6.42 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`EmbeddedSlice`/`ExpandSubItem` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.68%** branch +- `Slice`: **47.63%** line / **33.47%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.68 for `DataTree`, 46.47 / 32.1 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+1.16 line** / **+1.37 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`FocusSliceOrChild` + help-topic fallback + variant backref tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **54.48%** line / **42.76%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.68 for `DataTree`, 47.63 / 33.47 for `Slice`): + +- `DataTree`: **+0.00 line** / **+0.16 branch** +- `Slice`: **+6.85 line** / **+9.29 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`GetCloseSlices` + `GetCanDeleteNow`/`GetCanEditNow` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **56.04%** line / **44.13%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.84 for `DataTree`, 54.48 / 42.76 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+1.56 line** / **+1.37 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`GetSeqContext`/`GetAtomicContext` + `GetCanSplitNow` null-path tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **60.27%** line / **46.45%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.84 for `DataTree`, 56.04 / 44.13 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+4.23 line** / **+2.32 branch** +- `ObjSeqHashMap`: **no change** + Wave 2 desired target: - `DataTree` line coverage toward ~46-50%