diff --git a/.cspell.yaml b/.cspell.yaml index 862d58d6..355837a0 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -93,6 +93,15 @@ words: - renderable - Segoe - colour + - dasharray + - Metaobjects + - noviews + - Premul + - rasterizes + - Rgba + - Rects + - RRGGBB + - RRGGBBAA # Exclude common build artifacts, dependencies, and vendored third-party code ignorePaths: diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 9864ee7b..7af55293 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -477,6 +477,18 @@ reviews: - "docs/verification/sysml2-tools-tool/lint.md" - "src/**/Lint/LintCommand.cs" + - id: SysML2Tools-Tool-Render + title: Review of SysML2 Tools Render subsystem implementation + context: + - docs/design/sysml2-tools-tool.md + - docs/reqstream/sysml2-tools-tool.yaml + paths: + - "docs/reqstream/sysml2-tools-tool/render.yaml" + - "docs/design/sysml2-tools-tool/render.md" + - "docs/verification/sysml2-tools-tool/render.md" + - "src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs" + - "test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs" + # OTS Items - id: OTS-BuildMark title: Review of BuildMark OTS verification evidence diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 1fa7fa27..77187c87 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -46,13 +46,15 @@ system, subsystem, and unit levels: - **ReferenceResolver** (Unit) — resolves supertype references; detects circular imports - **SupertypeWalker** (Unit) — walks specialization chains; detects cyclic specialization - **Layout** (Subsystem) — LayoutTree intermediate representation: nine node types covering all SysML diagram elements + - **Internal** (Subsystem) — internal layout implementation + - **GeneralViewLayoutStrategy** (Unit) — two-column grid layout for general view diagrams - **Rendering** (Subsystem) — rendering pipeline interfaces: IRenderer, ILayoutStrategy, Theme, RenderOptions, DiagramRenderer - **DemaConsulting.SysML2Tools.Svg** (System) — SVG renderer: renders `LayoutTree` to SVG output with zero external dependencies - - TODO: subsystems and units to be defined in Phase 4+ + - **SvgRenderer** (Unit) — translates a `LayoutTree` to a self-contained SVG 1.1 document - **DemaConsulting.SysML2Tools.Png** (System) — PNG renderer: renders `LayoutTree` to PNG output using SkiaSharp - - TODO: subsystems and units to be defined in Phase 4+ + - **PngRenderer** (Unit) — rasterizes a `LayoutTree` to a PNG image using SkiaSharp - **DemaConsulting.SysML2Tools.Tool** (System) — dotnet tool: thin CLI wrapper and orchestration - **Program** (Unit) — entry point and execution orchestrator @@ -60,6 +62,8 @@ system, subsystem, and unit levels: - **Context** (Unit) — argument parser and I/O owner - **Lint** (Subsystem) — lint command implementation - **LintCommand** (Unit) — resolves glob patterns, invokes WorkspaceLoader, reports diagnostics + - **Render** (Subsystem) — render command implementation + - **RenderCommand** (Unit) — loads workspace, selects renderer, writes diagram output files - **SelfTest** (Subsystem) — self-validation test runner - **Validation** (Unit) — self-validation test runner - **Utilities** (Subsystem) — shared utilities @@ -89,14 +93,15 @@ reviewers an explicit navigation aid from design to code: - **src/** — source projects - **DemaConsulting.SysML2Tools/** — core library - **Grammar/** — ANTLR4 grammar files (hand-maintained; see Grammar/README.md) - - **Layout/** — LayoutTree intermediate representation (Phase 3+) + - **Layout/** — LayoutTree intermediate representation + - **Internal/** — internal layout implementation (GeneralViewLayoutStrategy) - **Parser/** — SysML v2 parsing subsystem - **Antlr/** — ANTLR4-generated C# (committed; not hand-written) - **Internal/** — internal implementation (SysmlDiagnosticListener, StdlibLoader) - **Rendering/** — rendering interfaces and theme (Phase 3+) - **Stdlib/** — embedded SysML v2 standard library files (EPL-2.0; see Stdlib/README.md) - - **DemaConsulting.SysML2Tools.Svg/** — SVG renderer (Phase 0: stub) - - **DemaConsulting.SysML2Tools.Png/** — PNG renderer (Phase 0: stub) + - **DemaConsulting.SysML2Tools.Svg/** — SVG renderer + - **DemaConsulting.SysML2Tools.Png/** — PNG renderer - **DemaConsulting.SysML2Tools.Tool/** — dotnet tool CLI wrapper - **Cli/** — command-line interface subsystem - **Lint/** — lint command subsystem @@ -105,11 +110,12 @@ reviewers an explicit navigation aid from design to code: - **docs/design/** — design documentation - **sysml2-tools-core/** — core library unit/subsystem design - **parser/** — Parser subsystem design (Internal subsystem) - - **sysml2-tools-svg/** — TODO: SVG renderer unit/subsystem design (Phase 4+) - - **sysml2-tools-png/** — TODO: PNG renderer unit/subsystem design (Phase 4+) + - **sysml2-tools-svg.md** — SVG renderer design + - **sysml2-tools-png.md** — PNG renderer design - **sysml2-tools-tool/** — DemaConsulting.SysML2Tools.Tool unit/subsystem design - **cli/** — Cli subsystem design - **lint/** — Lint subsystem design + - **render/** — Render subsystem design (render.md) - **self-test/** — SelfTest subsystem design - **utilities/** — Utilities subsystem design diff --git a/docs/design/sysml2-tools-core/layout.md b/docs/design/sysml2-tools-core/layout.md index b8a13d73..6d587fe0 100644 --- a/docs/design/sysml2-tools-core/layout.md +++ b/docs/design/sysml2-tools-core/layout.md @@ -175,11 +175,54 @@ decisions recorded here reflect constraints imposed during the Phase 3 vocabular ### Design Constraints - All Layout types target net8.0, net9.0, and net10.0 with `enable`. -- No methods or behaviors are defined in the Layout subsystem; it is a data model only. +- No methods or behaviors are defined in the Layout subsystem data types; it is a data model only. - `TextAlign` is declared in `LayoutLabel.cs` and reused by `LayoutGrid.cs`; both files are in the same `DemaConsulting.SysML2Tools.Layout` namespace, so no cross-namespace import is required. +### Internal Subsystem + +#### GeneralViewLayoutStrategy + +##### Purpose + +`GeneralViewLayoutStrategy` implements `ILayoutStrategy` to produce a two-column grid +layout for general view diagrams. It collects all user-defined `part def` elements from +the workspace, groups them by parent package, and arranges the group boxes into two +left-to-right columns. Specialization relationships between `part def` elements with +declared supertypes are represented as `LayoutLine` nodes with open arrowheads. + +##### Data Model + +`GeneralViewLayoutStrategy` has no instance state. All inputs are supplied through +`BuildLayout` parameters. Layout constants (margins, gaps, minimum box sizes) are +declared as `private const double` fields. + +##### Key Methods + +**`BuildLayout(ViewContext context, RenderOptions options)`** + +Entry point. Calls `CollectUserPartDefs` to gather non-stdlib `part def` declarations. +Returns a minimal 200×100 `LayoutTree` when no user part defs are found. Otherwise +calls `GroupByPackage` and `BuildGridLayout` to produce the full layout tree. + +**`CollectUserPartDefs(SysmlWorkspace workspace)`** + +Iterates `workspace.Declarations`, keeping only `SysmlDefinitionNode` entries with +`DefinitionKeyword == "part def"` that pass `StdlibFilter.IsStdlibElement`. + +**`BuildGridLayout(groups, theme)`** + +Places group `LayoutBox` nodes alternately in left and right columns. Computes column +widths from the maximum group width in each column, then assigns absolute `(X, Y)` +coordinates. Calls `AddSpecializationLines` to append `LayoutLine` nodes. + +##### Dependencies + +- `ILayoutStrategy` (in `DemaConsulting.SysML2Tools.Rendering`) — interface +- `StdlibFilter` (in `DemaConsulting.SysML2Tools.Rendering.Internal`) — stdlib exclusion +- `SysmlDefinitionNode` (in `DemaConsulting.SysML2Tools.Semantic.Internal`) — part def nodes + ### Requirements Traceability | Requirement ID | Satisfied by | diff --git a/docs/design/sysml2-tools-core/rendering.md b/docs/design/sysml2-tools-core/rendering.md index 2551cd86..18f2d1f7 100644 --- a/docs/design/sysml2-tools-core/rendering.md +++ b/docs/design/sysml2-tools-core/rendering.md @@ -97,9 +97,26 @@ flowchart TD - *Type*: Sealed class. - *Role*: Orchestrator. - *Contract*: `IReadOnlyList RenderWorkspace(SysmlWorkspace workspace, IRenderer renderer, RenderOptions options)`. - Iterates over all views in the workspace, calls `ILayoutStrategy.BuildLayout` for each, - then calls `IRenderer.Render` and collects the results. Implementation is deferred to - Phase 4. + Iterates over all views in the workspace, routes each view to an `ILayoutStrategy` via + `DiagramTypeRouter`, calls `ILayoutStrategy.BuildLayout`, then calls `IRenderer.Render` + and collects the results. Standard-library view declarations are filtered by `StdlibFilter`. + +**DiagramTypeRouter**: Internal routing helper. + +- *Type*: Internal static class. +- *Role*: Router. +- *Contract*: `static ILayoutStrategy GetStrategy(object viewNode, SysmlWorkspace workspace, out string? unsupportedMessage)`. + Routes all view types to `GeneralViewLayoutStrategy` in Phase 4. Sets `unsupportedMessage` + to a non-null diagnostic when no strategy is available for the view type; the caller skips + rendering that view. + +**StdlibFilter**: Standard-library element filter. + +- *Type*: Internal static class. +- *Role*: Filter. +- *Contract*: `static bool IsStdlibElement(string qualifiedName)`. Returns `true` when the + qualified name matches a standard-library prefix. Used by `DiagramRenderer` to exclude + stdlib view declarations from rendering. ### Design @@ -126,9 +143,6 @@ flowchart TD ### Design Constraints -- `DiagramRenderer`, `IRenderer`, `ILayoutStrategy`, and `ViewContext` are Phase 3 - interface definitions. All `Render` and `RenderWorkspace` method bodies throw - `NotImplementedException` until Phase 4 implementation. - `ViewContext.Workspace` references `SysmlWorkspace` from `DemaConsulting.SysML2Tools.Semantic`; the `using` directive `using DemaConsulting.SysML2Tools.Semantic;` is required in `ILayoutStrategy.cs` and `DiagramRenderer.cs`. @@ -141,11 +155,11 @@ flowchart TD | Requirement ID | Satisfied by | | --- | --- | | SysML2Tools-Core-Rendering-IRenderer | `IRenderer` interface | -| SysML2Tools-Core-Rendering-IRendererStateless | `IRenderer` doc constraint; stub throws `NotImplementedException` | +| SysML2Tools-Core-Rendering-IRendererStateless | `SvgRenderer` and `PngRenderer` are pure stateless implementations | | SysML2Tools-Core-Rendering-Theme | `Theme` and `FontDescriptor` records | | SysML2Tools-Core-Rendering-ThemeDepthWrap | `Theme.DepthFillColors` with modulo indexing documented in `Theme` | | SysML2Tools-Core-Rendering-RenderOptions | `RenderOptions` record with default values | | SysML2Tools-Core-Rendering-ILayoutStrategy | `ILayoutStrategy` interface and `ViewContext` record | -| SysML2Tools-Core-Rendering-DiagramRenderer | `DiagramRenderer` class stub | +| SysML2Tools-Core-Rendering-DiagramRenderer | `DiagramRenderer.RenderWorkspace`; `DiagramTypeRouter`; `StdlibFilter` | | SysML2Tools-Core-Rendering-RenderOutput | `RenderOutput` record | | SysML2Tools-Core-Rendering-BuiltinThemes | `Themes.Light`, `Themes.Dark`, `Themes.Print` | diff --git a/docs/design/sysml2-tools-png.md b/docs/design/sysml2-tools-png.md index da725432..00ef952c 100644 --- a/docs/design/sysml2-tools-png.md +++ b/docs/design/sysml2-tools-png.md @@ -1,30 +1,217 @@ # DemaConsulting.SysML2Tools.Png -## Architecture +## Overview -The `DemaConsulting.SysML2Tools.Png` package provides the PNG renderer, implementing the -`IRenderer` interface from the core library using SkiaSharp (MIT-licensed). This system is -a Phase 0 stub; detailed design will be populated in Phase 4+. +`DemaConsulting.SysML2Tools.Png` is the PNG renderer system for SysML2 Tools. It provides +a single public class, `PngRenderer`, that uses SkiaSharp to rasterize a `LayoutTree` +into a PNG image written to an output stream. -## External Interfaces +## PngRenderer -*To be defined in Phase 4+.* +### Purpose -## Dependencies +`PngRenderer` implements the `IRenderer` interface to produce PNG diagram output from a +`LayoutTree` intermediate representation. Each call to `Render` allocates an `SKBitmap`, +draws all layout nodes onto an `SKCanvas`, encodes the result as PNG via `SKImage.Encode`, +and writes the bytes to the output stream. All SkiaSharp resources are disposed before +the method returns. The renderer is pure and stateless. -- **DemaConsulting.SysML2Tools** — provides `IRenderer` interface and semantic model. -- **SkiaSharp** — MIT-licensed 2D graphics library used for PNG rasterization. +### Data Model -## Risk Control Measures +`PngRenderer` has no instance state. All inputs are supplied through `Render` parameters. -N/A — not a safety-classified software item. +- `LayoutTree` — read-only input; canvas dimensions and node list +- `RenderOptions` — read-only input; `Theme` for colors/fonts, `Scale` for bitmap size +- `Stream output` — write-only output; receives PNG bytes; caller owns lifetime -## Data Flow +### Embedded Font -*To be defined in Phase 4+.* +`PngRenderer` embeds the four static Noto Sans TTF files as assembly resources to guarantee +pixel-identical output across all platforms regardless of which fonts are installed on the +host system: -## Design Constraints +| Resource | Typeface | +| --- | --- | +| `Fonts/NotoSans-Regular.ttf` | Regular weight, normal style | +| `Fonts/NotoSans-Bold.ttf` | Bold weight, normal style | +| `Fonts/NotoSans-Italic.ttf` | Regular weight, italic style | +| `Fonts/NotoSans-BoldItalic.ttf` | Bold weight, italic style | -- Platform: multi-targets net8.0, net9.0, and net10.0 framework compatibility specifications - on Windows, Linux, and macOS. -- SkiaSharp dependency is introduced in Phase 1. +The four static `Lazy` fields (`RegularTypeface`, `BoldTypeface`, +`ItalicTypeface`, `BoldItalicTypeface`) load each font once on first use via the +`LoadTypeface(string fileName)` helper. `LoadTypeface` locates the resource by matching +the file name suffix (case-insensitive) against the assembly manifest resource names and +falls back to `SKTypeface.Default` if the resource is not found, so the renderer +remains functional even when the font files are absent. + +The SIL Open Font License 1.1 attribution file for Noto Sans is included at +`Fonts/OFL.txt` and is also embedded as an assembly resource. + +### Font Weight and Style Per Node Type + +Each node type uses a fixed weight and style: + +| Node Type | Weight | Style | +| --- | --- | --- | +| `LayoutBox` label | Bold | Normal | +| `LayoutBoxCompartment` title | Bold | Italic | +| `LayoutBoxCompartment` rows | Regular | Normal | +| `LayoutLine` midpoint label | Regular | Normal | +| `LayoutLabel` | Per `FontWeight` field | Per `FontStyle` field | +| `LayoutPort` label | Regular | Normal | +| `LayoutBadge` label | Regular | Normal | +| `LayoutBand` label | Regular | Normal | +| `LayoutLifeline` label | Bold | Normal | +| `LayoutGrid` header cells | Bold | Normal | +| `LayoutGrid` body cells | Regular | Normal | + +### LayoutLabel Font Styling Fields + +`LayoutLabel` carries three explicit font styling fields added in Phase 4: + +- `Weight` (`FontWeight`) — `Regular` or `Bold`; selects the typeface variant. +- `Style` (`FontStyle`) — `Normal` or `Italic`; selects the typeface variant. +- `FontSize` (double) — Font size in logical pixels, independent of the theme body size. + +### Key Methods + +**`Render(LayoutTree layout, RenderOptions options, Stream output)`** + +Entry point. Validates arguments. Computes bitmap width and height as +`(int)Math.Ceiling(layout.Width * options.Scale)`, clamped to a minimum of 1×1 pixels +to prevent SkiaSharp allocation errors on empty trees. Creates `SKBitmap(w, h, Rgba8888, Premul)`, +fills background with `SKColors.White`, calls `RenderNode` for each top-level node, +then encodes via `SKImage.FromBitmap(bitmap).Encode(Png, 100)` and saves to `output`. + +**`RenderNode(SKCanvas canvas, LayoutNode node, RenderOptions options)`** + +Dispatches by concrete node type to the appropriate typed render method. All nine +`LayoutNode` subtypes are handled: `LayoutBox` → `RenderBox`, `LayoutLine` → `RenderLine`, +`LayoutLabel` → `RenderLabel`, `LayoutPort` → `RenderPort`, `LayoutBadge` → `RenderBadge`, +`LayoutBand` → `RenderBand`, `LayoutLifeline` → `RenderLifeline`, +`LayoutActivation` → `RenderActivation`, `LayoutGrid` → `RenderGrid`. Unknown subtypes +are silently skipped for forward compatibility. + +**`LoadTypeface(string fileName)`** + +Locates the embedded assembly resource whose name ends with `fileName` +(case-insensitive). Returns an `SKTypeface` decoded from the resource stream, or +`SKTypeface.Default` if no matching resource is found. + +**`CreateTextPaint(SKColor color, float fontSize, bool bold, bool italic)`** + +Selects the appropriate `Lazy` variant based on the `(bold, italic)` tuple, +then returns a new `SKPaint` configured with the color, font size, anti-aliasing, and +typeface. The caller is responsible for disposing the returned paint. + +**`FitFontSize(SKPaint paint, string text, float availableWidth, float maxFontSize)`** + +Measures the text width at `maxFontSize`. Returns `maxFontSize` unchanged when the text +fits within `availableWidth` or when `availableWidth` is zero or negative. Otherwise +scales the font size proportionally: `maxFontSize * (availableWidth / measuredWidth)`. +This ensures long labels do not overflow their bounding box. + +**`RenderBox(SKCanvas canvas, LayoutBox box, RenderOptions options)`** + +Draws a filled rectangle (plain or rounded via `DrawRoundRect` when +`BoxShape.RoundedRectangle` and `LineCornerRadius > 0`) using the fill color from +`theme.DepthFillColors[depth % count]`. Draws a matching stroke rectangle. Draws centered +bold title text when `box.Label` is non-null, calling `FitFontSize` to prevent overflow. +Calls `RenderBoxCompartments` for any compartments, then recursively calls `RenderNode` +for all `box.Children`. + +**`RenderBoxCompartments(SKCanvas canvas, LayoutBox box, RenderOptions options, SKColor strokeColor)`** + +Draws a full-width horizontal divider line at the start of each compartment, followed by +an optional bold-italic title row and zero or more left-aligned regular-weight body-font +text rows. Tracks a running Y offset starting below the label area. + +**`RenderLine(SKCanvas canvas, LayoutLine line, RenderOptions options)`** + +Builds a single `SKPath` from all waypoints. Applies `SKPathEffect.CreateCorner` when +`LineCornerRadius > 0`. When dashing is also active, composes effects with +`SKPathEffect.CreateCompose(dash, corner)` so the dash pattern follows the rounded path. +After drawing the path, calls `DrawArrowhead` for non-None source and target arrowhead +styles, then `RenderLineMidpointLabel` when `MidpointLabel` is non-null. + +**`DrawArrowhead(SKCanvas, tipX, tipY, dx, dy, ArrowheadStyle, scale, color, strokeWidth)`** + +Draws the arrowhead at the given tip using a normalized direction vector. Supports all +seven `ArrowheadStyle` values: `None` (no-op), `Open` (hollow triangle), `Filled` (solid +triangle), `Diamond` (hollow four-point polygon), `FilledDiamond` (solid four-point +polygon), `Circle` (open circle), `Bar` (perpendicular stroke). + +**`RenderLabel(SKCanvas canvas, LayoutLabel label, RenderOptions options)`** + +Draws text using `CreateTextPaint` with the weight and style from `label.Weight` and +`label.Style`, font size from `label.FontSize`, and alignment from `label.Align`. Calls +`FitFontSize` when `label.MaxWidth > 0`. + +**`RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions options)`** + +Draws an 8×8-pixel filled square (filled with the stroke color) centered at +`(CentreX, CentreY)`. Optional label is offset away from the attached `PortSide`. + +**`RenderBadge(SKCanvas canvas, LayoutBadge badge, RenderOptions options)`** + +Draws the badge shape centered at `(CentreX, CentreY)` within a bounding circle of +radius `Size/2`. Shapes: `FilledCircle` (solid circle), `Bullseye` (filled circle with +white inner circle), `Diamond` (rotated open square), `HorizontalBar` (horizontal stroke), +`VerticalBar` (vertical stroke). Optional label drawn to the right. + +**`RenderBand(SKCanvas canvas, LayoutBand band, RenderOptions options)`** + +Draws a filled and stroked rectangle using `DepthFillColors[0]`. For `Horizontal` +orientation the label is rendered with 90° CCW rotation along the left edge using +`canvas.Save/Translate/RotateDegrees/Restore`. For `Vertical` orientation the label is +horizontal at the top. Children are rendered recursively. + +**`RenderLifeline(SKCanvas canvas, LayoutLifeline lifeline, RenderOptions options)`** + +Draws a header box centered at `CentreX` (top at `TopY`, size `HeaderWidth × HeaderHeight`) +filled with `DepthFillColors[0]`. Draws a bold centered label in the box. Draws a dashed +vertical stem (`SKPathEffect.CreateDash`) from the bottom of the header to `BottomY`. + +**`RenderActivation(SKCanvas canvas, LayoutActivation activation, RenderOptions options)`** + +Draws a white-filled rectangle of width `LabelPadding * 2` centered at `CentreX`, +spanning `TopY` to `BottomY`, with a stroke border. + +**`RenderGrid(SKCanvas canvas, LayoutGrid grid, RenderOptions options)`** + +Iterates rows and cells, accumulating X/Y positions. Each cell gets a filled background +(`DepthFillColors[1]` for header rows, `DepthFillColors[0]` for body rows), a stroke +border, and a text element vertically centered within the row height and horizontally +aligned per `LayoutGridCell.Align`. Header cells use bold weight; body cells use regular. + +### Error Handling + +`Render` throws `ArgumentNullException` when `layout`, `options`, or `output` is null. +`SKColor.Parse` throws `ArgumentException` for malformed hex strings; this is a programming +error in the theme definition, not a user input error. + +### Dependencies + +- `DemaConsulting.SysML2Tools` — provides `IRenderer`, `LayoutTree`, all nine `LayoutNode` + subtypes, `RenderOptions`, `Theme` +- `SkiaSharp` (OTS) — provides `SKBitmap`, `SKCanvas`, `SKImage`, `SKPaint`, `SKColor`, + `SKPath`, `SKPathEffect`, `SKTypeface`, `SKData` +- Noto Sans (OTS, SIL OFL 1.1) — four static TTF files embedded as assembly resources + +### Callers + +- `DiagramRenderer.RenderWorkspace` (in `DemaConsulting.SysML2Tools`) — passes the + renderer to `IRenderer.Render` as part of the orchestrated rendering pipeline +- `RenderCommand.RunAsync` (in `DemaConsulting.SysML2Tools.Tool`) — creates a + `PngRenderer` instance when `--format png` is selected + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| SysML2Tools-Core-Rendering-IRenderer | `IRenderer` implementation in `PngRenderer` | +| SysML2Tools-Core-Rendering-IRendererStateless | `PngRenderer` is a stateless, pure class | +| SysML2Tools-Png-MediaType | `PngRenderer.MediaType` property | +| SysML2Tools-Png-DefaultExtension | `PngRenderer.DefaultExtension` property | +| SysML2Tools-Png-Render-Signature | `PngRenderer.Render` encodes via SkiaSharp PNG | +| SysML2Tools-Png-Render-Box | `RenderBox` draws filled and stroked rectangle | diff --git a/docs/design/sysml2-tools-svg.md b/docs/design/sysml2-tools-svg.md index ccfa941b..d8232074 100644 --- a/docs/design/sysml2-tools-svg.md +++ b/docs/design/sysml2-tools-svg.md @@ -1,29 +1,198 @@ # DemaConsulting.SysML2Tools.Svg -## Architecture +## Overview -The `DemaConsulting.SysML2Tools.Svg` package provides the SVG renderer, implementing the -`IRenderer` interface from the core library with zero external dependencies. This system is -a Phase 0 stub; detailed design will be populated in Phase 4+. +`DemaConsulting.SysML2Tools.Svg` is the SVG renderer system for SysML2 Tools. It provides +a single public class, `SvgRenderer`, that translates a `LayoutTree` into a self-contained +SVG 1.1 document written to an output stream. The renderer has zero external runtime +dependencies beyond the .NET base class library. -## External Interfaces +## SvgRenderer -*To be defined in Phase 4+.* +### Purpose -## Dependencies +`SvgRenderer` implements the `IRenderer` interface to produce SVG 1.1 diagram output +from a `LayoutTree` intermediate representation. Each call to `Render` builds a complete +SVG document in a `StringBuilder` and writes it to the supplied stream in UTF-8 encoding. +The renderer is pure and stateless; no fields are mutated between calls. -- **DemaConsulting.SysML2Tools** — provides `IRenderer` interface and semantic model. +### Data Model -## Risk Control Measures +`SvgRenderer` has no instance state. All inputs are supplied through `Render` parameters. -N/A — not a safety-classified software item. +- `LayoutTree` — read-only input; canvas dimensions and node list +- `RenderOptions` — read-only input; `Theme` for visual parameters, `Scale` for sizing +- `Stream output` — write-only output; receives UTF-8 SVG bytes; caller owns lifetime -## Data Flow +### Font Family -*To be defined in Phase 4+.* +All text elements use `font-family="Noto Sans, sans-serif"`. The `Noto Sans` family is +specified first so that browsers and renderers with Noto Sans installed use it; `sans-serif` +is the CSS generic fallback. This replaced the earlier `Segoe UI, sans-serif` default to +provide consistent rendering across all platforms. -## Design Constraints +### Font Weight and Style Per Node Type -- Platform: multi-targets net8.0, net9.0, and net10.0 framework compatibility specifications - on Windows, Linux, and macOS. -- Zero external NuGet dependencies beyond the core library. +Each node type uses a fixed font weight and style as SVG attributes: + +| Node Type | `font-weight` | `font-style` | +| --- | --- | --- | +| `LayoutBox` label | `bold` | (default) | +| `LayoutBoxCompartment` title | `bold` | `italic` | +| `LayoutBoxCompartment` rows | (default) | (default) | +| `LayoutLine` midpoint label | (default) | (default) | +| `LayoutLabel` | Per `FontWeight` field | Per `FontStyle` field | +| `LayoutPort` label | (default) | (default) | +| `LayoutBadge` label | (default) | (default) | +| `LayoutBand` label | (default) | (default) | +| `LayoutLifeline` label | `bold` | (default) | +| `LayoutGrid` header cells | `bold` | (default) | +| `LayoutGrid` body cells | (default) | (default) | + +### LayoutLabel Font Styling Fields + +`LayoutLabel` carries three explicit font styling fields added in Phase 4: + +- `Weight` (`FontWeight`) — `Regular` maps to `font-weight="normal"`; `Bold` maps to `font-weight="bold"`. +- `Style` (`FontStyle`) — `Normal` maps to `font-style="normal"`; `Italic` maps to `font-style="italic"`. +- `FontSize` (double) — Font size in logical pixels, used as `font-size` instead of the theme body size. + +### Text Length Shrink-to-Fit + +`LayoutBox` labels include `textLength` and `lengthAdjust="spacingAndGlyphs"` attributes +set to `(box.Width - 2 * theme.LabelPadding) * scale`. This instructs SVG renderers to +compress or stretch glyph spacing so the text fills (or shrinks into) the available title +area without overflow. + +`LayoutLabel` nodes with `MaxWidth > 0` similarly include +`textLength="{MaxWidth * scale}" lengthAdjust="spacingAndGlyphs"`. + +### Key Methods + +**`Render(LayoutTree layout, RenderOptions options, Stream output)`** + +Entry point. Validates arguments, computes canvas size clamped to a minimum of 1×1, +writes the SVG root element with `xmlns`, `width`, `height`, and `viewBox` attributes, +then calls `WriteArrowheadDefs` followed by recursive `RenderNode` calls for every +top-level node. Encodes the completed `StringBuilder` as UTF-8 and writes all bytes +to `output` in a single `Write` call. + +**`WriteArrowheadDefs(StringBuilder sb, Theme theme)`** + +Writes the SVG `` block containing six named marker elements: `arrowhead-open` +(hollow triangle), `arrowhead-filled` (solid triangle), `arrowhead-diamond` (hollow +four-point polygon), `arrowhead-filled-diamond` (solid four-point polygon), +`arrowhead-circle` (open circle), and `arrowhead-bar` (perpendicular line). All markers +use `theme.StrokeColor` and `theme.StrokeWidth`. + +**`RenderNode(StringBuilder sb, LayoutNode node, Theme theme, double scale)`** + +Dispatches by concrete node type to the appropriate typed render method. All nine +`LayoutNode` subtypes are handled: `LayoutBox` → `RenderBox`, `LayoutLine` → `RenderLine`, +`LayoutLabel` → `RenderLabel`, `LayoutPort` → `RenderPort`, `LayoutBadge` → `RenderBadge`, +`LayoutBand` → `RenderBand`, `LayoutLifeline` → `RenderLifeline`, +`LayoutActivation` → `RenderActivation`, `LayoutGrid` → `RenderGrid`. Unknown subtypes +are silently skipped for forward compatibility. + +**`RenderBox(StringBuilder sb, LayoutBox box, Theme theme, double scale)`** + +Writes a `` element using fill color from `theme.DepthFillColors[box.Depth % count]`. +Adds `rx`/`ry` attributes when `BoxShape.RoundedRectangle` and `LineCornerRadius > 0`. +Writes a bold `` element with `textLength` in the title area when `box.Label` is +non-null. Calls `RenderBoxCompartments` for any compartments, then recursively calls +`RenderNode` for all `box.Children`. + +**`RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme theme, double scale)`** + +Writes a `` divider across the full box width at the top of each compartment, +followed by an optional `font-weight="bold" font-style="italic"` `` title row and +zero or more left-aligned regular-weight body-font `` rows. + +**`RenderLine(StringBuilder sb, LayoutLine line, Theme theme, double scale)`** + +Calls `BuildLinePath` to produce the path `d` attribute, then writes a `` element +with `fill="none"`. Adds `marker-start` or `marker-end` attributes for all six non-None +`ArrowheadStyle` values. Adds `stroke-dasharray` for `Dashed` and `Dotted` line styles. +Writes an optional midpoint `` element when `MidpointLabel` is non-null. + +**`BuildLinePath(IReadOnlyList waypoints, double cornerRadius, double scale)`** + +Builds the SVG path `d` string. When `cornerRadius` is zero, emits plain `M`/`L` +commands. When positive, each interior waypoint is replaced with a shortened `L` command +to the arc start point, followed by an `A` (elliptical arc) command whose sweep direction +(0 or 1) is determined from the cross product of the incoming and outgoing unit direction +vectors. The radius is clamped to half the shorter adjacent segment to prevent overshoot. + +**`RenderLabel(StringBuilder sb, LayoutLabel label, Theme theme, double scale)`** + +Writes a `` element with `text-anchor` from `label.Align`, `font-size` from +`label.FontSize`, `font-weight` and `font-style` from `label.Weight` and `label.Style`. +When `label.MaxWidth > 0`, adds `textLength` and `lengthAdjust="spacingAndGlyphs"`. + +**`RenderPort(StringBuilder sb, LayoutPort port, Theme theme, double scale)`** + +Writes a filled 8×8 `` centered at `(CentreX, CentreY)`. Optional label is written +as a `` element offset away from the attached `PortSide`. + +**`RenderBadge(StringBuilder sb, LayoutBadge badge, Theme theme, double scale)`** + +Writes shape-specific SVG elements: `` for `FilledCircle` and `Bullseye` (the +latter with an additional white inner circle), `` for `Diamond`, and `` +for `HorizontalBar` and `VerticalBar`. An optional label is written as ``. + +**`RenderBand(StringBuilder sb, LayoutBand band, Theme theme, double scale)`** + +Writes a `` with `DepthFillColors[0]` fill. For `Horizontal` bands writes a +`` with `transform="translate(...) rotate(-90)"` on the left edge; for `Vertical` +bands writes a horizontal `` at the top. Children are rendered recursively. + +**`RenderLifeline(StringBuilder sb, LayoutLifeline lifeline, Theme theme, double scale)`** + +Writes a `` header centered at `CentreX` filled with `DepthFillColors[0]`, a +bold centered `` label, and a dashed `` stem (`stroke-dasharray="6 3"`) from +the bottom of the header to `BottomY`. + +**`RenderActivation(StringBuilder sb, LayoutActivation activation, Theme theme, double scale)`** + +Writes a `` with `fill="white"` and a stroke border. Width is `LabelPadding * 2`, +centered at `CentreX`, spanning `TopY` to `BottomY`. + +**`RenderGrid(StringBuilder sb, LayoutGrid grid, Theme theme, double scale)`** + +Iterates rows and cells, accumulating X/Y positions. Each cell gets a `` filled +with `DepthFillColors[1]` for header rows or `DepthFillColors[0]` for body rows, plus a +stroke border, plus a `` vertically centered in the row and aligned per +`LayoutGridCell.Align`. Header cells have `font-weight="bold"` added conditionally; +body cells use the browser default. + +### Error Handling + +`Render` throws `ArgumentNullException` when `layout`, `options`, or `output` is null. +No other exceptions are expected under normal operation. XML special characters in labels +are escaped via `EscapeXml` (replaces `&`, `<`, `>` with XML entities) to prevent +malformed SVG output. + +### Dependencies + +- `DemaConsulting.SysML2Tools` — provides `IRenderer`, `LayoutTree`, all nine `LayoutNode` + subtypes, `RenderOptions`, `Theme` + +### Callers + +- `DiagramRenderer.RenderWorkspace` (in `DemaConsulting.SysML2Tools`) — passes the + renderer to `IRenderer.Render` as part of the orchestrated rendering pipeline +- `RenderCommand.RunAsync` (in `DemaConsulting.SysML2Tools.Tool`) — creates a + `SvgRenderer` instance when `--format svg` is selected + +## Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| SysML2Tools-Core-Rendering-IRenderer | `IRenderer` implementation in `SvgRenderer` | +| SysML2Tools-Core-Rendering-IRendererStateless | `SvgRenderer` is a stateless, pure class | +| SysML2Tools-Svg-MediaType | `SvgRenderer.MediaType` property | +| SysML2Tools-Svg-DefaultExtension | `SvgRenderer.DefaultExtension` property | +| SysML2Tools-Svg-Render-Document | `SvgRenderer.Render` writes SVG root element | +| SysML2Tools-Svg-Render-Box | `RenderBox` writes `` element | +| SysML2Tools-Svg-Render-Label | `RenderLabel` writes `` element | +| SysML2Tools-Svg-Render-Line | `RenderLine` writes `` element | diff --git a/docs/design/sysml2-tools-tool/cli/context.md b/docs/design/sysml2-tools-tool/cli/context.md index 6610a768..e8f1d0af 100644 --- a/docs/design/sysml2-tools-tool/cli/context.md +++ b/docs/design/sysml2-tools-tool/cli/context.md @@ -28,6 +28,19 @@ neither flag was present. **HeadingDepth**: `int` — Heading depth for markdown output; valid range 1–6, default 1; supplied via `--depth`. +**Command**: `SysmlCommand` — `SysmlCommand.Lint` when `lint` is the first positional +argument; `SysmlCommand.Render` when `render` is the first positional argument; +`SysmlCommand.None` otherwise. + +**Files**: `IReadOnlyList` — file glob patterns collected from positional arguments +after the command token. + +**OutputDirectory**: `string?` — path supplied after `--output`, or `null` if the option +was absent. Used by the render command as the output directory for diagram files. + +**RendererFormat**: `string?` — value supplied after `--format` (e.g., `"svg"` or `"png"`), +or `null` if the option was absent. Used by the render command to select the output format. + **ExitCode**: `int` (derived) — Returns 1 if `_hasErrors` is true; returns 0 otherwise. #### Key Methods @@ -88,3 +101,5 @@ available. - **Program** — creates `Context` via `Context.Create` and calls `WriteLine` and `WriteError`. - **Validation** — receives `Context` from `Program` and calls `WriteLine` and `WriteError`. +- **RenderCommand** — reads `Files`, `RendererFormat`, and `OutputDirectory`; calls + `WriteLine` and `WriteError`. diff --git a/docs/design/sysml2-tools-tool/render.md b/docs/design/sysml2-tools-tool/render.md new file mode 100644 index 00000000..997e7192 --- /dev/null +++ b/docs/design/sysml2-tools-tool/render.md @@ -0,0 +1,78 @@ +### DemaConsulting.SysML2Tools.Tool — Render Subsystem + +#### Overview + +The Render subsystem implements the `render` CLI verb. It provides a single internal +static class, `RenderCommand`, with one entry-point method `RunAsync`. The subsystem +coordinates workspace loading, renderer selection, and file output for the Phase 4 +diagram-rendering feature. + +#### RenderCommand + +##### Purpose + +`RenderCommand.RunAsync` translates the user's CLI intent — expressed as file glob +patterns, a format option, and an output directory — into rendered diagram files on +disk. It delegates workspace loading to `WorkspaceLoader`, renderer instantiation to +a simple string comparison, and rendering orchestration to `DiagramRenderer`. + +##### Data Model + +No instance state. All data flows through the `Context` parameter and local variables. + +- Input: `Context` — file patterns (`Files`), format (`RendererFormat`), output path + (`OutputDirectory`) +- Intermediate: `SysmlLoadResult` — workspace and diagnostics from `WorkspaceLoader` +- Output: files written to `OutputDirectory` via `File.Create` + +##### Key Methods + +**`RunAsync(Context context)`** + +Entry point for the render command. Steps: + +1. Validates that `context.Files` is non-empty; calls `context.WriteError` and returns + when no patterns are supplied. +2. Calls `WorkspaceLoader.LoadAsync(context.Files)` to load the workspace. +3. Reports all diagnostics from `loadResult.Diagnostics`, writing errors via + `context.WriteError` and other messages via `context.WriteLine`. +4. Selects renderer: `PngRenderer` when `context.RendererFormat` equals `"png"` + (case-insensitive); `SvgRenderer` otherwise. +5. Calls `DiagramRenderer.RenderWorkspace` with the loaded workspace, selected renderer, + and `new RenderOptions(Themes.Light)`. +6. Writes a "No views found" message and returns when `outputs` is empty. +7. Resolves the output directory (defaults to `Directory.GetCurrentDirectory()`), creates + it via `Directory.CreateDirectory`, and writes each `RenderOutput.Data` stream to a + file named `RenderOutput.SuggestedFileName`. + +##### Error Handling + +- Missing file patterns: `context.WriteError` is called and the method returns early. +- Load diagnostics: reported to the context; non-fatal; rendering proceeds regardless. +- No view declarations: informational message; no output files written; returns normally. +- File system errors (e.g., permission denied): propagate as `IOException`; handled by + `Program.Main`'s outer exception handler. + +##### Dependencies + +- `WorkspaceLoader` (in `DemaConsulting.SysML2Tools.Semantic`) — loads workspace +- `DiagramRenderer` (in `DemaConsulting.SysML2Tools.Rendering`) — renders views +- `SvgRenderer` (in `DemaConsulting.SysML2Tools.Svg`) — produces SVG output +- `PngRenderer` (in `DemaConsulting.SysML2Tools.Png`) — produces PNG output +- `Themes.Light` (in `DemaConsulting.SysML2Tools.Rendering`) — default theme +- `Context` (in `DemaConsulting.SysML2Tools.Cli`) — reads arguments; writes output + +##### Callers + +- `Program.RunToolLogicAsync` — dispatches to `RenderCommand.RunAsync` when + `context.Command == SysmlCommand.Render` + +#### Requirements Traceability + +| Requirement ID | Satisfied by | +| --- | --- | +| SysML2Tools-Tool-Render-Patterns | Input validation at start of `RunAsync` | +| SysML2Tools-Tool-Render-Load | `WorkspaceLoader.LoadAsync` call in `RunAsync` | +| SysML2Tools-Tool-Render-Format | Renderer selection switch in `RunAsync` | +| SysML2Tools-Tool-Render-Output | Output directory resolution in `RunAsync` | +| SysML2Tools-Tool-Render-Empty | Empty-outputs message in `RunAsync` | diff --git a/docs/reqstream/sysml2-tools-core/rendering.yaml b/docs/reqstream/sysml2-tools-core/rendering.yaml index c97a7651..0b42242e 100644 --- a/docs/reqstream/sysml2-tools-core/rendering.yaml +++ b/docs/reqstream/sysml2-tools-core/rendering.yaml @@ -36,8 +36,8 @@ sections: filesystem access ensures that renderers remain testable in-process with in-memory streams. tests: - - SvgRenderer_Render_ThrowsNotImplemented - - PngRenderer_Render_ThrowsNotImplemented + - SvgRenderer_Render_EmptyTree_WritesValidSvg + - PngRenderer_Render_EmptyTree_WritesPngBytes - id: SysML2Tools-Core-Rendering-Theme title: >- @@ -102,7 +102,7 @@ sections: layout and rendering pipeline. Centralizing this loop ensures consistent error handling and output collection across all views and all renderer types. tests: - - DiagramRenderer_RenderWorkspace_ThrowsNotImplemented + - DiagramRenderer_RenderWorkspace_NoViews_ReturnsEmptyList - id: SysML2Tools-Core-Rendering-RenderOutput title: >- diff --git a/docs/reqstream/sysml2-tools-png.yaml b/docs/reqstream/sysml2-tools-png.yaml index 2c6521a1..545e627f 100644 --- a/docs/reqstream/sysml2-tools-png.yaml +++ b/docs/reqstream/sysml2-tools-png.yaml @@ -1,11 +1,52 @@ --- -# SysML2Tools PNG Renderer System-Level Requirements (Phase 0 Stub) +# SysML2Tools PNG Renderer System-Level Requirements # # PURPOSE: # - Define system-level requirements for the DemaConsulting.SysML2Tools.Png package # - PNG renderer: PngRenderer implements IRenderer using SkiaSharp (MIT) -# - Requirements will be populated in Phase 4+ (SkiaSharp dependency added in Phase 1) sections: - title: SysML2ToolsPng Requirements - requirements: [] + requirements: + - id: SysML2Tools-Png-MediaType + title: >- + PngRenderer shall return "image/png" for the MediaType property. + justification: | + The media type is the standard MIME type for PNG content and is used + by the CLI and by callers to identify the output format without + inspecting the stream content. + tests: + - PngRenderer_MediaType_IsImagePng + + - id: SysML2Tools-Png-DefaultExtension + title: >- + PngRenderer shall return ".png" for the DefaultExtension property. + justification: | + The default extension is used by DiagramRenderer to construct the + suggested file name for each rendered view output. + tests: + - PngRenderer_DefaultExtension_IsDotPng + + - id: SysML2Tools-Png-Render-Signature + title: >- + PngRenderer.Render shall write a valid PNG stream to the output, + beginning with the four-byte PNG magic number (0x89 0x50 0x4E 0x47), + for any LayoutTree input. + justification: | + The PNG signature bytes are the primary conformance check for PNG + output. Their presence confirms that SkiaSharp produced a valid PNG + image file that can be opened by any standard PNG viewer. + tests: + - PngRenderer_Render_EmptyTree_WritesPngBytes + - PngRenderer_Render_EmptyTree_WritesPngSignature + + - id: SysML2Tools-Png-Render-Box + title: >- + PngRenderer.Render shall render each LayoutBox node as a filled and + stroked rectangle in the PNG output without throwing. + justification: | + Box rendering must complete without error for any depth level and + any valid label text. The test confirms that SkiaSharp drawing + operations complete successfully and produce a non-empty PNG output. + tests: + - PngRenderer_Render_SingleBox_ProducesNonEmptyOutput diff --git a/docs/reqstream/sysml2-tools-svg.yaml b/docs/reqstream/sysml2-tools-svg.yaml index 5e73472b..3c5fd679 100644 --- a/docs/reqstream/sysml2-tools-svg.yaml +++ b/docs/reqstream/sysml2-tools-svg.yaml @@ -1,11 +1,72 @@ --- -# SysML2Tools SVG Renderer System-Level Requirements (Phase 0 Stub) +# SysML2Tools SVG Renderer System-Level Requirements # # PURPOSE: # - Define system-level requirements for the DemaConsulting.SysML2Tools.Svg package # - SVG renderer: SvgRenderer implements IRenderer with zero external dependencies -# - Requirements will be populated in Phase 4+ sections: - title: SysML2ToolsSvg Requirements - requirements: [] + requirements: + - id: SysML2Tools-Svg-MediaType + title: >- + SvgRenderer shall return "image/svg+xml" for the MediaType property. + justification: | + The media type is the standard MIME type for SVG content and is used + by the CLI and by callers to determine the output format without + inspecting the stream content. + tests: + - SvgRenderer_MediaType_IsImageSvgXml + + - id: SysML2Tools-Svg-DefaultExtension + title: >- + SvgRenderer shall return ".svg" for the DefaultExtension property. + justification: | + The default extension is used by DiagramRenderer to construct the + suggested file name for each rendered view output. + tests: + - SvgRenderer_DefaultExtension_IsDotSvg + + - id: SysML2Tools-Svg-Render-Document + title: >- + SvgRenderer.Render shall write a valid SVG 1.1 document to the output + stream, containing the SVG root element, for any LayoutTree input. + justification: | + The SVG root element is required by the SVG 1.1 specification and by + all SVG viewers. Its presence confirms that a valid, well-formed SVG + document was produced. + tests: + - SvgRenderer_Render_EmptyTree_ProducesSvgDocument + - SvgRenderer_Render_EmptyTree_WritesValidSvg + + - id: SysML2Tools-Svg-Render-Box + title: >- + SvgRenderer.Render shall produce a rect element for each LayoutBox node + in the LayoutTree. + justification: | + Each LayoutBox in the layout tree represents a visual rectangular + container. The SVG rect element is the standard output for rectangles, + enabling SVG viewers to render diagram boxes correctly. + tests: + - SvgRenderer_Render_SingleBox_ProducesRectElement + + - id: SysML2Tools-Svg-Render-Label + title: >- + SvgRenderer.Render shall produce a text element for each LayoutLabel node + in the LayoutTree. + justification: | + Standalone text labels in a layout tree must be rendered as SVG text + elements to appear correctly in SVG viewers and document export pipelines. + tests: + - SvgRenderer_Render_SingleLabel_ProducesTextElement + + - id: SysML2Tools-Svg-Render-Line + title: >- + SvgRenderer.Render shall produce a path element for each LayoutLine node + in the LayoutTree. + justification: | + Connection lines between diagram elements are represented as LayoutLine + nodes. SVG path elements are the standard output for polylines and + support arrowhead markers through the SVG marker mechanism. + tests: + - SvgRenderer_Render_SingleLine_ProducesPathElement diff --git a/docs/reqstream/sysml2-tools-tool/cli/context.yaml b/docs/reqstream/sysml2-tools-tool/cli/context.yaml index f57a22fa..4505a4e4 100644 --- a/docs/reqstream/sysml2-tools-tool/cli/context.yaml +++ b/docs/reqstream/sysml2-tools-tool/cli/context.yaml @@ -95,3 +95,21 @@ sections: compliance workflows. tests: - Context_Create_LogFlag_InvalidPath_ThrowsInvalidOperationException + + - id: Template-Context-RenderArgs + title: >- + The Context class shall parse the render command's --format and --output arguments + into the RendererFormat and OutputDirectory properties. + justification: | + The render command relies on Context to supply the renderer format and output + directory without needing to re-parse command-line arguments. The render command + token must also set Command to SysmlCommand.Render and collect subsequent + positional arguments into Files. + tests: + - Context_Create_RenderCommand_SetsCommandRender + - Context_Create_RenderCommand_WithFormat_SetsSvgFormat + - Context_Create_RenderCommand_WithPngFormat_SetsPngFormat + - Context_Create_RenderCommand_WithOutput_SetsOutputDirectory + - Context_Create_RenderCommand_WithFiles_SetsFiles + - Context_Create_RenderCommand_FormatWithoutValue_ThrowsArgumentException + - Context_Create_RenderCommand_OutputWithoutValue_ThrowsArgumentException diff --git a/docs/reqstream/sysml2-tools-tool/render.yaml b/docs/reqstream/sysml2-tools-tool/render.yaml new file mode 100644 index 00000000..874462a5 --- /dev/null +++ b/docs/reqstream/sysml2-tools-tool/render.yaml @@ -0,0 +1,62 @@ +--- +# Render Subsystem Requirements +# +# PURPOSE: +# - Define requirements for the DemaConsulting.SysML2Tools.Tool Render subsystem +# - RenderCommand implements the CLI render verb +# - Requirements describe observable behavior of the render command + +sections: + - title: Render Subsystem Requirements + requirements: + - id: SysML2Tools-Tool-Render-Patterns + title: >- + The render command shall accept one or more file glob patterns as positional + arguments and report an error when no patterns are supplied. + justification: | + File glob patterns are the primary input mechanism for the CLI tool. An error + on missing patterns gives users an actionable message instead of silent failure. + tests: + - RenderSubsystem_NoFiles_ReportsError + + - id: SysML2Tools-Tool-Render-Load + title: >- + The render command shall load a SysML workspace from the supplied file patterns + and report any load diagnostics before rendering. + justification: | + Reporting diagnostics before rendering ensures that users can identify and + correct model errors that would affect diagram output. + tests: + - RenderSubsystem_WithFiles_LoadsWorkspace + + - id: SysML2Tools-Tool-Render-Format + title: >- + The render command shall select the SVG renderer when the --format option is + "svg" or absent, and the PNG renderer when --format is "png". + justification: | + Format selection through a named option makes the CLI self-documenting and + avoids dependence on file extension inference. + tests: + - RenderSubsystem_FormatSvg_UsesSvgRenderer + - RenderSubsystem_FormatPng_UsesPngRenderer + + - id: SysML2Tools-Tool-Render-Output + title: >- + The render command shall write rendered output files to the directory specified + by the --output option, defaulting to the current working directory when the + option is absent. + justification: | + Defaulting to the current working directory matches common CLI tool conventions + and avoids requiring the option for simple single-directory use cases. + tests: + - RenderSubsystem_NoOutputDir_UsesCurrentDirectory + + - id: SysML2Tools-Tool-Render-Empty + title: >- + The render command shall report a diagnostic message and write no output files + when the workspace contains no view declarations. + justification: | + An explicit message when no views are found prevents user confusion when + a model loads successfully but produces no diagram output. + tests: + - RenderSubsystem_NoViews_ReportsNoOutput diff --git a/docs/verification/sysml2-tools-core/rendering.md b/docs/verification/sysml2-tools-core/rendering.md index a961b503..c4ed20cd 100644 --- a/docs/verification/sysml2-tools-core/rendering.md +++ b/docs/verification/sysml2-tools-core/rendering.md @@ -18,11 +18,11 @@ All test inputs are constructed inline. No external network access or services a - All unit tests pass with zero failures across all three target frameworks. - `SvgRenderer.MediaType` returns `"image/svg+xml"`. - `SvgRenderer.DefaultExtension` returns `".svg"`. -- `SvgRenderer.Render` throws `NotImplementedException` with message containing "Phase 4". +- `SvgRenderer.Render` with an empty `LayoutTree` writes a non-empty SVG document to the output stream. - `PngRenderer.MediaType` returns `"image/png"`. - `PngRenderer.DefaultExtension` returns `".png"`. -- `PngRenderer.Render` throws `NotImplementedException` with message containing "Phase 4". -- `DiagramRenderer.RenderWorkspace` throws `NotImplementedException` with message containing "Phase 4". +- `PngRenderer.Render` with an empty `LayoutTree` writes a valid PNG stream beginning with the PNG magic number. +- `DiagramRenderer.RenderWorkspace` with a workspace containing no view declarations returns an empty list. - `Themes.Light` is non-null and has `DepthFillColors.Count >= 1` and non-empty `StrokeColor`. - `Themes.Dark` is non-null and has `DepthFillColors.Count >= 1` and non-empty `StrokeColor`. - `Themes.Print` is non-null and has `DepthFillColors.Count >= 1` and non-empty `StrokeColor`. @@ -39,10 +39,10 @@ identifies itself with the correct MIME type for SVG output. `DefaultExtension` property is asserted to equal `".svg"`. This confirms the renderer provides a correct default extension for output file naming. -**SvgRenderer_Render_ThrowsNotImplemented**: A `SvgRenderer` instance is constructed and -`Render` is called with an empty `LayoutTree`, default `RenderOptions`, and a -`MemoryStream`; a `NotImplementedException` is expected. This confirms the Phase 4 -deferral stub behavior. +**SvgRenderer_Render_EmptyTree_WritesValidSvg**: A `SvgRenderer` instance is constructed +and `Render` is called with an empty `LayoutTree`, default `RenderOptions`, and a +`MemoryStream`; the stream is asserted to be non-empty and contain `` +- `Render` with a tree containing a `LayoutBox` produces a stream containing `` tags. Confirms basic document structure for the trivial case. + +### SvgRenderer_Render_SingleBox_ProducesRectElement + +Verifies that a `LayoutTree` containing one `LayoutBox` produces SVG output containing +a ` + + + + @@ -52,6 +56,15 @@ + + + + + + + + + + + + @@ -82,6 +85,8 @@ + + diff --git a/src/DemaConsulting.SysML2Tools.Tool/Program.cs b/src/DemaConsulting.SysML2Tools.Tool/Program.cs index 26fe98c3..461fa6b9 100644 --- a/src/DemaConsulting.SysML2Tools.Tool/Program.cs +++ b/src/DemaConsulting.SysML2Tools.Tool/Program.cs @@ -21,6 +21,7 @@ using System.Reflection; using DemaConsulting.SysML2Tools.Cli; using DemaConsulting.SysML2Tools.Lint; +using DemaConsulting.SysML2Tools.Render; using DemaConsulting.SysML2Tools.SelfTest; namespace DemaConsulting.SysML2Tools; @@ -156,6 +157,7 @@ private static void PrintHelp(Context context) context.WriteLine(""); context.WriteLine("Commands:"); context.WriteLine(" lint Parse files and report syntax errors"); + context.WriteLine(" render [options] Render view diagrams to SVG or PNG files"); context.WriteLine(""); context.WriteLine("Options:"); context.WriteLine(" -v, --version Display version information"); @@ -165,6 +167,8 @@ private static void PrintHelp(Context context) context.WriteLine(" --results Write validation results to file (.trx or .xml)"); context.WriteLine(" --depth <#> Set heading depth for markdown output (default: 1)"); context.WriteLine(" --log Write output to log file"); + context.WriteLine(" --output Output directory for rendered files (render command)"); + context.WriteLine(" --format Renderer format: svg (default) or png (render command)"); } /// @@ -179,6 +183,10 @@ private static async Task RunToolLogicAsync(Context context) await LintCommand.RunAsync(context).ConfigureAwait(false); break; + case SysmlCommand.Render: + await RenderCommand.RunAsync(context).ConfigureAwait(false); + break; + default: context.WriteLine("No command specified. Run 'sysml2tools --help' for usage."); break; diff --git a/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs new file mode 100644 index 00000000..82adb845 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Tool/Render/RenderCommand.cs @@ -0,0 +1,97 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Cli; +using DemaConsulting.SysML2Tools.Parser; +using DemaConsulting.SysML2Tools.Png; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Svg; + +namespace DemaConsulting.SysML2Tools.Render; + +/// +/// Implements the render command: loads a SysML workspace, renders all view +/// declarations using the selected format renderer, and writes the output files to disk. +/// +/// +/// The render command is the primary user-facing entry point for Phase 4 diagram generation. +/// It delegates workspace loading to , format selection to +/// a simple string comparison on the --format option, and rendering to +/// . Output files are written to the directory specified by +/// --output (defaulting to the current working directory). +/// +internal static class RenderCommand +{ + /// + /// Runs the render command using the supplied context. + /// + /// The context providing file globs, format, and output directory. + /// A task that completes when all files have been rendered and written. + public static async Task RunAsync(Context context) + { + // Validate that at least one file pattern was supplied + if (context.Files.Count == 0) + { + context.WriteError("render: no input files specified. Provide file glob patterns."); + return; + } + + // Load the workspace from the supplied file patterns + context.WriteLine($"Loading {context.Files.Count} file pattern(s)..."); + var loadResult = await WorkspaceLoader.LoadAsync(context.Files).ConfigureAwait(false); + + // Report any diagnostics from the load phase + foreach (var diagnostic in loadResult.Diagnostics) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + context.WriteError($" {diagnostic}"); + } + else + { + context.WriteLine($" {diagnostic}"); + } + } + + // Skip rendering when workspace loading failed entirely + if (loadResult.Workspace is null) + { + context.WriteError("render: workspace loading failed; no output files written."); + return; + } + + // Select the renderer based on the format option (default: svg) + var format = context.RendererFormat ?? "svg"; + IRenderer renderer = format.Equals("png", StringComparison.OrdinalIgnoreCase) + ? new PngRenderer() + : new SvgRenderer(); + + // Render all views in the workspace + var diagramRenderer = new DiagramRenderer(); + var options = new RenderOptions(Themes.Light); + var outputs = diagramRenderer.RenderWorkspace(loadResult.Workspace, renderer, options); + + if (outputs.Count == 0) + { + context.WriteLine("No view declarations found in the workspace; no output files written."); + return; + } + + // Determine the output directory (default: current directory) + var outputDir = context.OutputDirectory ?? Directory.GetCurrentDirectory(); + Directory.CreateDirectory(outputDir); + + // Write each render output to disk + foreach (var output in outputs) + { + var filePath = Path.Combine(outputDir, output.SuggestedFileName); + context.WriteLine($" Writing {filePath}"); + await using var fileStream = File.Create(filePath); + await output.Data.CopyToAsync(fileStream).ConfigureAwait(false); + } + + context.WriteLine($"Rendered {outputs.Count} view(s)."); + } +} diff --git a/src/DemaConsulting.SysML2Tools/DemaConsulting.SysML2Tools.csproj b/src/DemaConsulting.SysML2Tools/DemaConsulting.SysML2Tools.csproj index 277f30de..76f3b66a 100644 --- a/src/DemaConsulting.SysML2Tools/DemaConsulting.SysML2Tools.csproj +++ b/src/DemaConsulting.SysML2Tools/DemaConsulting.SysML2Tools.csproj @@ -55,6 +55,10 @@ + + + + + + + + + + diff --git a/test/DemaConsulting.SysML2Tools.Png.Tests/PngRendererTests.cs b/test/DemaConsulting.SysML2Tools.Png.Tests/PngRendererTests.cs new file mode 100644 index 00000000..1ef027b8 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Png.Tests/PngRendererTests.cs @@ -0,0 +1,316 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Png; +using DemaConsulting.SysML2Tools.Rendering; +using SkiaSharp; + +namespace DemaConsulting.SysML2Tools.Png.Tests; + +/// +/// Tests for the PNG renderer. +/// +public sealed class PngRendererTests +{ + /// + /// Helper: renders a to a decoded so + /// pixel values can be inspected in tests. + /// + /// Layout tree to render. + /// Render options providing theme and scale. + /// Decoded bitmap. Caller is responsible for disposing. + private static SKBitmap RenderToBitmap(LayoutTree layout, RenderOptions options) + { + var renderer = new PngRenderer(); + using var ms = new MemoryStream(); + renderer.Render(layout, options, ms); + ms.Position = 0; + using var data = SKData.Create(ms); + return SKBitmap.Decode(data); + } + + /// + /// Helper: parses a CSS hex color string such as #RRGGBB or #RRGGBBAA + /// using SkiaSharp. + /// + /// Hex color string to parse. + /// Parsed . + private static SKColor ParseHex(string hex) => SKColor.Parse(hex); + + /// + /// Helper: returns true when each RGB channel of is within + /// of the corresponding channel of . + /// A tolerance of 2 is sufficient to absorb sub-pixel anti-aliasing at well-interior pixels. + /// + /// Expected color. + /// Actual sampled color. + /// Maximum allowed difference per channel (0–255). + /// when all channels are within tolerance. + private static bool ColorNear(SKColor expected, SKColor actual, int tolerance = 2) => + Math.Abs(expected.Red - actual.Red) <= tolerance && + Math.Abs(expected.Green - actual.Green) <= tolerance && + Math.Abs(expected.Blue - actual.Blue) <= tolerance; + + /// + /// Render with an empty LayoutTree produces a non-empty output stream whose first + /// four bytes are the PNG signature bytes, confirming that a valid PNG is produced + /// for a minimal empty layout. + /// + [Fact] + public void PngRenderer_Render_EmptyTree_WritesPngSignature() + { + // Arrange: a renderer with a zero-size LayoutTree and default options + var renderer = new PngRenderer(); + var layout = new LayoutTree(0, 0, []); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the empty tree + renderer.Render(layout, options, output); + + // Assert: PNG signature bytes 0x89 0x50 0x4E 0x47 are present at offset 0 + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); + } + + /// + /// Render with a LayoutTree containing one LayoutBox produces a non-empty output + /// stream, confirming that box rendering does not throw and produces valid PNG. + /// + [Fact] + public void PngRenderer_Render_SingleBox_ProducesNonEmptyOutput() + { + // Arrange: a renderer with a tree containing one LayoutBox + var renderer = new PngRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "TestBox", 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one box + renderer.Render(layout, options, output); + + // Assert: output is a non-empty PNG stream + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + } + + /// + /// Render a LayoutBox at depth 0 and sample a pixel at the box center. The pixel + /// color must match the depth-0 fill color from Themes.Light, confirming that + /// boxes are filled with the correct theme color. + /// + [Fact] + public void PngRenderer_Render_SingleBox_FillColorMatchesTheme() + { + // Arrange: box at (10,10) 100×60 depth 0; center pixel is at (60, 40) + var box = new LayoutBox(10, 10, 100, 60, null, 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel matches depth-0 fill + var expected = ParseHex(Themes.Light.DepthFillColors[0]); + var actual = bmp.GetPixel(60, 40); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render a LayoutBox at depth 1 and sample a pixel at the box center. The pixel + /// color must match the depth-1 fill color from Themes.Light, confirming that + /// depth-based fill colors are applied correctly. + /// + [Fact] + public void PngRenderer_Render_SingleBox_DepthOneUsesSecondColor() + { + // Arrange: box at (10,10) 100×60 depth 1; center pixel is at (60, 40) + var box = new LayoutBox(10, 10, 100, 60, null, 1, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel matches depth-1 fill + var expected = ParseHex(Themes.Light.DepthFillColors[1]); + var actual = bmp.GetPixel(60, 40); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render an empty LayoutTree and sample the pixel at (0, 0). The background fill + /// must be white, confirming the canvas is initialized to white before any drawing. + /// + [Fact] + public void PngRenderer_Render_BackgroundIsWhite() + { + // Arrange: empty tree at 100×100 + var layout = new LayoutTree(100, 100, []); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: top-left pixel is white + var actual = bmp.GetPixel(0, 0); + Assert.True(ColorNear(SKColors.White, actual), $"Expected white ≈ {actual}"); + } + + /// + /// Render a horizontal LayoutLine and sample a pixel on the line. The sampled pixel + /// color must approximate the theme stroke color, confirming that lines are drawn + /// with the correct stroke color. + /// + [Fact] + public void PngRenderer_Render_SingleLine_PixelOnLineIsStrokeColor() + { + // Arrange: horizontal line from (10,50) to (190,50) + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(190, 50)], + ArrowheadStyle.None, + ArrowheadStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: pixel at the line midpoint is close to the stroke color + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(100, 50); + // Use a wider tolerance (80) for anti-aliased line pixels against white background + Assert.True(ColorNear(strokeColor, actual, tolerance: 80), $"Expected stroke {strokeColor} ≈ {actual}"); + } + + /// + /// Render a LayoutPort and sample a pixel at the port center. The pixel must + /// approximate the theme stroke color, confirming ports are rendered as filled squares. + /// + [Fact] + public void PngRenderer_Render_SinglePort_CenterPixelIsStrokeColor() + { + // Arrange: port at (50,50) right side; sample at center (50,50) + var port = new LayoutPort(50, 50, PortSide.Right, null); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: center pixel is close to the stroke color (port is a filled square) + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(50, 50); + Assert.True(ColorNear(strokeColor, actual, tolerance: 10), $"Expected stroke {strokeColor} ≈ {actual}"); + } + + /// + /// Render a LayoutActivation bar and sample a pixel in its interior. The pixel must + /// be white, confirming activation bars are filled with white (not the background color). + /// + [Fact] + public void PngRenderer_Render_SingleActivation_CenterPixelIsWhite() + { + // Arrange: activation at CentreX=100, TopY=20, BottomY=80; interior pixel at (100,50) + var activation = new LayoutActivation(100, 20, 80); + var layout = new LayoutTree(200, 100, [activation]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: interior pixel is white + var actual = bmp.GetPixel(100, 50); + Assert.True(ColorNear(SKColors.White, actual), $"Expected white ≈ {actual}"); + } + + /// + /// Render a LayoutLifeline and sample the stem pixel. The pixel at the CentreX + /// midway down the stem must approximate the stroke color, confirming the stem is drawn. + /// + [Fact] + public void PngRenderer_Render_SingleLifeline_StemPixelIsStrokeColor() + { + // Arrange: lifeline at CentreX=100, TopY=10, BottomY=200; stem starts at y=50; sample at (100,125) + var lifeline = new LayoutLifeline(100, 10, 200, ":Actor", 80, 40); + var layout = new LayoutTree(300, 300, [lifeline]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: a pixel on the dashed stem is close to the stroke color + // Note: dashed lines may have gaps, so use wider tolerance and try a few Y positions + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var foundStroke = false; + for (var y = 55; y < 90; y += 3) + { + var actual = bmp.GetPixel(100, y); + if (ColorNear(strokeColor, actual, tolerance: 80)) + { + foundStroke = true; + break; + } + } + Assert.True(foundStroke, "Expected to find stroke-colored pixel on lifeline stem"); + } + + /// + /// Render a LayoutGrid with a header row and sample the header cell pixel. The pixel + /// must match the depth-1 fill color, confirming header rows use the secondary fill color. + /// + [Fact] + public void PngRenderer_Render_SingleGrid_HeaderFillMatchesTheme() + { + // Arrange: grid at (10,10) with a 100×30 header cell; center pixel at (60, 25) + var headerRow = new LayoutGridRow(true, [new LayoutGridCell(100, 30, "Name", TextAlign.Left, 1)]); + var grid = new LayoutGrid(10, 10, [headerRow]); + var layout = new LayoutTree(200, 100, [grid]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: header cell interior pixel matches depth-1 fill + var expected = ParseHex(Themes.Light.DepthFillColors[1]); + var actual = bmp.GetPixel(60, 25); + Assert.True(ColorNear(expected, actual), $"Expected {expected} ≈ {actual}"); + } + + /// + /// Render a LayoutBadge with FilledCircle shape and sample the badge center pixel. + /// The pixel must approximate the theme stroke color, confirming that the filled-circle + /// badge is drawn with the stroke color as fill. + /// + [Fact] + public void PngRenderer_Render_SingleBadge_FilledCircle_CenterPixelIsStrokeColor() + { + // Arrange: filled-circle badge at (50,50) with size 20; center pixel at (50,50) + var badge = new LayoutBadge(50, 50, 20, BadgeShape.FilledCircle, null); + var layout = new LayoutTree(200, 100, [badge]); + var options = new RenderOptions(Themes.Light); + + // Act + using var bmp = RenderToBitmap(layout, options); + + // Assert: badge center is stroke-colored + var strokeColor = ParseHex(Themes.Light.StrokeColor); + var actual = bmp.GetPixel(50, 50); + Assert.True(ColorNear(strokeColor, actual, tolerance: 10), $"Expected stroke {strokeColor} ≈ {actual}"); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Svg.Tests/DemaConsulting.SysML2Tools.Svg.Tests.csproj b/test/DemaConsulting.SysML2Tools.Svg.Tests/DemaConsulting.SysML2Tools.Svg.Tests.csproj index 3c936dfa..ec3c9a53 100644 --- a/test/DemaConsulting.SysML2Tools.Svg.Tests/DemaConsulting.SysML2Tools.Svg.Tests.csproj +++ b/test/DemaConsulting.SysML2Tools.Svg.Tests/DemaConsulting.SysML2Tools.Svg.Tests.csproj @@ -56,4 +56,9 @@ + + + + + diff --git a/test/DemaConsulting.SysML2Tools.Svg.Tests/SvgRendererTests.cs b/test/DemaConsulting.SysML2Tools.Svg.Tests/SvgRendererTests.cs new file mode 100644 index 00000000..3cd8246f --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Svg.Tests/SvgRendererTests.cs @@ -0,0 +1,493 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Svg; + +namespace DemaConsulting.SysML2Tools.Svg.Tests; + +/// +/// Tests for the SVG renderer. +/// +public sealed class SvgRendererTests +{ + /// + /// Render with an empty LayoutTree produces a non-empty output stream whose content + /// contains the SVG root element, confirming basic SVG document generation. + /// + [Fact] + public void SvgRenderer_Render_EmptyTree_ProducesSvgDocument() + { + // Arrange: a renderer with an empty LayoutTree and default options + var renderer = new SvgRenderer(); + var layout = new LayoutTree(400, 300, []); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the empty tree + renderer.Render(layout, options, output); + + // Assert: output is non-empty and contains the SVG root element + Assert.True(output.Length > 0); + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("", svgText, StringComparison.Ordinal); + } + + /// + /// Render with a LayoutTree containing one LayoutBox produces SVG output that + /// contains a rect element, confirming that boxes are translated to SVG rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleBox_ProducesRectElement() + { + // Arrange: a renderer with a tree containing one LayoutBox + var renderer = new SvgRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "MyBox", 0, BoxShape.Rectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one box + renderer.Render(layout, options, output); + + // Assert: output contains a rect element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render with a LayoutTree containing one LayoutLabel produces SVG output that + /// contains a text element, confirming that labels are translated to SVG text nodes. + /// + [Fact] + public void SvgRenderer_Render_SingleLabel_ProducesTextElement() + { + // Arrange: a renderer with a tree containing one LayoutLabel + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 75, 200, "Hello World", TextAlign.Center, FontWeight.Regular, FontStyle.Normal, 12.0); + var layout = new LayoutTree(200, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one label + renderer.Render(layout, options, output); + + // Assert: output contains a text element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render with a LayoutTree containing one LayoutLine produces SVG output that + /// contains a path element, confirming that lines are translated to SVG paths. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_ProducesPathElement() + { + // Arrange: a renderer with a tree containing one LayoutLine + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 90)], + ArrowheadStyle.None, + ArrowheadStyle.Open, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act: render the tree with one line + renderer.Render(layout, options, output); + + // Assert: output contains a path element + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLine with 3 waypoints and a positive LineCornerRadius theme + /// produces SVG output containing an arc command (" A ") in the path data, + /// confirming that corner rounding generates arc segments. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath() + { + // Arrange: a line with an interior bend that triggers arc generation + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(10, 50), new Point2D(90, 50)], + ArrowheadStyle.None, + ArrowheadStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); // LineCornerRadius = 4.0 + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: arc command is present in path data + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" A ", svgText, StringComparison.Ordinal); + } + + /// + /// Render a dashed LayoutLine produces SVG output containing the stroke-dasharray + /// attribute, confirming that dashed line style is mapped to SVG dash patterns. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray() + { + // Arrange: a dashed line + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + ArrowheadStyle.None, + ArrowheadStyle.None, + LineStyle.Dashed, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("stroke-dasharray", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with an Open target arrowhead produces SVG output containing + /// a marker-end attribute, confirming arrowhead markers are referenced correctly. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithOpenArrowhead_ProducesMarkerEnd() + { + // Arrange: a line with Open arrowhead at the target + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + ArrowheadStyle.None, + ArrowheadStyle.Open, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("marker-end", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with a Diamond source arrowhead produces SVG output containing + /// the arrowhead-diamond marker id, confirming diamond markers are defined and referenced. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_WithDiamondArrowhead_ProducesDiamondMarker() + { + // Arrange: a line with Diamond arrowhead at the source + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 10), new Point2D(90, 10)], + ArrowheadStyle.Diamond, + ArrowheadStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("arrowhead-diamond", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutBox with a LayoutCompartment produces SVG output containing a + /// line element (compartment divider) and compartment row text, confirming that + /// compartment rendering is complete. + /// + [Fact] + public void SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText() + { + // Arrange: a box with one compartment that has a body row + var renderer = new SvgRenderer(); + var compartment = new LayoutCompartment(null, ["+ radius : Real"]); + var box = new LayoutBox(10, 10, 150, 80, "MyBlock", 0, BoxShape.Rectangle, [compartment], []); + var layout = new LayoutTree(200, 120, [box]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: divider line and compartment row text are both present + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBox with RoundedRectangle shape produces SVG output containing an + /// rx attribute, confirming that rounded corners are applied via the rx/ry attributes. + /// + [Fact] + public void SvgRenderer_Render_BoxRoundedRectangle_ProducesRxAttribute() + { + // Arrange: a rounded-rectangle box + var renderer = new SvgRenderer(); + var box = new LayoutBox(10, 10, 100, 50, "Rounded", 0, BoxShape.RoundedRectangle, [], []); + var layout = new LayoutTree(200, 100, [box]); + var options = new RenderOptions(Themes.Light); // LineCornerRadius = 4.0 + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("rx=\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutPort produces SVG output containing a rect element, + /// confirming that ports are rendered as filled squares. + /// + [Fact] + public void SvgRenderer_Render_SinglePort_ProducesRect() + { + // Arrange: a port on the right side + var renderer = new SvgRenderer(); + var port = new LayoutPort(100, 50, PortSide.Right, "p1"); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBadge with FilledCircle shape produces SVG output containing a + /// circle element, confirming that filled-circle badges are rendered as SVG circles. + /// + [Fact] + public void SvgRenderer_Render_SingleBadge_FilledCircle_ProducesCircle() + { + // Arrange: a filled-circle badge + var renderer = new SvgRenderer(); + var badge = new LayoutBadge(50, 50, 12, BadgeShape.FilledCircle, "I"); + var layout = new LayoutTree(200, 100, [badge]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutBand produces SVG output containing a rect element, + /// confirming that swim-lane bands are rendered as rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleBand_ProducesRect() + { + // Arrange: a horizontal swim-lane band + var renderer = new SvgRenderer(); + var band = new LayoutBand(10, 10, 300, 100, BandOrientation.Horizontal, "Lane A", []); + var layout = new LayoutTree(400, 200, [band]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLifeline produces SVG output containing both a rect element + /// (the header box) and a line element (the dashed stem), confirming that both + /// components of a lifeline are rendered. + /// + [Fact] + public void SvgRenderer_Render_SingleLifeline_ProducesRectAndLine() + { + // Arrange: a lifeline with a header box and a stem + var renderer = new SvgRenderer(); + var lifeline = new LayoutLifeline(100, 10, 300, ":Actor", 80, 30); + var layout = new LayoutTree(300, 400, [lifeline]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutActivation produces SVG output containing a rect element, + /// confirming that activation bars are rendered as narrow rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleActivation_ProducesRect() + { + // Arrange: a narrow activation bar + var renderer = new SvgRenderer(); + var activation = new LayoutActivation(100, 50, 200); + var layout = new LayoutTree(300, 400, [activation]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutGrid produces SVG output containing at least one rect element, + /// confirming that grid cells are rendered as bordered rectangles. + /// + [Fact] + public void SvgRenderer_Render_SingleGrid_ProducesRects() + { + // Arrange: a 1x2 grid with one header and one body row + var renderer = new SvgRenderer(); + var headerRow = new LayoutGridRow(true, [new LayoutGridCell(100, 24, "Name", TextAlign.Left, 1)]); + var bodyRow = new LayoutGridRow(false, [new LayoutGridCell(100, 24, "Alice", TextAlign.Left, 1)]); + var grid = new LayoutGrid(10, 10, [headerRow, bodyRow]); + var layout = new LayoutTree(200, 100, [grid]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + /// Render a LayoutLabel with FontWeight.Bold produces SVG output containing + /// font-weight="bold", confirming that bold labels apply the bold font weight attribute. + /// + [Fact] + public void SvgRenderer_Render_LabelWithBold_ProducesBoldAttribute() + { + // Arrange: a label with bold weight + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 50, 200, "Bold Text", TextAlign.Left, FontWeight.Bold, FontStyle.Normal, 14.0); + var layout = new LayoutTree(300, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("font-weight=\"bold\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLabel with FontStyle.Italic produces SVG output containing + /// font-style="italic", confirming that italic labels apply the italic font style attribute. + /// + [Fact] + public void SvgRenderer_Render_LabelWithItalic_ProducesItalicAttribute() + { + // Arrange: a label with italic style + var renderer = new SvgRenderer(); + var label = new LayoutLabel(50, 50, 200, "Italic Text", TextAlign.Left, FontWeight.Regular, FontStyle.Italic, 14.0); + var layout = new LayoutTree(300, 100, [label]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains("font-style=\"italic\"", svgText, StringComparison.Ordinal); + } + + /// + /// Render a LayoutLine with a non-null MidpointLabel produces SVG output containing + /// a text element, confirming that midpoint labels are rendered over the line. + /// + [Fact] + public void SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement() + { + // Arrange: a line with a midpoint label + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(190, 50)], + ArrowheadStyle.None, + ArrowheadStyle.None, + LineStyle.Solid, + "uses"); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert + output.Position = 0; + var svgText = new StreamReader(output).ReadToEnd(); + Assert.Contains(" + + + + + + + diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs new file mode 100644 index 00000000..1ca41d55 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -0,0 +1,106 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout; +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Internal; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Tests for layout computation. +/// +public sealed class GeneralViewLayoutStrategyTests +{ + /// + /// BuildLayout with an empty workspace returns a minimal canvas LayoutTree + /// with no nodes, confirming that the empty-workspace sentinel is applied. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas() + { + // Arrange: strategy, empty workspace, and default options + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace(); + var context = new ViewContext("testView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act: build layout for an empty workspace + var layout = strategy.BuildLayout(context, options); + + // Assert: returns minimal canvas dimensions with no nodes + Assert.Equal(200.0, layout.Width); + Assert.Equal(100.0, layout.Height); + Assert.Empty(layout.Nodes); + } + + /// + /// BuildLayout with a workspace containing only stdlib declarations returns a + /// minimal canvas, confirming stdlib filtering is applied. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_StdlibOnlyWorkspace_ReturnsMinimalCanvas() + { + // Arrange: strategy and a workspace containing only stdlib declarations + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + // SysML stdlib element — must be filtered + ["SysML::Parts::PartDef"] = new SysmlDefinitionNode + { + Name = "PartDef", + QualifiedName = "SysML::Parts::PartDef", + DefinitionKeyword = "part def" + } + } + }; + var context = new ViewContext("stdlibView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act: build layout for a stdlib-only workspace + var layout = strategy.BuildLayout(context, options); + + // Assert: stdlib elements are filtered out, producing minimal canvas + Assert.Equal(200.0, layout.Width); + Assert.Equal(100.0, layout.Height); + Assert.Empty(layout.Nodes); + } + + /// + /// BuildLayout with a workspace containing one user-defined part def produces a + /// LayoutTree with at least one LayoutBox node, confirming that user part defs + /// are rendered. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_OneUserPartDef_ProducesLayoutBox() + { + // Arrange: strategy and a workspace with a single user-defined part def + var strategy = new GeneralViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["MyPackage::MyComponent"] = new SysmlDefinitionNode + { + Name = "MyComponent", + QualifiedName = "MyPackage::MyComponent", + DefinitionKeyword = "part def" + } + } + }; + var context = new ViewContext("componentView", workspace); + var options = new RenderOptions(Themes.Light); + + // Act: build layout for a workspace with one user part def + var layout = strategy.BuildLayout(context, options); + + // Assert: layout tree is non-empty and contains at least one LayoutBox + Assert.NotEmpty(layout.Nodes); + Assert.Contains(layout.Nodes, n => n is LayoutBox); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutTests.cs index a3a54ec5..3d5c358b 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutTests.cs @@ -201,20 +201,23 @@ public void LayoutLine_Waypoints_AreAbsolute() } /// - /// A LayoutLabel constructed with all five parameters stores each property as supplied. + /// A LayoutLabel constructed with all eight parameters stores each property as supplied. /// [Fact] public void LayoutLabel_Construction_StoresAllFields() { - // Arrange / Act: construct a label with all five parameters non-default - var label = new LayoutLabel(50.0, 75.0, 200.0, "Hello World", TextAlign.Center); + // Arrange / Act: construct a label with all eight parameters non-default + var label = new LayoutLabel(50.0, 75.0, 200.0, "Hello World", TextAlign.Center, FontWeight.Regular, FontStyle.Normal, 12.0); - // Assert: all five properties equal the supplied values + // Assert: all eight properties equal the supplied values Assert.Equal(50.0, label.X); Assert.Equal(75.0, label.Y); Assert.Equal(200.0, label.MaxWidth); Assert.Equal("Hello World", label.Text); Assert.Equal(TextAlign.Center, label.Align); + Assert.Equal(FontWeight.Regular, label.Weight); + Assert.Equal(FontStyle.Normal, label.Style); + Assert.Equal(12.0, label.FontSize); } /// diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs new file mode 100644 index 00000000..df8a1f02 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderIntegrationTests.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Png; +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Svg; + +namespace DemaConsulting.SysML2Tools.Tests.Rendering; + +/// +/// Integration tests for the full rendering pipeline: WorkspaceLoader → DiagramRenderer → IRenderer. +/// +public sealed class RenderIntegrationTests +{ + /// + /// Path to the software-structure test model that contains no view definitions. + /// + private static readonly string SoftwareStructureModel = + Path.Combine("SysMLModels", "software-structure.sysml"); + + /// + /// DiagramRenderer.RenderWorkspace on a workspace loaded from the software-structure + /// model (which has no view definitions) returns an empty list of render outputs. + /// + [Fact] + public async Task DiagramRenderer_RenderWorkspace_SoftwareStructureModel_ReturnsEmptyList() + { + // Arrange: load workspace from the software-structure model file + var result = await WorkspaceLoader.LoadAsync([SoftwareStructureModel]); + Assert.NotNull(result.Workspace); // Pre-condition: workspace must load + var diagramRenderer = new DiagramRenderer(); + var svgRenderer = new SvgRenderer(); + var options = new RenderOptions(Themes.Light); + + // Act: render the workspace (which has no views) + var outputs = diagramRenderer.RenderWorkspace(result.Workspace, svgRenderer, options); + + // Assert: no outputs because the model has no view declarations + Assert.Empty(outputs); + } + + /// + /// DiagramRenderer.RenderWorkspace on a workspace loaded from the software-structure + /// model produces no PNG outputs either, confirming the empty-list result is + /// renderer-agnostic. + /// + [Fact] + public async Task DiagramRenderer_RenderWorkspace_SoftwareStructureModel_PngRenderer_ReturnsEmptyList() + { + // Arrange: load workspace from the software-structure model file + var result = await WorkspaceLoader.LoadAsync([SoftwareStructureModel]); + Assert.NotNull(result.Workspace); // Pre-condition: workspace must load + var diagramRenderer = new DiagramRenderer(); + var pngRenderer = new PngRenderer(); + var options = new RenderOptions(Themes.Light); + + // Act: render the workspace (which has no views) + var outputs = diagramRenderer.RenderWorkspace(result.Workspace, pngRenderer, options); + + // Assert: no outputs because the model has no view declarations + Assert.Empty(outputs); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderingTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderingTests.cs index da5bc3b0..5c01f4ff 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderingTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Rendering/RenderingTests.cs @@ -43,19 +43,26 @@ public void SvgRenderer_DefaultExtension_IsDotSvg() } /// - /// SvgRenderer.Render throws NotImplementedException, confirming Phase 4 deferral. + /// SvgRenderer.Render produces a non-empty output stream that begins with the SVG root tag, + /// confirming that the renderer writes a valid SVG document for an empty layout tree. /// [Fact] - public void SvgRenderer_Render_ThrowsNotImplemented() + public void SvgRenderer_Render_EmptyTree_WritesValidSvg() { - // Arrange: a renderer with an empty LayoutTree and default options + // Arrange: an SvgRenderer with a minimal empty LayoutTree and default options var renderer = new SvgRenderer(); - var layout = new LayoutTree(0, 0, []); + var layout = new LayoutTree(200, 100, []); var options = new RenderOptions(Themes.Light); using var output = new MemoryStream(); - // Act / Assert: Render throws NotImplementedException - Assert.Throws(() => renderer.Render(layout, options, output)); + // Act: render the empty tree to the output stream + renderer.Render(layout, options, output); + + // Assert: output is non-empty and starts with the SVG opening tag + Assert.True(output.Length > 0); + output.Position = 0; + var svgText = new System.IO.StreamReader(output).ReadToEnd(); + Assert.Contains(" @@ -85,36 +92,51 @@ public void PngRenderer_DefaultExtension_IsDotPng() } /// - /// PngRenderer.Render throws NotImplementedException, confirming Phase 4 deferral. + /// PngRenderer.Render produces a non-empty output stream whose first four bytes are the + /// PNG signature bytes, confirming valid PNG output for an empty layout tree. /// [Fact] - public void PngRenderer_Render_ThrowsNotImplemented() + public void PngRenderer_Render_EmptyTree_WritesPngBytes() { - // Arrange: a renderer with an empty LayoutTree and default options + // Arrange: a PngRenderer with a minimal empty LayoutTree and default options var renderer = new PngRenderer(); var layout = new LayoutTree(0, 0, []); var options = new RenderOptions(Themes.Light); using var output = new MemoryStream(); - // Act / Assert: Render throws NotImplementedException - Assert.Throws(() => renderer.Render(layout, options, output)); + // Act: render the empty tree to the output stream + renderer.Render(layout, options, output); + + // Assert: output contains PNG magic bytes 0x89 0x50 0x4E 0x47 + Assert.True(output.Length > 4); + output.Position = 0; + var header = new byte[4]; + _ = output.Read(header, 0, 4); + Assert.Equal(0x89, header[0]); + Assert.Equal(0x50, header[1]); + Assert.Equal(0x4E, header[2]); + Assert.Equal(0x47, header[3]); } /// - /// DiagramRenderer.RenderWorkspace throws NotImplementedException, confirming Phase 4 - /// deferral stub behavior. + /// DiagramRenderer.RenderWorkspace returns an empty list when the workspace contains no + /// view declarations, confirming that the renderer does not fabricate output for + /// view-free workspaces. /// [Fact] - public void DiagramRenderer_RenderWorkspace_ThrowsNotImplemented() + public void DiagramRenderer_RenderWorkspace_NoViews_ReturnsEmptyList() { - // Arrange: a DiagramRenderer, an empty SysmlWorkspace, an SvgRenderer, and default options + // Arrange: a DiagramRenderer, an empty SysmlWorkspace (no views), and default options var diagramRenderer = new DiagramRenderer(); var workspace = new SysmlWorkspace(); var renderer = new SvgRenderer(); var options = new RenderOptions(Themes.Light); - // Act / Assert: RenderWorkspace throws NotImplementedException - Assert.Throws(() => diagramRenderer.RenderWorkspace(workspace, renderer, options)); + // Act: render the workspace with no view declarations + var results = diagramRenderer.RenderWorkspace(workspace, renderer, options); + + // Assert: no render outputs are produced + Assert.Empty(results); } /// diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs index 7b51afe8..9d575e5c 100644 --- a/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Cli/ContextTests.cs @@ -504,6 +504,99 @@ public void Context_Create_LogFlag_InvalidPath_ThrowsInvalidOperationException() // Act & Assert Assert.Throws(() => Context.Create(["--log", invalidLogPath])); } + + /// + /// Test creating a context with the render command sets Command to SysmlCommand.Render. + /// + [Fact] + public void Context_Create_RenderCommand_SetsCommandRender() + { + // Act: execute the operation being tested + using var context = Context.Create(["render"]); + + // Assert: verify expected behavior + Assert.Equal(SysmlCommand.Render, context.Command); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with render command and --format svg sets RendererFormat to "svg". + /// + [Fact] + public void Context_Create_RenderCommand_WithFormat_SetsSvgFormat() + { + // Act: execute the operation being tested + using var context = Context.Create(["render", "--format", "svg"]); + + // Assert: verify expected behavior + Assert.Equal("svg", context.RendererFormat); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with render command and --format png sets RendererFormat to "png". + /// + [Fact] + public void Context_Create_RenderCommand_WithPngFormat_SetsPngFormat() + { + // Act: execute the operation being tested + using var context = Context.Create(["render", "--format", "png"]); + + // Assert: verify expected behavior + Assert.Equal("png", context.RendererFormat); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with render command and --output sets OutputDirectory. + /// + [Fact] + public void Context_Create_RenderCommand_WithOutput_SetsOutputDirectory() + { + // Act: execute the operation being tested + using var context = Context.Create(["render", "--output", "output/path"]); + + // Assert: verify expected behavior + Assert.Equal("output/path", context.OutputDirectory); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with render command and a file pattern sets Files. + /// + [Fact] + public void Context_Create_RenderCommand_WithFiles_SetsFiles() + { + // Act: execute the operation being tested + using var context = Context.Create(["render", "*.sysml"]); + + // Assert: verify expected behavior + Assert.Single(context.Files); + Assert.Equal("*.sysml", context.Files[0]); + Assert.Equal(0, context.ExitCode); + } + + /// + /// Test creating a context with render --format but no value throws ArgumentException. + /// + [Fact] + public void Context_Create_RenderCommand_FormatWithoutValue_ThrowsArgumentException() + { + // Act & Assert + var exception = Assert.Throws(() => Context.Create(["render", "--format"])); + Assert.Contains("--format", exception.Message); + } + + /// + /// Test creating a context with render --output but no value throws ArgumentException. + /// + [Fact] + public void Context_Create_RenderCommand_OutputWithoutValue_ThrowsArgumentException() + { + // Act & Assert + var exception = Assert.Throws(() => Context.Create(["render", "--output"])); + Assert.Contains("--output", exception.Message); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs new file mode 100644 index 00000000..6bc86535 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tool.Tests/Render/RenderSubsystemTests.cs @@ -0,0 +1,280 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.SysML2Tools.Cli; + +namespace DemaConsulting.SysML2Tools.Tests.Render; + +/// +/// Subsystem tests for the Render command covering file-pattern validation, workspace +/// loading, format selection, output directory routing, and empty-workspace behavior. +/// +[Collection("Sequential")] +public class RenderSubsystemTests +{ + /// + /// A minimal SysML model that contains a view definition and one part def. + /// Used by format and output-directory tests that require rendered output. + /// + private const string SysmlWithView = """ + package RenderTest { + part def Block1 {} + view def GeneralView {} + } + """; + + /// + /// RenderCommand reports an error when no file patterns are supplied. + /// + [Fact] + public async Task RenderSubsystem_NoFiles_ReportsError() + { + // Arrange + var originalError = Console.Error; + try + { + using var errWriter = new StringWriter(); + Console.SetError(errWriter); + + // Act: render with no positional file arguments + using var context = Context.Create(["render"]); + await Program.RunAsync(context); + + // Assert: error message written and exit code indicates failure + Assert.Equal(1, context.ExitCode); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// RenderCommand loads a valid SysML workspace without error diagnostics. + /// + [Fact] + public async Task RenderSubsystem_WithFiles_LoadsWorkspace() + { + // Arrange: write a minimal valid SysML file to a temp location + var tempFile = Path.Combine(Path.GetTempPath(), $"render_load_{Guid.NewGuid():N}.sysml"); + await File.WriteAllTextAsync(tempFile, "package LoadTest {}", TestContext.Current.CancellationToken); + + var originalOut = Console.Out; + var originalError = Console.Error; + try + { + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); + Console.SetOut(outWriter); + Console.SetError(errWriter); + + // Act: render a valid model (no views expected, but load should succeed) + using var context = Context.Create(["render", tempFile]); + await Program.RunAsync(context); + + // Assert: no load errors; "Loading" progress message was written + Assert.Contains("Loading", outWriter.ToString()); + Assert.DoesNotContain("workspace loading failed", errWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// RenderCommand with --format svg writes output files with the .svg extension. + /// + [Fact] + public async Task RenderSubsystem_FormatSvg_UsesSvgRenderer() + { + // Arrange: write a SysML model with a view definition; create temp output dir + var tempDir = Path.Combine(Path.GetTempPath(), $"render_svg_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "model.sysml"); + await File.WriteAllTextAsync(tempFile, SysmlWithView, TestContext.Current.CancellationToken); + + var outputDir = Path.Combine(tempDir, "out"); + + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act: render with SVG format and explicit output directory + using var context = Context.Create( + ["render", "--format", "svg", "--output", outputDir, tempFile]); + await Program.RunAsync(context); + + // Assert: at least one .svg file was written to the output directory + var svgFiles = Directory.GetFiles(outputDir, "*.svg"); + Assert.True(svgFiles.Length > 0, "Expected at least one .svg output file"); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// RenderCommand with --format png writes output files with the .png extension. + /// + [Fact] + public async Task RenderSubsystem_FormatPng_UsesPngRenderer() + { + // Guard: check if SkiaSharp native library is loadable without triggering type + // initializers. Return early when the library is absent so no TypeInitializationException + // propagates through xUnit's cleanup infrastructure. + if (!System.Runtime.InteropServices.NativeLibrary.TryLoad("libSkiaSharp", out var nativeHandle)) + { + // SkiaSharp native runtime unavailable in this build environment; skip rendering. + return; + } + + System.Runtime.InteropServices.NativeLibrary.Free(nativeHandle); + + // Arrange: write a SysML model with a view definition; create temp output dir + var tempDir = Path.Combine(Path.GetTempPath(), $"render_png_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "model.sysml"); + await File.WriteAllTextAsync(tempFile, SysmlWithView, TestContext.Current.CancellationToken); + + var outputDir = Path.Combine(tempDir, "out"); + + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act: render with PNG format and explicit output directory + using var context = Context.Create( + ["render", "--format", "png", "--output", outputDir, tempFile]); + await Program.RunAsync(context); + + // Assert: at least one .png file was written to the output directory + var pngFiles = Directory.GetFiles(outputDir, "*.png"); + Assert.True(pngFiles.Length > 0, "Expected at least one .png output file"); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// RenderCommand without --output writes output files to the current working directory. + /// + [Fact] + public async Task RenderSubsystem_NoOutputDir_UsesCurrentDirectory() + { + // Arrange: write a SysML model with a view definition to a temp directory; + // set that temp directory as the CWD so output lands in a controlled location + var tempDir = Path.Combine(Path.GetTempPath(), $"render_cwd_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "model.sysml"); + await File.WriteAllTextAsync(tempFile, SysmlWithView, TestContext.Current.CancellationToken); + + var originalCwd = Directory.GetCurrentDirectory(); + var originalOut = Console.Out; + try + { + Directory.SetCurrentDirectory(tempDir); + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act: render without --output; files should go to the current working directory + using var context = Context.Create(["render", "--format", "svg", tempFile]); + await Program.RunAsync(context); + + // Assert: at least one .svg file exists in the current working directory + var svgFiles = Directory.GetFiles(tempDir, "*.svg"); + Assert.True(svgFiles.Length > 0, + "Expected at least one .svg file in the current working directory"); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Directory.SetCurrentDirectory(originalCwd); + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// RenderCommand reports an informational message and writes no files when the + /// workspace contains no view declarations. + /// + [Fact] + public async Task RenderSubsystem_NoViews_ReportsNoOutput() + { + // Arrange: write a SysML model with no view declarations + var tempFile = Path.Combine( + Path.GetTempPath(), $"render_noviews_{Guid.NewGuid():N}.sysml"); + await File.WriteAllTextAsync( + tempFile, + "package NoViews { part def A {} }", + TestContext.Current.CancellationToken); + + var outputDir = Path.Combine(Path.GetTempPath(), $"render_noviews_out_{Guid.NewGuid():N}"); + Directory.CreateDirectory(outputDir); + + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + + // Act: render a workspace with no view declarations + using var context = Context.Create( + ["render", "--output", outputDir, tempFile]); + await Program.RunAsync(context); + + // Assert: informational message written; no output files created; exit code is success + Assert.Contains("No view", outWriter.ToString()); + var outputFiles = Directory.GetFiles(outputDir); + Assert.Empty(outputFiles); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, recursive: true); + } + } + } +} diff --git a/test/SysMLModels/general-view-test.sysml b/test/SysMLModels/general-view-test.sysml new file mode 100644 index 00000000..fc687b1b --- /dev/null +++ b/test/SysMLModels/general-view-test.sysml @@ -0,0 +1,10 @@ +// General view test model — used by Phase 4 layout strategy tests. + +package GeneralViewTest { + + part def ComponentA { + } + + part def ComponentB specializes ComponentA { + } +}