diff --git a/docs/advanced-filemaker-scripting-syntax.md b/docs/advanced-filemaker-scripting-syntax.md new file mode 100644 index 0000000..d18905f --- /dev/null +++ b/docs/advanced-filemaker-scripting-syntax.md @@ -0,0 +1,186 @@ +# Advanced FileMaker Scripting Syntax + +Status: authoritative reference for all script-step POCOs. + +## Purpose + +SharpFM renders FileMaker script XML as editable display text. Some XML +state doesn't naturally appear in FileMaker Pro's own display (object +IDs, flags, embedded newlines, etc.). To round-trip that state faithfully +through display-text editing, SharpFM extends FM Pro's display grammar +with a small set of named conventions — collectively, **Advanced +FileMaker Scripting Syntax**. + +The extensions are safe because **FileMaker Pro never consumes SharpFM's +display text**. FM Pro reads only the binary `Mac-XMSS` clipboard +payload; `POCO.ToXml()` always emits pure FM Pro XML regardless of +whatever extensions are present in the display. The display text is a +SharpFM-internal surface for user editing, and our display parser only +ever reads our own extended output. + +## Core invariant + +> `ToDisplayLine()` and `FromDisplayParams()` together are lossless for +> every piece of state the POCO carries. XML state that is dropped is +> dropped because it carries no information, not because the display +> can't express it. + +Every POCO's docstring contains a **zero-loss audit** (see below) that +enumerates XML state explicitly and marks each item as either rendered +by FM Pro natively, covered by an advanced-syntax extension, or +intentionally dropped with a written rationale. + +## The three extension forms + +Pick the form that matches the shape of the hidden state: + +| Shape of state | Syntax | Example | Used for | +|---|---|---|---| +| Id annotation on a named reference | `(#id)` suffix after the name | `"Find Customer" (#7)` | Script, Layout, TableOccurrence, Field id round-trip | +| Boolean / enum flag already in FM Pro's grammar | word token inline, matching FM Pro's wording | `Exit after last: On` | Flags FM Pro itself renders; we mirror its wording | +| Bulk invisible state (multi-slot / structured) | trailing `; Kind: [...]` block, word-token values | `; Buttons: ["OK" commit; "Cancel" nocommit]` | Button configurations, input-field metadata, any future bulk state | + +### Form 1 — `(#id)` suffix + +Appended to a quoted or `Table::Field` name: + +- `FieldRef` emits `People::FirstName (#7)` when an id is known; plain + `People::FirstName` otherwise. +- `PerformScriptStep` emits `"Find Customer" (#42)`. +- `GoToLayoutStep` emits `"Invoices Detail" (#11)`. + +Omitted when the id is zero or unknown — `(#0)` would be visual noise +for unresolved references. + +### Form 2 — inline word tokens + +Parsed at a specific named prefix, matching FM Pro's own rendering of +the same flag: + +- `Exit after last: On` on found-set iterators. +- `With dialog: Off` on steps that can suppress their confirmation UI. +- `Restore: On|Off` — **reserved**. Not currently emitted by any POCO; + see "What to drop vs. surface" below for the rationale. + +### Form 3 — trailing `; Kind: [...]` blocks + +Bulk or structured state that doesn't reduce to a single flag: + +- `; Buttons: ["OK" commit; "Cancel" nocommit; "" nocommit]` — used by + Show Custom Dialog for its button configuration. +- Future bulk state (Input Field specs, etc.) takes this form. + +## Parsing precedence + +Named-prefix inline tokens (Form 2) are parsed before trailing +`Kind: [...]` blocks (Form 3). A display line like + +``` +Go to Record/Request/Page [ Next ; Exit after last: On ; Buttons: [...] ] +``` + +is tokenized as: +1. Positional `Next` (Form 2 equivalent — fixed-position enum). +2. Named inline `Exit after last: On` (Form 2). +3. Named block `Buttons: [...]` (Form 3). + +Form 1 (`(#id)`) is applied inside each name-bearing token — e.g. inside +the bracketed name-and-id of a named ref — and does not collide with +Form 2 or Form 3 separators. + +## Zero-loss audit requirement + +Every POCO author must complete this audit in the class XML doc +comment. Template: + +``` +/// +/// Zero-loss audit for StepName: +/// +/// <Step> attributes (enable/id/name) — round-tripped. +/// <Calculation> CDATA — round-tripped via Calculation. +/// <SomeElement state="..."/> — Form 2 token "Some: On|Off". +/// <Dropped/> — intentionally dropped; rationale: ... +/// +/// +``` + +Items fall into exactly one of these buckets: + +1. **Rendered natively by FM Pro** — FM Pro's display grammar already + covers it; SharpFM mirrors the wording. +2. **Covered by an extension form** — one of the three above. +3. **Intentionally dropped** — rationale required. See next section for + how to judge. + +Omitting the audit is a review blocker. + +## What to drop vs. surface + +Hidden state is surfaced only when a user could meaningfully change it. +Some state is structurally present in XML but semantically fixed — FM +Pro never alters it, never emits it in clipboard output, and no user +workflow produces a different value. Round-tripping such state adds +visual noise for zero information. + +### Canonical drop: `` on `If` + +Upstream `agentic-fm` snippets include the element; FM Pro's own +clipboard output never does; no FM Pro user interaction produces +`state="True"`. `IfStep` drops it on both read and write. The audit +entry documents the drop: + +> <Restore state="False"/> — intentionally dropped. FM Pro never +> changes the value and never emits the element in clipboard output; it +> carries no information worth round-tripping. + +### Canonical surface: field `id` via `(#id)` suffix + +`` is a real identity — the id +selects which field is referenced, and two fields named `F` in different +tables are not interchangeable. Dropping the id would change semantics. +`FieldRef` always emits `(#12)` when an id is available. + +### The heuristic + +- If two valid FM Pro script states would be visually identical under + the display grammar without the extension, **surface** the state. +- If the state has a fixed value that no user can change, **drop** it. +- If you're not sure which, **surface**. Reversing a surface → drop + later is non-breaking; reversing a drop → surface may break tests + users wrote against the earlier display. + +## Adjacent convention: `//` disabled-step prefix + +Disabled steps are prefixed with `//` in display text: + +``` +// Set Error Capture [ On ] +``` + +Parsing strips the `//` and sets `ScriptStep.Enabled = false`. This is +a document-level convention (applied to any step line) rather than a +per-step extension. Covered here for completeness. + +## Implementation touch points + +- `FieldRef.ToDisplayString` / `FieldRef.FromDisplayToken` + (`src/SharpFM.Model/Scripting/Values/FieldRef.cs`) — Form 1 reference + implementation. +- `CommentStep.ReturnGlyph` + (`src/SharpFM.Model/Scripting/Steps/CommentStep.cs`) — the `⏎` + (U+23CE) glyph for single-line rendering of multi-line comment text. + An idiom adjacent to the three forms but specific to Comment. +- `ScriptLineParser.ParseLine` + (`src/SharpFM.Model/Scripting/ScriptLineParser.cs`) — disabled-step + prefix and bracket tokenization. +- `PerformScriptStep.FromDisplayParams`, + `GoToLayoutStep.FromDisplayParams` — Form 1 regex parsers for named + refs with `(#id)` suffixes. + +## Change log + +- **2026-04** — Extracted from `docs/step-definitions.md:44-69` into + its own document as part of the POCO big-bang migration. Rationale + section ("what to drop vs. surface") added with `Restore` on `If` as + the canonical drop example. diff --git a/docs/plans/script-step-poco-sweep.md b/docs/plans/script-step-poco-sweep.md new file mode 100644 index 0000000..2c46745 --- /dev/null +++ b/docs/plans/script-step-poco-sweep.md @@ -0,0 +1,217 @@ +# Script Step POCO Migration — Sweep-Phase Plan + +Status: completed on `fuzzz/all-pocos`. All 205 script steps are typed +`IStepFactory` POCOs discovered via `StepRegistry`. This doc is kept as +a record of the sweep's approach; new step POCOs (e.g. after a FileMaker +release that adds a step) should follow the same per-step TDD workflow +below. + +## Context + +The [pilot](../advanced-filemaker-scripting-syntax.md) established the +typed POCO pattern with three representative steps: `BeepStep`, +`SetErrorCaptureStep`, `IfStep`. The sweep covers the remaining 189 +FileMaker script steps (206 total − 14 pre-existing POCOs − 3 pilot). +When the sweep finishes, all 206 steps are typed POCOs, +`StepCatalogLoader` and the catalog-driven helpers are deleted, and +the `step-catalog-en.json` file is no longer embedded. + +The sweep is not a single PR. Each wave lands independently, leaves the +tree green, and can be reviewed in isolation. + +## Tier-based ordering + +Run the catalog through the tier filter from the earlier analysis (see +message history in the pilot branch's `agent/catalogs` walkthrough): + +| Tier | Shape | Count | Rationale | +|---|---|---|---| +| A | Zero-param | 43 | Trivially mechanical; establish the cadence. | +| B | Only boolean/enum params | 29 | Locks in `ParamMetadata.ValidValues` patterns. | +| C | Text-bearing | 7 | Introduces text-escaping and display bracket grammar. | +| D | Calc / field / named-ref / complex | ~110 | The variety bucket. Contains real work — advanced-syntax decisions land here. | + +Migrate in A → B → C → D order. Within a tier, batch by FileMaker +category (`control`, `windows`, `fields`, etc.) to keep related steps' +PRs discoverable. + +## Per-step TDD workflow + +For each step, in this order: + +1. **Copy canonical XML** from + `C:\source\personal\agentic-fm\agent\snippet_examples\steps\\.xml` + (outside this repo — read-only reference, never committed) into an + inline `const string` fixture in the new test class. +2. **Write the round-trip test first.** Parse the fixture → run through + the POCO's `Metadata.FromXml!(el).ToXml()` → assert + `XNode.DeepEquals` against the source. Fails because the POCO + doesn't exist yet. +3. **Write the POCO** — implement `IStepFactory`, define `Metadata`, + implement `ToXml` / `ToDisplayLine` / `FromXml` / + `FromDisplayParams`. Pattern from `BeepStep.cs` (zero-param), + `SetErrorCaptureStep.cs` (single-boolean), or `IfStep` (block-pair, + calc) — pick the closest match. +4. **Add the display tests.** `ToDisplayLine()` returns expected + string; `FromDisplay(Metadata.FromDisplay!(...))` survives a + round-trip. +5. **Add the disabled-step test.** `enable="False"` → `step.Enabled` + is false → `ToXml()` emits `enable="False"`. +6. **Complete the zero-loss audit** in the POCO's XML doc comment. + Every XML element/attribute either rendered natively by FM Pro, + covered by an advanced-syntax extension, or intentionally dropped + with rationale (see + [`advanced-filemaker-scripting-syntax.md`](../advanced-filemaker-scripting-syntax.md)). +7. **For Tier D steps that need advanced syntax**, land the syntax + extension behind its own commit before the POCO that uses it, so + reviewers can validate the grammar decision in isolation. + +## Rollout cadence + +- **One PR per batch of ~10 POCOs within a tier.** Smaller is too + noisy; larger is unreviewable. +- **Tier A fits in 4-5 PRs.** Tier B in 3. Tier C in 1. Tier D is + where PR planning gets real — expect 12-15 PRs depending on + per-step complexity. +- **Each PR's diff is only steps + their tests.** No consumer rewires, + no deletions (with the specific exceptions listed below). +- **Commit per POCO within a PR** so each step is individually + revertable if the pattern needs adjusting. + +## Consumer migration schedule + +Consumers of the legacy `StepCatalogLoader` / `StepCatalogGenerated` +flip to `StepRegistry` as their dependencies complete: + +| Consumer | Flips when | What changes | +|---|---|---| +| `FmScriptCompletionProvider` | Already migrated in pilot | — | +| `ScriptValidator` | All block-pair partners (If/End If, Loop/End Loop, Open/Commit Transaction, etc.) are POCOs | `definition.BlockPair` reads become `StepRegistry.ByName[name].BlockPair` | +| `ScriptTextParser.FromDisplayLine` | All catalog-name resolutions have POCO equivalents | Drops the `StepCatalogLoader.ByName.TryGetValue` fallback | +| `FmScript.ApplyUpdate` | Typed setters exist on every POCO | MCP apply-op dispatches to POCO-specific `With*` methods instead of generic `CatalogXmlBuilder.UpdateParam` | + +Until its flip point, each consumer keeps reading the legacy surface — +the pilot's `StepRegistry` bridge populates `StepXmlFactory` / +`StepDisplayFactory` so this works transparently. + +## Deletion schedule + +In order: + +1. **Per-wave**: as a tier completes, delete any catalog-helper code + paths that are now exclusively serving that tier's step shapes. + (Usually none — the helpers are shape-agnostic.) +2. **After Tier D completes** — when all 206 steps are POCOs and all + consumers above have flipped: + - Delete `src/SharpFM.Model/Scripting/StepCatalogLoader.cs`. + - Delete `src/SharpFM.Model/Scripting/IStepCatalog.cs`. + - Delete `src/SharpFM.Model/Scripting/Serialization/StepXmlFactory.cs`. + - Delete `src/SharpFM.Model/Scripting/Serialization/StepDisplayFactory.cs`. + - Remove the `StepRegistry → legacy factory` bridge from + `StepRegistry.Scan()` — now dead code. + - Delete `src/SharpFM.Model/Scripting/Steps/RawStep.cs`. + - Delete `src/SharpFM.Model/Scripting/Steps/RawStepAllowList.cs` + and its tests. + - Delete `src/SharpFM.Model/Scripting/Serialization/CatalogXmlBuilder.cs`. + - Delete `src/SharpFM.Model/Scripting/Serialization/CatalogParamExtractor.cs`. + - Delete `src/SharpFM.Model/Scripting/Serialization/CatalogDisplayRenderer.cs`. + - Delete `src/SharpFM.Model/Scripting/Serialization/CatalogValidator.cs`. + - Remove the `` + line from `src/SharpFM.Model/SharpFM.Model.csproj`. + - Delete `src/SharpFM.Model/Scripting/Catalog/step-catalog-en.json`. + - Introduce `UnknownStep` in place of `RawStep` for forward-compat + against future FileMaker step additions (wraps raw XElement; + same semantics as the retired `RawStep` when `Definition == null`). +3. **After ship verification** — `StepDefinition` / `StepParam` / + `StepBlockPair` / `BlockPairRole` / `StepEnumValue` no longer have + any catalog-side producers. They may be kept (used by + `StepMetadata.BlockPair`) or retired depending on whether the new + metadata types fully absorb them. Expect a small cleanup commit + here. + +## Coverage verification + +One test asserts every step catalogued by upstream is present in our +registry: + +```csharp +[Fact] +public void StepRegistry_CoversFullCatalog() +{ + var expected = File.ReadAllText("path/to/canonical/step-list.json"); + var names = JsonSerializer.Deserialize(expected); + foreach (var name in names) + Assert.True(StepRegistry.ByName.ContainsKey(name), + $"Missing POCO for step '{name}'"); +} +``` + +The canonical list is a snapshot of agentic-fm step names taken at +sweep kickoff (checked in as a one-off fixture — not the same as the +JSON catalog, just names + ids). As steps are added to upstream later, +the fixture is updated deliberately, preventing silent drift. + +## Regression containment + +The pilot's `FmScriptCompletionProvider` regression (only 3 steps +suggested in completions) resolves as the sweep progresses. Gate each +sweep PR on **monotonically increasing completion coverage**: + +- `CompletionProviderTests` has an `EmptyLine_SuggestsPocoStepNames` + test that enumerates all expected pilot step names. +- Each sweep PR adds the new step names to that test's expected list. +- A PR that removes a name from the list is a review blocker unless + justified (e.g. a step was consolidated). + +After Tier A merges, the list is 43 + 14 + 3 = 60 steps. After Tier B, +89. After Tier C, 96. After Tier D, 206. Upstream adds thereafter append. + +## Risks and gotchas (filled in during pilot execution) + +> This section gets populated with concrete discoveries from pilot +> implementation. Left as a placeholder until pilot lands. + +Observed so far: + +- **`StepRegistry` lazy init doesn't run before pre-existing consumers + touch `StepXmlFactory`.** Mitigation: single `[ModuleInitializer]` + on `StepRegistry` itself. Acceptable — one initializer on the + registry, not per-POCO. +- **Legacy `FmScript.ToDisplayLines` reads `step.Definition.BlockPair` + for indent decisions.** POCOs passing `Definition = null` break + indentation on block-pair steps. Mitigation for pilot: project a + minimal `StepDefinition` with just `BlockPair` populated into + `base(...)`. Cleanup: when `FmScript.ToDisplayLines` migrates to + `StepRegistry`, drop the synthesized `Definition`. +- **Tests that used `Beep` as a "catalog-but-not-POCO" canary break + when Beep migrates.** Mitigation: swap to `Halt Script` (or + whatever zero-param step is still on the catalog-only path). Expect + similar swaps as each subsequent zero-param step migrates. + +## Open questions deferred from pilot + +- **Plugin-scenario multi-assembly registration.** Today + `StepRegistry.Scan()` reflects only the model assembly. If a plugin + ships its own POCOs (future scenario), should the registry scan + loaded plugin assemblies too? Probable answer: yes, with an + `RegisterAssembly(Assembly)` public method plugins call on load. + Address when the first plugin author asks for it. +- **`Metadata.Notes` wiring into UI.** The pilot populates `StepNotes` + on `SetErrorCaptureStep` and `IfStep`. No UI reads it yet. Tooltip + and hover integration is a post-sweep concern — sequencing depends + on Monaco / Avalonia tooltip API decisions outside this migration's + scope. +- **`Metadata.MonacoSnippet` or synthesis.** Pilot dropped the + catalog's `MonacoSnippet` field entirely; completion falls back to + the step name for self-closing steps and provides nothing for + multi-param ones. Options: (a) let each POCO author an optional + snippet string, (b) synthesize at runtime from `ParamMetadata`, (c) + defer snippets until someone complains. Recommend (c) — the three + pilot POCOs work fine without; revisit after Tier B lands. +- **When to retire `StepDefinition`/`StepParam` records.** The new + metadata types (`StepMetadata` / `ParamMetadata`) are intended to + replace them, but `StepMetadata.BlockPair` currently reuses + `StepBlockPair` and `IfStep` still synthesizes a `StepDefinition` + for legacy `FmScript.ToDisplayLines`. Clean-up sequence TBD — + probably a small follow-up commit after the legacy factories are + deleted. diff --git a/docs/step-definitions.md b/docs/step-definitions.md new file mode 100644 index 0000000..e4532f6 --- /dev/null +++ b/docs/step-definitions.md @@ -0,0 +1,921 @@ +# FileMaker Script Step — Typed POCO Migration Tracker + +This document is the working checklist for migrating FileMaker script steps from the generic `RawStep` fallback to typed POCOs under `src/SharpFM.Model/Scripting/Steps/`. It exists to drive TDD: each step block holds a placeholder for verbatim FileMaker Pro clipboard XML, a list of the expected display-text form, and a checklist for the small refactor needed to land the typed POCO. + +**Rule of thumb:** we only add a typed POCO when a step has behavior the generic catalog-driven pipeline can't express (named references with ids, discriminated unions, bespoke display formats, nested calculations, custom parsing). Simple steps stay on `RawStep` indefinitely — that's fine, `RawStep` is fully lossless for the source XML. + +--- + +## How to use this document + +For each step you want to migrate: + +1. **Copy verbatim XML from FileMaker Pro.** Create the step in the script workspace, right-click the step in the script and choose Copy, then paste directly into the sample block here. Do this once per meaningful permutation (e.g. each `LayoutDestination` for Go to Layout, each `RowPageLocation` for Go to Record). +2. **Write failing tests first.** Create `tests/SharpFM.Tests/Scripting/Steps/{StepName}StepTests.cs` with the verbatim fixture as a constant and one test per behavior assertion: display rendering, round-trip XML preservation, and display-text → XML parsing. +3. **Run the tests and watch them fail.** They'll fail against the current `RawStep` path with whatever generic output the catalog produces. +4. **Add the typed POCO.** Create `src/SharpFM.Model/Scripting/Steps/{StepName}Step.cs` following the `GoToLayoutStep` pattern. Register with `StepXmlFactory` and `StepDisplayFactory` via a `[ModuleInitializer]`. +5. **Run the tests and watch them pass.** +6. **Check the regression list.** Any previously-failing test assertions for this step (see the tests under `tests/SharpFM.Tests/Scripting/` that failed after the `StepParamValue` deletion) should come back green. +7. **Check the box** on this document. + +## Reference: value types already defined + +Reuse these wherever they fit — don't create new ones unless the step genuinely needs a new shape. + +| Type | Location | Purpose | +|---|---|---| +| `NamedRef(int Id, string Name)` | `Values/NamedRef.cs` | id+name pairs for Script, Layout (named), TableOccurrence refs | +| `FieldRef(Table?, Id, Name, VariableName?)` | `Values/FieldRef.cs` | field or variable, with `ToDisplayString()` → `Table::Field` / `$var` | +| `Calculation(string Text)` | `Values/Calculation.cs` | CDATA-wrapped calc expression | +| `Animation(string WireValue)` | `Values/Animation.cs` | raw wire-string for FM animation names (unknown values pass through) | +| `LayoutTarget` (sealed record hierarchy) | `Values/LayoutTarget.cs` | Original / Named / ByNameCalc / ByNumberCalc | + +## Reference: architecture pattern + +See `src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs` as the canonical example. Every typed POCO has: + +- Typed properties (no `XElement`, no string bags) +- `public static new ScriptStep FromXml(XElement step)` — parse from source XML +- `public static ScriptStep FromDisplayParams(bool enabled, string[] hrParams)` — parse from display-text tokens +- `public override XElement ToXml()` — pure function of the typed state +- `public override string ToDisplayLine()` — pure function of the typed state +- A `[ModuleInitializer]` method that calls `StepXmlFactory.Register` and `StepDisplayFactory.Register` + +## Reference: display extension style guide + +See [`advanced-filemaker-scripting-syntax.md`](advanced-filemaker-scripting-syntax.md) for the full spec — the three extension forms (`(#id)` suffix, inline word tokens, trailing `; Kind: [...]` blocks), parsing precedence, the zero-loss audit every POCO must complete, and the judgment call on when to drop hidden state rather than surface it. + +--- + +## Priority 1 — handler-backed steps (failing regressions) + +These 13 steps used to have bespoke display rendering or display-text parsing via the retired `Handlers/` tree. Their tests are currently failing because the generic catalog path can't reproduce their special formatting. Each one needs a typed POCO. + +P1 regression status: + +| Previously failing test | Status | Fixed by | +|---|---|---| +| `ScriptStepTests.SetField_FromXml_ToDisplayLine` | ✓ | `SetFieldStep` | +| `ScriptStepTests.SetVariable_FromXml_ToDisplayLine` | ✓ | `SetVariableStep` | +| `ScriptStepTests.SetVariable_WithRepetition_ToDisplayLine` | ✓ | `SetVariableStep` | +| `ScriptStepTests.PerformScript_FromXml_ToDisplayLine` | ✓ | `PerformScriptStep` | +| `FmScriptModelTests.FromDisplayText_ToXml_SetVariable` | ✓ | `SetVariableStep` | +| `FmScriptModelTests.FromXml_ToDisplayText_IfEndIf_Indented` | ✓ | `ControlFlowSteps` | +| `FmScriptModelTests.RoundTrip_RealisticScript` | ✓ | All above + Loop / End Loop typed POCOs | + +--- + +### Set Field *(#76)* + +- [x] **Typed POCO landed** — `SetFieldStep.cs`, tests in `SetFieldStepTests.cs` + +**Why it's special:** FM Pro's display format is `Set Field [ Target ; Calculation ]` — the Field param appears *before* the Calculation param, even though the catalog order is [Calculation, Field]. The old `SetFieldHandler.ToDisplayLine` reordered these explicitly. + +**Fields to carry:** `FieldRef Target`, `Calculation Expression`. Both required (though tolerant of missing for partial input). + +**Verbatim XML samples** *(captured from FM Pro via Raw Clipboard Viewer)*: + +```xml + + + + + +``` + +```xml + + + + + +``` + +```xml + + + + + +``` + +**Expected display forms:** + +- `Set Field [ ScriptDefinitionHelper::ModifiedBy ; "just-a-string" ]` +- `Set Field [ ScriptDefinitionHelper::ModifiedBy ; $variable + " string" ]` +- `Set Field [ ScriptDefinitionHelper::ModifiedBy ; ScriptDefinitionHelper::PrimaryKey & " " & ScriptDefinitionHelper::CreatedBy ]` (multi-line calc preserves internal newlines in CDATA but renders on one line for the display form) +- `Set Field [ MyField ]` (no calc — degenerate case, still worth testing) + +**Notes on the captured XML:** + +- The `Field` element carries `table`, `id`, and `name` attributes — all three must round-trip (id is the lossless anchor, matching the `(#id)` convention used by `GoToLayoutStep`). +- `Calculation` content is wrapped in CDATA and may contain literal newlines (sample 3). The typed POCO's `Calculation.Text` should preserve those newlines byte-for-byte. +- Param order in the source XML is `[Calculation, Field]` but FM Pro's display is `[ Field ; Calculation ]` — this reorder is exactly what `SetFieldHandler.ToDisplayLine` used to do. + +--- + +### Set Variable *(#141)* + +- [x] **Typed POCO landed** — `SetVariableStep.cs`, tests in `SetVariableStepTests.cs` + +**Why it's special:** The canonical display is `Set Variable [ $name[rep] ; Value: calc ]` — the `[rep]` suffix is shown only when repetition is set and not equal to 1. The catalog params are `Value` (namedCalc), `Repetition` (namedCalc), `Name` (text), which the old `SetVariableHandler` reordered and rewrapped into the display form above. Parsing back is also bespoke (`ParseVarRepetition` splits `$name[rep]`). + +**Fields to carry:** `string Name`, `Calculation Value`, `Calculation Repetition` (default `"1"`). + +**Verbatim XML samples** *(captured from FM Pro via Raw Clipboard Viewer)*: + +```xml + + + + + + + + + $count + +``` + +```xml + + + + + + + + + $arr + +``` + +```xml + + + + + + + + + $arr + +``` + +**Expected display forms:** + +- `Set Variable [ $count ; Value: 0 ]` +- `Set Variable [ $arr[3] ; Value: "third" ]` +- `Set Variable [ $arr[$anotherVariable] ; Value: $count + 5 ]` +- `Set Variable [ $x ]` (no value — degenerate) + +**Notes on the captured XML:** + +- XML param order is `[Value, Repetition, Name]`; display reorders to `[ Name[rep] ; Value: calc ]`. +- `Repetition` is a full `Calculation`, not an integer — it can be a literal (`1`, `3`) or any calc expression (`$anotherVariable`, `$count + 5`). The typed POCO should carry `Calculation Repetition`, not `int`. +- `Repetition` is *always present* in the source XML, even when it equals `1`. The display-line writer must suppress `[rep]` when `Repetition.Text == "1"` (literal-one check, not arithmetic), and the XML writer must always emit a `` element (round-trip requirement). +- `Name` carries the `$` prefix as part of the text content — do not strip it on parse; treat the whole string as the variable name. Parsing `$arr[3]` or `$arr[$anotherVariable]` back from display text means splitting on the outermost `[` / `]` to separate name from repetition calc (watch for nested brackets in the repetition expression). +- Both `Value` and `Repetition` wrap their `Calculation` in a named wrapper element (``) — this is the `namedCalc` catalog shape. + +--- + +### Perform Script *(#1)* + +- [x] **Typed POCO landed** — `PerformScriptStep.cs`, tests in `PerformScriptStepTests.cs` + +**Why it's special:** Two discriminated modes (like Go to Layout's `LayoutTarget`): + +1. **Static reference** — a named ` + +``` + +```xml + + + + + + + +``` + +```xml + + + + +``` + +```xml + + + + + + +``` + +**Expected display forms** *(FM Pro's native wording confirmed; SharpFM appends `(#id)` on static refs per the style guide)*: + +- `Perform Script [ Specified: From list ; "Dummy-Script-For-Reference" (#4) ; Parameter: $$SomeGlobalVariable ]` +- `Perform Script [ Specified: By name ; $$globalVar & " literal-string" ; Parameter: $$SomeGlobalVariable ]` *(no id — by-name form has no ` - "; - - var editor = new ScriptClipEditor(xml); - // Trigger a non-sealed edit so the rebuild loop runs end-to-end. - var line1 = editor.Document.GetLineByNumber(1); - var t = editor.Document.GetText(line1.Offset, line1.Length); - editor.Document.Replace(line1.Offset, line1.Length, t.Replace("$x > 0", "$x > 1")); - - var outXml = editor.ToXml(); - var outDoc = XDocument.Parse(outXml); - - // Script wrapper metadata preserved - var scriptEl = outDoc.Root!.Element("Script"); - Assert.NotNull(scriptEl); - Assert.Equal("42", scriptEl!.Attribute("id")!.Value); - Assert.Equal("MyScript", scriptEl.Attribute("name")!.Value); - - // Sealed Beep's custom child preserved - var beep = scriptEl.Elements("Step").FirstOrDefault(s => s.Attribute("id")?.Value == "93"); - Assert.NotNull(beep); - Assert.NotNull(beep!.Element("WrapperPreserved")); - } - - // --- Idempotency --- - - [Fact] - public void ToXml_CalledTwice_ProducesIdenticalOutput() - { - var editor = new ScriptClipEditor(ScriptWithSealedBeepXml); - var first = editor.ToXml(); - var second = editor.ToXml(); - Assert.Equal(first, second); - } - - // --- Sealed step as the very last line (no trailing newline) --- - - [Fact] - public void SealedStep_AtEndOfDocumentNoTrailingNewline_Preserved() - { - var xml = @" - 0]]> - - "; - - var editor = new ScriptClipEditor(xml); - var outXml = editor.ToXml(); - var outDoc = XDocument.Parse(outXml); - var beep = outDoc.Root!.Elements("Step").Last(); - - Assert.Equal("Beep", beep.Attribute("name")!.Value); - Assert.NotNull(beep.Element("EofCanary")); - } - - // --- FromXml reload --- - - [Fact] - public void SealedAnchors_AfterDocumentShrink_AllOffsetsWithinBounds() - { - // Regression guard for the "stale renderer survives clip swap" bug - // that crashed AvaloniaEdit with ArgumentOutOfRangeException on - // Document.GetLineByOffset. The architectural fix (detach - // renderers on clip swap) is at the UI layer and needs an Avalonia - // context to test. This covers the defensive side: the editor's - // own SealedAnchors iterator must never yield an anchor whose - // offset lies past the current document length. - var editor = new ScriptClipEditor(ScriptWithSealedBeepXml); - Assert.Single(editor.SealedAnchors); - - // Drastically shrink the document — clears every line the - // original anchor could live on. - editor.Document.Replace(0, editor.Document.TextLength, string.Empty); - - foreach (var anchor in editor.SealedAnchors) - { - Assert.InRange(anchor.Offset, 0, editor.Document.TextLength); - } - } - - [Fact] - public void SealedAnchors_AfterDocumentReplacedWithShorterContent_AllOffsetsWithinBounds() - { - // Same invariant but with content that has steps — proves the - // iterator stays safe when the TextView swaps documents too. - var editor = new ScriptClipEditor(ScriptWithSealedBeepXml); - Assert.Single(editor.SealedAnchors); - - editor.Document.Replace(0, editor.Document.TextLength, "If [ $x > 0 ]"); - - foreach (var anchor in editor.SealedAnchors) - { - Assert.InRange(anchor.Offset, 0, editor.Document.TextLength); - } - } - - [Fact] - public void FromXml_Reload_RebuildsAnchorCacheFreshForNewContent() - { - var editor = new ScriptClipEditor(ScriptWithSealedBeepXml); - Assert.Single(editor.SealedAnchors); - - // Reload with different content — previous anchor should be gone, - // new anchors built for whatever sealed steps the new XML has. - var differentXml = @" - 0]]> - - "; - editor.FromXml(differentXml); - - // No sealed anchors — both If and End If are POCOs. - Assert.Empty(editor.SealedAnchors); - - // Reload with a sealed step back — a fresh anchor appears. - editor.FromXml(ScriptWithSealedBeepXml); - Assert.Single(editor.SealedAnchors); - } -} diff --git a/tests/SharpFM.Tests/Scripting/Serialization/CatalogDisplayRendererTests.cs b/tests/SharpFM.Tests/Scripting/Serialization/CatalogDisplayRendererTests.cs deleted file mode 100644 index 87c63aa..0000000 --- a/tests/SharpFM.Tests/Scripting/Serialization/CatalogDisplayRendererTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Xml.Linq; -using SharpFM.Model.Scripting; -using SharpFM.Model.Scripting.Serialization; -using Xunit; - -namespace SharpFM.Tests.Scripting.Serialization; - -/// -/// Verifies that the stateless catalog-driven renderer reproduces the -/// display strings the retired StepParamValue pipeline produced. -/// These tests cross-reference existing HandlerTests / ScriptStepTests -/// expectations so unmigrated catalog steps continue rendering identically. -/// -public class CatalogDisplayRendererTests -{ - private static XElement Parse(string xml) => XElement.Parse(xml); - - [Fact] - public void Render_NoParamsStep_ReturnsBareName() - { - var el = Parse(""); - var def = StepCatalogLoader.ByName["Else"]; - - Assert.Equal("Else", CatalogDisplayRenderer.Render(el, def)); - } - - [Fact] - public void Render_IfStep_WithCalculation_BuildsBracketedForm() - { - var el = Parse( - "" - + " 0]]>"); - var def = StepCatalogLoader.ByName["If"]; - - Assert.Equal("If [ $x > 0 ]", CatalogDisplayRenderer.Render(el, def)); - } - - [Fact] - public void Render_EnumParam_ResolvesHrValue() - { - var el = Parse( - "" - + "" - + ""); - var def = StepCatalogLoader.ByName["Go to Record/Request/Page"]; - - var result = CatalogDisplayRenderer.Render(el, def); - // Catalog order renders enum first; boolean Exit is False and - // may render as "Exit after last: Off" depending on catalog metadata. - Assert.Contains("Next", result); - } - - [Fact] - public void Render_CommentStep_FormatsWithHashPrefix() - { - var el = Parse("hello world"); - var def = StepCatalogLoader.ByName["# (comment)"]; - - Assert.Equal("# hello world", CatalogDisplayRenderer.Render(el, def)); - } - - [Fact] - public void Render_EmptyComment_StillReturnsHashPrefix() - { - var el = Parse(""); - var def = StepCatalogLoader.ByName["# (comment)"]; - - Assert.Equal("# ", CatalogDisplayRenderer.Render(el, def)); - } - - [Fact] - public void Render_NamedLayoutRef_RendersQuotedName() - { - // Without a typed POCO, generic rendering only carries the name - // (no id suffix). This is the faithful pre-migration behavior. - var el = Parse( - "" - + "" - + ""); - var def = StepCatalogLoader.ByName["Go to Layout"]; - - var result = CatalogDisplayRenderer.Render(el, def); - Assert.Contains("\"Detail\"", result); - } -} diff --git a/tests/SharpFM.Tests/Scripting/Serialization/CatalogValidatorTests.cs b/tests/SharpFM.Tests/Scripting/Serialization/CatalogValidatorTests.cs deleted file mode 100644 index 393983d..0000000 --- a/tests/SharpFM.Tests/Scripting/Serialization/CatalogValidatorTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Linq; -using System.Xml.Linq; -using SharpFM.Model.Scripting; -using SharpFM.Model.Scripting.Serialization; -using Xunit; - -namespace SharpFM.Tests.Scripting.Serialization; - -public class CatalogValidatorTests -{ - private static XElement Parse(string xml) => XElement.Parse(xml); - - [Fact] - public void Validate_ValidStep_ReturnsNoDiagnostics() - { - var el = Parse( - "" - + " 0]]>"); - var def = StepCatalogLoader.ByName["If"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 0); - - Assert.Empty(diagnostics); - } - - [Fact] - public void Validate_NoParams_ReturnsNoDiagnostics() - { - var el = Parse(""); - var def = StepCatalogLoader.ByName["Else"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 0); - - Assert.Empty(diagnostics); - } - - [Fact] - public void Validate_FieldParam_BadValue_EmitsFieldOrVariableWarning() - { - // Set Field's Field param is of type "field" — a bare identifier - // without :: or $ prefix should trip the field-or-variable diagnostic. - var el = Parse( - "" - + "" - + ""); - var def = StepCatalogLoader.ByName["Set Field"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 3); - - Assert.Contains(diagnostics, d => - d.Message.Contains("Expected field reference") - && d.Line == 3); - } - - [Fact] - public void Validate_FieldParam_TableQualified_PassesValidation() - { - var el = Parse( - "" - + "" - + ""); - var def = StepCatalogLoader.ByName["Set Field"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 0); - - Assert.DoesNotContain(diagnostics, d => d.Message.Contains("Expected field reference")); - } - - [Fact] - public void Validate_MissingOptionalParam_IsSilent() - { - var el = Parse("" - + ""); - var def = StepCatalogLoader.ByName["Go to Layout"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 0); - - // OriginalLayout has no Layout or Animation element — both optional. - Assert.Empty(diagnostics); - } -} diff --git a/tests/SharpFM.Tests/Scripting/Serialization/CatalogXmlBuilderTests.cs b/tests/SharpFM.Tests/Scripting/Serialization/CatalogXmlBuilderTests.cs deleted file mode 100644 index 7940d05..0000000 --- a/tests/SharpFM.Tests/Scripting/Serialization/CatalogXmlBuilderTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Generic; -using System.Xml.Linq; -using SharpFM.Model.Scripting; -using SharpFM.Model.Scripting.Serialization; -using Xunit; - -namespace SharpFM.Tests.Scripting.Serialization; - -/// -/// Tests the stateless catalog-driven XML builder that replaces the -/// retired StepParamValue construction path. Covers the three -/// public entry points (BuildStep, BuildStepFromMap, -/// UpdateParam) and the major param-type branches. -/// -public class CatalogXmlBuilderTests -{ - // --- BuildStep (from display tokens) --- - - /// - /// Synthetic single-param calculation step — isolates the builder's - /// calculation-emission path from catalog quirks like flag-style - /// boolean prefixes that confuse positional matching. - /// - private static StepDefinition MakeSyntheticCalcStep() => new() - { - Name = "SyntheticCalc", - Id = 9999, - Params = new[] - { - new StepParam { XmlElement = "Calculation", Type = "calculation", Required = true } - } - }; - - [Fact] - public void BuildStep_CalculationParam_WrapsExpressionInCdata() - { - var def = MakeSyntheticCalcStep(); - var xml = CatalogXmlBuilder.BuildStep(def, enabled: true, ["$x > 0"]); - - Assert.Equal("True", xml.Attribute("enable")!.Value); - Assert.Equal("9999", xml.Attribute("id")!.Value); - Assert.Equal("SyntheticCalc", xml.Attribute("name")!.Value); - Assert.Contains("$x > 0", xml.Element("Calculation")!.Value); - } - - [Fact] - public void BuildStep_DisabledFlag_EmitsEnableFalse() - { - var def = StepCatalogLoader.ByName["Else"]; - var xml = CatalogXmlBuilder.BuildStep(def, enabled: false, []); - - Assert.Equal("False", xml.Attribute("enable")!.Value); - } - - [Fact] - public void BuildStep_SetField_FromDisplayTokens_ReordersByCatalog() - { - // Catalog order is [Calculation, Field]. Display provides tokens - // positionally — the first positional match binds to Calculation, - // the second to Field. - var def = StepCatalogLoader.ByName["Set Field"]; - var xml = CatalogXmlBuilder.BuildStep(def, enabled: true, ["People::FirstName", "$count + 1"]); - - Assert.NotNull(xml.Element("Calculation")); - Assert.NotNull(xml.Element("Field")); - } - - [Fact] - public void BuildStep_NamedRefParam_EmitsNameWithoutHardcodedId() - { - // Pre-refactor, the old BuildNamedRefXml hardcoded id="0". - // The new generic path emits ONLY the name attribute — ids come - // from typed POCOs that carry them explicitly. - var def = StepCatalogLoader.ByName["Go to Layout"]; - var xml = CatalogXmlBuilder.BuildStep(def, enabled: true, ["SelectedLayout", "\"Detail\""]); - - var layout = xml.Element("Layout"); - Assert.NotNull(layout); - Assert.Equal("Detail", layout!.Attribute("name")!.Value); - Assert.Null(layout.Attribute("id")); // id is not invented anymore - } - - // --- BuildStepFromMap (from param-name dict) --- - - [Fact] - public void BuildStepFromMap_IfStep_ReadsCalculationByParamName() - { - var def = StepCatalogLoader.ByName["If"]; - var map = new Dictionary { ["Calculation"] = "Get(FoundCount) > 0" }; - - var xml = CatalogXmlBuilder.BuildStepFromMap(def, enabled: true, map); - - Assert.Contains("Get(FoundCount) > 0", xml.Element("Calculation")!.Value); - } - - [Fact] - public void BuildStepFromMap_NullMap_StillEmitsStructuralElement() - { - var def = StepCatalogLoader.ByName["Else"]; - var xml = CatalogXmlBuilder.BuildStepFromMap(def, enabled: true, null); - - Assert.Equal("Else", xml.Attribute("name")!.Value); - } - - // --- UpdateParam --- - - [Fact] - public void UpdateParam_IfCalculation_ReplacesExpression() - { - var original = XElement.Parse( - "" - + " 0]]>"); - var def = StepCatalogLoader.ByName["If"]; - - var updated = CatalogXmlBuilder.UpdateParam(original, def, "Calculation", "$x >= 100"); - - Assert.NotNull(updated); - Assert.Contains("$x >= 100", updated!.Element("Calculation")!.Value); - Assert.DoesNotContain("$x > 0", updated.Element("Calculation")!.Value); - } - - [Fact] - public void UpdateParam_UnknownParamName_ReturnsNull() - { - var original = XElement.Parse( - "" - + " 0]]>"); - var def = StepCatalogLoader.ByName["If"]; - - var updated = CatalogXmlBuilder.UpdateParam(original, def, "NonExistent", "value"); - - Assert.Null(updated); - } - - [Fact] - public void UpdateParam_LeavesOriginalUntouched() - { - var original = XElement.Parse( - "" - + " 0]]>"); - var def = StepCatalogLoader.ByName["If"]; - - CatalogXmlBuilder.UpdateParam(original, def, "Calculation", "different"); - - Assert.Contains("$x > 0", original.Element("Calculation")!.Value); - } -} diff --git a/tests/SharpFM.Tests/Scripting/Serialization/FieldParamCatalogRoundTripTests.cs b/tests/SharpFM.Tests/Scripting/Serialization/FieldParamCatalogRoundTripTests.cs deleted file mode 100644 index edb692f..0000000 --- a/tests/SharpFM.Tests/Scripting/Serialization/FieldParamCatalogRoundTripTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Linq; -using System.Xml.Linq; -using SharpFM.Model.Scripting; -using SharpFM.Model.Scripting.Serialization; -using Xunit; - -namespace SharpFM.Tests.Scripting.Serialization; - -/// -/// Coverage for the field-param refactor on the catalog (RawStep) side: -/// CatalogParamExtractor.ExtractField, CatalogXmlBuilder.BuildFieldXml, -/// and CatalogValidator's tolerance for the lossless (#id) -/// suffix. The 47-ish catalog-path field params rely on these helpers -/// for id preservation across display-text editing. -/// -public class FieldParamCatalogRoundTripTests -{ - private static XElement MakeStep(string xml) => XElement.Parse(xml); - - // "Check Selection" (id=18) is a catalog-known, non-POCO step with - // SelectAll (boolean) + Field params. Both elements are present so the - // extract-then-rebuild round-trip has tokens for each catalog slot. - private const string CheckSelectionXml = - "" - + "" - + ""; - - [Fact] - public void Extract_FieldParam_IncludesIdSuffix() - { - var el = MakeStep(CheckSelectionXml); - var def = StepCatalogLoader.ByName["Check Selection"]; - - // The field param is the first (and only) param. - var fieldParam = def.Params.First(p => p.Type == "field" || p.Type == "fieldOrVariable"); - var extracted = CatalogParamExtractor.Extract(el, fieldParam); - - Assert.Equal("People::Email (#7)", extracted); - } - - [Fact] - public void Build_FieldParam_ParsesIdSuffixFromDisplayToken() - { - var def = StepCatalogLoader.ByName["Check Selection"]; - - // Check Selection has two params — SelectAll (boolean, label "Select") - // and Field (no label). Pass both tokens so positional matching - // assigns the field token to the Field slot. - var step = CatalogXmlBuilder.BuildStep(def, enabled: true, - hrParams: new[] { "On", "People::Email (#7)" }); - - var field = step.Element("Field"); - Assert.NotNull(field); - Assert.Equal("People", field!.Attribute("table")!.Value); - Assert.Equal("7", field.Attribute("id")!.Value); - Assert.Equal("Email", field.Attribute("name")!.Value); - } - - [Fact] - public void Build_FieldParam_NoIdSuffix_YieldsIdZero() - { - // Back-compat: tokens without (#id) still work (id=0 sentinel). - var def = StepCatalogLoader.ByName["Check Selection"]; - var step = CatalogXmlBuilder.BuildStep(def, enabled: true, - hrParams: new[] { "On", "People::Email" }); - - var field = step.Element("Field"); - Assert.Equal("People", field!.Attribute("table")!.Value); - Assert.Equal("0", field.Attribute("id")!.Value); - } - - [Fact] - public void FullRoundTrip_FieldParam_PreservesIdAttribute() - { - // Extract BOTH params from source display-side, rebuild from - // those tokens, verify Field attributes survive byte-for-byte. - var source = MakeStep(CheckSelectionXml); - var def = StepCatalogLoader.ByName["Check Selection"]; - - var tokens = def.Params - .Select(p => - { - var extracted = CatalogParamExtractor.Extract(source, p); - // Label-prefixed tokens mimic the display format - // MatchDisplayParams expects for labelled params. - return p.HrLabel != null && extracted != null - ? $"{p.HrLabel}: {extracted}" - : extracted; - }) - .Where(t => t != null) - .Select(t => t!) - .ToArray(); - - var rebuilt = CatalogXmlBuilder.BuildStep(def, enabled: true, hrParams: tokens); - var originalField = source.Element("Field")!; - var rebuiltField = rebuilt.Element("Field")!; - - Assert.Equal(originalField.Attribute("table")!.Value, rebuiltField.Attribute("table")!.Value); - Assert.Equal(originalField.Attribute("id")!.Value, rebuiltField.Attribute("id")!.Value); - Assert.Equal(originalField.Attribute("name")!.Value, rebuiltField.Attribute("name")!.Value); - } - - [Fact] - public void Validate_FieldParam_WithIdSuffix_ProducesNoWarning() - { - // The validator's field-shape check must tolerate the (#id) suffix. - // Without the refactor, "People::Email (#7)" failed the shape check - // because it didn't contain literal "::" on its own (after the suffix). - var el = MakeStep(CheckSelectionXml); - var def = StepCatalogLoader.ByName["Check Selection"]; - - var diagnostics = CatalogValidator.Validate(el, def, lineIndex: 0); - - Assert.DoesNotContain(diagnostics, d => - d.Message.Contains("Expected field reference")); - } -} diff --git a/tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs b/tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs deleted file mode 100644 index 196459a..0000000 --- a/tests/SharpFM.Tests/Scripting/StepCatalogLoaderTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Linq; -using SharpFM.Scripting; -using Xunit; - -namespace SharpFM.Tests.ScriptConverter; - -public class StepCatalogLoaderTests -{ - [Fact] - public void LoadsCatalog_HasExpectedStepCount() - { - Assert.True(StepCatalogLoader.All.Count > 200); - } - - [Fact] - public void LookupById_Comment_ReturnsCorrectDefinition() - { - var step = StepCatalogLoader.ById[89]; - Assert.Equal("# (comment)", step.Name); - } - - [Fact] - public void LookupById_If_ReturnsCorrectDefinition() - { - var step = StepCatalogLoader.ById[68]; - Assert.Equal("If", step.Name); - Assert.NotNull(step.BlockPair); - Assert.Equal(BlockPairRole.Open, step.BlockPair!.Role); - } - - [Fact] - public void LookupById_SetVariable_HasCorrectParams() - { - var step = StepCatalogLoader.ById[141]; - Assert.Equal("Set Variable", step.Name); - var paramNames = step.Params.Select(p => p.XmlElement).ToArray(); - Assert.Contains("Name", paramNames); - // Value and Repetition are wrapper elements, xmlElement is "Calculation" - var wrappers = step.Params.Select(p => p.WrapperElement).ToArray(); - Assert.Contains("Value", wrappers); - Assert.Contains("Repetition", wrappers); - } - - [Fact] - public void LookupByName_CaseInsensitive() - { - Assert.True(StepCatalogLoader.ByName.ContainsKey("set variable")); - Assert.True(StepCatalogLoader.ByName.ContainsKey("SET VARIABLE")); - Assert.Equal(141, StepCatalogLoader.ByName["set variable"].Id); - } - - [Fact] - public void AllEntries_WithIds_HaveValidIds() - { - foreach (var step in StepCatalogLoader.All.Where(s => s.Id.HasValue)) - { - Assert.True(step.Id > 0, $"Step '{step.Name}' has invalid id {step.Id}"); - } - } - - [Fact] - public void AllEntries_HaveNoDuplicateIds() - { - var ids = StepCatalogLoader.All.Where(s => s.Id.HasValue).Select(s => s.Id!.Value).ToList(); - var distinct = ids.Distinct().ToList(); - Assert.Equal(distinct.Count, ids.Count); - } - - [Fact] - public void BlockPairSteps_HaveMatchingPartners() - { - var openSteps = StepCatalogLoader.All - .Where(s => s.BlockPair?.Role == BlockPairRole.Open) - .ToList(); - - foreach (var step in openSteps) - { - foreach (var partner in step.BlockPair!.Partners) - { - Assert.True( - StepCatalogLoader.ByName.ContainsKey(partner), - $"Step '{step.Name}' has partner '{partner}' that doesn't exist in catalog"); - } - } - } -} diff --git a/tests/SharpFM.Tests/Scripting/StepCatalogTests.cs b/tests/SharpFM.Tests/Scripting/StepCatalogTests.cs deleted file mode 100644 index 8a8e6ee..0000000 --- a/tests/SharpFM.Tests/Scripting/StepCatalogTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Text.Json; -using Xunit; - -namespace SharpFM.Tests.Scripting; - -public class StepCatalogTests -{ - // --- BlockPairRole serialization (converter is on StepBlockPair.Role) --- - - [Theory] - [InlineData("{\"role\":\"open\",\"partners\":[]}", BlockPairRole.Open)] - [InlineData("{\"role\":\"middle\",\"partners\":[]}", BlockPairRole.Middle)] - [InlineData("{\"role\":\"close\",\"partners\":[]}", BlockPairRole.Close)] - public void BlockPairRole_Deserializes(string json, BlockPairRole expected) - { - var pair = JsonSerializer.Deserialize(json); - Assert.Equal(expected, pair!.Role); - } - - [Theory] - [InlineData(BlockPairRole.Open, "open")] - [InlineData(BlockPairRole.Middle, "middle")] - [InlineData(BlockPairRole.Close, "close")] - public void BlockPairRole_Serializes(BlockPairRole value, string expected) - { - var pair = new StepBlockPair { Role = value, Partners = [] }; - var json = JsonSerializer.Serialize(pair); - Assert.Contains($"\"role\":\"{expected}\"", json); - } - - [Fact] - public void BlockPairRole_Deserialize_Unknown_Throws() - { - Assert.Throws(() => - JsonSerializer.Deserialize("{\"role\":\"unknown\",\"partners\":[]}")); - } - - // --- StepDefinition record --- - - [Fact] - public void StepDefinition_Deserializes_FromJson() - { - var json = """ - { - "name": "Set Variable", - "id": 141, - "category": "Control", - "selfClosing": true, - "params": [ - { "xmlElement": "Name", "type": "text" } - ] - } - """; - - var def = JsonSerializer.Deserialize(json); - Assert.NotNull(def); - Assert.Equal("Set Variable", def!.Name); - Assert.Equal(141, def.Id); - Assert.Equal("Control", def.Category); - Assert.True(def.SelfClosing); - Assert.Single(def.Params); - Assert.Equal("Name", def.Params[0].XmlElement); - } - - [Fact] - public void StepDefinition_Defaults_AreCorrect() - { - var def = new StepDefinition(); - Assert.Equal("", def.Name); - Assert.Null(def.Id); - Assert.Equal("", def.Category); - Assert.False(def.SelfClosing); - Assert.Empty(def.Params); - Assert.Null(def.BlockPair); - } - - // --- StepParam record --- - - [Fact] - public void StepParam_Deserializes_WithOptionalFields() - { - var json = """ - { - "xmlElement": "Calculation", - "type": "namedCalc", - "hrLabel": "Value", - "wrapperElement": "Value", - "required": true, - "invertedHr": true - } - """; - - var param = JsonSerializer.Deserialize(json); - Assert.NotNull(param); - Assert.Equal("Calculation", param!.XmlElement); - Assert.Equal("namedCalc", param.Type); - Assert.Equal("Value", param.HrLabel); - Assert.Equal("Value", param.WrapperElement); - Assert.True(param.Required); - Assert.True(param.InvertedHr); - } - - [Fact] - public void StepParam_Defaults_AreCorrect() - { - var param = new StepParam(); - Assert.Equal("", param.XmlElement); - Assert.Equal("", param.Type); - Assert.Null(param.HrLabel); - Assert.Null(param.XmlAttr); - Assert.Null(param.WrapperElement); - Assert.Null(param.ParentElement); - Assert.False(param.Required); - Assert.False(param.InvertedHr); - } - - // --- StepBlockPair record --- - - [Fact] - public void StepBlockPair_Deserializes() - { - var json = """{"role": "open", "partners": ["Else", "Else If", "End If"]}"""; - - var pair = JsonSerializer.Deserialize(json); - Assert.NotNull(pair); - Assert.Equal(BlockPairRole.Open, pair!.Role); - Assert.Equal(3, pair.Partners.Length); - Assert.Contains("Else", pair.Partners); - Assert.Contains("End If", pair.Partners); - } - - // --- StepParam with hrEnumValues --- - - [Fact] - public void StepParam_HrEnumValues_Deserializes() - { - var json = """ - { - "xmlElement": "RowPageLocation", - "type": "enum", - "hrEnumValues": { "1": "First", "2": "Last", "3": "Previous", "4": "Next" } - } - """; - - var param = JsonSerializer.Deserialize(json); - Assert.NotNull(param!.HrEnumValues); - Assert.Equal("First", param.HrEnumValues!["1"]); - Assert.Equal(4, param.HrEnumValues.Count); - } -} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AVPlayerPlayStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerPlayStepTests.cs new file mode 100644 index 0000000..8f8436b --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerPlayStepTests.cs @@ -0,0 +1,27 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AVPlayerPlayStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = AVPlayerPlayStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("AVPlayer Play", out var metadata)); + Assert.Equal(177, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetOptionsStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetOptionsStepTests.cs new file mode 100644 index 0000000..86f87c8 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetOptionsStepTests.cs @@ -0,0 +1,27 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AVPlayerSetOptionsStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = AVPlayerSetOptionsStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("AVPlayer Set Options", out var metadata)); + Assert.Equal(179, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetPlaybackStateStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetPlaybackStateStepTests.cs new file mode 100644 index 0000000..654e86d --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AVPlayerSetPlaybackStateStepTests.cs @@ -0,0 +1,43 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AVPlayerSetPlaybackStateStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = AVPlayerSetPlaybackStateStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsHrMappedValue() + { + var step = AVPlayerSetPlaybackStateStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + Assert.Equal("AVPlayer Set Playback State [ Stopped ]", step.ToDisplayLine()); + } + + [Fact] + public void FromDisplay_ParsesHrValueBack() + { + var step = AVPlayerSetPlaybackStateStep.Metadata.FromDisplay!(true, new[] { "Stopped" }); + Assert.True(XNode.DeepEquals(XElement.Parse(CanonicalXml), step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("AVPlayer Set Playback State", out var metadata)); + Assert.Equal(178, metadata!.Id); + Assert.Single(metadata.Params); + Assert.Equal("enum", metadata.Params[0].Type); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AddAccountStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AddAccountStepTests.cs new file mode 100644 index 0000000..6caf515 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AddAccountStepTests.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AddAccountStepTests +{ + private const string CanonicalXml = """$example"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = AddAccountStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_RoundTripsThroughFromDisplayParams() + { + var step1 = AddAccountStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + var display = step1.ToDisplayLine(); + var open = display.IndexOf('['); + var close = display.LastIndexOf(']'); + var inner = display.Substring(open + 1, close - open - 1).Trim(); + var tokens = inner.Split(';', System.StringSplitOptions.TrimEntries); + + var step2 = AddAccountStep.Metadata.FromDisplay!(true, tokens); + Assert.True(XNode.DeepEquals(step1.ToXml(), step2.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Add Account", out var metadata)); + Assert.Equal(134, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AdjustWindowStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AdjustWindowStepTests.cs new file mode 100644 index 0000000..d50b272 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AdjustWindowStepTests.cs @@ -0,0 +1,43 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AdjustWindowStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = AdjustWindowStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsHrMappedValue() + { + var step = AdjustWindowStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + Assert.Equal("Adjust Window [ Resize to Fit ]", step.ToDisplayLine()); + } + + [Fact] + public void FromDisplay_ParsesHrValueBack() + { + var step = AdjustWindowStep.Metadata.FromDisplay!(true, new[] { "Resize to Fit" }); + Assert.True(XNode.DeepEquals(XElement.Parse(CanonicalXml), step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Adjust Window", out var metadata)); + Assert.Equal(31, metadata!.Id); + Assert.Single(metadata.Params); + Assert.Equal("enum", metadata.Params[0].Type); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AllowFormattingBarStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AllowFormattingBarStepTests.cs new file mode 100644 index 0000000..6869d95 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AllowFormattingBarStepTests.cs @@ -0,0 +1,49 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AllowFormattingBarStepTests +{ + private const string TrueStateXml = """"""; + private const string FalseStateXml = """"""; + + [Fact] + public void RoundTrip_True_IsPreserved() + { + var source = XElement.Parse(TrueStateXml); + var step = AllowFormattingBarStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void RoundTrip_False_IsPreserved() + { + var source = XElement.Parse(FalseStateXml); + var step = AllowFormattingBarStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsExpectedFormat() + { + // Setting underlying prop=true renders as "On" + // (boolean: XML True displays as On). + var stepTrue = ((AllowFormattingBarStep)AllowFormattingBarStep.Metadata.FromXml!(XElement.Parse(TrueStateXml))); + Assert.Equal("Allow Formatting Bar [ On ]", stepTrue.ToDisplayLine()); + + var stepFalse = ((AllowFormattingBarStep)AllowFormattingBarStep.Metadata.FromXml!(XElement.Parse(FalseStateXml))); + Assert.Equal("Allow Formatting Bar [ Off ]", stepFalse.ToDisplayLine()); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Allow Formatting Bar", out var metadata)); + Assert.Equal(115, metadata!.Id); + Assert.Single(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/AllowUserAbortStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/AllowUserAbortStepTests.cs new file mode 100644 index 0000000..b297f8e --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/AllowUserAbortStepTests.cs @@ -0,0 +1,49 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class AllowUserAbortStepTests +{ + private const string TrueStateXml = """"""; + private const string FalseStateXml = """"""; + + [Fact] + public void RoundTrip_True_IsPreserved() + { + var source = XElement.Parse(TrueStateXml); + var step = AllowUserAbortStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void RoundTrip_False_IsPreserved() + { + var source = XElement.Parse(FalseStateXml); + var step = AllowUserAbortStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsExpectedFormat() + { + // Setting underlying prop=true renders as "On" + // (boolean: XML True displays as On). + var stepTrue = ((AllowUserAbortStep)AllowUserAbortStep.Metadata.FromXml!(XElement.Parse(TrueStateXml))); + Assert.Equal("Allow User Abort [ On ]", stepTrue.ToDisplayLine()); + + var stepFalse = ((AllowUserAbortStep)AllowUserAbortStep.Metadata.FromXml!(XElement.Parse(FalseStateXml))); + Assert.Equal("Allow User Abort [ Off ]", stepFalse.ToDisplayLine()); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Allow User Abort", out var metadata)); + Assert.Equal(85, metadata!.Id); + Assert.Single(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ArrangeAllWindowsStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ArrangeAllWindowsStepTests.cs new file mode 100644 index 0000000..d7fe1f3 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ArrangeAllWindowsStepTests.cs @@ -0,0 +1,43 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ArrangeAllWindowsStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ArrangeAllWindowsStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsHrMappedValue() + { + var step = ArrangeAllWindowsStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + Assert.Equal("Arrange All Windows [ Tile Horizontally ]", step.ToDisplayLine()); + } + + [Fact] + public void FromDisplay_ParsesHrValueBack() + { + var step = ArrangeAllWindowsStep.Metadata.FromDisplay!(true, new[] { "Tile Horizontally" }); + Assert.True(XNode.DeepEquals(XElement.Parse(CanonicalXml), step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Arrange All Windows", out var metadata)); + Assert.Equal(120, metadata!.Id); + Assert.Single(metadata.Params); + Assert.Equal("enum", metadata.Params[0].Type); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/BeepStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/BeepStepTests.cs new file mode 100644 index 0000000..3b06fa2 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/BeepStepTests.cs @@ -0,0 +1,64 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +/// +/// Zero-parameter POCO canonical pattern. Fixtures are inline raw string +/// literals copied from agentic-fm's snippet_examples so the test reads +/// without having to open a separate file. +/// +public class BeepStepTests +{ + // Canonical shape from + // agent/snippet_examples/steps/miscellaneous/Beep.xml + private const string CanonicalSnippet = """ + + + + + """; + + private static XElement CanonicalStepElement() => + XDocument.Parse(CanonicalSnippet).Root!.Element("Step")!; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = CanonicalStepElement(); + var step = BeepStep.Metadata.FromXml!(source); + + Assert.IsType(step); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsBareName() + { + var step = new BeepStep(); + Assert.Equal("Beep", step.ToDisplayLine()); + } + + [Fact] + public void Disabled_RoundTrips() + { + var source = XElement.Parse(""""""); + var step = BeepStep.Metadata.FromXml!(source); + + Assert.False(step.Enabled); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasBeep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Beep", out var metadata)); + Assert.Equal(93, metadata!.Id); + Assert.Equal("miscellaneous", metadata.Category); + Assert.Null(metadata.BlockPair); + Assert.Empty(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ChangePasswordStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ChangePasswordStepTests.cs new file mode 100644 index 0000000..da1a63f --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ChangePasswordStepTests.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ChangePasswordStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ChangePasswordStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_RoundTripsThroughFromDisplayParams() + { + var step1 = ChangePasswordStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + var display = step1.ToDisplayLine(); + var open = display.IndexOf('['); + var close = display.LastIndexOf(']'); + var inner = display.Substring(open + 1, close - open - 1).Trim(); + var tokens = inner.Split(';', System.StringSplitOptions.TrimEntries); + + var step2 = ChangePasswordStep.Metadata.FromDisplay!(true, tokens); + Assert.True(XNode.DeepEquals(step1.ToXml(), step2.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Change Password", out var metadata)); + Assert.Equal(83, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CheckFoundSetStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CheckFoundSetStepTests.cs new file mode 100644 index 0000000..1876832 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CheckFoundSetStepTests.cs @@ -0,0 +1,51 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +/// +/// Zero-param POCO tests for CheckFoundSetStep. Fixture is inline per the +/// pilot pattern; no FixtureLoader, no file I/O. +/// +public class CheckFoundSetStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CheckFoundSetStep.Metadata.FromXml!(source); + + Assert.IsType(step); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsBareName() + { + var step = new CheckFoundSetStep(); + Assert.Equal("Check Found Set", step.ToDisplayLine()); + } + + [Fact] + public void Disabled_RoundTrips() + { + var source = XElement.Parse(""""""); + var step = CheckFoundSetStep.Metadata.FromXml!(source); + + Assert.False(step.Enabled); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Check Found Set", out var metadata)); + Assert.Equal(20, metadata!.Id); + Assert.Empty(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CheckRecordStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CheckRecordStepTests.cs new file mode 100644 index 0000000..93995b9 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CheckRecordStepTests.cs @@ -0,0 +1,51 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +/// +/// Zero-param POCO tests for CheckRecordStep. Fixture is inline per the +/// pilot pattern; no FixtureLoader, no file I/O. +/// +public class CheckRecordStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CheckRecordStep.Metadata.FromXml!(source); + + Assert.IsType(step); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsBareName() + { + var step = new CheckRecordStep(); + Assert.Equal("Check Record", step.ToDisplayLine()); + } + + [Fact] + public void Disabled_RoundTrips() + { + var source = XElement.Parse(""""""); + var step = CheckRecordStep.Metadata.FromXml!(source); + + Assert.False(step.Enabled); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Check Record", out var metadata)); + Assert.Equal(19, metadata!.Id); + Assert.Empty(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CheckSelectionStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CheckSelectionStepTests.cs new file mode 100644 index 0000000..01b23da --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CheckSelectionStepTests.cs @@ -0,0 +1,27 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class CheckSelectionStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CheckSelectionStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Check Selection", out var metadata)); + Assert.Equal(18, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ClearStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ClearStepTests.cs new file mode 100644 index 0000000..c2f2c10 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ClearStepTests.cs @@ -0,0 +1,27 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ClearStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ClearStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Clear", out var metadata)); + Assert.Equal(49, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CloseDataFileStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CloseDataFileStepTests.cs new file mode 100644 index 0000000..a93f70b --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CloseDataFileStepTests.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class CloseDataFileStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CloseDataFileStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_RoundTripsThroughFromDisplayParams() + { + var step1 = CloseDataFileStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + var display = step1.ToDisplayLine(); + var open = display.IndexOf('['); + var close = display.LastIndexOf(']'); + var inner = display.Substring(open + 1, close - open - 1).Trim(); + var tokens = inner.Split(';', System.StringSplitOptions.TrimEntries); + + var step2 = CloseDataFileStep.Metadata.FromDisplay!(true, tokens); + Assert.True(XNode.DeepEquals(step1.ToXml(), step2.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Close Data File", out var metadata)); + Assert.Equal(196, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CloseFileStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CloseFileStepTests.cs new file mode 100644 index 0000000..7508958 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CloseFileStepTests.cs @@ -0,0 +1,44 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class CloseFileStepTests +{ + private const string CanonicalXml = """ + file:NameOfFile + """; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CloseFileStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_BareStep_UsesCurrentFile() + { + var bare = XElement.Parse(""); + var step = CloseFileStep.Metadata.FromXml!(bare); + Assert.Equal("Close File [ Current File ]", step.ToDisplayLine()); + } + + [Fact] + public void Display_WithFileReference_UsesName() + { + var step = (CloseFileStep)CloseFileStep.Metadata.FromXml!(XElement.Parse( + "file:Employees")); + Assert.Equal("Close File [ \"Employees\" ]", step.ToDisplayLine()); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Close File", out var metadata)); + Assert.Equal(34, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ClosePopoverStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ClosePopoverStepTests.cs new file mode 100644 index 0000000..0ad026c --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ClosePopoverStepTests.cs @@ -0,0 +1,51 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +/// +/// Zero-param POCO tests for ClosePopoverStep. Fixture is inline per the +/// pilot pattern; no FixtureLoader, no file I/O. +/// +public class ClosePopoverStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ClosePopoverStep.Metadata.FromXml!(source); + + Assert.IsType(step); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsBareName() + { + var step = new ClosePopoverStep(); + Assert.Equal("Close Popover", step.ToDisplayLine()); + } + + [Fact] + public void Disabled_RoundTrips() + { + var source = XElement.Parse(""""""); + var step = ClosePopoverStep.Metadata.FromXml!(source); + + Assert.False(step.Enabled); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Close Popover", out var metadata)); + Assert.Equal(169, metadata!.Id); + Assert.Empty(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CloseWindowStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CloseWindowStepTests.cs new file mode 100644 index 0000000..e3a45d8 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CloseWindowStepTests.cs @@ -0,0 +1,27 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class CloseWindowStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CloseWindowStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Close Window", out var metadata)); + Assert.Equal(121, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs index f362a37..1a051c4 100644 --- a/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs +++ b/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs @@ -226,8 +226,8 @@ public void FullRoundTrip_Script_WithMixedComments_Preserves() // The multi-line comment must still be exactly ONE step after round-trip. Assert.Equal(4, script2.Steps.Count); - Assert.Equal("# (comment)", script2.Steps[0].Definition?.Name); - Assert.Equal("# (comment)", script2.Steps[2].Definition?.Name); + Assert.IsType(script2.Steps[0]); + Assert.IsType(script2.Steps[2]); var roundTrippedMultiLine = Assert.IsType(script2.Steps[2]); Assert.Equal("line1\nline2\nline3", roundTrippedMultiLine.Text); diff --git a/tests/SharpFM.Tests/Scripting/Steps/CommitRecordsRequestsStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CommitRecordsRequestsStepTests.cs new file mode 100644 index 0000000..aea7fad --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CommitRecordsRequestsStepTests.cs @@ -0,0 +1,67 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class CommitRecordsRequestsStepTests +{ + // Canonical FM Pro clipboard form — NoInteract=True means no dialog, + // which surfaces in display as "With dialog: Off". + private const string NoDialogXml = """ + + """; + + private const string WithDialogXml = """ + + """; + + [Fact] + public void RoundTrip_NoDialog_IsPreserved() + { + var source = XElement.Parse(NoDialogXml); + var step = CommitRecordsRequestsStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void RoundTrip_WithDialog_IsPreserved() + { + var source = XElement.Parse(WithDialogXml); + var step = CommitRecordsRequestsStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void InvertedHr_Display_Correct() + { + // NoInteract=True (XML) -> "With dialog: Off" (display). + var step = (CommitRecordsRequestsStep)CommitRecordsRequestsStep.Metadata.FromXml!(XElement.Parse(NoDialogXml)); + Assert.False(step.WithDialog); + Assert.StartsWith("Commit Records/Requests [ With dialog: Off", step.ToDisplayLine()); + } + + [Fact] + public void Display_RoundTripsThroughFromDisplayParams() + { + var step1 = CommitRecordsRequestsStep.Metadata.FromXml!(XElement.Parse(NoDialogXml)); + var display = step1.ToDisplayLine(); + var open = display.IndexOf('['); + var close = display.LastIndexOf(']'); + var inner = display.Substring(open + 1, close - open - 1).Trim(); + var tokens = inner.Split(';', System.StringSplitOptions.TrimEntries); + + var step2 = CommitRecordsRequestsStep.Metadata.FromDisplay!(true, tokens); + Assert.True(XNode.DeepEquals(step1.ToXml(), step2.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Commit Records/Requests", out var metadata)); + Assert.Equal(75, metadata!.Id); + Assert.Equal(3, metadata.Params.Count); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/CommitTransactionStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CommitTransactionStepTests.cs new file mode 100644 index 0000000..ac18b23 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/CommitTransactionStepTests.cs @@ -0,0 +1,51 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +/// +/// Zero-param POCO tests for CommitTransactionStep. Fixture is inline per the +/// pilot pattern; no FixtureLoader, no file I/O. +/// +public class CommitTransactionStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = CommitTransactionStep.Metadata.FromXml!(source); + + Assert.IsType(step); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_EmitsBareName() + { + var step = new CommitTransactionStep(); + Assert.Equal("Commit Transaction", step.ToDisplayLine()); + } + + [Fact] + public void Disabled_RoundTrips() + { + var source = XElement.Parse(""""""); + var step = CommitTransactionStep.Metadata.FromXml!(source); + + Assert.False(step.Enabled); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Commit Transaction", out var metadata)); + Assert.Equal(206, metadata!.Id); + Assert.Empty(metadata.Params); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ConfigureAIAccountStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ConfigureAIAccountStepTests.cs new file mode 100644 index 0000000..bafd405 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ConfigureAIAccountStepTests.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ConfigureAIAccountStepTests +{ + private const string CanonicalXml = """"""; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ConfigureAIAccountStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Display_RoundTripsThroughFromDisplayParams() + { + var step1 = ConfigureAIAccountStep.Metadata.FromXml!(XElement.Parse(CanonicalXml)); + var display = step1.ToDisplayLine(); + var open = display.IndexOf('['); + var close = display.LastIndexOf(']'); + var inner = display.Substring(open + 1, close - open - 1).Trim(); + var tokens = inner.Split(';', System.StringSplitOptions.TrimEntries); + + var step2 = ConfigureAIAccountStep.Metadata.FromDisplay!(true, tokens); + Assert.True(XNode.DeepEquals(step1.ToXml(), step2.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Configure AI Account", out var metadata)); + Assert.Equal(212, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ConfigureLocalNotificationStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ConfigureLocalNotificationStepTests.cs new file mode 100644 index 0000000..1a3f169 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ConfigureLocalNotificationStepTests.cs @@ -0,0 +1,28 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ConfigureLocalNotificationStepTests +{ + private const string CanonicalXml = """ + <Calculation><![CDATA["title"]]></Calculation> + """; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ConfigureLocalNotificationStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Configure Local Notification", out var metadata)); + Assert.Equal(187, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ConfigureMachineLearningModelStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ConfigureMachineLearningModelStepTests.cs new file mode 100644 index 0000000..45e7785 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ConfigureMachineLearningModelStepTests.cs @@ -0,0 +1,28 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ConfigureMachineLearningModelStepTests +{ + private const string CanonicalXml = """ + + """; + + [Fact] + public void RoundTrip_CanonicalXml_IsPreserved() + { + var source = XElement.Parse(CanonicalXml); + var step = ConfigureMachineLearningModelStep.Metadata.FromXml!(source); + Assert.True(XNode.DeepEquals(source, step.ToXml())); + } + + [Fact] + public void Registry_HasStep() + { + Assert.True(StepRegistry.ByName.TryGetValue("Configure Machine Learning Model", out var metadata)); + Assert.Equal(202, metadata!.Id); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/ConfigureNfcReadingStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/ConfigureNfcReadingStepTests.cs new file mode 100644 index 0000000..f1e1446 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Steps/ConfigureNfcReadingStepTests.cs @@ -0,0 +1,28 @@ +using System.Xml.Linq; +using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Steps; +using Xunit; + +namespace SharpFM.Tests.Scripting.Steps; + +public class ConfigureNfcReadingStepTests +{ + private const string CanonicalXml = """ +