diff --git a/.cspell.yaml b/.cspell.yaml index 0ff9e28..3207e7b 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -26,6 +26,7 @@ words: - deserialize - deserializes - dogfooding + - enation - fileassert - fontconfig - initialise diff --git a/.github/agents/developer.agent.md b/.github/agents/developer.agent.md index 5f208eb..5f2b988 100644 --- a/.github/agents/developer.agent.md +++ b/.github/agents/developer.agent.md @@ -19,7 +19,7 @@ Perform software development tasks by determining and applying appropriate stand If one exists, fetch it as the starting point; adjust placeholder names and heading depth to match the target path before writing the file - For each modified file, identify which companion artifacts need updating - (requirements, design docs, tests, review-sets) + (requirements, design docs, verification docs, tests, review-sets, README.md, user guides) - Include companion artifact updates in the work plan 4. **Execute work** following standards requirements and quality checks 5. **Formatting**: Run `pwsh ./fix.ps1` to silently apply all @@ -35,8 +35,7 @@ Perform software development tasks by determining and applying appropriate stand # Developer Agent Report **Result**: (SUCCEEDED|FAILED) - -## Work Summary +**Report**: `.agent-logs/developer-{subject}-{unique-id}.md` - **Files Modified**: {List of files created/modified/deleted} - **Languages Detected**: {Languages identified} diff --git a/.github/agents/formal-review.agent.md b/.github/agents/formal-review.agent.md index 7dd8e84..14e487a 100644 --- a/.github/agents/formal-review.agent.md +++ b/.github/agents/formal-review.agent.md @@ -44,6 +44,7 @@ standards from the selection matrix in AGENTS.md. # Formal Review Report **Result**: (SUCCEEDED|FAILED) +**Report**: `.agent-logs/formal-review-{subject}-{unique-id}.md` ## Review Summary diff --git a/.github/agents/implementation.agent.md b/.github/agents/implementation.agent.md index 7cc0352..d94e880 100644 --- a/.github/agents/implementation.agent.md +++ b/.github/agents/implementation.agent.md @@ -28,36 +28,18 @@ The state-transitions include retrying a limited number of times: ## PLANNING State (start) -Call the **explore** agent as a sub-agent (built-in agent type) with: +Call the **planning** agent as a sub-agent (custom agent from `.github/agents/`) with: - **context**: the user's request + any previous quality findings + retry context -- **goal**: produce a verified implementation plan through these steps: - - 1. Investigate the codebase and develop a concrete implementation plan that - addresses the request - 2. **Identify companion artifact deliverables**: for every code change in the - plan, list the requirements files, design documents, and review-set entries - that must be created or updated - traceability must flow requirements → - design → code, so these are mandatory deliverables, not optional extras - 3. Review the plan for assumptions, weaknesses, and gaps - identify up to 5 - key assumptions and rate each as: - - **VERIFIED**: confirmed by codebase evidence - - **LIKELY**: consistent with codebase patterns but not directly confirmed - - **UNVERIFIED**: not confirmed by any evidence - 4. For any assumption rated UNVERIFIED or LIKELY, attempt to resolve it - through additional investigation and revise the plan to address identified - weaknesses - repeat the critique-and-strengthen cycle up to 2 additional - times if unresolved issues remain, but stop as soon as the plan is stable - 5. List up to 5 risks to the implementation - 6. Assess feasibility: can this be implemented in a single development pass? - 7. State a **recommendation**: GO or INCOMPLETE - GO if the plan is sound, or - INCOMPLETE if critical unknowns remain that only the user can resolve - -Once the explore sub-agent finishes: - -- IF recommendation is INCOMPLETE: Transition to REPORT with Result: INCOMPLETE, +- **goal**: produce a verified implementation plan, or a targeted plan to address + the identified quality issues if this is a retry + +Once the planning sub-agent finishes: + +- IF Result is FAILED: Transition to REPORT with Result: FAILED +- IF Result is INCOMPLETE: Transition to REPORT with Result: INCOMPLETE, listing the unknowns and what CAN be implemented once they are resolved -- OTHERWISE (GO): Transition to DEVELOPMENT +- OTHERWISE (SUCCEEDED): Transition to DEVELOPMENT ## DEVELOPMENT State @@ -76,7 +58,8 @@ Once the developer sub-agent finishes: Call the **quality** agent as a sub-agent (custom agent from `.github/agents/`) with: -- **context**: the user's request + development summary + files changed + previous issues (if any) +- **context**: the user's request + development summary + files changed + planning companion artifact table + + previous issues (if any) - **goal**: check the quality of the work performed for any issues Once the quality sub-agent finishes: @@ -92,6 +75,9 @@ Once the quality sub-agent finishes: this agent may report INCOMPLETE when the request cannot be implemented without information only the user can provide. +For full planning details (assumptions, risks, feasibility), read the planning +report file referenced in the planning agent's response. + Generate the completion report using the template below, then save it to `.agent-logs/{agent-name}-{subject}-{unique-id}.md` per the AGENTS.md reporting requirements, and return the summary to the caller. @@ -102,19 +88,20 @@ requirements, and return the summary to the caller. # Implementation Orchestration Report **Result**: (SUCCEEDED|FAILED|INCOMPLETE) -**Final State**: (PLANNING|DEVELOPMENT|QUALITY|REPORT) +**Report**: `.agent-logs/implementation-{subject}-{unique-id}.md` +**Last Active State**: (PLANNING|DEVELOPMENT|QUALITY) **Retry Count**: ## State Machine Execution -- **Planning Results**: {Implementation plan, assumption ratings, risks, and recommendation} +- **Planning Results**: {Planning report path; plan summary and SUCCEEDED/INCOMPLETE/FAILED result} - **Development Results**: {Summary of developer agent results} - **Quality Results**: {Summary of quality agent results} - **State Transitions**: {Log of state changes and decisions} ## Sub-Agent Coordination -- **Explore Agent (Planning)**: {Plan, assumption verdicts, top risks, GO/INCOMPLETE recommendation} +- **Planning Agent**: {Report file path, SUCCEEDED/INCOMPLETE/FAILED result, plan summary} - **Developer Agent**: {Development status and files modified} - **Quality Agent**: {Validation results and compliance status} @@ -123,4 +110,9 @@ requirements, and return the summary to the caller. - **Implementation Success**: {Overall completion status} - **Quality Compliance**: {Final quality validation status} - **Issues Resolved**: {Problems encountered and resolution attempts} + +## Unknowns (only when Result is INCOMPLETE) + +- **Unresolved Questions**: {List each question the user must answer} +- **What Can Proceed**: {Work that can be done without the missing information} ``` diff --git a/.github/agents/lint-fix.agent.md b/.github/agents/lint-fix.agent.md index 549e751..36d3ca1 100644 --- a/.github/agents/lint-fix.agent.md +++ b/.github/agents/lint-fix.agent.md @@ -68,8 +68,7 @@ submission, not during normal development. # Lint Fix Report **Result**: (SUCCEEDED|FAILED) - -## Summary +**Report**: `.agent-logs/lint-fix-{subject}-{unique-id}.md` - **Iterations**: {Number of fix-loop iterations performed} - **Files Modified**: {List of all files changed} diff --git a/.github/agents/planning.agent.md b/.github/agents/planning.agent.md new file mode 100644 index 0000000..20e75ee --- /dev/null +++ b/.github/agents/planning.agent.md @@ -0,0 +1,134 @@ +--- +name: planning +description: Planning agent that investigates the codebase, develops a verified implementation plan, and identifies all companion artifact deliverables. +user-invocable: true +--- + +# Planning Agent + +Investigate the codebase and produce a verified implementation plan with all +companion artifact deliverables. + +## Step 1 — Load Standards + +Read the relevant standards from `.github/standards/` using the selection matrix +in `AGENTS.md` based on the artifact types in scope for the request (requirements, +design, verification, documentation, code). + +## Step 2 — Investigate and Plan + +Read `docs/design/introduction.md` first (if present), then investigate the +codebase to develop a concrete implementation plan: + +- Identify all files to create, modify, or delete +- Describe the change required for each file + +## Step 3 — Identify Companion Artifact Deliverables + +For each planned change, assess the mandatory companion artifacts below (create/update/N/A +with justification): + +- **Requirements** — functional changes require a requirement entry +- **Design Documentation** — new or changed components require design docs +- **Verification Documentation** — new or changed components require verification docs +- **Tests** — functional changes require test coverage +- **Review Sets** — changes to the software item hierarchy (units or subsystems + added, removed, or reorganized) require review-set updates +- **README.md** — user-facing changes require README updates +- **User Guide** — user-facing features require user guide updates + +## Step 4 — Critique and Strengthen + +Identify up to 5 key assumptions and rate each: + +- **VERIFIED**: confirmed by codebase evidence +- **LIKELY**: consistent with codebase patterns but not directly confirmed +- **UNVERIFIED**: not confirmed by any evidence + +For UNVERIFIED or LIKELY assumptions, investigate further and revise the plan. +Repeat up to 2 more times, stopping when the plan is stable. + +## Step 5 — Risk Assessment + +List up to 5 risks with a brief mitigation for each. + +## Step 6 — Feasibility Assessment + +State whether this can be implemented in a single development pass and any +preconditions that affect feasibility. + +## Step 7 — Recommendation + +- **SUCCEEDED** — the plan is sound and the developer agent can proceed +- **INCOMPLETE** — critical unknowns remain that only the user can resolve; + list each unknown explicitly +- **FAILED** — investigation could not produce a viable plan + +# REPORT Phase + +Save the full analysis to `.agent-logs/planning-{subject}-{unique-id}.md` per +the AGENTS.md reporting requirements. + +Then respond to the caller with ONLY the lean structured summary below. + +# Report Template + +```markdown +# Planning Report + +**Result**: (SUCCEEDED|INCOMPLETE|FAILED) +**Request Summary**: {Brief restatement of the task as understood} +**Report**: `.agent-logs/planning-{subject}-{unique-id}.md` + +## Implementation Plan + +| File | Action | Description | +|------|--------|-------------| +| {path} | create/modify/delete | {what changes and why} | + +## Companion Artifact Deliverables + +| Category | File | Action | +|----------|------|--------| +| Requirements | {path} | create/update/N/A — {justification} | +| Design Documentation | {path} | create/update/N/A — {justification} | +| Verification Documentation | {path} | create/update/N/A — {justification} | +| Tests | {path} | create/update/N/A — {justification} | +| Review Sets | {path} | create/update/N/A — {justification} | +| README.md | {path} | create/update/N/A — {justification} | +| User Guide | {path} | create/update/N/A — {justification} | + +## Assumption Analysis + +| # | Assumption | Rating | Resolution | +|---|-----------|--------|------------| +| 1 | {assumption} | VERIFIED/LIKELY/UNVERIFIED | {resolution or N/A} | + +## Risk Assessment + +1. **[severity]** {risk} — {mitigation} + +## Feasibility Assessment + +{Single-pass or not, and why. Any preconditions.} + +## Unknowns + +{Only present when Result is INCOMPLETE. List each question the user must +resolve before implementation can proceed.} +``` + +# Lean Structured Response (returned to caller) + +```markdown +**Result**: (SUCCEEDED|INCOMPLETE|FAILED) +**Report**: `.agent-logs/planning-{subject}-{unique-id}.md` + +**Plan**: +{Repeat the Implementation Plan table} + +**Companion Artifacts**: +{Repeat the Companion Artifact Deliverables table} + +**Unknowns**: {Only if INCOMPLETE — list questions for the user} +``` diff --git a/.github/agents/quality.agent.md b/.github/agents/quality.agent.md index 380d11f..26fd251 100644 --- a/.github/agents/quality.agent.md +++ b/.github/agents/quality.agent.md @@ -13,14 +13,23 @@ Grade and validate software development work by ensuring compliance with project 1. **Analyze the task request AND completed work** to determine scope: identify which artifact categories were changed, and which *should have been changed* given the task - new user-visible features always require requirements, - design, and review-set coverage regardless of whether those files were touched; - test-only additions (corner-case tests, defensive boundary tests, regression - tests) do not require a corresponding requirement + design, verification docs, and README/user guide updates regardless of + whether those files were touched; Review Sets are always in scope when + the software item hierarchy changes (units or subsystems added, removed, or + reorganized); test-only additions (corner-case tests, defensive boundary + tests, regression tests) do not require a corresponding requirement; if a + planning companion artifact table is provided in context, cross-reference it + — any artifact listed as create/update must be covered in the evaluation and + FAIL if the artifact was not produced 2. **Read relevant standards** using the selection matrix in AGENTS.md 3. **Evaluate all in-scope categories** - N/A only when the task genuinely cannot affect a category; if the task introduces new user-visible features or - structural changes then Requirements, Design Documentation, and Review - Management are always in scope and FAIL if the artifacts were not updated + structural changes then Requirements, Design Documentation, and Verification + Documentation are always in scope and FAIL if the artifacts were not updated; + Documentation (README/user guide) is always in scope for user-facing changes + and FAIL if not updated; Review Sets are always in scope when the + software item hierarchy changes (units or subsystems added, removed, or + reorganized) and FAIL if review-sets were not updated 4. **Validate tool compliance** using ReqStream, ReviewMark, and build tools 5. **Generate focused quality report** per the AGENTS.md reporting requirements - save to `.agent-logs/{agent-name}-{subject}-{unique-id}.md` and return the summary to the caller @@ -36,6 +45,7 @@ For each checklist item in the template below, record as `(PASS|FAIL|N/A) - {one # Quality Assessment Report **Result**: (SUCCEEDED|FAILED) +**Report**: `.agent-logs/quality-{subject}-{unique-id}.md` **Overall Grade**: (PASS|FAIL) ## Required Fixes (only when Result is FAILED) @@ -50,13 +60,13 @@ Priority-ordered list of issues that MUST be resolved for the next retry: - **Evaluated**: {List sections assessed and why} - **Skipped**: {One-line per skipped section with reason, e.g., "Design - Documentation: N/A - no design files modified"} + Documentation: N/A - no component behavior, structure, or interface changed"} ## Requirements Compliance: (PASS|FAIL|N/A) - Were requirements created/updated for all functional changes? - Were source filters applied for platform-specific requirements? -- Is requirements traceability maintained to tests? +- Is forward traceability from requirements to verification artifacts preserved? ## Design Documentation Compliance: (PASS|FAIL|N/A) @@ -80,7 +90,14 @@ Priority-ordered list of issues that MUST be resolved for the next retry: - Are cross-hierarchy test dependencies documented in design docs? - Do all tests pass? -## Review Management Compliance: (PASS|FAIL|N/A) +## Verification Documentation Compliance: (PASS|FAIL|N/A) + +- Were verification documents created/updated for all new or changed components? +- Do verification documents include all mandatory sections (Verification Approach, Test Environment, + Acceptance Criteria, Test Scenarios)? +- Is requirements-to-test coverage tracked via the ReqStream trace matrix (not embedded in verification docs)? + +## Review Sets Compliance: (PASS|FAIL|N/A) - Were review-sets updated for structural changes? - Is review scope appropriate for change magnitude? diff --git a/.github/agents/template-sync.agent.md b/.github/agents/template-sync.agent.md index 44d29ff..df4d488 100644 --- a/.github/agents/template-sync.agent.md +++ b/.github/agents/template-sync.agent.md @@ -22,18 +22,34 @@ Delegate each group to a sub-agent. - **Root config files** - all non-collection files at the repository root - **One group per flat `docs/` folder** - e.g. `docs/build_notes/`, `docs/user_guide/` +- **One group for root files in each of `docs/design/`, `docs/verification/`, + `docs/reqstream/`** - e.g. `docs/design/introduction.md` — separate from the + system subtrees beneath them - **One group per system subtree** in `docs/design/`, `docs/verification/`, `docs/reqstream/` - each subtree and all its descendants is one group # Orchestration -For each group intersecting the requested scope, call a sub-agent with: +For Audit mode, call an **explore** sub-agent (built-in) per group. +For Sync, Scaffold, and Recreate modes, call a **general-purpose** sub-agent (built-in) per group. + +For each group intersecting the requested scope, call the appropriate sub-agent with: - **context**: - Group scope and template URL from the `# Reference Template` section in `AGENTS.md` + - Applicable standards from the `# Standards Application` matrix in `AGENTS.md` + for the file types in the group scope - Project-specific names substitute for placeholders at matching path depth - (e.g. `SystemName` → `{SystemName}`, `system-name` → `{system-name}`) - - If a template counterpart cannot be fetched, skip the file and report it + (e.g. `MySystem` → `{SystemName}`, `my-system` → `{system-name}`) + - For files within `{system-name}/` subtrees in `docs/design/`, `docs/verification/`, + and `docs/reqstream/`: consult `docs/design/introduction.md` to determine whether + each item is a subsystem or unit, then select the appropriate template + (`subsystem-name.*` or `unit-name.*`) regardless of the item's folder depth — + do not infer item type from path depth alone + - If a file has no template counterpart, skip it and report it as + "No template found" — this is not a failure + - If a file appears in `repository-map.md` but its template cannot be fetched, + report Result: FAILED and list the affected files - **goal**: - Based on the given mode: - **Audit** - fetch each template counterpart; compare headings; report missing @@ -42,15 +58,54 @@ For each group intersecting the requested scope, call a sub-agent with: - **Scaffold** - fetch `repository-map.md` from the template URL in `AGENTS.md` to identify files that should exist but don't; for each, fetch the template, populate all sections, write the file; run `pwsh ./fix.ps1` - - **Recreate** - read the existing file in full, then fetch the template; use - full semantic understanding to map old content onto template sections, - splitting or consolidating as needed; create extra sections for any content - that has no template home; write the rebuilt file; run `pwsh ./fix.ps1` - - When writing any section: HTML comments and TODO placeholders in the template - are instructions - always resolve them to real content; infer from README, - related files, sibling docs, and path; if confident write directly; if - ambiguous offer 2–3 concrete options and ask the user; keep asking until they - answer - never leave a TODO unless the user explicitly requests it + - **Recreate** - fetch the template and use it as the blueprint for a + freshly authored document: + - Work through the template section by section; for each section, find + any `TEMPLATE-DIRECTIVE` blocks (both `` + in markdown and `# ` in YAML) — execute + each directive (read specified standards, apply structural guidance, + substitute content), then **remove the directive block entirely** from + the output; gather the relevant technical details from all available + sources — the old file, README, related docs, sibling files, and any + other repo context — to populate that section correctly; the old file's + structure and headings are irrelevant; only its factual content is mined + as a source + - **Gap-check**: after all template sections are filled, scan the old + file once more for any technical information not yet captured; if + found, preserve it by appending new relevant sections at the end + - **Before writing**: do a mandatory self-check — for every section that + has a `TEMPLATE-DIRECTIVE` block in the template, explicitly state what + format the directive requires, then verify the drafted content matches + that format exactly (e.g. if the directive says "no sub-headings", + confirm there are no `###` headings inside that section; if it says + "bold-name paragraph blocks", confirm each entry is `**Name**: prose` + with no sub-heading); fix any mismatches before writing the file + - Write the rebuilt file; run `pwsh ./fix.ps1` + - When writing any section: `TEMPLATE-DIRECTIVE` blocks are directives — + execute them (read specified standards, apply structural guidance, substitute + content) and **remove the block entirely** from the written file; inline + `TODO:` placeholders in YAML string values (e.g. `title:`, `justification:`) + are content placeholders — always resolve them to real content; infer from + README, related files, sibling docs, and path; if confident write directly; + if ambiguous, **do not ask interactively** — return the unresolved questions + in the result so the orchestrator can ask the user and re-invoke; never leave + a TODO or TEMPLATE-DIRECTIVE in the output unless the user explicitly requests it + - Return results in this format for each file in the group: + + ```markdown + ### {file-path} + + - **Template**: {template path or "not found"} + - **Missing sections**: {list or "none"} + - **Heading depth issues**: {list or "none"} + - **Content format issues**: {list or "none"} *(Recreate only)* + - **Action**: (Reported | Sections added | Created | Rebuilt | No template found) + - **Unresolved Questions**: {list or "none"} + ``` + +If any sub-agent returns unresolved questions, collect them, ask the user, then +re-invoke the affected sub-agent(s) with the answers before assembling the final report. +If questions remain unresolved after asking the user, report Result: INCOMPLETE. Collect sub-agent results and assemble the final report. @@ -59,7 +114,8 @@ Collect sub-agent results and assemble the final report. ```markdown # Template Sync Report -**Result**: (SUCCEEDED|FAILED) +**Result**: (SUCCEEDED|FAILED|INCOMPLETE) +**Report**: `.agent-logs/template-sync-{subject}-{unique-id}.md` **Mode**: (Audit|Sync|Scaffold|Recreate) ## Files @@ -69,9 +125,16 @@ Collect sub-agent results and assemble the final report. - **Template**: {template path} - **Missing sections**: {list or "none"} - **Heading depth issues**: {list or "none"} +- **Content format issues**: {list of sections where intra-section content did not + match the template comment's prescribed format, or "none"} *(Recreate only)* - **Action**: (Reported | Sections added | Created | Rebuilt | No template found) +- **Unresolved Questions**: {list or "none"} ## Summary - **Conformant**: {count} | **Deviations**: {count} | **Updated**: {count} + +## Unknowns (only when Result is INCOMPLETE) + +- **Unresolved Questions**: {List each placeholder or ambiguity the user must resolve} ``` diff --git a/.github/standards/coding-principles.md b/.github/standards/coding-principles.md index 9e67fbb..6797c61 100644 --- a/.github/standards/coding-principles.md +++ b/.github/standards/coding-principles.md @@ -3,11 +3,6 @@ name: Coding Principles description: Follow these standards when developing any software code. --- -# Coding Principles Standards - -This document defines universal coding principles and quality standards for software development within -Continuous Compliance environments. - # Core Principles ## Literate Coding @@ -20,10 +15,9 @@ All code MUST follow literate programming principles: matches design intent without reading the full codebase - **Logical Separation**: Complex functions use block comments to separate and describe logical steps within the implementation -- **Full Symbol Documentation**: ALL symbols have comprehensive documentation - because reviewers and auditors must verify every implementation detail, not - just the public interface - access-level specifics (public, protected, - private, internal, etc.) vary by language; see the language-specific standard +- **Full Symbol Documentation**: ALL symbols have comprehensive documentation — + not just the public interface, because reviewers and auditors must verify every + implementation detail. Access-level specifics vary by language; see the language-specific standard. - **Clarity Over Cleverness**: Code should be immediately understandable by team members ## API Documentation @@ -79,13 +73,13 @@ interface correctly without reading the implementation: ## Universal Anti-Patterns -- **Skip Literate Coding**: Don't skip literate programming comments - they are required for maintainability -- **Ignore Compiler Warnings**: Don't ignore compiler warnings - they exist for quality enforcement +- **Skip Literate Coding**: Don't skip literate programming comments +- **Ignore Compiler Warnings**: Don't ignore compiler warnings - **Hidden Dependencies**: Don't create untestable code with hidden dependencies - **Hidden Functionality**: Don't implement functionality without requirement traceability because untraced functionality cannot be validated during audits - **Monolithic Functions**: Don't write monolithic functions with multiple responsibilities -- **Overcomplicated Solutions**: Don't make solutions more complex than necessary - favor simplicity and clarity +- **Overcomplicated Solutions**: Don't make solutions more complex than necessary - **Premature Optimization**: Don't optimize for performance before establishing correctness - **Copy-Paste Programming**: Don't duplicate logic - extract common functionality into reusable components - **Magic Numbers**: Don't use unexplained constants - either name them or add clear comments diff --git a/.github/standards/csharp-language.md b/.github/standards/csharp-language.md index 6df39cd..ec05a25 100644 --- a/.github/standards/csharp-language.md +++ b/.github/standards/csharp-language.md @@ -12,9 +12,6 @@ Read these standards first before applying this standard: # API Documentation and Literate Coding Example -The example below demonstrates good XmlDoc API documentation combined with -literate coding comments. - ```csharp /// /// Converts a raw sensor reading into a validated measurement ready for downstream consumers. diff --git a/.github/standards/csharp-testing.md b/.github/standards/csharp-testing.md index 181de02..1f93b72 100644 --- a/.github/standards/csharp-testing.md +++ b/.github/standards/csharp-testing.md @@ -66,8 +66,6 @@ These are non-obvious v3 behaviors that differ from v2 or common assumptions: # Quality Checks -Before submitting C# tests, verify: - - [ ] All tests follow AAA pattern with clear section comments - [ ] Test names follow hierarchical naming pattern above - [ ] Each test verifies single, specific behavior (no shared state between tests) diff --git a/.github/standards/design-documentation.md b/.github/standards/design-documentation.md index 635cb6d..e5b7bf9 100644 --- a/.github/standards/design-documentation.md +++ b/.github/standards/design-documentation.md @@ -28,7 +28,9 @@ docs/design/ └── {package-name}.md # heading depth ## ``` -Subsystems may nest recursively. Each file's heading depth equals its folder depth under `docs/design/`. +All sections in every file are mandatory; write "N/A - {justification}" rather than removing any. +Determine subsystem vs. unit classification from `docs/design/introduction.md` — folder depth does not determine classification. +Do not record version numbers anywhere in design documentation — version information is managed in SBOMs. # introduction.md (MANDATORY) @@ -43,8 +45,7 @@ Must include: # System Design (MANDATORY) -Create `{system-name}.md` (`#` heading) and `{system-name}/` folder. All sections mandatory; -write "N/A - {justification}" rather than removing any section: +Create `{system-name}.md` (`#` heading) and `{system-name}/` folder: - **Architecture**: software items, relationships, and collaboration - **External Interfaces**: name, direction, format, constraints @@ -55,8 +56,7 @@ write "N/A - {justification}" rather than removing any section: # Subsystem Design (MANDATORY) -Place `{subsystem-name}.md` in the **parent** folder; create `{subsystem-name}/` for children. -All sections mandatory; write "N/A - {justification}" rather than removing any section: +Place `{subsystem-name}.md` in the **parent** folder; create `{subsystem-name}/` for children: - **Overview**: responsibility, boundaries, contained units - **Interfaces**: what it exposes and consumes @@ -64,28 +64,34 @@ All sections mandatory; write "N/A - {justification}" rather than removing any s # Unit Design (MANDATORY) -Place `{unit-name}.md` in the **parent** folder. All sections mandatory; -write "N/A - {justification}" rather than removing any section: +Place `{unit-name}.md` in the **parent** folder: - **Purpose**: single responsibility - **Data Model**: fields, properties, types, invariants (IEC 62304 §5.4.2) - **Key Methods**: name, purpose, algorithm, preconditions, postconditions, parameter types - **Error Handling**: detection and handling; what is propagated vs. handled locally -- **Interactions**: dependencies on other units/subsystems/OTS; who calls this unit +- **Dependencies**: other units, subsystems, OTS items, and shared packages used +- **Callers**: units or subsystems that call or consume this unit # OTS Integration Design (when OTS items exist) Create `docs/design/ots.md` (`#` heading) covering the overall OTS integration strategy. -For each OTS item, create `docs/design/ots/{ots-name}.md` (`##` heading) covering: -why chosen, which features/APIs used, integration patterns, version constraints. +For each OTS item, create `docs/design/ots/{ots-name}.md` (`##` heading) with sections: + +- **Purpose**: why chosen and what it provides to the local system +- **Features Used**: which specific features, APIs, or capabilities are consumed +- **Integration Pattern**: how it is consumed; initialization, configuration, disposal requirements # Shared Package Integration Design (when Shared Packages exist) Create `docs/design/shared.md` (`#` heading) covering the overall consumption strategy. -For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` heading) covering: -which advertised features are consumed, integration pattern, configuration/initialization. +For each Shared Package, create `docs/design/shared/{package-name}.md` (`##` heading) with sections: + +- **Advertised Features Consumed**: which features the local system relies on +- **Integration Pattern**: how the package is referenced, initialized, and consumed +- **Assumptions**: any assumptions the local system makes about the package's behavior # Writing Guidelines @@ -102,7 +108,7 @@ which advertised features are consumed, integration pattern, configuration/initi - [ ] System design includes all mandatory sections (Architecture, External Interfaces, Dependencies, Risk Control Measures, Data Flow, Design Constraints) - [ ] Subsystem design includes all mandatory sections (Overview, Interfaces, Design) -- [ ] Unit design includes all mandatory sections (Purpose, Data Model, Key Methods, Error Handling, Interactions) +- [ ] Unit design includes all mandatory sections (Purpose, Data Model, Key Methods, Error Handling, Dependencies, Callers) - [ ] Non-applicable mandatory sections contain "N/A - {justification}" - [ ] `docs/design/ots.md` and `docs/design/ots/{ots-name}.md` exist when OTS items are present - [ ] `docs/design/shared.md` and `docs/design/shared/{package-name}.md` exist when Shared Packages are present diff --git a/.github/standards/reqstream-usage.md b/.github/standards/reqstream-usage.md index 95d36a1..2371164 100644 --- a/.github/standards/reqstream-usage.md +++ b/.github/standards/reqstream-usage.md @@ -25,8 +25,8 @@ docs/reqstream/ │ ├── platform-requirements.yaml # Platform support requirements │ ├── {subsystem-name}.yaml # Subsystem requirements │ ├── {subsystem-name}/ # Subsystem folder (kebab-case); may nest recursively -│ │ ├── {child-subsystem}.yaml # Child subsystem requirements -│ │ ├── {child-subsystem}/ # Child subsystem folder +│ │ ├── {subsystem-name}.yaml # Child subsystem requirements +│ │ ├── {subsystem-name}/ # Child subsystem folder │ │ └── {unit-name}.yaml # Unit requirements │ └── {unit-name}.yaml # System-level unit requirements ├── ots/ # OTS items appear as a distinct section in reports @@ -35,62 +35,66 @@ docs/reqstream/ └── {package-name}.yaml # Requirements for Shared Package dependencies ``` -Local items have matching relative paths across `docs/reqstream/`, `docs/design/`, and -`docs/verification/`. OTS items appear in `docs/reqstream/ots/`, `docs/design/ots/`, and -`docs/verification/ots/`. Shared Packages appear in `docs/reqstream/shared/`, -`docs/design/shared/`, and `docs/verification/shared/`. +Local items have matching relative paths across `docs/reqstream/`, `docs/design/`, and `docs/verification/`: + +- Requirements: `{system-name}[/{subsystem-name}...]/{item-name}.yaml` +- Design: `{system-name}[/{subsystem-name}...]/{item-name}.md` +- Verification: `{system-name}[/{subsystem-name}...]/{item-name}.md` # Requirements File Format -```yaml -sections: - - title: Functional Requirements - requirements: - - id: System-Component-Feature # Used as-is in all reports - make it readable - title: The system shall perform the required function. - justification: | - Business rationale and any regulatory references. - # ReqStream extracts this field into the justifications report (--justifications) - children: # ReqStream validates this decomposition chain - - ChildSystem-Feature-Behavior # Downward links only (see requirements-principles.md) - tests: # ReqStream matches these by method name in test results - - TestMethodName - - windows@PlatformSpecificTest # Only test runs on Windows count as evidence -``` +Each file adds requirements at exactly one level of the hierarchy. The file spells out +its full ancestry as nested `{ItemName} Requirements` sections down to that level, then +places requirements there. ReqStream merges identical section title paths across included +files automatically. Always determine item classification from `docs/design/introduction.md` - +folder depth does not determine whether an item is a subsystem or unit. -# OTS Software Requirements +Valid section nestings (names in `{braces}` are placeholders): + +```text +{SystemName} Requirements # system-level requirements +├── {SubsystemName} Requirements # root subsystem requirements +│ ├── {SubsystemName} Requirements # nested subsystem (may recurse) +│ │ └── {UnitName} Requirements # unit under a nested subsystem +│ └── {UnitName} Requirements # unit under a root subsystem +└── {UnitName} Requirements # unit directly under the system +OTS Software Requirements # OTS root section (fixed title) +└── {OtsName} Requirements # requirements for one OTS item +Shared Package Requirements # shared package root section (fixed title) +└── {PackageName} Requirements # requirements for one shared package +``` -Use nested sections in `docs/reqstream/ots/` because ReqStream renders the `ots/` -subtree as a distinct section in generated reports, separate from local -system requirements: +Each file implements one path through this tree: ```yaml sections: - - title: OTS Software Requirements + - title: '{SystemName} Requirements' sections: - - title: System.Text.Json + - title: '{SubsystemName} Requirements' requirements: - - id: SystemTextJson-Core-ReadJson - title: System.Text.Json shall be able to read JSON files. - tests: - - JsonReaderTests.TestReadValidJson + - id: System-Subsystem-Feature # Used as-is in all reports - make it readable + title: The subsystem shall perform the required function. + justification: | # ReqStream extracts this into the justifications report (--justifications) + Business rationale and any regulatory references. + tags: # Optional: categorize for filtering with --filter + - security + children: # Optional: ReqStream validates this decomposition chain + - System-Subsystem-Unit-Feat # Downward links only (see requirements-principles.md) + tests: # ReqStream matches these by method name in test results + - TestMethodName + - windows@PlatformSpecificTest # Only test runs on Windows count as evidence ``` -# Shared Package Requirements +# Tags (OPTIONAL) -Use nested sections in `docs/reqstream/shared/` - ReqStream renders the `shared/` -subtree as a distinct section in reports, separate from local and OTS requirements: +Tags are free-form - no mandatory vocabulary. Common tags: `security`, `safety`, `performance`, +`compliance`, `reliability`, `critical`. Use `--filter` to selectively export or enforce subsets +(OR logic across comma-separated tags): -```yaml -sections: - - title: Shared Package Requirements - sections: - - title: MyOrg.SharedLibrary - requirements: - - id: SharedLibrary-Core-ParseConfig - title: MyOrg.SharedLibrary shall parse configuration files. - tests: - - SharedLibraryIntegrationTests.TestParseValidConfig +```bash +dotnet reqstream --requirements requirements.yaml \ + --filter security,critical \ + --report docs/requirements_doc/generated/security_requirements.md ``` # Semantic IDs (MANDATORY) @@ -145,13 +149,9 @@ dotnet reqstream --requirements requirements.yaml \ Before submitting requirements, verify: -- [ ] All requirements have semantic IDs (`System-Section-Feature` pattern) -- [ ] Every requirement links to at least one passing test +- [ ] All requirements have semantic IDs (`System-Component-Feature` pattern) +- [ ] Every requirement has a justification explaining business/regulatory need +- [ ] Every requirement links to at least one test - [ ] Platform-specific requirements use source filters (`platform@TestName`) -- [ ] Comprehensive justification explains business/regulatory need -- [ ] Files organized under `docs/reqstream/` following the folder structure pattern above -- [ ] All documentation folders use kebab-case names matching source code structure -- [ ] OTS requirements placed in `ots/` subfolder -- [ ] Shared Package requirements placed in `shared/` subfolder -- [ ] Valid YAML syntax passes yamllint validation -- [ ] Test result formats compatible (TRX, JUnit XML) +- [ ] All files and folders use kebab-case names matching source code structure +- [ ] All files are organized under `docs/reqstream/` following the folder structure above diff --git a/.github/standards/reviewmark-usage.md b/.github/standards/reviewmark-usage.md index 990d707..b521433 100644 --- a/.github/standards/reviewmark-usage.md +++ b/.github/standards/reviewmark-usage.md @@ -88,12 +88,11 @@ When constructing review-sets, follow these principles to maintain manageable sc # Review-Set Organization -Organize review-sets using these standard patterns to ensure comprehensive coverage -while keeping each review manageable in scope: - -**Naming conventions**: See `software-items.md` - kebab-case placeholders -(e.g., `{system-name}`) are always kebab-case; cased placeholders -(e.g., `{SystemName}`) follow your language's convention. +**Naming conventions**: Placeholders in documentation, requirements, design, and +verification file paths are kebab-case (e.g., `{system-name}`). Placeholders in +source and test file paths may use the casing conventional for the project's +source language or repository layout (e.g., `{SystemName}`). Review-set name +placeholders are always PascalCase (e.g., `{SystemName}`). ## `Purpose` Review (only one per repository) @@ -110,12 +109,12 @@ Reviews user-facing capabilities and system promises: - Design introduction: `docs/design/introduction.md` - System design: `docs/design/{system-name}.md` -## `{System}-Architecture` Review (one per system) +## `{SystemName}-Architecture` Review (one per system) Reviews system architecture and operational validation: - **Purpose**: Proves that the system is designed and tested to satisfy its requirements -- **Title**: "Review that {System} Architecture Satisfies Requirements" +- **Title**: "Review that {SystemName} Architecture Satisfies Requirements" - **Scope**: Excludes subsystem and unit files, relying on system-level design to describe what subsystems and units it uses - **File Path Patterns**: @@ -126,12 +125,12 @@ Reviews system architecture and operational validation: - System verification design: `docs/verification/{system-name}.md` - System integration tests: `test/{SystemName}.Tests/{SystemName}Tests.{ext}` -## `{System}-Design` Review (one per system) +## `{SystemName}-Design` Review (one per system) Reviews architectural and design consistency: - **Purpose**: Proves the system design is consistent and complete -- **Title**: "Review that {System} Design is Consistent and Complete" +- **Title**: "Review that {SystemName} Design is Consistent and Complete" - **Scope**: Only brings in top-level requirements and relies on brevity of design documentation - **File Path Patterns**: - System requirements: `docs/reqstream/{system-name}.yaml` @@ -142,12 +141,12 @@ Reviews architectural and design consistency: - OTS overview: `docs/design/ots.md` _(only if OTS items exist)_ - Shared Package overview: `docs/design/shared.md` _(only if Shared Package items exist)_ -## `{System}-Verification` Review (one per system) +## `{SystemName}-Verification` Review (one per system) Reviews verification completeness and consistency: - **Purpose**: Proves the system verification design is consistent and covers all requirements -- **Title**: "Review that {System} Verification is Consistent and Complete" +- **Title**: "Review that {SystemName} Verification is Consistent and Complete" - **Scope**: Only brings in top-level requirements and all verification docs for the system - **File Path Patterns**: - System requirements: `docs/reqstream/{system-name}.yaml` @@ -157,45 +156,47 @@ Reviews verification completeness and consistency: - OTS overview: `docs/verification/ots.md` _(only if OTS items exist)_ - Shared Package overview: `docs/verification/shared.md` _(only if Shared Package items exist)_ -## `{System}-AllRequirements` Review (one per system) +## `{SystemName}-AllRequirements` Review (one per system) Reviews requirements quality and traceability: - **Purpose**: Proves the requirements are consistent and complete -- **Title**: "Review that All {System} Requirements are Complete" +- **Title**: "Review that All {SystemName} Requirements are Complete" - **Scope**: Only brings in requirements files to keep review manageable - **File Path Patterns**: - Root requirements: `requirements.yaml` - System requirements: `docs/reqstream/{system-name}.yaml` - Subsystem/unit requirements: `docs/reqstream/{system-name}/**/*.yaml` -## `{System}-{Subsystem[-Child...]}` Review (one per subsystem at any depth) +## `{SystemName}-{SubsystemName}[-{SubsystemName}...]` Review (one per subsystem at any depth) Reviews subsystem architecture and interfaces: - **Purpose**: Proves that the subsystem is designed and tested to satisfy its requirements -- **Title**: "Review that {System} {Subsystem} Satisfies Subsystem Requirements" +- **Title**: "Review that {SystemName} {SubsystemName} Satisfies Subsystem Requirements" - **Scope**: Excludes units under the subsystem, relying on subsystem design to describe what units it uses - **File Path Patterns**: - - Requirements: `docs/reqstream/{system-name}/.../{subsystem-name}.yaml` - - Design: `docs/design/{system-name}/.../{subsystem-name}.md` - - Verification design: `docs/verification/{system-name}/.../{subsystem-name}.md` - - Tests: `test/{SystemName}.Tests/.../{SubsystemName}/{SubsystemName}Tests.{ext}` + - Requirements: `docs/reqstream/{system-name}[/{subsystem-name}...]/{subsystem-name}.yaml` + - Design: `docs/design/{system-name}[/{subsystem-name}...]/{subsystem-name}.md` + - Verification design: `docs/verification/{system-name}[/{subsystem-name}...]/{subsystem-name}.md` + - Tests: `test/{SystemName}.Tests[/{SubsystemName}...]/{SubsystemName}Tests.{ext}` -## `{System}-{Subsystem[-Child...]}-{Unit}` Review (one per unit) +## `{SystemName}-{SubsystemName}[-{SubsystemName}...]-{UnitName}` Review (one per unit) Reviews individual software unit implementation: - **Purpose**: Proves the unit is designed, implemented, and tested to satisfy its requirements -- **Title**: "Review that {System} {Subsystem} {Unit} Implementation is Correct" +- **Title**: "Review that {SystemName} {SubsystemName} {UnitName} Implementation is Correct" - **Scope**: Complete unit review including all artifacts - **File Path Patterns**: - - Requirements: `docs/reqstream/{system-name}/.../{unit-name}.yaml` - - Design: `docs/design/{system-name}/.../{unit-name}.md` - - Verification design: `docs/verification/{system-name}/.../{unit-name}.md` - - Source: `src/{SystemName}/.../{UnitName}.{ext}` - - Tests: `test/{SystemName}.Tests/.../{UnitName}Tests.{ext}` + - Requirements: `docs/reqstream/{system-name}[/{subsystem-name}...]/{unit-name}.yaml` + - Design: `docs/design/{system-name}[/{subsystem-name}...]/{unit-name}.md` + - Verification design: `docs/verification/{system-name}[/{subsystem-name}...]/{unit-name}.md` + - Source (C# example): `src/{SystemName}[/{SubsystemName}...]/{UnitName}.cs` + - Tests (C# example): `test/{SystemName}.Tests[/{SubsystemName}...]/{UnitName}Tests.cs` + - Source (snake_case C++ example): `src/{system_name}[/{subsystem_name}...]/{unit_name}.cpp` + - Tests (snake_case C++ example): `test/{system_name}_tests[/{subsystem_name}...]/{unit_name}_tests.cpp` ## `OTS-{OtsName}` Review (one per OTS item) @@ -208,7 +209,8 @@ Reviews OTS item integration design, requirements, and verification evidence: - OTS requirements: `docs/reqstream/ots/{ots-name}.yaml` - OTS integration design: `docs/design/ots/{ots-name}.md` - OTS verification: `docs/verification/ots/{ots-name}.md` - - Tests (if applicable): `test/{OtsSoftwareTests}/...` (cased per language) + - Tests (if applicable): `test/OtsSoftwareTests/...` (C#) or `test/ots_software_tests/...` + (Python/other) — fixed repo-level name, no system prefix ## `Shared-{PackageName}` Review (one per Shared Package) @@ -231,17 +233,6 @@ Before submitting ReviewMark configuration, verify: - [ ] `.reviewmark.yaml` exists at repository root with proper structure - [ ] Review-set organization follows the standard hierarchy patterns -- [ ] Purpose review-set includes README.md, user guide, system requirements, design introduction, and system design files -- [ ] System-level reviews follow hierarchical scope principle (exclude subsystem/unit details) -- [ ] Subsystem reviews follow hierarchical scope principle (exclude unit source code) -- [ ] Only unit reviews include actual source code files -- [ ] Architecture review-sets include system verification design alongside system design -- [ ] Design review-sets include all system design files -- [ ] Verification review-sets include all system verification files -- [ ] Subsystem review-sets include subsystem verification design -- [ ] Unit review-sets include unit verification design -- [ ] OTS review-sets include OTS requirements, integration design, and verification evidence -- [ ] Shared Package review-sets include Shared Package requirements, integration design, and verification evidence - [ ] Each review-set focuses on a single compliance question (single focus principle) - [ ] File patterns use correct glob syntax and match intended files - [ ] Review-set file counts remain manageable (context management principle) diff --git a/.github/standards/software-items.md b/.github/standards/software-items.md index 328a08e..6c29525 100644 --- a/.github/standards/software-items.md +++ b/.github/standards/software-items.md @@ -3,12 +3,6 @@ name: Software Items description: Follow these standards when categorizing software components. --- -# Software Items Definition Standards - -This document defines standards for categorizing software items within -Continuous Compliance environments because proper categorization determines -requirements management approach, testing strategy, and review scope. - # Software Item Categories Categorize all software into six primary groups: @@ -40,17 +34,28 @@ Categorize all software into six primary groups: # Naming Conventions in File Path Patterns -Two placeholder styles appear in path patterns across these standards: +Three placeholder forms appear in path patterns across these standards: -- **Kebab-case** (`{system-name}`, `{unit-name}`): always kebab-case - - used in documentation and requirements paths -- **Cased** (`{SystemName}`, `{UnitName}`): follow your language's convention - - `PascalCase` for C#/Java, `snake_case` for C++/Python - - used in source and test file paths +- **Kebab-case** (`{system-name}`, `{unit-name}`): always kebab-case — + documentation and requirements file paths +- **PascalCase IDs** (`{SystemName}`, `{UnitName}`): always PascalCase — + requirements IDs, ReviewMark IDs, and other documentation identifiers +- **Language-cased** (`{SystemName}` or `{system_name}`): follow your language's + convention — `PascalCase` for C#/Java, `snake_case` for C++/Python — + source and test file/folder names -# Categorization Guidelines +## Nesting Depth Notation -Choose the appropriate category based on scope and testability: +Subsystems nest to any depth. Patterns use bracket-ellipsis to express this without +enumerating levels — `[/{subsystem-name}...]` in paths, `[-{SubsystemName}...]` in +dash-separated IDs. Examples covering all three forms: + +- `{SystemName}[-{SubsystemName}...]-{UnitName}-Feature` (PascalCase ID) +- `docs/design/{system-name}[/{subsystem-name}...]/{unit-name}.md` (kebab-case doc path) +- `src/{SystemName}[/{SubsystemName}...]/{UnitName}.cs` (C# source path) +- `src/{system_name}[/{subsystem_name}...]/{unit_name}.cpp` (C++/Python source path) + +# Categorization Guidelines ## Software Package @@ -89,13 +94,15 @@ Choose the appropriate category based on scope and testability: - Examples: System.Text.Json, Entity Framework, third-party APIs - **Artifact locations** (OTS items have no internal design documentation): - Requirements: `docs/reqstream/ots/{ots-name}.yaml` - - Design: `docs/design/ots/{ots-name}.md` (integration/usage design - how the local system uses this item) + - Design: `docs/design/ots/{ots-name}.md` (integration/usage design) - Verification: `docs/verification/ots/{ots-name}.md` - These folders sit parallel to system folders (not inside any system folder) - System design documentation records which OTS items each system depends on - **OTS test project**: If no other verification evidence is available (e.g., vendor test results, - published compliance reports), a dedicated test project (`OtsSoftwareTests` / `ots_software_tests`, - cased per language) holds OTS integration tests - one test file per OTS item requiring tests. + published compliance reports), a dedicated test project holds OTS integration tests - one test + file per OTS item requiring tests. OTS items are repo-level (not per-system), so the project + uses a fixed repo-level name: `test/OtsSoftwareTests/` (C#) or `test/ots_software_tests/` + (Python/other) — never prefixed with a system or project name. ## Shared Package @@ -107,10 +114,9 @@ Choose the appropriate category based on scope and testability: downstream integration tests that transitively prove the advertised features are functional - **Artifact locations** (no internal design documentation in the consuming repository): - Requirements: `docs/reqstream/shared/{package-name}.yaml` - - Design: `docs/design/shared/{package-name}.md` (integration/usage design - which features are consumed and how) + - Design: `docs/design/shared/{package-name}.md` (integration/usage design) - Verification: `docs/verification/shared/{package-name}.md` - These folders sit parallel to system and OTS folders -- System design documentation records which Shared Packages each system depends on # Software Item Artifact Model diff --git a/.github/standards/technical-documentation.md b/.github/standards/technical-documentation.md index 0dc4455..23893bd 100644 --- a/.github/standards/technical-documentation.md +++ b/.github/standards/technical-documentation.md @@ -6,9 +6,6 @@ globs: ["docs/**/*.md", "README.md", "!docs/**/generated/**"] # Technical Documentation Standards -This document defines standards for technical documentation within Continuous -Compliance environments. - # Core Principles Technical documentation serves as compliance evidence and must be structured @@ -40,8 +37,7 @@ docs/{collection}/ Without `title.txt` and `definition.yaml` the pipeline cannot generate the document. When creating a new document collection, create these three files together and use -the existing collections under `docs/` as templates - they share a consistent -structure across all collections. +the existing collections under `docs/` as templates. The `generated/` folder is **never committed** to the repository - it is created locally and in CI by the build pipeline. Do not flag its absence as a conformance @@ -85,8 +81,7 @@ elsewhere causes duplicate sections in the compiled PDF. ## Document Ordering -List documents in logical reading order in Pandoc configuration because -readers need coherent information flow from general to specific topics. +List documents in logical reading order in `definition.yaml`. ## Heading Depth Rule (MANDATORY) @@ -110,15 +105,10 @@ available - keep internal structure flat to avoid excessive nesting. Write technical documentation for clarity and compliance verification: - **Clear and Concise**: Use direct language and avoid unnecessary complexity. - Regulatory reviewers must understand content quickly. -- **Structured Sections**: Use consistent heading hierarchy and section - organization. Enables automated processing and review. -- **Specific Examples**: Include concrete examples with actual values rather - than placeholders. Supports implementation verification. +- **Structured Sections**: Use consistent heading hierarchy and section organization. +- **Specific Examples**: Include concrete examples with actual values rather than placeholders. - **Current Information**: Keep documentation synchronized with code changes. - Outdated documentation invalidates compliance evidence. -- **Traceable Content**: Link documentation to requirements and implementation - where applicable for audit trails. +- **Traceable Content**: Link documentation to requirements and implementation where applicable. ## References Sections @@ -138,26 +128,16 @@ Instead use **verbal references** - plain prose that identifies the target by na > > Refer to the *System Requirements* document for the full specification. -Verbal references are readable by both AI agents and humans in any rendering environment. - # Markdown Format Requirements -Markdown documentation in this repository must follow the formatting standards -defined in `.markdownlint-cli2.yaml` (subject to any exclusions configured there) -for consistency and professional presentation: - -- **120 Character Line Limit**: Keep lines 120 characters or fewer for readability. - Break long lines naturally at punctuation or logical breaks. -- **No Trailing Whitespace**: Remove all trailing spaces and tabs from line - endings to prevent formatting inconsistencies. -- **Blank Lines Around Headings**: Include a blank line both before and after - each heading to improve document structure and readability. -- **Blank Lines Around Lists**: Include a blank line both before and after - numbered and bullet lists to ensure proper rendering and visual separation. -- **ATX-Style Headers**: Use `#` syntax for headers instead of underline style - for consistency across all documentation. -- **Consistent List Indentation**: Use 2-space indentation for nested list - items to maintain uniform formatting. +Follow `.markdownlint-cli2.yaml` formatting standards: + +- **120 Character Line Limit**: Keep lines 120 characters or fewer; break at punctuation or logical breaks. +- **No Trailing Whitespace**: Remove all trailing spaces and tabs. +- **Blank Lines Around Headings**: Include a blank line before and after each heading. +- **Blank Lines Around Lists**: Include a blank line before and after numbered and bullet lists. +- **ATX-Style Headers**: Use `#` syntax, not underline style. +- **Consistent List Indentation**: Use 2-space indentation for nested list items. # Auto-Generated Content (CRITICAL) @@ -168,8 +148,6 @@ build outputs that are overwritten on every CI run: respective `docs/` sections, or in `docs/generated/` for final release artifacts - **Source Modification**: Update source files (requirements YAML, `.reviewmark.yaml`, tool configuration) instead of generated output -- **Tool Integration**: Generated content integrates with CI/CD pipelines and - manual changes disrupt automation # README.md Best Practices @@ -192,20 +170,12 @@ Structure README.md for both human readers and AI agent processing: - **Code Block Languages**: Specify language for syntax highlighting and tool processing - **Clear Prerequisites**: List exact version requirements and dependencies -## Quality Guidelines - -- **Scannable Structure**: Use bullet points, headings, and short paragraphs -- **Current Examples**: Verify all code examples work with current version -- **Link Validation**: Ensure all external links are accessible and current -- **Consistent Tone**: Professional, helpful tone appropriate for technical audience - # Quality Checks Before submitting technical documentation, verify: - [ ] Documentation organized under `docs/` following standard folder structure - [ ] Pandoc collections include `introduction.md` with Purpose and Scope sections -- [ ] Content follows clear and concise writing guidelines with specific examples - [ ] No modifications made to auto-generated markdown files in compliance folders - [ ] README.md includes all required sections with absolute URLs and concrete examples - [ ] Documentation integrated into ReviewMark review-sets for formal review diff --git a/.github/standards/testing-principles.md b/.github/standards/testing-principles.md index 73974ff..917463e 100644 --- a/.github/standards/testing-principles.md +++ b/.github/standards/testing-principles.md @@ -3,11 +3,6 @@ name: Testing Principles description: Follow these standards when developing any software tests. --- -# Testing Principles Standards - -This document defines universal testing principles and quality standards for test development within -Continuous Compliance environments. - # Test Dependency Boundaries (MANDATORY) Respect software item hierarchy boundaries to ensure review-sets can validate proper architectural scope. diff --git a/.github/standards/verification-documentation.md b/.github/standards/verification-documentation.md index 8dc4408..494e40f 100644 --- a/.github/standards/verification-documentation.md +++ b/.github/standards/verification-documentation.md @@ -28,7 +28,8 @@ docs/verification/ └── {package-name}.md # heading depth ## ``` -Subsystems may nest recursively. Each file's heading depth equals its folder depth under `docs/verification/`. +All sections in every file are mandatory; write "N/A - {justification}" rather than removing any. +Determine subsystem vs. unit classification from `docs/design/introduction.md` — folder depth does not determine classification. # introduction.md (MANDATORY) @@ -41,50 +42,44 @@ Must include: # System Verification Design (MANDATORY) -Create `{system-name}.md` (`#` heading) and `{system-name}/` folder. All sections mandatory; -write "N/A - {justification}" rather than removing any section: +Create `{system-name}.md` (`#` heading) and `{system-name}/` folder: -- **Verification Strategy**: test types (unit, integration, end-to-end), framework, project structure +- **Verification Approach**: test types (unit, integration, end-to-end), framework, project structure - **Test Environment**: OS, runtime, external services, files, or configuration required - **Acceptance Criteria**: what constitutes a passing system test (IEC 62304 §5.7.2) -- **System-Level Test Scenarios**: named scenarios for each system requirement -- **Requirements Coverage**: requirement → scenario(s) → test method(s) mapping +- **Test Scenarios**: named scenarios for each system requirement # Subsystem Verification Design (MANDATORY) -Place `{subsystem-name}.md` in the **parent** folder; create `{subsystem-name}/` for children. -All sections mandatory; write "N/A - {justification}" rather than removing any section: +Place `{subsystem-name}.md` in the **parent** folder; create `{subsystem-name}/` for children: -- **Verification Strategy**: integration test approach and mocking at subsystem boundary +- **Verification Approach**: integration test approach and mocking at subsystem boundary - **Test Environment**: any environment setup beyond the standard test runner - **Acceptance Criteria**: what constitutes a passing subsystem test (IEC 62304 §5.5.2) - **Test Scenarios**: named scenarios including boundary conditions, error paths, and normal operation -- **Requirements Coverage**: requirement → scenario(s) → test method(s) mapping # Unit Verification Design (MANDATORY) -Place `{unit-name}.md` in the **parent** folder. All sections mandatory; -write "N/A - {justification}" rather than removing any section: +Place `{unit-name}.md` in the **parent** folder: - **Verification Approach**: what is mocked/stubbed and why; injected vs. real dependencies - **Test Environment**: any environment setup beyond the standard test runner - **Acceptance Criteria**: what constitutes passing unit tests (IEC 62304 §5.5.2) - **Test Scenarios**: named scenarios including boundary values, error paths, and normal operation -- **Requirements Coverage**: requirement → scenario(s) → test method(s) mapping # OTS Verification Evidence (when OTS items exist) Create `docs/verification/ots.md` (`#` heading) covering the overall OTS verification strategy. For each OTS item, create `docs/verification/ots/{ots-name}.md` (`##` heading) covering: -verification approach (self-validation, integration tests, vendor evidence) and requirements coverage. +verification approach (self-validation, integration tests, vendor evidence). # Shared Package Verification Evidence (when Shared Packages exist) Create `docs/verification/shared.md` (`#` heading) covering the overall Shared Package verification strategy. For each Shared Package, create `docs/verification/shared/{package-name}.md` (`##` heading) covering: -verification approach and requirements coverage. +verification approach. # Writing Guidelines @@ -97,14 +92,10 @@ verification approach and requirements coverage. - [ ] `introduction.md` includes Companion Artifact Structure - [ ] Each file's heading depth matches its folder depth - [ ] All folders use kebab-case mirroring source structure -- [ ] System verification includes all mandatory sections (Verification Strategy, Test Environment, - Acceptance Criteria, System-Level Test Scenarios, Requirements Coverage) -- [ ] Subsystem verification includes all mandatory sections (Verification Strategy, Test Environment, - Acceptance Criteria, Test Scenarios, Requirements Coverage) -- [ ] Unit verification includes all mandatory sections (Verification Approach, Test Environment, - Acceptance Criteria, Test Scenarios, Requirements Coverage) +- [ ] Each system/subsystem/unit file includes all mandatory sections (Verification Approach, + Test Environment, Acceptance Criteria, Test Scenarios) - [ ] Non-applicable mandatory sections contain "N/A - {justification}" -- [ ] Every requirement is mapped to at least one named test scenario +- [ ] Requirements-to-test coverage is tracked via the ReqStream trace matrix, not in these documents - [ ] `docs/verification/ots.md` and `docs/verification/ots/{ots-name}.md` exist when OTS items are present - [ ] `docs/verification/shared.md` and `docs/verification/shared/{package-name}.md` exist when Shared Packages are present - [ ] Documents are integrated into ReviewMark review-sets diff --git a/.reviewmark.yaml b/.reviewmark.yaml index c82ab31..7676bea 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -77,6 +77,7 @@ reviews: - "docs/design/file-assert/cli.md" # subsystem design - "docs/verification/file-assert/cli.md" # subsystem verification - "test/**/Cli/CliTests.cs" # subsystem tests + - "test/**/Cli/ScopedContextTests.cs" # subsystem tests # FileAssert-Configuration Review (one per subsystem) - id: FileAssert-Configuration @@ -285,11 +286,53 @@ reviews: - "src/**/Modeling/FileAssertZipAssert.cs" # implementation - "test/**/Modeling/FileAssertZipAssertTests.cs" # unit tests + # FileAssert-Cli-IContext Review (one per unit) + - id: FileAssert-Cli-IContext + title: Review that FileAssert Cli IContext Implementation is Correct + paths: + - "docs/reqstream/file-assert/cli/i-context.yaml" # requirements + - "docs/design/file-assert/cli/i-context.md" # design + - "docs/verification/file-assert/cli/i-context.md" # verification + - "src/**/Cli/IContext.cs" # implementation + - "src/**/Cli/Context.cs" # implementation + - "test/**/Cli/ScopedContextTests.cs" # unit tests + + # FileAssert-Utilities-IFileContainer Review (one per unit) + - id: FileAssert-Utilities-IFileContainer + title: Review that FileAssert Utilities IFileContainer Implementation is Correct + paths: + - "docs/reqstream/file-assert/utilities/i-file-container.yaml" # requirements + - "docs/design/file-assert/utilities/i-file-container.md" # design + - "docs/verification/file-assert/utilities/i-file-container.md" # verification + - "src/**/Utilities/IFileContainer.cs" # implementation + - "test/**/Utilities/IFileContainerTests.cs" # unit tests + + # FileAssert-Utilities-DirectoryFileContainer Review (one per unit) + - id: FileAssert-Utilities-DirectoryFileContainer + title: Review that FileAssert Utilities DirectoryFileContainer Implementation is Correct + paths: + - "docs/reqstream/file-assert/utilities/directory-file-container.yaml" # requirements + - "docs/design/file-assert/utilities/directory-file-container.md" # design + - "docs/verification/file-assert/utilities/directory-file-container.md" # verification + - "src/**/Utilities/DirectoryFileContainer.cs" # implementation + - "test/**/Utilities/IFileContainerTests.cs" # unit tests + + # FileAssert-Utilities-ZipFileContainer Review (one per unit) + - id: FileAssert-Utilities-ZipFileContainer + title: Review that FileAssert Utilities ZipFileContainer Implementation is Correct + paths: + - "docs/reqstream/file-assert/utilities/zip-file-container.yaml" # requirements + - "docs/design/file-assert/utilities/zip-file-container.md" # design + - "docs/verification/file-assert/utilities/zip-file-container.md" # verification + - "src/**/Utilities/ZipFileContainer.cs" # implementation + - "test/**/Utilities/IFileContainerTests.cs" # unit tests + # FileAssert-OTS-BuildMark Review - id: FileAssert-OTS-BuildMark title: Review FileAssert OTS BuildMark Requirements and Verification paths: - "docs/reqstream/ots/buildmark.yaml" # OTS requirements + - "docs/design/ots/buildmark.md" # OTS design - "docs/verification/ots/buildmark.md" # OTS verification # FileAssert-OTS-FileAssert Review @@ -297,6 +340,7 @@ reviews: title: Review FileAssert OTS FileAssert Requirements and Verification paths: - "docs/reqstream/ots/fileassert.yaml" # OTS requirements + - "docs/design/ots/fileassert.md" # OTS design - "docs/verification/ots/fileassert.md" # OTS verification # FileAssert-OTS-Pandoc Review @@ -304,6 +348,7 @@ reviews: title: Review FileAssert OTS Pandoc Requirements and Verification paths: - "docs/reqstream/ots/pandoc.yaml" # OTS requirements + - "docs/design/ots/pandoc.md" # OTS design - "docs/verification/ots/pandoc.md" # OTS verification # FileAssert-OTS-ReqStream Review @@ -311,6 +356,7 @@ reviews: title: Review FileAssert OTS ReqStream Requirements and Verification paths: - "docs/reqstream/ots/reqstream.yaml" # OTS requirements + - "docs/design/ots/reqstream.md" # OTS design - "docs/verification/ots/reqstream.md" # OTS verification # FileAssert-OTS-ReviewMark Review @@ -318,6 +364,7 @@ reviews: title: Review FileAssert OTS ReviewMark Requirements and Verification paths: - "docs/reqstream/ots/reviewmark.yaml" # OTS requirements + - "docs/design/ots/reviewmark.md" # OTS design - "docs/verification/ots/reviewmark.md" # OTS verification # FileAssert-OTS-SarifMark Review @@ -325,6 +372,7 @@ reviews: title: Review FileAssert OTS SarifMark Requirements and Verification paths: - "docs/reqstream/ots/sarifmark.yaml" # OTS requirements + - "docs/design/ots/sarifmark.md" # OTS design - "docs/verification/ots/sarifmark.md" # OTS verification # FileAssert-OTS-SonarMark Review @@ -332,6 +380,7 @@ reviews: title: Review FileAssert OTS SonarMark Requirements and Verification paths: - "docs/reqstream/ots/sonarmark.yaml" # OTS requirements + - "docs/design/ots/sonarmark.md" # OTS design - "docs/verification/ots/sonarmark.md" # OTS verification # FileAssert-OTS-VersionMark Review @@ -339,6 +388,7 @@ reviews: title: Review FileAssert OTS VersionMark Requirements and Verification paths: - "docs/reqstream/ots/versionmark.yaml" # OTS requirements + - "docs/design/ots/versionmark.md" # OTS design - "docs/verification/ots/versionmark.md" # OTS verification # FileAssert-OTS-WeasyPrint Review @@ -346,6 +396,7 @@ reviews: title: Review FileAssert OTS WeasyPrint Requirements and Verification paths: - "docs/reqstream/ots/weasyprint.yaml" # OTS requirements + - "docs/design/ots/weasyprint.md" # OTS design - "docs/verification/ots/weasyprint.md" # OTS verification # FileAssert-OTS-xUnit Review @@ -353,4 +404,37 @@ reviews: title: Review FileAssert OTS xUnit Requirements and Verification paths: - "docs/reqstream/ots/xunit.yaml" # OTS requirements + - "docs/design/ots/xunit.md" # OTS design - "docs/verification/ots/xunit.md" # OTS verification + + # FileAssert-OTS-YamlDotNet Review + - id: FileAssert-OTS-YamlDotNet + title: Review FileAssert OTS YamlDotNet Requirements and Verification + paths: + - "docs/reqstream/ots/yamldotnet.yaml" # OTS requirements + - "docs/design/ots/yamldotnet.md" # OTS design + - "docs/verification/ots/yamldotnet.md" # OTS verification + + # FileAssert-OTS-PdfPig Review + - id: FileAssert-OTS-PdfPig + title: Review FileAssert OTS PdfPig Requirements and Verification + paths: + - "docs/reqstream/ots/pdfpig.yaml" # OTS requirements + - "docs/design/ots/pdfpig.md" # OTS design + - "docs/verification/ots/pdfpig.md" # OTS verification + + # FileAssert-OTS-HtmlAgilityPack Review + - id: FileAssert-OTS-HtmlAgilityPack + title: Review FileAssert OTS HtmlAgilityPack Requirements and Verification + paths: + - "docs/reqstream/ots/htmlagilitypack.yaml" # OTS requirements + - "docs/design/ots/htmlagilitypack.md" # OTS design + - "docs/verification/ots/htmlagilitypack.md" # OTS verification + + # FileAssert-OTS-FileSystemGlobbing Review + - id: FileAssert-OTS-FileSystemGlobbing + title: Review FileAssert OTS FileSystemGlobbing Requirements and Verification + paths: + - "docs/reqstream/ots/filesystemglobbing.yaml" # OTS requirements + - "docs/design/ots/filesystemglobbing.md" # OTS design + - "docs/verification/ots/filesystemglobbing.md" # OTS verification diff --git a/AGENTS.md b/AGENTS.md index cb80fe9..1f54a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # Project Overview -- **name**: FileAssert +- **project-name**: FileAssert +- **organization**: DEMA Consulting +- **project-tagline**: File Testing Tool - **description**: A .NET command-line application for asserting file properties using YAML-defined test suites - **languages**: C# - **technologies**: .NET, YamlDotNet, HtmlAgilityPack, PdfPig, System.Xml.Linq, System.Text.Json, xUnit @@ -25,11 +27,16 @@ └── DemaConsulting.FileAssert.Tests/ ``` +# Language and Spelling (ALL Agents) + +Always use **US English** spelling in all output (code, comments, documentation, +commit messages, and reports). + # Reference Template This repository follows a reference template for structure and file conventions. -- **Template URL**: `https://github.com/demaconsulting/Agents/raw/refs/heads/template` +- **template-url**: `https://github.com/demaconsulting/Agents/raw/refs/heads/template` - **Repository map**: `{template-url}/repository-map.md` - **Template files**: `{template-url}/{file-path}` for files described in the map @@ -70,7 +77,6 @@ from `.github/standards/`. Use this matrix to determine which to load: - **Verification docs**: `software-items.md`, `verification-documentation.md`, `technical-documentation.md` - **Review configuration**: `software-items.md`, `reviewmark-usage.md` - **Any documentation**: `technical-documentation.md` -- **Structural audit**: `template-sync` agent Load only the standards relevant to your specific task scope. @@ -80,11 +86,15 @@ The default agent should handle simple, straightforward tasks directly. Delegate to specialized agents only for specific scenarios: - **Pre-PR lint cleanup** (fix all lint issues before pull request) → Call the lint-fix agent -- **Light development work** (small fixes, simple features) → Call the developer agent +- **Scoped fixes with no new user-visible behavior** (PR review comments, doc + corrections, known bug fixes with defined root cause) → Call the developer agent - **Light quality checking** (basic validation) → Call the quality agent -- **Formal feature implementation** (complex, multi-step) → Call the implementation agent -- **Formal bug resolution** (complex debugging, systematic fixes) → Call the implementation agent +- **Any change introducing new user-visible behavior** (features, enhancements, + new commands or options) → Call the implementation agent +- **Formal bug resolution** (complex debugging, unknown root cause) → Call the implementation agent - **Formal reviews** (compliance verification, detailed analysis) → Call the formal-review agent +- **Structural audit**: (repository layout vs. template) → Call the template-sync agent +- **Implementation planning only** (review a plan before committing to implementation) → Call the planning agent # Agent Reporting (Specialized Agents Must Follow) @@ -93,16 +103,16 @@ Specialized agents MUST generate a completion report: 1. Save to `.agent-logs/{agent-name}-{subject}-{unique-id}.md` where `{subject}` is a kebab-case task summary (max 5 words) and `{unique-id}` is a short unique suffix (e.g., 8-char hex or timestamp) -2. Start with `**Result**: (SUCCEEDED|FAILED)` as the first metadata field +2. Start with `**Result**: (SUCCEEDED|FAILED|INCOMPLETE)` as the first metadata field 3. Include the agent-specific report sections defined in each agent's prompt 4. Return the summary to the caller Result semantics for orchestrator decision-making: -- **SUCCEEDED**: Work completed and all applicable quality gates met +- **SUCCEEDED**: Work completed and all quality gates applicable to that agent's scope met - **FAILED**: Work could not be completed or quality gates not met - **INCOMPLETE**: Work cannot proceed without information only the user can - provide (implementation agent only) + provide (implementation, planning, and template-sync agents) # Formatting (After Making Changes) diff --git a/README.md b/README.md index b3e53de..1f5dabd 100644 --- a/README.md +++ b/README.md @@ -188,17 +188,21 @@ tests: count: 1 - name: TestProject_PackageValid - # Distribution zip contains required entries + # Distribution zip contains required entries with full assertion suite tags: [package] files: - pattern: "output/package.zip" zip: - entries: + files: - pattern: 'lib/net8.0/MyLib.dll' min: 1 max: 1 - pattern: 'lib/**/*.dll' min: 1 + - pattern: 'LICENSE' + min: 1 + text: + - contains: "MIT License" ``` ### Acceptance Criteria Reference @@ -245,10 +249,17 @@ tests: | `json[].count` | Exact number of matched JSON nodes | | `json[].min` | Minimum number of matched JSON nodes | | `json[].max` | Maximum number of matched JSON nodes | -| `zip:` | Zip archive entry assertions (fails if not a valid zip) | -| `zip.entries[].pattern` | Glob pattern selecting zip archive entry names | -| `zip.entries[].min` | Minimum number of matching zip entries | -| `zip.entries[].max` | Maximum number of matching zip entries | +| `zip:` | Zip archive assertions (fails if file is not a valid zip) | +| `zip.files[].pattern` | Glob pattern selecting zip archive entry names | +| `zip.files[].min` | Minimum number of matching zip entries | +| `zip.files[].max` | Maximum number of matching zip entries | +| `zip.files[].text` | Text content assertions applied to each matching zip entry | +| `zip.files[].pdf` | PDF assertions applied to each matching zip entry | +| `zip.files[].xml` | XML assertions applied to each matching zip entry | +| `zip.files[].html` | HTML assertions applied to each matching zip entry | +| `zip.files[].yaml` | YAML assertions applied to each matching zip entry | +| `zip.files[].json` | JSON assertions applied to each matching zip entry | +| `zip.files[].zip` | Nested zip assertions (zip-in-zip) | ## Self Validation diff --git a/docs/design/file-assert.md b/docs/design/file-assert.md index 45931c8..d3dd06d 100644 --- a/docs/design/file-assert.md +++ b/docs/design/file-assert.md @@ -21,6 +21,8 @@ files do not meet the declared constraints. | | if the file cannot be parsed. | | Output and logging | Report results to stdout/stderr and optionally to a log file. | | Self-validation | Verify core functionality at run time via built-in tests. | +| Validation output depth | Accept a `--depth` value (1-6) controlling the Markdown heading level of | +| | self-validation output, defaulting to 1. | | Results serialization | Write test outcome records to TRX or JUnit XML format. | ## Software Item Hierarchy @@ -32,13 +34,14 @@ one or more units: | :------------ | :-------- | :---------------------------------------------------------------------------- | | FileAssert | System | — | | Program | Unit | — | -| Cli | Subsystem | Context | +| Cli | Subsystem | IContext, Context | | Configuration | Subsystem | FileAssertConfig, FileAssertData | | Modeling | Subsystem | FileAssertTest, FileAssertFile, FileAssertRule, | | | | FileAssertTextAssert, FileAssertPdfAssert, FileAssertXmlAssert, | | | | FileAssertHtmlAssert, FileAssertYamlAssert, FileAssertJsonAssert, | | | | FileAssertZipAssert | -| Utilities | Subsystem | PathHelpers, TemporaryDirectory | +| Utilities | Subsystem | IFileContainer, DirectoryFileContainer, ZipFileContainer, | +| | | PathHelpers, TemporaryDirectory | | SelfTest | Subsystem | Validation | ## Execution Flow @@ -49,7 +52,9 @@ The following sequence describes the normal execution path: 2. `Program.Run` inspects context flags in priority order: a. `--version` — prints the version string and exits. b. `--help` — prints usage information and exits. - c. `--validate` — delegates to `Validation.Run` for self-validation and exits. + c. `--validate` — delegates to `Validation.Run` for self-validation and exits. The optional + `--depth` value (default 1, range 1-6) sets the Markdown heading level used for the + self-validation report so it can be embedded at an appropriate level within a larger document. d. Default — delegates to `Program.RunToolLogic`. 3. `RunToolLogic` resolves the configuration file from `context.ConfigFile` (default: `.fileassert.yaml`; overridden by `--config`). If absent, it prints guidance (default @@ -90,14 +95,15 @@ The following sequence describes the normal execution path: The FileAssert system contains one top-level unit and five subsystems. There is no system-level code; the system boundary is defined by the combination of its parts. -| Item | Level | Responsibility | -| :------------ | :-------- | :------------------------------------------------------------------------- | -| Program | Unit | Entry point; creates `Context`; dispatches to validation or config logic. | -| Cli | Subsystem | Contains `Context`; owns arg parsing, I/O references, filter list, exit. | -| Configuration | Subsystem | Contains `FileAssertConfig`/`FileAssertData`; YAML deserialization, tests. | -| Modeling | Subsystem | Contains assertion classes; pure domain objects evaluating file rules. | -| Utilities | Subsystem | Contains `PathHelpers` and `TemporaryDirectory`; shared utilities. | -| SelfTest | Subsystem | Contains `Validation`; runs built-in assertions when `--validate` passed. | +| Item | Level | Responsibility | +| :------------ | :-------- | :---------------------------------------------------------------------------------- | +| Program | Unit | Entry point; creates `Context`; dispatches to validation or config logic. | +| Cli | Subsystem | Contains `IContext`/`Context`; owns arg parsing, I/O references, filter list, exit. | +| Configuration | Subsystem | Contains `FileAssertConfig`/`FileAssertData`; YAML deserialization, tests. | +| Modeling | Subsystem | Contains assertion classes; pure domain objects evaluating file rules. | +| Utilities | Subsystem | Contains `IFileContainer`, `DirectoryFileContainer`, `ZipFileContainer`, | +| | | `PathHelpers`, and `TemporaryDirectory`; shared utilities. | +| SelfTest | Subsystem | Contains `Validation`; runs built-in assertions when `--validate` passed. | All subsystems receive a `Context` instance (created by `Program`) rather than reading command-line arguments directly. This removes argument-parsing concerns from every diff --git a/docs/design/file-assert/cli.md b/docs/design/file-assert/cli.md index a7af625..c1e23fd 100644 --- a/docs/design/file-assert/cli.md +++ b/docs/design/file-assert/cli.md @@ -8,9 +8,10 @@ and execution decisions. ### Subsystem Contents -| Unit | File | Responsibility | -| :-------- | :------------ | :-------------------------------------------------------- | -| `Context` | `Context.cs` | Parses arguments and owns all I/O operations. | +| Unit | File | Responsibility | +| :--------- | :------------ | :------------------------------------------------------------------- | +| `IContext` | `IContext.cs` | Output contract interface for reporting messages and errors. | +| `Context` | `Context.cs` | Parses arguments and owns all I/O operations. | ### Subsystem Responsibilities @@ -23,6 +24,16 @@ and execution decisions. ### Interfaces +The `IContext` interface exposes the following public members: + +#### Methods + +| Method | Signature | Description | +| :----------- | :------------------------------------ | :----------------------------------------------------------------------- | +| `WriteLine` | `void WriteLine(string message)` | Writes an informational message. | +| `WriteError` | `void WriteError(string message)` | Writes an error message and marks the context as having errors. | +| `WithPrefix` | `IContext WithPrefix(string prefix)` | Returns a scoped context prepending `"{prefix} > "` to error messages. | + The `Context` unit exposes the following public interface: #### Properties @@ -39,7 +50,7 @@ The `Context` unit exposes the following public interface: | `Filters` | `IReadOnlyList` | Positional name-or-tag filter arguments. | | `ExitCode` | `int` | `0` when no errors have been reported; `1` otherwise. | -#### Methods +#### Context Methods | Method | Signature | Description | | :----------- | :------------------------------------- | :----------------------------------------------------------- | @@ -78,7 +89,11 @@ The `Context` class uses the following collaboration flow: 6. `WriteError` additionally sets the internal `_hasErrors` flag, causing `ExitCode` to return `1` for the remainder of the context's lifetime. -### Interactions with Other Subsystems +### Dependencies + +- None. + +### Callers | Consumer | Usage | | :---------------- | :------------------------------------------------------------------- | diff --git a/docs/design/file-assert/cli/context.md b/docs/design/file-assert/cli/context.md index 2d1f3a1..31448fd 100644 --- a/docs/design/file-assert/cli/context.md +++ b/docs/design/file-assert/cli/context.md @@ -38,11 +38,29 @@ Delegates argument parsing to the private `ArgumentParser` nested class. Opens a ```csharp public void WriteLine(string message) public void WriteError(string message) +public IContext WithPrefix(string prefix) ``` `WriteLine` writes to stdout and the log file (unless `--silent` suppresses console output). `WriteError` sets the internal error flag, writes to stderr in red (unless silent), and writes to the log file. +`WithPrefix` creates and returns a new `ScopedContext` that prepends `"{prefix} > "` to every +`WriteError` message before delegating to this context. The prefix must not be null. + +##### ScopedContext Nested Class + +`ScopedContext` is a private sealed class nested inside `Context`. It implements `IContext` and +holds a reference to its parent `IContext` and a prefix string: + +- `WriteLine` — delegated unchanged to the parent context. +- `WriteError` — the message is rewritten as `"{_prefix} > {message}"` before passing to the + parent, so all errors bubble up through the chain and reach the root context's `_hasErrors` + and `_errorCount` fields. +- `WithPrefix` — creates a further-nested `ScopedContext(this, prefix)`, enabling arbitrarily + deep breadcrumb chains (e.g., `"outer.zip > inner.zip > error message"`). + +`ScopedContext` is used by `FileAssertZipAssert.Run` to scope every error message with the +archive entry's display path before passing the context to nested `FileAssertFile` assertions. ##### Argument Parsing @@ -57,6 +75,10 @@ The private nested class `ArgumentParser` processes each argument in order: #### Design Decisions +- **Implements `IContext`**: `Context` implements the `IContext` interface, which is also + implemented by `ScopedContext`. This allows all asserters to accept `IContext` rather than the + concrete `Context`, enabling `FileAssertZipAssert` to supply a scoped context without those + asserters requiring any knowledge of the scoping mechanism. - **Sealed with IDisposable**: The class is sealed to prevent inheritance of internal state, and implements `IDisposable` to ensure the log file stream is always closed. - **Factory method**: The `Create` factory method is `public` so tests and the self-validation @@ -100,6 +122,7 @@ to the console. | `Context.Create(string[])` | Factory: parses args, opens log file, returns initialized instance. | | `WriteLine(string)` | Writes to stdout (unless silent) and log file. | | `WriteError(string)` | Sets error flag and counter; writes to stderr/log (unless silent). | +| `WithPrefix(string) → IContext` | Returns a new `ScopedContext` prepending the given prefix to errors.| | `Dispose()` | Closes and disposes the log-file stream writer. | | `ArgumentParser.ParseArguments(string[])` | Inner class: translates argument array into named parser state. | @@ -112,9 +135,15 @@ to the console. | Value-requiring flag with no value | `ArgumentException` propagated to the caller of `Create`. | | `--depth` value not in 1–6 range | `ArgumentException` propagated to the caller of `Create`. | | Log file cannot be opened | `InvalidOperationException` wrapping the underlying I/O. | +| Null `prefix` passed to `WithPrefix` | `ArgumentNullException` thrown before `ScopedContext` created.| | Assertion or rule failure at runtime | Handled by `WriteError`; no throw — errors in `_errorCount`. | -#### Interactions +#### Dependencies + +- **Internal dependency**: `ArgumentParser` (private nested class) is used exclusively by + `Context.Create`. `ScopedContext` (private nested class) is returned by `WithPrefix`. + +#### Callers - **Callers**: `Program.RunToolLogic` constructs a `Context` via `Context.Create` and passes it to all execution paths. `Validation.Run` constructs additional `Context` instances to drive @@ -122,6 +151,6 @@ to the console. - **Consumers**: `FileAssertConfig.Run`, `FileAssertTest.Run`, `FileAssertFile.Run`, and every assert unit (`FileAssertTextAssert`, `FileAssertPdfAssert`, `FileAssertXmlAssert`, `FileAssertHtmlAssert`, `FileAssertYamlAssert`, `FileAssertJsonAssert`, `FileAssertZipAssert`) - receive a `Context` reference and call `WriteLine` / `WriteError` to report results. -- **Internal dependency**: `ArgumentParser` (private nested class) is used exclusively by - `Context.Create`. + receive an `IContext` reference and call `WriteLine` / `WriteError` to report results. + `FileAssertZipAssert` additionally calls `context.WithPrefix(displayPath)` to derive a scoped + context for nested zip entry assertions. diff --git a/docs/design/file-assert/cli/i-context.md b/docs/design/file-assert/cli/i-context.md new file mode 100644 index 0000000..6162fba --- /dev/null +++ b/docs/design/file-assert/cli/i-context.md @@ -0,0 +1,99 @@ +### IContext Design + +#### Overview + +`IContext` is the output contract interface for reporting assertion results and errors within +FileAssert. It is implemented by `Context` (the root context) and by `Context.ScopedContext` (a +scoped wrapper that prepends a path prefix to every error message). + +Accepting `IContext` in asserter `Run` methods and in `FileAssertFile.Run` allows +`FileAssertZipAssert` to pass a scoped context to nested asserters when processing zip archive +entries, without those asserters requiring any knowledge of the scoping mechanism. + +#### Purpose + +`IContext` exists to decouple the asserters from the concrete output and error-tracking +implementation. By depending only on this interface, each asserter can write informational +output and errors, and request a scoped (breadcrumb-prefixed) child context, without knowing +whether it holds the root `Context` or a nested `ScopedContext`. This enables consistent, +self-describing error reporting across plain and nested (zip) assertion scenarios while keeping +the scoping mechanism entirely transparent to callers. + +#### Interface Members + +```csharp +internal interface IContext +{ + void WriteLine(string message); + void WriteError(string message); + IContext WithPrefix(string prefix); +} +``` + +| Member | Description | +| :----------------------------- | :-------------------------------------------------------------------------------- | +| `WriteLine(string message)` | Writes an informational output line. Does not affect the error state. | +| `WriteError(string message)` | Writes an error message and marks the context as having errors. | +| `WithPrefix(string prefix)` | Returns a new scoped context that prepends `"{prefix} > "` to all error messages. | + +#### ScopedContext + +`ScopedContext` is a private sealed nested class inside `Context`. It implements `IContext` and holds +a reference to its parent `IContext`. All calls are delegated: + +- `WriteLine` — delegated unchanged to the parent. +- `WriteError` — the message is prefixed with `"{_prefix} > "` before being passed to the parent. +- `WithPrefix` — creates a further-nested `ScopedContext` wrapping `this`, enabling arbitrary breadcrumb depth. + +This chain ensures that all scoped errors ultimately reach the root `Context` and increment its +`ErrorCount` and `ExitCode`. + +#### Design Rationale + +- **Interface not abstract class**: Using an interface rather than a base class avoids inheritance + hierarchies and allows `ScopedContext` to be a lightweight private nested class with no + instance state beyond a prefix string and a parent reference. +- **Prefix as breadcrumb**: Scoped error messages produced while asserting a zip entry appear as + `"archive.zip > entry.xml > error message"`, giving users immediate context about which archive + and entry caused the failure without requiring any special formatting in the asserter itself. +- **`WithPrefix` on `IContext`**: Declaring `WithPrefix` on the interface rather than only on + `Context` means that asserters can scope further without a cast, supporting arbitrarily deep + nesting (e.g., zip-in-zip-in-zip). + +#### Data Model + +`IContext` carries no instance data. `ScopedContext` holds: + +| Field | Type | Description | +| :-------- | :--------- | :---------------------------------------------------- | +| `_parent` | `IContext` | The parent context to delegate all calls to. | +| `_prefix` | `string` | The prefix prepended to every `WriteError` message. | + +#### Key Methods + +| Method | Description | +| :------------------------------ | :------------------------------------------------ | +| `WriteLine(string)` | Informational output, no error state change. | +| `WriteError(string)` | Error message with prefix, delegates to parent. | +| `WithPrefix(string) → IContext` | Returns a new nested `ScopedContext`. | + +#### Error Handling + +| Scenario | Handling | +| :--------------------------------- | :-------------------------------------------------------------------- | +| Null prefix passed to `WithPrefix` | `ArgumentNullException` thrown before construction of scoped context. | + +#### Dependencies + +- No external dependencies. `IContext` and `ScopedContext` are self-contained within the `Cli` + namespace. + +#### Callers + +- `FileAssertFile.Run` — accepts `IContext` instead of `Context`. +- All 7 asserters (`FileAssertTextAssert`, `FileAssertXmlAssert`, `FileAssertHtmlAssert`, + `FileAssertYamlAssert`, `FileAssertJsonAssert`, `FileAssertPdfAssert`, + `FileAssertZipAssert`) — each `Run` method accepts `IContext`. +- `FileAssertZipAssert.Run` — calls `context.WithPrefix(displayPath)` before passing the + scoped context to nested file assertions inside a zip archive. +- `FileAssertTest.Run` — accepts `IContext` and passes it down the assertion chain. diff --git a/docs/design/file-assert/configuration.md b/docs/design/file-assert/configuration.md index 2247392..651d208 100644 --- a/docs/design/file-assert/configuration.md +++ b/docs/design/file-assert/configuration.md @@ -56,13 +56,19 @@ and runs the tests. 3. `FileAssertData` classes are pure data holders: they carry no logic and are used only during deserialization. Once `ReadFromFile` returns, the DTOs are discarded. -### Interactions with Other Subsystems +### Dependencies -| Dependency | Usage | -| :---------- | :-------------------------------------------------------------------------- | -| Cli | Receives a `Context` to report errors and write progress output. | -| Modeling | Delegates test construction to `FileAssertTest.Create` and execution to | -| | `FileAssertTest.Run`. | +| Dependency | Usage | +| :------------------------- | :--------------------------------------------------------------- | +| Cli | Receives a `Context` to report errors and write progress output. | +| Modeling | Delegates test construction to `FileAssertTest.Create` and | +| | execution to `FileAssertTest.Run`. | +| DemaConsulting.TestResults | Aggregates per-test results into TRX/JUnit output documents. | + +### Callers + +- `Program` — reads the configuration file and invokes + `FileAssertConfig.ReadFromFile` followed by `FileAssertConfig.Run`. ### YAML Configuration Format diff --git a/docs/design/file-assert/configuration/file-assert-config.md b/docs/design/file-assert/configuration/file-assert-config.md index e002c17..b1f089d 100644 --- a/docs/design/file-assert/configuration/file-assert-config.md +++ b/docs/design/file-assert/configuration/file-assert-config.md @@ -143,12 +143,15 @@ serialization. | Results file write failure | Exception caught; error written via `context.WriteError`. | | Individual test assertion failures | Accumulated in `context` via `WriteError`; run continues. | -#### Interactions +#### Dependencies -- **Caller**: `Program.RunToolLogic` calls `ReadFromFile` with `context.ConfigFile`, then calls - `Run(context, context.Filters)`. - **Creates**: `FileAssertTest` instances via `FileAssertTest.Create` during `ReadFromFile`. - **Calls**: `FileAssertTest.MatchesFilter` and `FileAssertTest.Run` for each qualifying test. - **Uses**: `Context` for output and error reporting; `DemaConsulting.TestResults.IO.TrxSerializer` and `JUnitSerializer` for results serialization; `YamlDotNet.Serialization.DeserializerBuilder` for configuration parsing. + +#### Callers + +- **Caller**: `Program.RunToolLogic` calls `ReadFromFile` with `context.ConfigFile`, then calls + `Run(context, context.Filters)`. diff --git a/docs/design/file-assert/configuration/file-assert-data.md b/docs/design/file-assert/configuration/file-assert-data.md index 742385f..eb7a737 100644 --- a/docs/design/file-assert/configuration/file-assert-data.md +++ b/docs/design/file-assert/configuration/file-assert-data.md @@ -105,19 +105,12 @@ assertion blocks. Represents the `zip:` assertion block for a file entry. -| Property | YAML alias | Type | Description | -| :--------- | :---------- | :--------------------------------- | :------------------------------------- | -| `Entries` | `entries` | `List?` | Entry glob pattern constraints. | +| Property | YAML alias | Type | Description | +| :--------- | :---------- | :---------------------------- | :-------------------------------------------------------- | +| `Files` | `files` | `List?` | Full file assertions applied to the zip archive contents. | -##### FileAssertZipEntryData - -Represents a single zip archive entry count constraint. - -| Property | YAML alias | Type | Description | -| :-------- | :--------- | :-------- | :------------------------------------------------------ | -| `Pattern` | `pattern` | `string?` | Glob pattern matched against normalized entry names. | -| `Min` | `min` | `int?` | Minimum number of matching entries; null means no bound. | -| `Max` | `max` | `int?` | Maximum number of matching entries; null means no bound. | +The `Files` property maps to the `files:` YAML key inside a `zip:` block. Each entry uses the +same `FileAssertFileData` schema as top-level file assertions. #### Design Decisions @@ -155,8 +148,7 @@ by YamlDotNet. | `FileAssertPdfMetadataRuleData` | `Field?, Contains?, Matches?` | | `FileAssertPdfPagesData` | `Min: int?`, `Max: int?` | | `FileAssertQueryData` | `Query?, Count?, Min?, Max?` | -| `FileAssertZipData` | `Entries: List?` | -| `FileAssertZipEntryData` | `Pattern?, Min?, Max?` | +| `FileAssertZipData` | `Files: List?` (deserialized from the YAML `files:` key)| All properties are nullable so that absent YAML keys deserialize cleanly to `null`. @@ -172,7 +164,11 @@ a `YamlException` that propagates directly to `FileAssertConfig.ReadFromFile`. C validation (e.g. exactly one rule type per `FileAssertRuleData`) is the responsibility of the Modeling subsystem factory methods. -#### Interactions +#### Dependencies + +- None. + +#### Callers - **Populated by**: `YamlDotNet.Serialization.Deserializer` inside `FileAssertConfig.ReadFromFile` via `DeserializerBuilder().IgnoreUnmatchedProperties().Build()`. diff --git a/docs/design/file-assert/modeling.md b/docs/design/file-assert/modeling.md index 656d74c..d1d65a5 100644 --- a/docs/design/file-assert/modeling.md +++ b/docs/design/file-assert/modeling.md @@ -19,7 +19,7 @@ executable domain objects and drives the assertion logic. | `FileAssertHtmlAssert` | `FileAssertHtmlAssert.cs` | Parses HTML; applies XPath node count assertions. | | `FileAssertYamlAssert` | `FileAssertYamlAssert.cs` | Parses YAML; applies dot-notation path assertions. | | `FileAssertJsonAssert` | `FileAssertJsonAssert.cs` | Parses JSON; applies dot-notation path assertions. | -| `FileAssertZipAssert` | `FileAssertZipAssert.cs` | Opens zip archive; applies entry glob count checks. | +| `FileAssertZipAssert` | `FileAssertZipAssert.cs` | Opens zip; applies full assertion suite to entries. | ### Subsystem Responsibilities @@ -31,8 +31,8 @@ executable domain objects and drives the assertion logic. - Parse matched files as PDF, XML, HTML, YAML, or JSON documents when the corresponding assertion block is declared. - Report an immediate error when a file cannot be parsed as the declared format. - Apply structured-document query assertions (XPath or dot-notation) to parsed document nodes. -- Open zip archives and match entry names against glob patterns, enforcing count constraints. -- Report assertion failures via the `Context` from the Cli subsystem. +- Open zip archives, wrap them in `ZipFileContainer`, and apply the full assertion suite to their entries. +- Report assertion failures via the `IContext` interface from the Cli subsystem, enabling scoped breadcrumb error messages for nested zip assertions. ### Object Hierarchy @@ -69,7 +69,8 @@ FileAssertTest | Dependency | Usage | | :------------------------- | :----------------------------------------------------------------------------- | -| `Context` (Cli subsystem) | Receives assertion failure messages and error exit code. | +| `IContext` (Cli subsystem) | Receives assertion failure messages and error exit code. | +| `IFileContainer` (Utilities subsystem) | Uniform file-access abstraction; enables assertions over directories and zips. | | `FileAssertData` DTOs | Input types for all `Create` factory methods. | | Microsoft.Extensions.FileSystemGlobbing | Cross-platform glob evaluation for file discovery. | | YamlDotNet, PdfPig, HtmlAgilityPack, System.Xml.Linq, System.Text.Json, System.IO.Compression | Format-specific parsing libraries. | @@ -84,18 +85,30 @@ Domain objects are constructed and executed in the following layers: 2. `FileAssertTextAssert.Create` iterates rule data, calling `FileAssertRule.Create` for each entry to produce the correct concrete rule subclass. 3. During execution, `FileAssertConfig.Run` calls `FileAssertTest.Run` → `FileAssertFile.Run` - → assert unit `Run` methods, threading `Context` through every layer so all failures are - reported via a single path. + → assert unit `Run` methods, threading `IContext` through every layer so all failures are + reported via a single path. `FileAssertTest.Run` wraps the base path in a + `DirectoryFileContainer` before passing it down. `FileAssertZipAssert.Run` calls + `context.WithPrefix(displayPath)` to create a scoped `IContext` that prepends the archive + path as a breadcrumb to every nested error message. -### Interactions with Other Subsystems +### Dependencies -| Dependency | Usage | -| :------------ | :------------------------------------------------------- | -| Cli | Receives `Context` to report assertion failures. | -| Configuration | Accepts DTO types for test, file, and rule construction. | +| Dependency | Usage | +| :------------ | :------------------------------------------------------------- | +| Cli | Receives `IContext` to report assertion failures. | +| Utilities | Accesses files via `IFileContainer` and `ZipFileContainer`. | +| Configuration | Accepts DTO types for test, file, and rule construction. | + +### Callers + +- `FileAssertConfig` — instantiates `FileAssertTest` items and drives the modeling subsystem + through `FileAssertTest.Run`. ### Design Decisions +- **`IContext` over `Context`**: All asserters and `FileAssertFile` accept `IContext` rather than + the concrete `Context` class. This allows `FileAssertZipAssert` to pass a scoped prefix context + to nested asserters without those asserters requiring any knowledge of the scoping mechanism. - **Factory methods over constructors**: Each domain class provides an `internal static Create` method that validates the DTO and constructs the domain object, keeping constructors private. - **Error accumulation**: Failures are reported via `context.WriteError` rather than exceptions, @@ -109,3 +122,7 @@ Domain objects are constructed and executed in the following layers: - **Immediate failure on parse error**: If a file-type assertion block is declared and the file cannot be parsed, an error is written immediately and no further assertions for that file are evaluated. +- **Full assertion suite in zip archives**: `FileAssertZipAssert` wraps the zip entry stream in + a `ZipFileContainer`, then runs `FileAssertFile` against it. This means every asserter (text, + xml, html, yaml, json, pdf, and recursive zip) is available for zip entry validation without + any per-asserter changes. diff --git a/docs/design/file-assert/modeling/file-assert-file.md b/docs/design/file-assert/modeling/file-assert-file.md index 4a48776..89ad875 100644 --- a/docs/design/file-assert/modeling/file-assert-file.md +++ b/docs/design/file-assert/modeling/file-assert-file.md @@ -39,13 +39,14 @@ that block is present. ##### Execution Method ```csharp -internal void Run(Context context, string basePath) +internal void Run(IContext context, IFileContainer container) ``` Execution proceeds in five phases: 1. **File discovery** — `Microsoft.Extensions.FileSystemGlobbing.Matcher` evaluates - `Pattern` relative to `basePath` and returns the list of matched file paths. + `Pattern` against the entries returned by `container.GetEntries()` and returns the + list of matched entry paths. 2. **Minimum count validation** — If `Min` is set and the match count is below it, an error is written and execution returns immediately. @@ -57,31 +58,17 @@ Execution proceeds in five phases: it, an error is written and execution returns immediately. Early return prevents misleading per-file errors when the count constraint already signals a failure. -5. **Per-file validation** — Each matched file is inspected individually: - a. Validates size constraints (`MinSize`, `MaxSize`) using `FileInfo.Length`. Size - violations are recorded via `context.WriteError` but do NOT cause early return; +5. **Per-file validation** — Each matched entry is inspected individually: + a. Validates size constraints (`MinSize`, `MaxSize`) using `container.GetEntrySize(entryPath)`. + Size violations are recorded via `context.WriteError` but do NOT cause early return; the remaining per-file assertions continue to execute. - b. If `TextAssert` is defined, delegates to `FileAssertTextAssert` which reads the - file as text and applies each `FileAssertRule`. - c. If `PdfAssert` is defined, attempts to parse the file using PdfPig; reports - an immediate error if parsing fails, otherwise applies metadata, page, and - body text assertions. - d. If `XmlAssert` is defined, attempts to parse the file using `System.Xml.Linq`; - reports an immediate error if parsing fails, otherwise applies XPath node count - assertions. - e. If `HtmlAssert` is defined, attempts to parse the file using HtmlAgilityPack; - reports an immediate error if parsing fails, otherwise applies XPath node count - assertions. - f. If `YamlAssert` is defined, attempts to parse the file using YamlDotNet; reports - an immediate error if parsing fails, otherwise applies dot-notation path count - assertions. - g. If `JsonAssert` is defined, attempts to parse the file using `System.Text.Json`; - reports an immediate error if parsing fails, otherwise applies dot-notation path - count assertions. - h. If `ZipAssert` is defined, attempts to open the file as a zip archive using - `System.IO.Compression.ZipFile`; reports an immediate error if the archive cannot - be opened, otherwise matches entry names against each configured glob pattern and - enforces the declared minimum and maximum count constraints. + b. If `TextAssert` is defined, delegates to `FileAssertTextAssert.Run(context, container, entryPath)`. + c. If `PdfAssert` is defined, delegates to `FileAssertPdfAssert.Run(context, container, entryPath)`. + d. If `XmlAssert` is defined, delegates to `FileAssertXmlAssert.Run(context, container, entryPath)`. + e. If `HtmlAssert` is defined, delegates to `FileAssertHtmlAssert.Run(context, container, entryPath)`. + f. If `YamlAssert` is defined, delegates to `FileAssertYamlAssert.Run(context, container, entryPath)`. + g. If `JsonAssert` is defined, delegates to `FileAssertJsonAssert.Run(context, container, entryPath)`. + h. If `ZipAssert` is defined, delegates to `FileAssertZipAssert.Run(context, container, entryPath)`. ##### Count Constraint Error Messages @@ -94,8 +81,8 @@ Pattern '' matched file(s), but expected exactly ##### Size Constraint Error Messages ```text -File '' is byte(s), which is less than the minimum bytes -File '' is byte(s), which exceeds the maximum bytes +File '' is byte(s), which is less than the minimum bytes +File '' is byte(s), which exceeds the maximum bytes ``` #### YAML Configuration @@ -116,6 +103,9 @@ All properties except `pattern` are optional. #### Design Decisions +- **`IContext` and `IFileContainer` parameters**: Accepting interfaces rather than concrete types + allows `FileAssertFile` to be called identically for on-disk files (via `DirectoryFileContainer`) + and for zip archive entries (via `ZipFileContainer`), with no conditional logic in this class. - **Glob via FileSystemGlobbing**: The `Microsoft.Extensions.FileSystemGlobbing` library is already a project dependency and provides cross-platform glob support consistent with the rest of the .NET ecosystem. @@ -154,10 +144,10 @@ delegates per-file content validation to file-type-specific assert units. #### Key Methods -| Method | Purpose | -| :--------------------------------------- | :----------------------------------------------------------------- | -| `Create(FileAssertFileData data)` | Factory: validates pattern, builds assert units, returns instance. | -| `Run(Context context, string basePath)` | Discovers files, checks count/size, delegates to assert units. | +| Method | Purpose | +| :----------------------------------------------- | :----------------------------------------------------------------- | +| `Create(FileAssertFileData data)` | Factory: validates pattern, builds assert units, returns instance. | +| `Run(IContext context, IFileContainer container)` | Discovers entries, checks count/size, delegates to assert units. | #### Error Handling @@ -170,10 +160,8 @@ delegates per-file content validation to file-type-specific assert units. | File size outside `MinSize`/`MaxSize` | Error written via `context.WriteError`; per-file assertions continue. | | Parse errors in assert units | Assert units catch parse exceptions and call `context.WriteError`. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertTest.Run` iterates the `Files` collection and calls `Run` on each instance. -- **Created by**: `FileAssertTest.Create` via `FileAssertFile.Create`. - **Delegates to**: - `FileAssertTextAssert.Run` for text content rules. - `FileAssertPdfAssert.Run` for PDF document rules. @@ -183,3 +171,8 @@ delegates per-file content validation to file-type-specific assert units. - `FileAssertJsonAssert.Run` for JSON path rules. - `FileAssertZipAssert.Run` for zip archive entry rules. - **OTS dependency**: `Microsoft.Extensions.FileSystemGlobbing.Matcher` for file discovery. + +#### Callers + +- **Caller**: `FileAssertTest.Run` iterates the `Files` collection and calls `Run` on each instance. +- **Created by**: `FileAssertTest.Create` via `FileAssertFile.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-html-assert.md b/docs/design/file-assert/modeling/file-assert-html-assert.md index 47624ae..9e9216c 100644 --- a/docs/design/file-assert/modeling/file-assert-html-assert.md +++ b/docs/design/file-assert/modeling/file-assert-html-assert.md @@ -14,20 +14,20 @@ matching nodes. The main class coordinating XPath-based node count assertions for an HTML file. -###### FileAssertHtmlAssert Properties +###### FileAssertHtmlAssert Fields -| Property | -| :-------- | -| `Queries` | +| Field | Type | Description | +| :--------- | :------------------------- | :---------------------- | +| `_queries` | `IReadOnlyList` | XPath query assertions. | -Each `HtmlQuery` entry holds: +Each `HtmlQuery` private nested record holds: -| Property | -| :------- | -| `Query` | -| `Count` | -| `Min` | -| `Max` | +| Property | Type | Description | +| :------- | :------- | :------------------------------- | +| `Query` | `string` | XPath query to evaluate. | +| `Count` | `int?` | Exact number of matched nodes. | +| `Min` | `int?` | Minimum number of matched nodes. | +| `Max` | `int?` | Maximum number of matched nodes. | ###### FileAssertHtmlAssert Factory @@ -38,7 +38,7 @@ internal static FileAssertHtmlAssert Create(IEnumerable dat ###### FileAssertHtmlAssert Run ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Execution proceeds in the following steps: @@ -99,41 +99,44 @@ constraints per query. #### Data Model -| Field / Property | -| :--------------- | -| `Queries` | +| Field / Property | Type | Description | +| :--------------- | :------------------------- | :-------------------------------------- | +| `_queries` | `IReadOnlyList` | Ordered list of XPath query assertions. | Each `HtmlQuery` (private nested record) holds: -| Property | -| :------- | -| `Query` | -| `Count` | -| `Min` | -| `Max` | +| Property | Type | Description | +| :------- | :------- | :--------------------------------------- | +| `Query` | `string` | XPath query to evaluate. | +| `Count` | `int?` | Expected exact node count; `null` = N/A. | +| `Min` | `int?` | Minimum node count; `null` = no bound. | +| `Max` | `int?` | Maximum node count; `null` = no bound. | #### Key Methods -| Method | -| :---------------------------------------------- | -| `Create(IEnumerable data)` | -| `Run(Context context, string fileName)` | +| Method | Purpose | +| :---------------------------------------------- | :--------------------------------------------------- | +| `Create(IEnumerable data)` | Converts query DTOs to `HtmlQuery` instances. | +| `Run(IContext, IFileContainer, string)` | Parses the HTML file and evaluates each XPath query. | #### Error Handling -| Scenario | -| :----------------------------------------------------------------------- | -| `IOException` or `UnauthorizedAccessException` while loading the file | -| Query XPath expression is invalid (`XPathException`) | -| Query result below `Min` | -| Query result above `Max` | -| Query result not equal to `Count` | +| Scenario | Handling | +| :-------------------------------------------------------------------- | :------------------------------------------------------------------- | +| `IOException` or `UnauthorizedAccessException` while loading the file | Error written via `context.WriteError`; `Run` returns immediately. | +| Query XPath expression is invalid (`XPathException`) | Error written via `context.WriteError`; subsequent queries continue. | +| Query result below `Min` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `HtmlAssert.Run(context, fileName)` when the `html:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertHtmlAssert.Create`. - **OTS dependency**: `HtmlAgilityPack.HtmlDocument` for lenient HTML parsing and `HtmlDocument.DocumentNode.SelectNodes` for XPath evaluation. - **Configuration dependency**: `FileAssertQueryData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `HtmlAssert.Run(context, container, entryPath)` when the `html:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertHtmlAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-json-assert.md b/docs/design/file-assert/modeling/file-assert-json-assert.md index b22acd4..18910af 100644 --- a/docs/design/file-assert/modeling/file-assert-json-assert.md +++ b/docs/design/file-assert/modeling/file-assert-json-assert.md @@ -16,14 +16,14 @@ The main class coordinating dot-notation path assertions for a JSON file. ###### FileAssertJsonAssert Properties -| Field | Type | Description | -| :--------- | :---------------------------- | :---------------------------------- | -| `_queries` | `IReadOnlyList` | Dot-notation path query assertions. | +| Field | Type | Description | +|:-----------|:---------------------------|:------------------------------------| +| `_queries` | `IReadOnlyList` | Dot-notation path query assertions. | Each `JsonQuery` entry holds: | Property | Type | Description | -| :------- | :------- | :------------------------------- | +|:---------|:---------|:---------------------------------| | `Query` | `string` | Dot-notation path to evaluate. | | `Count` | `int?` | Exact number of matched nodes. | | `Min` | `int?` | Minimum number of matched nodes. | @@ -38,13 +38,16 @@ internal static FileAssertJsonAssert Create(IEnumerable dat ###### FileAssertJsonAssert Run ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Execution proceeds in the following steps: -1. Reads the file content and calls `JsonDocument.Parse`. -2. If a `JsonException` is thrown, writes the error below and returns immediately. +1. Opens the matched entry via `container.OpenEntry(entryPath)`, reads its content, and calls + `JsonDocument.Parse`. +2. If a `JsonException` is thrown, writes the parse-error message below and returns immediately. + If an `IOException` or `UnauthorizedAccessException` is thrown while opening or reading the + entry, writes the IO-error message below and returns immediately. 3. For each query entry: traverses the JSON element tree following the dot-notation path segments, counts the matched properties or array elements, and applies `Count`, `Min`, and `Max` constraints against the match count. @@ -55,6 +58,12 @@ Execution proceeds in the following steps: File '' could not be parsed as a JSON document ``` +###### FileAssertJsonAssert IO Error Message + +```text +File '' could not be read +``` + ###### FileAssertJsonAssert Query Error Messages ```text @@ -99,38 +108,42 @@ enforces min, max, and exact element-count constraints per path. #### Data Model | Field / Property | Type | Description | -| :--------------- | :------------------------- | :-------------------------------------------- | +|:-----------------|:---------------------------|:----------------------------------------------| | `_queries` | `IReadOnlyList` | Ordered list of dot-notation path assertions. | Each `JsonQuery` (private nested record) holds: -| Property | Type | Description | -| :------- | :------- | :------------------------------------------------------- | -| `Query` | `string` | Dot-notation path to traverse. | -| `Count` | `int?` | Expected exact element count; `null` = N/A. | -| `Min` | `int?` | Minimum element count; `null` = no bound. | -| `Max` | `int?` | Maximum element count; `null` = no bound. | +| Property | Type | Description | +|:---------|:---------|:--------------------------------------------| +| `Query` | `string` | Dot-notation path to traverse. | +| `Count` | `int?` | Expected exact element count; `null` = N/A. | +| `Min` | `int?` | Minimum element count; `null` = no bound. | +| `Max` | `int?` | Maximum element count; `null` = no bound. | #### Key Methods | Method | Purpose | -| :---------------------------------------------- | :--------------------------------------------------------------- | +|:------------------------------------------------|:-----------------------------------------------------------------| | `Create(IEnumerable data)` | Factory: converts query DTOs to `JsonQuery` instances. | -| `Run(Context context, string fileName)` | Parses the JSON file and evaluates each dot-notation path query. | +| `Run(IContext, IFileContainer, string)` | Parses the JSON file and evaluates each dot-notation path query. | #### Error Handling -| Scenario | Handling | -| :------------------------------------------ | :------------------------------------------------------------------- | -| `JsonException` during `JsonDocument.Parse` | Error written via `context.WriteError`; `Run` returns immediately. | -| Query result below `Min` | Error written via `context.WriteError`; subsequent queries continue. | -| Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | -| Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | +| Scenario | Handling | +|:-------------------------------------------------------------------------------|:---------------------------------------------------------------------------------| +| `JsonException` during `JsonDocument.Parse` | Parse-error message written via `context.WriteError`; `Run` returns immediately. | +| `IOException`/`UnauthorizedAccessException` while opening or reading the entry | IO-error message written via `context.WriteError`; `Run` returns immediately. | +| Query result below `Min` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `JsonAssert.Run(context, fileName)` when the `json:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertJsonAssert.Create`. - **OTS dependency**: `System.Text.Json.JsonDocument` (BCL) for parsing and element traversal. - **Configuration dependency**: `FileAssertQueryData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `JsonAssert.Run(context, container, entryPath)` when the `json:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertJsonAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-pdf-assert.md b/docs/design/file-assert/modeling/file-assert-pdf-assert.md index 41dbf04..a20a19a 100644 --- a/docs/design/file-assert/modeling/file-assert-pdf-assert.md +++ b/docs/design/file-assert/modeling/file-assert-pdf-assert.md @@ -29,7 +29,7 @@ internal static PdfMetadataRule FromData(FileAssertPdfMetadataRuleData data) ###### PdfMetadataRule Apply ```csharp -internal void Apply(Context context, string fileName, string? value) +internal void Apply(IContext context, string fileName, string? value) ``` Checks `Contains` substring presence (ordinal) and `Matches` regex against `fieldValue`. @@ -61,7 +61,7 @@ internal static PdfPages FromData(FileAssertPdfPagesData data) ###### PdfPages Apply ```csharp -internal void Apply(Context context, string fileName, int n) +internal void Apply(IContext context, string fileName, int n) ``` Reports an error if `n < Min` or `n > Max`. @@ -96,17 +96,24 @@ Creates metadata rules, page constraints, and text rules from the DTO. ###### FileAssertPdfAssert Run ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Execution proceeds in the following steps: 1. Attempts to open the file as a PDF using `PdfDocument.Open`. -2. If an exception is thrown, writes the error below and returns immediately. +2. If an exception is thrown during open/read, the appropriate error is written and `Run` + returns immediately. Three layered catch clauses are used: + - `catch (IOException ex)` — writes `"File '' could not be read: {ex.Message}"`. + - `catch (UnauthorizedAccessException ex)` — writes the same `"could not be read"` message. + - `catch (Exception)` — fallback for any unrecognized PdfPig parse exception (PdfPig + surfaces a wide variety of types for malformed input). Writes + `"File '' could not be parsed as a PDF document"`. 3. Applies each metadata rule for each declared field. 4. If `Pages` is defined, applies page count assertions. -5. If `Text` rules are defined, extracts page text via PdfPig, concatenates, and applies - each rule. +5. If `Text` rules are defined, extracts page text via PdfPig, concatenates each page's text + with a newline (`"\n"`) separator between pages so adjacent words from different pages do + not run together, and applies each rule to the resulting string. ###### FileAssertPdfAssert Parse Error Message @@ -202,12 +209,12 @@ Inner class `PdfPages` holds: | Method | Purpose | | :------------------------------------------------------------------ | :-------------------------------------------- | | `Create(FileAssertPdfData data)` | Builds metadata/page/text rules from DTO. | -| `Run(Context context, string fileName)` | Opens PDF; applies metadata/page/text rules. | +| `Run(IContext, IFileContainer, string)` | Opens PDF; applies metadata/page/text rules. | | `GetMetadataField(PdfDocument doc, string field)` *(private)* | Maps field to `DocumentInformation` property. | | `PdfMetadataRule.FromData(FileAssertPdfMetadataRuleData)` *(inner)* | Creates a `PdfMetadataRule` from DTO. | -| `PdfMetadataRule.Apply(Context, string, string?)` *(inner)* | Applies `Contains`/`Matches` to field value. | +| `PdfMetadataRule.Apply(IContext, string, string?)` *(inner)* | Applies `Contains`/`Matches` to field value. | | `PdfPages.FromData(FileAssertPdfPagesData)` *(inner)* | Creates `PdfPages` from DTO. | -| `PdfPages.Apply(Context, string, int)` *(inner)* | Checks `Min`/`Max` against actual page count. | +| `PdfPages.Apply(IContext, string, int)` *(inner)* | Checks `Min`/`Max` against actual page count. | #### Error Handling @@ -220,12 +227,15 @@ Inner class `PdfPages` holds: | Body text rule failure | Delegated to `FileAssertRule.Apply`; errors reported individually. | | Unrecognised metadata field name | Result is `null`; `Contains`/`Matches` run against null. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `PdfAssert.Run(context, fileName)` when the `pdf:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertPdfAssert.Create`. - **Delegates to**: `FileAssertRule.Apply` for body text validation. - **OTS dependency**: `PdfPig.PdfDocument` for PDF parsing and text extraction. - **Configuration dependency**: `FileAssertPdfData`, `FileAssertPdfMetadataRuleData`, `FileAssertPdfPagesData`, and `FileAssertRuleData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `PdfAssert.Run(context, container, entryPath)` when the `pdf:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertPdfAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-rule.md b/docs/design/file-assert/modeling/file-assert-rule.md index cad0cdc..019a631 100644 --- a/docs/design/file-assert/modeling/file-assert-rule.md +++ b/docs/design/file-assert/modeling/file-assert-rule.md @@ -30,7 +30,7 @@ supported rule types. ##### Abstract Method ```csharp -internal abstract void Apply(Context context, string fileName, string content) +internal abstract void Apply(IContext context, string fileName, string content) ``` Each derived class implements `Apply` to perform its specific check against @@ -137,10 +137,10 @@ Regex objects are compiled at construction with a ten-second evaluation timeout. #### Key Methods -| Method | Purpose | -| :--------------------------------------------------------------------- | :----------------------------------------- | -| `Create(FileAssertRuleData data)` *(static on base)* | Returns concrete rule subclass for `data`. | -| `Apply(Context context, string fileName, string content)` *(abstract)* | Runs rule-specific check on file content. | +| Method | Purpose | +| :--------------------------------------------------------------------- | :----------------------------------------- | +| `Create(FileAssertRuleData data)` *(static on base)* | Returns concrete rule subclass for `data`. | +| `Apply(IContext context, string fileName, string content)` *(abstract)* | Runs rule-specific check on file content. | #### Error Handling @@ -151,7 +151,12 @@ Regex objects are compiled at construction with a ten-second evaluation timeout. | Regex evaluation timeout (>10 seconds) | `RegexMatchTimeoutException` propagated to `Apply` caller. | | Rule check fails at `Apply` time | Error written via `context.WriteError`; no exception thrown. | -#### Interactions +#### Dependencies + +- **Cli dependency**: `IContext` from the Cli subsystem (used by `Apply` to report failures). +- **Configuration dependency**: `FileAssertRuleData` DTOs from the Configuration subsystem. + +#### Callers - **Created by**: - `FileAssertTextAssert.Create` for text content rules. @@ -159,4 +164,3 @@ Regex objects are compiled at construction with a ten-second evaluation timeout. - **Called by**: - `FileAssertTextAssert.Run` — iterates rules and calls `Apply(context, fileName, content)`. - `FileAssertPdfAssert.Run` — iterates `_text` rules and calls `Apply(context, fileName, pdfText)`. -- **Configuration dependency**: `FileAssertRuleData` DTOs from the Configuration subsystem. diff --git a/docs/design/file-assert/modeling/file-assert-test.md b/docs/design/file-assert/modeling/file-assert-test.md index fbd071b..e30550e 100644 --- a/docs/design/file-assert/modeling/file-assert-test.md +++ b/docs/design/file-assert/modeling/file-assert-test.md @@ -42,13 +42,13 @@ Returns `true` when: ##### Execution Method ```csharp -internal void Run(Context context, string basePath) +internal void Run(IContext context, string basePath) ``` -Iterates `Files` and calls `Run(context, basePath)` on each entry. Errors reported -by individual file assertions accumulate in the context and do not stop subsequent -assertions from running. Passing a null `context` or null `basePath` throws -`ArgumentNullException`. +Creates a `DirectoryFileContainer(basePath)` and then iterates `Files`, calling +`Run(context, container)` on each entry. Errors reported by individual file assertions +accumulate in the context and do not stop subsequent assertions from running. Passing a null +`context` or null `basePath` throws `ArgumentNullException`. #### YAML Configuration @@ -90,11 +90,11 @@ filter criteria for selective execution, and drive execution of its assertions. #### Key Methods -| Method | Purpose | -| :------------------------------------------- | :----------------------------------------------------------- | -| `Create(FileAssertTestData data)` | Validates `Name`; builds `FileAssertFile` list. | -| `MatchesFilter(IEnumerable filters)` | Returns `true` if filters empty or any matches name or tag. | -| `Run(Context context, string basePath)` | Iterates `Files` and calls `Run(context, basePath)` on each. | +| Method | Purpose | +| :------------------------------------------- | :-------------------------------------------------------------- | +| `Create(FileAssertTestData data)` | Validates `Name`; builds `FileAssertFile` list. | +| `MatchesFilter(IEnumerable filters)` | Returns `true` if filters empty or any matches name or tag. | +| `Run(IContext context, string basePath)` | Wraps basePath in `DirectoryFileContainer`; runs each file. | #### Error Handling @@ -106,11 +106,16 @@ filter criteria for selective execution, and drive execution of its assertions. | Null `context` or `basePath` in `Run` | `ArgumentNullException` thrown. | | Individual file assertion failures | Accumulated in `context`; subsequent files continue. | -#### Interactions +#### Dependencies + +- **Creates and owns**: `FileAssertFile` instances via `FileAssertFile.Create`. +- **Calls**: `FileAssertFile.Run(context, container)` for each file assertion, where `container` + is a `DirectoryFileContainer` wrapping `basePath`. +- **OTS dependency**: `DirectoryFileContainer` (Utilities subsystem) for wrapping the base path. + +#### Callers - **Created by**: `FileAssertConfig.ReadFromFile` via `FileAssertTest.Create` for each `FileAssertTestData` entry. - **Called by**: `FileAssertConfig.Run` — calls `MatchesFilter(filterList)` then `Run(context, basePath)` for each qualifying test. -- **Creates and owns**: `FileAssertFile` instances via `FileAssertFile.Create`. -- **Calls**: `FileAssertFile.Run(context, basePath)` for each file assertion. diff --git a/docs/design/file-assert/modeling/file-assert-text-assert.md b/docs/design/file-assert/modeling/file-assert-text-assert.md index dfd0deb..402500b 100644 --- a/docs/design/file-assert/modeling/file-assert-text-assert.md +++ b/docs/design/file-assert/modeling/file-assert-text-assert.md @@ -36,11 +36,12 @@ Creates a `FileAssertRule` for each entry in the data list via `FileAssertRule.C ##### Run Method ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Reads the entire file content as a UTF-8 string and applies each rule via -`rule.Apply(context, fileName, content)`. +`rule.Apply(context, displayPath, content)`, where `displayPath` is obtained from +`container.GetDisplayPath(entryPath)`. Execution proceeds in the following steps: @@ -52,17 +53,18 @@ Execution proceeds in the following steps: ###### Run Error Message ```text -File '' could not be read as text +File '' could not be read as text ``` -| Parameter | Type | Description | -| :----------- | :-------- | :------------------------------------------------------ | -| `context` | `Context` | Reporting sink used to record errors. Must not be null. | -| `fileName` | `string` | Full path to the file to validate. Must not be null. | +| Parameter | Type | Description | +| :---------- | :--------------- | :------------------------------------------------------------------------ | +| `context` | `IContext` | Reporting sink used to record errors. Must not be null. | +| `container` | `IFileContainer` | Container used to open the entry. Must not be null. | +| `entryPath` | `string` | Path of the entry, **relative to** `container`. Must not be null. | -| Exception | Condition | -| :-------------------------- | :----------------------------------------------------- | -| `ArgumentNullException` | Thrown when `context` or `fileName` is null. | +| Exception | Condition | +| :-------------------------- | :--------------------------------------------------------------------- | +| `ArgumentNullException` | Thrown when `context`, `container`, or `entryPath` is null. | #### YAML Configuration @@ -105,21 +107,24 @@ file-type assert units, keeping `FileAssertFile` free of rule-application logic. | Method | Purpose | | :--------------------------------------------- | :----------------------------------------------------------------- | | `Create(IEnumerable data)` | Static factory: creates a `FileAssertRule` for each DTO entry. | -| `Run(Context context, string fileName)` | Reads the file as UTF-8 text and applies each rule to the content. | +| `Run(IContext, IFileContainer, string)` | Reads the file as UTF-8 text and applies each rule to the content. | #### Error Handling -| Scenario | Handling | -| :----------------------------------------------------- | :--------------------------------------------------- | -| Null `data` passed to `Create` | `ArgumentNullException` thrown. | -| Null `context` or `fileName` passed to `Run` | `ArgumentNullException` thrown. | -| `IOException` or `UnauthorizedAccessException` on read | Error via `context.WriteError`; `Run` returns. | -| Individual rule check fails | Error via `context` in `Rule.Apply`; rules continue. | +| Scenario | Handling | +| :---------------------------------------------------------- | :--------------------------------------------------- | +| Null `data` passed to `Create` | `ArgumentNullException` thrown. | +| Null `context`, `container`, or `entryPath` passed to `Run` | `ArgumentNullException` thrown. | +| `IOException` or `UnauthorizedAccessException` on read | Error via `context.WriteError`; `Run` returns. | +| Individual rule check fails | Error via `context` in `Rule.Apply`; rules continue. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `TextAssert.Run(context, fileName)` when the `text:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertTextAssert.Create`. - **Delegates to**: `FileAssertRule.Apply` for each content rule. - **Configuration dependency**: `FileAssertRuleData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `TextAssert.Run(context, container, entryPath)` when the `text:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertTextAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-xml-assert.md b/docs/design/file-assert/modeling/file-assert-xml-assert.md index d982e8a..10d793b 100644 --- a/docs/design/file-assert/modeling/file-assert-xml-assert.md +++ b/docs/design/file-assert/modeling/file-assert-xml-assert.md @@ -3,9 +3,10 @@ #### Overview The `FileAssertXmlAssert` class attempts to parse a matched file as an XML document using -`System.Xml.Linq` (`XDocument.Load`). If parsing fails, an error is reported and no further -assertions are evaluated. Otherwise it evaluates each XPath query against the document and -applies min, max, and exact count constraints to the number of matching nodes. +`System.Xml.Linq`. The entry is opened through `IFileContainer.OpenEntry` and the resulting +stream is passed to `XDocument.Load(stream)`. If parsing fails, an error is reported and no +further assertions are evaluated. Otherwise it evaluates each XPath query against the document +and applies min, max, and exact count constraints to the number of matching nodes. #### Class Structure @@ -15,14 +16,14 @@ The main class coordinating XPath-based node count assertions for an XML file. ###### FileAssertXmlAssert Fields -| Field | Type | Description | -| :--------- | :------------------------------- | :---------------------- | -| `_queries` | `IReadOnlyList` | XPath query assertions. | +| Field | Type | Description | +|:-----------|:--------------------------|:------------------------| +| `_queries` | `IReadOnlyList` | XPath query assertions. | Each `XmlQuery` entry holds: | Property | Type | Description | -| :------- | :------- | :------------------------------- | +|:---------|:---------|:---------------------------------| | `Query` | `string` | XPath expression to evaluate. | | `Count` | `int?` | Exact number of matched nodes. | | `Min` | `int?` | Minimum number of matched nodes. | @@ -37,12 +38,13 @@ internal static FileAssertXmlAssert Create(IEnumerable data ###### FileAssertXmlAssert Run ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Execution proceeds in the following steps: -1. Attempts to load the file using `XDocument.Load(fileName)`. +1. Opens the matched entry via `container.OpenEntry(entryPath)` and loads the resulting stream + using `XDocument.Load(stream)`. 2. If an exception is thrown, writes the error below and returns immediately. 3. For each query entry: evaluates the XPath expression against the document using `System.Xml.XPath` extension methods. If the XPath expression is malformed and throws @@ -96,13 +98,13 @@ exact node-count constraints per query. #### Data Model | Field | Type | Description | -| :--------- | :------------------------ | :-------------------------------------- | +|:-----------|:--------------------------|:----------------------------------------| | `_queries` | `IReadOnlyList` | Ordered list of XPath query assertions. | Each `XmlQuery` (private nested record) holds: | Property | Type | Description | -| :------- | :------- | :--------------------------------------- | +|:---------|:---------|:-----------------------------------------| | `Query` | `string` | XPath expression to evaluate. | | `Count` | `int?` | Expected exact node count; `null` = N/A. | | `Min` | `int?` | Minimum node count; `null` = no bound. | @@ -111,24 +113,28 @@ Each `XmlQuery` (private nested record) holds: #### Key Methods | Method | Purpose | -| :---------------------------------------------- | :------------------------------------------------------------ | +|:------------------------------------------------|:--------------------------------------------------------------| | `Create(IEnumerable data)` | Converts query DTOs to `XmlQuery` instances. | -| `Run(Context context, string fileName)` | Loads the XML file and evaluates each XPath query against it. | +| `Run(IContext, IFileContainer, string)` | Loads the XML file and evaluates each XPath query against it. | #### Error Handling -| Scenario | Handling | -| :----------------------------------------------- | :-------------------------------------------------------------------------------- | -| `XDocument.Load` throws on parse failure | Error written via `context.WriteError`; `Run` returns immediately. | -| XPath expression malformed (`XPathException`) | Error written via `context.WriteError`; evaluation continues with the next query. | -| Query result below `Min` | Error written via `context.WriteError`; subsequent queries continue. | -| Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | -| Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | +| Scenario | Handling | +|:----------------------------------------------|:----------------------------------------------------------------------------------| +| `Create` called with a blank/whitespace query | `InvalidOperationException` thrown at construction time before any file access. | +| `XDocument.Load` throws on parse failure | Error written via `context.WriteError`; `Run` returns immediately. | +| XPath expression malformed (`XPathException`) | Error written via `context.WriteError`; evaluation continues with the next query. | +| Query result below `Min` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | +| Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `XmlAssert.Run(context, fileName)` when the `xml:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertXmlAssert.Create`. - **OTS dependency**: `System.Xml.Linq.XDocument` and `System.Xml.XPath` extension methods (BCL). - **Configuration dependency**: `FileAssertQueryData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `XmlAssert.Run(context, container, entryPath)` when the `xml:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertXmlAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-yaml-assert.md b/docs/design/file-assert/modeling/file-assert-yaml-assert.md index 7793c73..78f76f9 100644 --- a/docs/design/file-assert/modeling/file-assert-yaml-assert.md +++ b/docs/design/file-assert/modeling/file-assert-yaml-assert.md @@ -37,7 +37,7 @@ internal static FileAssertYamlAssert Create(IEnumerable dat ###### FileAssertYamlAssert Run ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` Execution proceeds in the following steps: @@ -118,7 +118,7 @@ Each `YamlQuery` (private nested record) holds: | Method | Purpose | | :---------------------------------------------- | :--------------------------------------------------------------- | | `Create(IEnumerable data)` | Converts query DTOs to `YamlQuery` instances. | -| `Run(Context context, string fileName)` | Parses the YAML file and evaluates each dot-notation path query. | +| `Run(IContext, IFileContainer, string)` | Parses the YAML file and evaluates each dot-notation path query. | #### Error Handling @@ -129,10 +129,13 @@ Each `YamlQuery` (private nested record) holds: | Query result above `Max` | Error written via `context.WriteError`; subsequent queries continue. | | Query result not equal to `Count` | Error written via `context.WriteError`; subsequent queries continue. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `YamlAssert.Run(context, fileName)` when the `yaml:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertYamlAssert.Create`. - **OTS dependency**: `YamlDotNet.RepresentationModel.YamlStream` for parsing and traversal. - **Configuration dependency**: `FileAssertQueryData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `YamlAssert.Run(context, container, entryPath)` when the `yaml:` + assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertYamlAssert.Create`. diff --git a/docs/design/file-assert/modeling/file-assert-zip-assert.md b/docs/design/file-assert/modeling/file-assert-zip-assert.md index 40677be..fa7caff 100644 --- a/docs/design/file-assert/modeling/file-assert-zip-assert.md +++ b/docs/design/file-assert/modeling/file-assert-zip-assert.md @@ -2,30 +2,22 @@ #### Overview -The `FileAssertZipAssert` class validates the contents of a zip archive by matching entry names -against glob patterns and enforcing minimum and maximum count constraints. It is created from a -`FileAssertZipData` DTO and is invoked by `FileAssertFile` when a `zip:` assertion block is -declared. Wrapping zip entry validation in a dedicated unit keeps `FileAssertFile` free of -archive-inspection logic and makes the zip assertion pattern consistent with all other file-type -assert units. +The `FileAssertZipAssert` class validates the contents of a zip archive by applying the full +FileAssert assertion suite to its entries. It accepts an `IFileContainer` and an entry path, +opens the entry as a zip archive, wraps it in a `ZipFileContainer`, and runs all configured +`FileAssertFile` assertions against the archive's contents using a scoped `IContext` for +breadcrumb-style error messages. -#### Class Structure - -##### Nested Class: Entry - -The `Entry` nested class holds the compiled state for a single entry constraint: +This design replaces the earlier glob-count-only approach; the full assertion suite (text, xml, +html, yaml, json, pdf, and recursive zip) is now available for zip entry validation. -| Property | -| :-------- | -| `Pattern` | -| `Min` | -| `Max` | +#### Class Structure ##### Properties -| Property | -| :-------- | -| `Entries` | +| Property | Type | Description | +| :------- | :------------------------------ | :-------------------------------------------- | +| `Files` | `IReadOnlyList` | The file assertions to apply to zip contents. | ##### Factory Method @@ -33,136 +25,133 @@ The `Entry` nested class holds the compiled state for a single entry constraint: internal static FileAssertZipAssert Create(FileAssertZipData data) ``` -Converts each `FileAssertZipEntryData` DTO into an `Entry` instance after validating that a -pattern is specified. +Converts each `FileAssertFileData` DTO from `data.Files` into a +`FileAssertFile` instance after validating that a pattern is specified. -| Parameter | -| :-------- | -| `data` | +| Parameter | Description | +| :-------- | :--------------------------------------- | +| `data` | The zip assertion DTO; must not be null. | -| Return / Exception | -| :-------------------------- | -| Returns | -| `ArgumentNullException` | -| `InvalidOperationException` | +| Return / Exception | Condition | +| :-------------------------- | :---------------------------------------------------------- | +| Returns | A new `FileAssertZipAssert` with all file assertions built. | +| `ArgumentNullException` | `data` is null. | +| `InvalidOperationException` | A file entry has a null or whitespace pattern. | ##### Run Method ```csharp -internal void Run(Context context, string fileName) +internal void Run(IContext context, IFileContainer container, string entryPath) ``` -Opens the zip archive, collects all file entry names, and evaluates each entry constraint. +Opens the zip entry via `container.OpenEntry(entryPath)`, wraps it in a `ZipFileContainer`, +and runs all `FileAssertFile` assertions against the archive contents. Execution proceeds in the following steps: -1. Attempts to open the zip archive with `ZipFile.OpenRead(fileName)`. -2. If an `IOException`, `InvalidDataException`, or `UnauthorizedAccessException` is thrown, - writes the error below and returns immediately. -3. Enumerates all archive entries, normalizing separators to forward slashes and excluding - directory entries (names ending with `/`). -4. For each configured `Entry`, uses `Matcher.Match(".", allEntries)` from - `Microsoft.Extensions.FileSystemGlobbing` to count matched entries. -5. Writes an error if the match count is below `Min` or above `Max`. +1. Calls `container.GetDisplayPath(entryPath)` to get the display path for breadcrumb use. +2. Opens the entry stream via `container.OpenEntry(entryPath)`. +3. Wraps the stream in a `ZipFileContainer(stream, displayPath)`. +4. If `InvalidDataException`, `IOException`, or `UnauthorizedAccessException` is thrown constructing + the `ZipFileContainer`, writes the parse error and returns immediately. The stream is disposed in + a nested `try` block even when the `ZipFileContainer` constructor throws. +5. Creates a scoped context via `context.WithPrefix(displayPath)`. +6. Runs each `FileAssertFile` in `Files` against the `ZipFileContainer` and scoped context. ###### Run Error Messages ```text -File '' could not be read as a zip archive +File '' could not be read as a zip archive ``` -```text -Zip '' entry pattern '' matched entry(s), -but expected at least -``` - -```text -Zip '' entry pattern '' matched entry(s), -but expected at most -``` - -| Parameter | -| :--------- | -| `context` | -| `fileName` | +| Parameter | Description | +| :---------- | :------------------------------------------------------- | +| `context` | The `IContext` to report errors through. | +| `container` | The `IFileContainer` that owns the zip entry. | +| `entryPath` | The relative path of the zip entry within the container. | #### YAML Configuration -Zip entry constraints are declared under the `zip:` key of a file entry: +Zip entry assertions are declared under the `zip:` key of a file entry, using the same +`files:` structure as top-level test files: ```yaml files: - pattern: "output/package.zip" zip: - entries: + files: - pattern: 'lib/net8.0/MyLib.dll' min: 1 max: 1 - pattern: 'lib/**/*.dll' min: 1 + text: + - contains: "Copyright" ``` +The file assertions inside a `zip:` block use the same `FileAssertFileData` schema as +top-level file assertions, enabling the full assertion suite (text, xml, html, yaml, json, +pdf, nested zip) against the archive contents. + #### Design Decisions -- **Dedicated unit for zip validation**: Wrapping zip archive inspection in `FileAssertZipAssert` - keeps `FileAssertFile` free of archive-handling logic and makes the pattern consistent with all - other file-type assert units (`FileAssertTextAssert`, `FileAssertXmlAssert`, etc.). -- **Forward-slash normalization**: Zip entry names are normalized to forward slashes before - matching so that glob patterns work consistently regardless of the creating platform. -- **Directory entry exclusion**: Entries whose names end with `/` are directory markers and are - excluded from matching to avoid false counts from container entries. -- **Virtual root for Matcher**: `Matcher.Match(".", allEntries)` applies the glob - pattern directly to the normalized entry name list without any filesystem path manipulation, - because zip entry names are self-contained paths rather than paths relative to a directory root. - The `"."` root is required because `InMemoryDirectoryInfo` rejects empty or null root paths. -- **Immediate failure on parse error**: If the file cannot be opened as a zip archive, an error - is written immediately and no entry constraints are evaluated, consistent with the behavior of - all other file-type assert units. +- **Full assertion suite in archives**: By wrapping the zip entry stream in a `ZipFileContainer` + and running `FileAssertFile` assertions, every asserter (text, xml, html, yaml, json, pdf, and + recursive zip) becomes available for archive entry validation without any per-asserter changes. +- **`IFileContainer` parameter**: Accepting `IFileContainer` rather than a file path allows zip + entries within outer archives to be opened as streams and wrapped directly, enabling zip-in-zip + assertion scenarios. +- **Stream disposal on `ZipArchive` constructor failure**: A nested `try` block ensures the entry + stream is disposed even when the `ZipFileContainer` constructor throws `InvalidDataException`. + Without this guard, the stream from `container.OpenEntry` would remain open, locking the + underlying file or archive entry. +- **Scoped context for breadcrumbs**: `context.WithPrefix(displayPath)` creates a scoped + `IContext` that prepends the archive's display path to every error message, giving users + unambiguous context (`"outer.zip > entry.xml > error"`) without requiring any formatting + logic in the individual asserters. +- **Forward-slash normalization handled by `ZipFileContainer`**: Entry path normalization is the + responsibility of `ZipFileContainer.GetEntries`, not `FileAssertZipAssert`. This keeps the + asserter free of container-specific logic. #### Purpose -`FileAssertZipAssert` is responsible for validating the contents of a zip archive. It enumerates -the archive's file entries, matches them against glob patterns, and enforces min and max -count constraints per pattern. +`FileAssertZipAssert` is responsible for opening a zip archive entry from an `IFileContainer` +and applying the full FileAssert assertion suite to its contents using a scoped context. #### Data Model -| Property | -| :-------- | -| `Entries` | - -The `Entry` nested class holds: - -| Property | -| :-------- | -| `Pattern` | -| `Min` | -| `Max` | +| Property | Type | Description | +| :------- | :------------------------------ | :------------------------------------------- | +| `Files` | `IReadOnlyList` | File assertions applied to the zip contents. | #### Key Methods -| Method | -| :-------------------------------------- | -| `Create(FileAssertZipData data)` | -| `Run(Context context, string fileName)` | +| Method | Purpose | +| :------------------------------------------------------------------ | :--------------------------------------------------------- | +| `Create(FileAssertZipData data)` | Factory: builds `FileAssertFile` list from DTO. | +| `Run(IContext context, IFileContainer container, string entryPath)` | Opens zip entry, wraps in container, runs file assertions. | #### Error Handling -| Scenario | -| :-------------------------------------------------------------------------------------------- | -| Null `data` passed to `Create` | -| Entry with null or whitespace pattern in `Create` | -| `IOException`, `InvalidDataException`, or `UnauthorizedAccessException` on `ZipFile.OpenRead` | -| Entry match count below `Min` | -| Entry match count above `Max` | +| Scenario | Handling | +| :------------------------------------------------------------------------------------------- | :----------------------------------------------------- | +| Null `data` passed to `Create` | `ArgumentNullException` thrown. | +| File entry with null or whitespace pattern in `Create` | `InvalidOperationException` thrown. | +| `IOException`, `InvalidDataException`, or `UnauthorizedAccessException` opening entry as zip | Error written via `context.WriteError`; `Run` returns. | +| Entry match count below `Min` | Reported by the `FileAssertFile` instance. | +| Entry match count above `Max` | Reported by the `FileAssertFile` instance. | -#### Interactions +#### Dependencies -- **Caller**: `FileAssertFile.Run` calls `ZipAssert.Run(context, fileName)` when the `zip:` - assertion block is declared. -- **Created by**: `FileAssertFile.Create` via `FileAssertZipAssert.Create`. +- **Delegates to**: `FileAssertFile.Run` with a `ZipFileContainer` and scoped `IContext`. - **OTS dependencies**: - - `System.IO.Compression.ZipFile` (BCL) for opening the archive. - - `Microsoft.Extensions.FileSystemGlobbing.Matcher` for matching entry names against glob patterns. -- **Configuration dependency**: `FileAssertZipData` and `FileAssertZipEntryData` DTOs from the + - `System.IO.Compression.ZipArchive` (BCL) via `ZipFileContainer`. + - `Microsoft.Extensions.FileSystemGlobbing.Matcher` via `FileAssertFile`. +- **Configuration dependency**: `FileAssertZipData` and `FileAssertFileData` DTOs from the Configuration subsystem. + +#### Callers + +- **Caller**: `FileAssertFile.Run` calls `ZipAssert.Run(context, container, entryPath)` when the + `zip:` assertion block is declared. +- **Created by**: `FileAssertFile.Create` via `FileAssertZipAssert.Create`. diff --git a/docs/design/file-assert/program.md b/docs/design/file-assert/program.md index 3e1e6ec..6b0e48b 100644 --- a/docs/design/file-assert/program.md +++ b/docs/design/file-assert/program.md @@ -130,7 +130,7 @@ outcomes reflected in `context.ExitCode`. | `InvalidOperationException` | Invalid state detected by downstream code | Caught in `Main`; message printed to standard error; exit code `1` | | All other exceptions | Unexpected runtime failure (programming error or I/O fault) | Printed to standard error and re-thrown; runtime generates crash log | -### Interactions +### Dependencies - `Context` provides the parsed flags, config path, filters, and output methods used by every execution path. @@ -138,6 +138,10 @@ outcomes reflected in `context.ExitCode`. - `FileAssertConfig` loads the YAML configuration and executes matching assertions during normal tool runs. +### Callers + +- None. + ### Design Decisions - **Public `Run` method**: `Run` is `public` so that unit tests and the self-validation tests diff --git a/docs/design/file-assert/selftest.md b/docs/design/file-assert/selftest.md index c98b8b9..4ea0f10 100644 --- a/docs/design/file-assert/selftest.md +++ b/docs/design/file-assert/selftest.md @@ -49,7 +49,7 @@ produces structured test results that can be written to a TRX or JUnit XML file. 4. Results are accumulated and, when `context.ResultsFile` is non-null, serialized to TRX or JUnit XML using `DemaConsulting.TestResults`. -### Interactions with Other Subsystems +### Dependencies | Dependency | Usage | | :---------- | :----------------------------------------------------------------- | @@ -58,6 +58,10 @@ produces structured test results that can be written to a TRX or JUnit XML file. | Program | References `Program.Version` for the system information header. | | | Calls `Program.Run` and `Context.Create` to exercise the tool. | +### Callers + +- None. + ### Design Decisions - **Self-contained tests**: Each built-in test creates its own `Context` with a temporary log diff --git a/docs/design/file-assert/selftest/validation.md b/docs/design/file-assert/selftest/validation.md index d3ba3e5..26dc769 100644 --- a/docs/design/file-assert/selftest/validation.md +++ b/docs/design/file-assert/selftest/validation.md @@ -125,14 +125,16 @@ N/A — `Validation` is a `static` class with no instance fields. All state is l | Temporary directory creation failure | | Temporary directory deletion failure | -#### Interactions +#### Dependencies -- **Caller**: `Program.Run` calls `Validation.Run(context)` when `context.Validate` is `true`. - **Calls internally**: - `Program.Run(Context)` to execute each built-in test scenario in-process. - `Context.Create(string[])` to construct per-test contexts with `--silent` and `--config`. - `TemporaryDirectory.GetFilePath` to build all fixture and log file paths safely. - `DemaConsulting.TestResults.IO.TrxSerializer.Serialize` and `JUnitSerializer.Serialize` for results serialization. -- **OTS dependencies**: `System.Runtime.InteropServices.RuntimeInformation` for system info - output; `System.Text.RegularExpressions.Regex` (source-generated) for version string matching. +- **OTS dependencies**: `DemaConsulting.TestResults` for TRX/JUnit serialization. + +#### Callers + +- **Caller**: `Program.Run` calls `Validation.Run(context)` when `context.Validate` is `true`. diff --git a/docs/design/file-assert/utilities.md b/docs/design/file-assert/utilities.md index 7b353aa..e36f5ed 100644 --- a/docs/design/file-assert/utilities.md +++ b/docs/design/file-assert/utilities.md @@ -8,10 +8,13 @@ domain subsystem. ### Subsystem Contents -| Unit | File | Responsibility | -| :------------------- | :---------------------- | :--------------------------------------------------------------------- | -| `PathHelpers` | `PathHelpers.cs` | Safe path-combination utility with path-traversal protection. | -| `TemporaryDirectory` | `TemporaryDirectory.cs` | Disposable temporary directory with safe path resolution and clean-up. | +| Unit | File | Responsibility | +| :------------------------- | :--------------------------- | :--------------------------------------------------------------------- | +| `PathHelpers` | `PathHelpers.cs` | Safe path-combination utility with path-traversal protection. | +| `TemporaryDirectory` | `TemporaryDirectory.cs` | Disposable temporary directory with safe path resolution and clean-up. | +| `IFileContainer` | `IFileContainer.cs` | Uniform file-access interface over directories and zip archives. | +| `DirectoryFileContainer` | `DirectoryFileContainer.cs` | IFileContainer implementation backed by a local filesystem directory. | +| `ZipFileContainer` | `ZipFileContainer.cs` | IFileContainer implementation backed by a ZipArchive stream. | ### Subsystem Responsibilities @@ -19,6 +22,11 @@ domain subsystem. - Reject relative paths containing `..` or absolute paths when a relative path is expected. - Create uniquely-named temporary directories and delete them automatically on disposal. - Ensure all file paths within a temporary directory remain within its boundary. +- Provide a uniform `IFileContainer` abstraction for enumerating, opening, and measuring file + entries regardless of whether they reside on disk or inside a zip archive. +- Expose local filesystem directories as `IFileContainer` instances via `DirectoryFileContainer`. +- Expose zip archive streams as `IFileContainer` instances via `ZipFileContainer`, enabling the + full assertion suite to be applied to zip entry contents. ### Interfaces @@ -31,12 +39,19 @@ domain subsystem. | `TemporaryDirectory.DirectoryPath` | Full path to the temporary directory. | | `TemporaryDirectory.GetFilePath(relative)` | Resolves a relative path within the directory; creates intermediate subdirectories. | | `TemporaryDirectory.Dispose()` | Deletes the temporary directory and all its contents. | +| `IFileContainer.GetEntries()` | Returns all relative entry paths with forward-slash separators. | +| `IFileContainer.OpenEntry(entryPath)` | Opens the named entry as a readable stream; throws `IOException` on failure. | +| `IFileContainer.GetEntrySize(entryPath)` | Returns the uncompressed byte length of the named entry. | +| `IFileContainer.GetDisplayPath(entryPath)` | Returns a human-readable display path for use in error messages. | +| `DirectoryFileContainer(basePath)` | IFileContainer over a local filesystem directory. | +| `ZipFileContainer(stream, displayName)` | IFileContainer over a zip archive stream; supports nested zips. | #### Consumed -| Dependency | Usage | -| :---------------------------- | :---------------------------------------------------------------------------- | -| .NET BCL (`Path`, `Directory`) | All path manipulation and file-system operations within both units. | +| Dependency | Usage | +| :----------------------------------------- | :---------------------------------------------------------------------------- | +| .NET BCL (`Path`, `Directory`, `File`) | All path manipulation and file-system operations. | +| `System.IO.Compression.ZipArchive` | Zip archive access in `ZipFileContainer`. | ### Design @@ -47,14 +62,34 @@ domain subsystem. 2. `PathHelpers` performs validation independently of `TemporaryDirectory`, so it can be used directly by other subsystems (such as `SelfTest`) without going through `TemporaryDirectory`. -Neither unit holds references to `Context` or any other subsystem; they are pure utilities with no -awareness of the tool's execution state. +`IFileContainer` and its two implementations provide a uniform virtual-file-system API: -### Interactions with Other Subsystems +1. `DirectoryFileContainer` wraps a local directory path; `GetEntries` enumerates files + recursively, normalizes separators to forward slashes, and returns empty for non-existent + directories. `GetDisplayPath` returns the full on-disk path. +2. `ZipFileContainer` wraps a `ZipArchive` opened from a caller-supplied `Stream`; `GetEntries` + filters out directory marker entries; `GetDisplayPath` returns `"{displayName} > {entryPath}"`, + enabling breadcrumb-style paths in nested zip scenarios. `OpenEntry` throws `IOException` when + the requested entry is not present. +3. Both implementations are `IDisposable`; `DirectoryFileContainer.Dispose` is a no-op while + `ZipFileContainer.Dispose` closes the underlying archive and stream. + +Neither `PathHelpers` nor `TemporaryDirectory` holds references to `Context` or any other +subsystem; they are pure utilities with no awareness of the tool's execution state. +`IFileContainer` and its implementations are similarly isolated, depending only on .NET BCL +file-system and compression APIs. + +### Dependencies + +- None. + +### Callers | Consumer | Usage | | :-------- | :---------------------------------------------------------------------------------------- | | SelfTest | Uses `TemporaryDirectory` and `PathHelpers.SafePathCombine` for fixture file management. | +| Modeling | Uses `IFileContainer`, `DirectoryFileContainer`, and `ZipFileContainer` to abstract file | +| | access across asserters, enabling the full assertion suite for both on-disk and zip files.| | Tests | Uses `TemporaryDirectory` for isolated file-system fixtures in all test projects. | ### Design Decisions @@ -66,3 +101,9 @@ awareness of the tool's execution state. - **`Environment.CurrentDirectory` over `Path.GetTempPath()`**: On macOS, `/tmp` is a symlink to `/private/tmp`. Using the current directory avoids path-comparison failures caused by symlink resolution. See *TemporaryDirectory Design* for details. +- **`IFileContainer` over direct file paths in asserters**: Accepting an `IFileContainer` + interface rather than a file path string allows asserters to be reused unchanged for both + on-disk files and zip archive entries, without any conditional logic in the asserter. +- **`IOException` from `ZipFileContainer.OpenEntry`**: Using the parent class rather than + `FileNotFoundException` avoids conflating file-system semantics with archive-entry semantics. + All asserter catch clauses handle `IOException` uniformly. diff --git a/docs/design/file-assert/utilities/directory-file-container.md b/docs/design/file-assert/utilities/directory-file-container.md new file mode 100644 index 0000000..80084e8 --- /dev/null +++ b/docs/design/file-assert/utilities/directory-file-container.md @@ -0,0 +1,127 @@ +### DirectoryFileContainer Design + +#### Purpose + +`DirectoryFileContainer` is the filesystem implementation of `IFileContainer`. It exposes a local +directory as a container of file entries, enumerating all files recursively, opening them via their +absolute paths, and returning their on-disk sizes and full paths for error messages. + +It is used by `FileAssertTest.Run`, which wraps the user-supplied `basePath` in a +`DirectoryFileContainer` before passing it down to `FileAssertFile.Run` and the asserters. + +#### Class Structure + +##### Constructor + +```csharp +internal DirectoryFileContainer(string basePath) +``` + +Stores the base directory path. Validates that `basePath` is not null. Does not verify that the +directory exists at construction time — a non-existent directory is handled gracefully in +`GetEntries()`. + +##### GetEntries Method + +```csharp +public IReadOnlyList GetEntries() +``` + +Returns all files found recursively under `BasePath`, each as a path relative to `BasePath` with +forward slashes as the separator. + +**Steps:** + +1. If `BasePath` does not exist (`Directory.Exists` returns `false`), return `Array.Empty()`. +2. Call `Directory.EnumerateFiles(BasePath, "*", SearchOption.AllDirectories)`. +3. Convert each absolute path to a relative path via `Path.GetRelativePath(BasePath, f)`. +4. Replace back-slashes with forward slashes via `.Replace('\\', '/')`. +5. Return the list as a read-only collection. + +**Rationale for empty-list on missing directory:** Glob-based count constraints treat zero matches +as a valid count. Returning an empty list rather than throwing allows `Min = 0` constraints to pass +against directories that may legitimately not exist yet. + +##### OpenEntry Method + +```csharp +public Stream OpenEntry(string entryPath) +``` + +Opens the file at `Path.Combine(BasePath, entryPath)` for reading using `File.OpenRead`. Throws +`FileNotFoundException` (a subclass of `IOException`) when the file does not exist. + +##### GetEntrySize Method + +```csharp +public long GetEntrySize(string entryPath) +``` + +Returns the byte length of the file at `Path.Combine(BasePath, entryPath)` via `new FileInfo(fullPath).Length`. + +##### GetDisplayPath Method + +```csharp +public string GetDisplayPath(string entryPath) +``` + +Returns `Path.Combine(BasePath, entryPath)` — the full on-disk path of the entry. This is used in +error messages so users can identify the exact file that failed an assertion. + +##### Dispose Method + +```csharp +public void Dispose() +``` + +No-op. `DirectoryFileContainer` holds no disposable resources. The method is provided for +symmetry with `ZipFileContainer` so both can be used in `using` statements. + +#### Design Decisions + +- **Empty list on missing directory**: Returning an empty list rather than throwing when the + directory does not exist is intentional. Zero-match count constraints are valid, and some callers + construct the container before the directory is guaranteed to exist. +- **Full path as display path**: Error messages should identify files by their full path so users + can navigate to them. The relative entry path alone is insufficient context. +- **No-op Dispose**: `DirectoryFileContainer` does not open any resources; all streams returned by + `OpenEntry` are the caller's responsibility. Implementing `IDisposable` as a no-op provides + symmetry with `ZipFileContainer` and allows callers to use `using var container = ...` uniformly. + +#### Data Model + +| Field | Type | Description | +| :--------- | :------- | :--------------------------------------- | +| `BasePath` | `string` | The absolute path of the root directory. | + +#### Key Methods + +| Method | Description | +| :----------------------------------------- | :----------------------------------------------------- | +| `DirectoryFileContainer(string basePath)` | Constructor: stores the base path. | +| `GetEntries() → IReadOnlyList` | Enumerate all files recursively with forward slashes. | +| `OpenEntry(string) → Stream` | Open a file by relative path for reading. | +| `GetEntrySize(string) → long` | Return the file size in bytes. | +| `GetDisplayPath(string) → string` | Return the full file-system path for error messages. | +| `Dispose()` | No-op for IDisposable symmetry. | + +#### Error Handling + +| Scenario | Handling | +| :------------------------------------ | :----------------------------------------------------------------- | +| Non-existent base directory | `GetEntries()` returns empty list; no exception. | +| Missing file on `OpenEntry` | `FileNotFoundException` thrown by `File.OpenRead`. | +| Null `entryPath` | `ArgumentNullException` thrown before path combination. | +| Null `basePath` | `ArgumentNullException` thrown in constructor. | + +#### Dependencies + +- .NET BCL: `File`, `Directory`, `Path`, `FileInfo`, `SearchOption` + +#### Callers + +- `FileAssertTest.Run` — creates a `DirectoryFileContainer(basePath)` at the top of the assertion + chain. +- `FileAssertFile.Run` — calls all four interface methods to enumerate, open, measure, and display + entries. +- All 7 asserters — receive the container via `FileAssertFile` delegation. diff --git a/docs/design/file-assert/utilities/i-file-container.md b/docs/design/file-assert/utilities/i-file-container.md new file mode 100644 index 0000000..8b499a6 --- /dev/null +++ b/docs/design/file-assert/utilities/i-file-container.md @@ -0,0 +1,79 @@ +### IFileContainer Design + +#### Purpose + +`IFileContainer` is the uniform file-access abstraction used by all asserters in FileAssert. +It decouples asserters from the filesystem by hiding whether a "file" resides on disk inside a +directory or as an entry inside a zip archive. Asserters call `GetEntries()`, `OpenEntry()`, +`GetEntrySize()`, and `GetDisplayPath()` without needing to know how the container is backed. + +Two implementations are provided: + +- `DirectoryFileContainer` — backed by a local filesystem directory. +- `ZipFileContainer` — backed by a `ZipArchive` opened from a `Stream`. + +#### Interface Members + +```csharp +internal interface IFileContainer +{ + IReadOnlyList GetEntries(); + Stream OpenEntry(string entryPath); + long GetEntrySize(string entryPath); + string GetDisplayPath(string entryPath); +} +``` + +| Member | Description | +| :----------------------------------- | :----------------------------------------------------------------------------------- | +| `GetEntries()` | Returns all relative entry paths, using forward slashes as the path separator. | +| `OpenEntry(string entryPath)` | Opens the named entry for sequential reading; throws `IOException` on failure. | +| `GetEntrySize(string entryPath)` | Returns the uncompressed byte length of the named entry. | +| `GetDisplayPath(string entryPath)` | Returns a human-readable path for the entry, used in error messages. | + +#### Design Rationale + +- **Single abstraction for both directories and archives**: Asserters can be written once and used + against both on-disk files and zip archive entries without any conditional logic. +- **Forward-slash normalization in `GetEntries()`**: All returned paths use `/` as the separator + regardless of platform, ensuring glob patterns work consistently across Windows, Linux, and macOS. +- **`IOException` for missing entries**: Both implementations throw `IOException` (or a subclass) when + an entry cannot be opened, allowing asserters to catch a single exception type for all I/O failures. +- **`IDisposable` on implementations**: Both `DirectoryFileContainer` and `ZipFileContainer` + implement `IDisposable`. `DirectoryFileContainer.Dispose()` is a no-op; `ZipFileContainer.Dispose()` + closes the underlying `ZipArchive` and stream. Using `IDisposable` on both allows callers to use + `using` statements uniformly. + +#### Data Model + +`IFileContainer` itself is stateless. Each implementation holds its own state — see +`DirectoryFileContainer` and `ZipFileContainer` design documents for details. + +#### Key Methods + +| Method | Description | +| :--------------------------------------- | :------------------------------------------------------- | +| `GetEntries() → IReadOnlyList` | Enumerate all entries (forward-slash paths). | +| `OpenEntry(string) → Stream` | Open a named entry as a readable stream. | +| `GetEntrySize(string) → long` | Return the uncompressed byte length of an entry. | +| `GetDisplayPath(string) → string` | Return a display path for use in error messages. | + +#### Error Handling + +| Scenario | Handling | +| :---------------------------------- | :------------------------------------------------------------------ | +| Entry not found on `OpenEntry` | `IOException` (or subclass) thrown by the implementation. | +| Null `entryPath` passed | `ArgumentNullException` thrown by both implementations. | + +#### Dependencies + +- `DirectoryFileContainer` — depends on .NET BCL (`File`, `Directory`, `Path`, `FileInfo`). +- `ZipFileContainer` — depends on `System.IO.Compression.ZipArchive`. + +#### Callers + +- `FileAssertFile.Run` — calls all four members to resolve, open, measure, and display entries. +- `FileAssertZipAssert.Run` — calls `container.OpenEntry(entryPath)` to get the zip entry stream, + then wraps it in a `ZipFileContainer` for nested assertion. +- All 7 asserters — call `container.OpenEntry(entryPath)` and `container.GetDisplayPath(entryPath)`. +- `FileAssertTest.Run` — creates a `DirectoryFileContainer` from `basePath`. diff --git a/docs/design/file-assert/utilities/path-helpers.md b/docs/design/file-assert/utilities/path-helpers.md index 9c8df57..d570d65 100644 --- a/docs/design/file-assert/utilities/path-helpers.md +++ b/docs/design/file-assert/utilities/path-helpers.md @@ -22,10 +22,13 @@ the base directory. **Validation steps:** 1. Reject null inputs via `ArgumentNullException.ThrowIfNull`. -2. Combine the paths with `Path.Combine` to produce the candidate path (preserving the +2. Reject rooted relative paths via `Path.IsPathRooted(relativePath)` — a rooted second + argument to `Path.Combine` replaces the base entirely, so any such input is invalid even + when the rooted path resolves underneath `basePath`. +3. Combine the paths with `Path.Combine` to produce the candidate path (preserving the caller's relative/absolute style). -3. Resolve both `basePath` and the candidate to absolute form with `Path.GetFullPath`. -4. Compute `Path.GetRelativePath(absoluteBase, absoluteCombined)` and reject the input if +4. Resolve both `basePath` and the candidate to absolute form with `Path.GetFullPath`. +5. Compute `Path.GetRelativePath(absoluteBase, absoluteCombined)` and reject the input if the result is exactly `".."`, starts with `".."` followed by `Path.DirectorySeparatorChar` or `Path.AltDirectorySeparatorChar`, or is itself rooted (absolute), which would indicate the combined path escapes the base directory. @@ -64,10 +67,12 @@ N/A — `PathHelpers` is a `static` class with no instance state or fields. **Algorithm:** 1. Reject null inputs via `ArgumentNullException.ThrowIfNull`. -2. Produce `combinedPath = Path.Combine(basePath, relativePath)`. -3. Resolve both `basePath` and `combinedPath` to absolute form with `Path.GetFullPath`. -4. Compute `Path.GetRelativePath(absoluteBase, absoluteCombined)`. -5. Throw `ArgumentException` if the relative result equals `".."`, starts with `"../"` or +2. Throw `ArgumentException` if `Path.IsPathRooted(relativePath)` returns true (rooted + relative paths replace the base under `Path.Combine` semantics). +3. Produce `combinedPath = Path.Combine(basePath, relativePath)`. +4. Resolve both `basePath` and `combinedPath` to absolute form with `Path.GetFullPath`. +5. Compute `Path.GetRelativePath(absoluteBase, absoluteCombined)`. +6. Throw `ArgumentException` if the relative result equals `".."`, starts with `"../"` or `"..\\"`, or is itself rooted. #### Error Handling @@ -82,7 +87,12 @@ N/A — `PathHelpers` is a `static` class with no instance state or fields. - **Combined or resolved path exceeds system maximum length**: `PathTooLongException` propagated from `Path.GetFullPath`; not caught by this method. -#### Interactions +#### Dependencies + +- **No internal FileAssert dependencies**: `PathHelpers` is a self-contained utility with no + references to other units in the system. + +#### Callers - **Callers**: - `TemporaryDirectory` — uses `SafePathCombine(Environment.CurrentDirectory, guid-name)` to @@ -90,5 +100,3 @@ N/A — `PathHelpers` is a `static` class with no instance state or fields. `GetFilePath`. - `Validation` built-in tests — uses `tempDir.GetFilePath(fileName)` (which internally calls `SafePathCombine`) to build fixture file paths. -- **No internal FileAssert dependencies**: `PathHelpers` is a self-contained utility with no - references to other units in the system. diff --git a/docs/design/file-assert/utilities/temporary-directory.md b/docs/design/file-assert/utilities/temporary-directory.md index f3dc7c7..f23f65f 100644 --- a/docs/design/file-assert/utilities/temporary-directory.md +++ b/docs/design/file-assert/utilities/temporary-directory.md @@ -122,10 +122,13 @@ directory. | Null or traversal-escaping `relativePath` | | Temporary directory deletion failure (suppressed) | -#### Interactions +#### Dependencies - **Uses**: `PathHelpers.SafePathCombine` for all path construction, ensuring traversal safety on both the directory name and every relative path passed to `GetFilePath`. + +#### Callers + - **Callers**: - `Validation` built-in tests — each test creates a `TemporaryDirectory` instance, writes fixture files via `GetFilePath`, and disposes it at the end of the test body. diff --git a/docs/design/file-assert/utilities/zip-file-container.md b/docs/design/file-assert/utilities/zip-file-container.md new file mode 100644 index 0000000..bf5d90e --- /dev/null +++ b/docs/design/file-assert/utilities/zip-file-container.md @@ -0,0 +1,153 @@ +### ZipFileContainer Design + +#### Purpose + +`ZipFileContainer` is the zip archive implementation of `IFileContainer`. It wraps a `ZipArchive` +opened from a caller-supplied `Stream`, exposing the archive's file entries as a virtual container. +It supports nested zip assertion by accepting a stream rather than a file path, enabling +zip-in-zip scenarios where the outer archive's entry stream is directly wrapped as an inner +`ZipFileContainer`. + +#### Class Structure + +##### Constructor + +```csharp +internal ZipFileContainer(Stream stream, string displayName) +``` + +Opens a `ZipArchive` from `stream` with `ZipArchiveMode.Read` and `leaveOpen: false`. The +`displayName` is stored for use in `GetDisplayPath`. Both parameters are validated non-null. + +The `leaveOpen: false` parameter means that when `ZipFileContainer` is disposed, the `ZipArchive` +closes the underlying `stream` automatically. + +**Exceptions thrown:** + +| Exception | Condition | +| :--------------------- | :----------------------------------------------- | +| `ArgumentNullException`| `stream` or `displayName` is null. | +| `InvalidDataException` | Stream does not contain a valid zip archive. | + +##### GetEntries Method + +```csharp +public IReadOnlyList GetEntries() +``` + +Returns the names of all non-directory entries in the zip archive as a read-only list with +forward slashes. Directory entries (whose names end with `/`) are excluded. + +**Steps:** + +1. Enumerate `_archive.Entries`. +2. Select `e.FullName.Replace('\\', '/')` for each entry. +3. Filter out entries whose name ends with `/` (directory markers). +4. Return as a read-only list. + +##### OpenEntry Method + +```csharp +public Stream OpenEntry(string entryPath) +``` + +Finds the zip entry with the given name and opens it for reading via `entry.Open()`. The +supplied `entryPath` is normalized by replacing any `\` with `/` before calling +`_archive.GetEntry(...)`, mirroring the normalization performed by `GetEntries()` so that +callers using either separator can locate entries. Throws `IOException` with a descriptive +message when the entry is not found. + +**Why `IOException` rather than `FileNotFoundException`:** `FileNotFoundException` implies a +file-system file. Inside a zip archive the abstraction is a "stream entry", so the more general +`IOException` (which `FileNotFoundException` is a subclass of) is appropriate and consistent +with the asserters' catch clauses. + +##### GetEntrySize Method + +```csharp +public long GetEntrySize(string entryPath) +``` + +Returns `entry.Length` (the uncompressed size) for the named entry. The supplied `entryPath` +is normalized by replacing any `\` with `/` before lookup, mirroring `OpenEntry` and +`GetEntries`. Throws `IOException` when the entry is not found. + +##### GetDisplayPath Method + +```csharp +public string GetDisplayPath(string entryPath) +``` + +Returns `"{_displayName} > {entryPath}"`. This provides a breadcrumb-style path that includes +the archive name, enabling users to trace nested errors back through the archive hierarchy. + +For example, if `displayName` is `"outer.zip"` and `entryPath` is `"lib/inner.dll"`, the +display path is `"outer.zip > lib/inner.dll"`. + +For nested archives where the outer `ZipFileContainer` was created with display name +`"outer.zip > inner.zip"`, the display path of a further-nested entry would be +`"outer.zip > inner.zip > entry.txt"`. + +##### Dispose Method + +```csharp +public void Dispose() +``` + +Disposes `_archive`. Because the `ZipArchive` was opened with `leaveOpen: false`, disposing +it also closes and disposes the underlying stream. + +#### Design Decisions + +- **Stream-based constructor**: Accepting a `Stream` rather than a file path allows the same class + to be used when the zip archive is itself a zip entry in an outer archive. The outer asserter + opens the entry as a stream and passes it directly. +- **`leaveOpen: false`**: Closing the stream on archive disposal simplifies ownership. The caller + that opened the entry stream via `DirectoryFileContainer.OpenEntry` or an outer + `ZipFileContainer.OpenEntry` transfers ownership to `ZipFileContainer`. +- **`IOException` on missing entry**: Using the parent type rather than `FileNotFoundException` + avoids conflating file-system semantics with archive-entry semantics. All callers catch + `IOException` for I/O failures. +- **Directory marker exclusion**: Zip archives may contain explicit directory entries whose names + end with `/`. These markers carry no file content, so `GetEntries` filters them out and exposes + only real file entries to the asserters. +- **Display name as breadcrumb prefix**: Embedding the archive name in `GetDisplayPath` makes + error messages self-explanatory without requiring the asserter to format the path itself. + +#### Data Model + +| Field | Type | Description | +| :------------- | :------------ | :------------------------------------------------------- | +| `_archive` | `ZipArchive` | The open zip archive wrapping the supplied stream. | +| `_displayName` | `string` | The archive's display name for breadcrumb path building. | + +#### Key Methods + +| Method | Description | +| :---------------------------------------- | :------------------------------------------------------------------- | +| `ZipFileContainer(Stream, string)` | Constructor: opens `ZipArchive` from stream. | +| `GetEntries() → IReadOnlyList` | Enumerate all file entries (no directory markers) with `/` paths. | +| `OpenEntry(string) → Stream` | Open a named entry; throws `IOException` when not found. | +| `GetEntrySize(string) → long` | Return the uncompressed size of a named entry. | +| `GetDisplayPath(string) → string` | Return `"{displayName} > {entryPath}"` for error messages. | +| `Dispose()` | Dispose the `ZipArchive` (also closes the underlying stream). | + +#### Error Handling + +| Scenario | Handling | +| :------------------------------- | :------------------------------------------------------------------ | +| Stream is not a valid zip file | `InvalidDataException` thrown by `ZipArchive` constructor. | +| Entry not found on `OpenEntry` | `IOException` thrown with `"Zip entry '{name}' not found"` message. | +| Null `stream` or `displayName` | `ArgumentNullException` thrown in constructor. | +| Null `entryPath` | `ArgumentNullException` thrown before archive lookup. | + +#### Dependencies + +- `System.IO.Compression.ZipArchive`, `ZipArchiveEntry` + +#### Callers + +- `FileAssertZipAssert.Run` — creates a `ZipFileContainer` from the zip entry's stream, then + runs all configured file assertions against it. +- Test project `IFileContainerTests.cs` — verifies entry enumeration, stream opening, size + reporting, and display path generation. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 2d4143e..6c63896 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -15,6 +15,7 @@ document does not restate requirements; it explains how they are realized. This document covers the detailed design of the following software units: - **Program** — entry point and execution orchestrator (`Program.cs`) +- **IContext** — output contract interface for reporting assertion results (`IContext.cs`) - **Context** — command-line argument parser and I/O owner (`Context.cs`) - **FileAssertConfig** — top-level configuration loader and test runner (`FileAssertConfig.cs`) - **FileAssertData** — YAML data transfer objects for configuration deserialization (`FileAssertData.cs`) @@ -28,6 +29,9 @@ This document covers the detailed design of the following software units: - **FileAssertYamlAssert** — YAML document assertions (`FileAssertYamlAssert.cs`) - **FileAssertJsonAssert** — JSON document assertions (`FileAssertJsonAssert.cs`) - **FileAssertZipAssert** — zip archive entry assertions (`FileAssertZipAssert.cs`) +- **IFileContainer** — uniform file-access abstraction over directories and zip archives (`IFileContainer.cs`) +- **DirectoryFileContainer** — filesystem implementation of IFileContainer (`DirectoryFileContainer.cs`) +- **ZipFileContainer** — zip archive implementation of IFileContainer (`ZipFileContainer.cs`) - **PathHelpers** — safe path-combination utility (`PathHelpers.cs`) - **TemporaryDirectory** — disposable temporary directory utility (`TemporaryDirectory.cs`) - **Validation** — self-validation test runner (`Validation.cs`) @@ -42,6 +46,7 @@ The following topics are out of scope: DemaConsulting.TestResults) - Build pipeline configuration - Deployment and packaging +- Test projects are out of scope. ## Software Structure @@ -52,6 +57,7 @@ subsystem, and unit levels: FileAssert (System) ├── Program (Unit) ├── Cli (Subsystem) +│ ├── IContext (Unit) │ └── Context (Unit) ├── Configuration (Subsystem) │ ├── FileAssertConfig (Unit) @@ -68,6 +74,9 @@ FileAssert (System) │ ├── FileAssertJsonAssert (Unit) │ └── FileAssertZipAssert (Unit) ├── Utilities (Subsystem) +│ ├── IFileContainer (Unit) +│ ├── DirectoryFileContainer (Unit) +│ ├── ZipFileContainer (Unit) │ ├── PathHelpers (Unit) │ └── TemporaryDirectory (Unit) └── SelfTest (Subsystem) @@ -85,6 +94,7 @@ reviewers an explicit navigation aid from design to code: src/DemaConsulting.FileAssert/ ├── Program.cs — entry point and execution orchestrator ├── Cli/ +│ ├── IContext.cs — output contract interface for asserters and scoping │ └── Context.cs — command-line argument parser and I/O owner ├── Configuration/ │ ├── FileAssertConfig.cs — top-level configuration loader and test runner @@ -101,6 +111,9 @@ src/DemaConsulting.FileAssert/ │ ├── FileAssertJsonAssert.cs — JSON document assertions (System.Text.Json) │ └── FileAssertZipAssert.cs — zip archive entry assertions (System.IO.Compression) ├── Utilities/ +│ ├── IFileContainer.cs — uniform file-access abstraction interface +│ ├── DirectoryFileContainer.cs — filesystem implementation of IFileContainer +│ ├── ZipFileContainer.cs — zip archive implementation of IFileContainer │ ├── PathHelpers.cs — safe path-combination utility │ └── TemporaryDirectory.cs — disposable temporary directory utility └── SelfTest/ @@ -129,17 +142,16 @@ Each in-house software item has corresponding artifacts in parallel directory tr - Source code: `src/{SystemName}/.../{Item}.cs` (PascalCase for C#) - Tests: `test/{SystemName}.Tests/.../{Item}Tests.cs` (PascalCase for C#) -OTS items have no design documentation; their artifacts sit parallel to system folders: +OTS items have integration/usage design docs at `docs/design/ots/{ots-name}.md` describing how +FileAssert integrates the third-party library; their artifacts sit parallel to system folders: - Requirements: `docs/reqstream/ots/{ots-name}.yaml` +- Design: `docs/design/ots/{ots-name}.md` - Verification: `docs/verification/ots/{ots-name}.md` Review-sets: defined in `.reviewmark.yaml` ## References -- [FileAssert User Guide][guide] -- [FileAssert Repository][repo] - -[guide]: https://github.com/demaconsulting/FileAssert/blob/main/README.md -[repo]: https://github.com/demaconsulting/FileAssert +- FileAssert User Guide — the `README.md` document at the root of the FileAssert repository. +- FileAssert Repository — the `demaconsulting/FileAssert` source repository hosted on GitHub. diff --git a/docs/design/ots/filesystemglobbing.md b/docs/design/ots/filesystemglobbing.md new file mode 100644 index 0000000..b4df0d1 --- /dev/null +++ b/docs/design/ots/filesystemglobbing.md @@ -0,0 +1,22 @@ +## FileSystemGlobbing OTS Design + +Microsoft.Extensions.FileSystemGlobbing is the glob pattern-matching library used by FileAssert. + +### Purpose + +FileSystemGlobbing is chosen to resolve file-assertion patterns (for example `**/*.dll`) against the +set of candidate files in a directory or container. It provides the standard .NET implementation of +include/exclude glob matching, removing the need for a custom wildcard matcher. + +### Features Used + +- `Matcher` configured with include patterns to match candidate file paths. +- Recursive-wildcard (`**`), single-segment wildcard (`*`), and exact-path matching. +- Evaluation of patterns against an in-memory list of entry paths supplied by the file container. + +### Integration Pattern + +FileSystemGlobbing is referenced as a NuGet package by the main `DemaConsulting.FileAssert` project. +The file assertion unit builds a `Matcher` from the configured pattern and runs it against the entry +paths exposed by the active `IFileContainer` (directory or zip archive). The matched set drives the +count constraints (min/max/exact) for the assertion. diff --git a/docs/design/ots/htmlagilitypack.md b/docs/design/ots/htmlagilitypack.md new file mode 100644 index 0000000..f9957bd --- /dev/null +++ b/docs/design/ots/htmlagilitypack.md @@ -0,0 +1,23 @@ +## HtmlAgilityPack OTS Design + +HtmlAgilityPack is the HTML parsing library used by FileAssert. + +### Purpose + +HtmlAgilityPack is chosen to read HTML documents under test for `html:` XPath assertions. It parses +real-world, syntactically imperfect HTML leniently and exposes an XPath-navigable document object +model, which plain XML parsers cannot do reliably. + +### Features Used + +- Lenient parsing of HTML documents (including malformed markup) into a navigable document. +- XPath query evaluation to select node sets for count and text assertions. +- Access to node inner text for `contains`/exact-text rules. + +### Integration Pattern + +HtmlAgilityPack is referenced as a NuGet package by the main `DemaConsulting.FileAssert` project. +The HTML asserter loads the document under test through HtmlAgilityPack, evaluates the configured +XPath queries, and applies count and text rules to the matched nodes. Because parsing is lenient, +only IO failures (not parse failures) are reported as errors; IO exceptions are caught at the +asserter boundary and reported through the context. diff --git a/docs/design/ots/pdfpig.md b/docs/design/ots/pdfpig.md new file mode 100644 index 0000000..b115035 --- /dev/null +++ b/docs/design/ots/pdfpig.md @@ -0,0 +1,23 @@ +## PdfPig OTS Design + +PdfPig (UglyToad.PdfPig) is the PDF parsing library used by FileAssert. + +### Purpose + +PdfPig is chosen to read PDF documents under test for `pdf:` assertions. It provides managed, +dependency-free PDF parsing for .NET, exposing page counts, document metadata, and per-page text +without requiring native libraries. + +### Features Used + +- Opening a PDF document from a stream and enumerating its pages for page-count assertions. +- Reading document information (metadata) fields such as title and author. +- Extracting page text for `contains`/`matches` content rules. +- Detection of files that are not valid PDF documents, surfaced as exceptions. + +### Integration Pattern + +PdfPig is referenced as a NuGet package by the main `DemaConsulting.FileAssert` project. The PDF +asserter opens the document under test through PdfPig, reads the required page, metadata, and text +information, and applies the configured rules. Exceptions raised for invalid PDF input are caught at +the asserter boundary and reported through the context. diff --git a/docs/design/ots/reviewmark.md b/docs/design/ots/reviewmark.md index e4f48a0..5a9900f 100644 --- a/docs/design/ots/reviewmark.md +++ b/docs/design/ots/reviewmark.md @@ -1,53 +1,52 @@ ## ReviewMark OTS Design -DemaConsulting.ReviewMark is a .NET dotnet global tool that reads a review configuration and -evidence store to generate a review plan and review report documenting formal file review +DemaConsulting.ReviewMark is a .NET dotnet global tool that reads a review configuration and a +review evidence store to generate a review plan and review report documenting formal file review coverage. ### Purpose ReviewMark provides continuous compliance evidence for formal code review. It reads the `.reviewmark.yaml` configuration, which defines review-sets (named groups of files that must be -reviewed together), and the review evidence store to produce two Markdown documents: a review -plan that lists all files included in review-sets, and a review report that records which files -have been reviewed, by whom, and when. +reviewed together), and a review evidence store to produce two Markdown documents: a review plan +that lists all files included in review-sets, and a review report that records which files have +been reviewed, by whom, and when. ReviewMark is chosen because it integrates directly with the repository-level review evidence pattern used by this project's Continuous Compliance methodology and produces Markdown output compatible with the Pandoc pipeline. -### Integration +### Features Used -ReviewMark is installed as a .NET local tool defined in `.config/dotnet-tools.json` under the -package name `demaconsulting.reviewmark` and restored with `dotnet tool restore`. The CI -pipeline invokes ReviewMark in two separate steps: - -- `dotnet reviewmark --plan docs/code_review_plan/generated/plan.md` — generates the review plan -- `dotnet reviewmark --report docs/code_review_report/generated/report.md` — generates the review - report - -### Configuration +- Review-set definition and file-coverage enforcement driven by the `.reviewmark.yaml` glob + patterns under `needs-review` and `reviews`. +- Generation of a Markdown review plan via `dotnet reviewmark --plan `. +- Generation of a Markdown review report via `dotnet reviewmark --report `. +- Configuration linting via `dotnet reviewmark --lint` to validate the review configuration. +- URL-based review evidence retrieval: `.reviewmark.yaml` declares an `evidence-source` of + `type: url` pointing at + `https://raw.githubusercontent.com/demaconsulting/FileAssert/reviews/index.json`, so ReviewMark + fetches the committed review records over the network rather than from a local directory. -ReviewMark reads its configuration from `.reviewmark.yaml` at the repository root. This file -defines review-sets, each consisting of a name, description, and a list of file glob patterns -identifying the files that belong to the set. The review evidence store, which contains committed -review records in the repository, provides the historical evidence that ReviewMark uses to -determine which files have been reviewed and when. +### Integration Pattern -### Interfaces - -The project uses the following ReviewMark command-line interfaces: +ReviewMark is installed as a .NET local tool defined in `.config/dotnet-tools.json` under the +package name `demaconsulting.reviewmark` and restored with `dotnet tool restore`. It reads its +configuration from `.reviewmark.yaml` at the repository root, which defines the review-sets and the +`evidence-source`. -| Invocation | Effect | -| :------------------------------------------------- | :---------------------------------------- | -| `dotnet reviewmark --plan ` | Generates a Markdown review plan | -| `dotnet reviewmark --report ` | Generates a Markdown review report | +Because the configured `evidence-source` is a URL on `raw.githubusercontent.com`, ReviewMark +requires outbound network access during its plan and report steps to fetch the review evidence +index; an environment without access to that host cannot retrieve current review records. This is a +deliberate change from a purely local evidence store and means the CI job running ReviewMark depends +on network availability. -Both generated Markdown files are consumed by Pandoc to produce the Review Plan PDF and the -Review Report PDF, respectively. +The CI pipeline invokes ReviewMark in two separate steps, each producing a Markdown document that +Pandoc consumes to render the corresponding PDF: -### Dependencies +- `dotnet reviewmark --plan docs/code_review_plan/generated/plan.md` — generates the review plan. +- `dotnet reviewmark --report docs/code_review_report/generated/report.md` — generates the review + report. -ReviewMark has no transitive NuGet dependencies that propagate to the main source project. It -reads the `.reviewmark.yaml` configuration and the repository's review evidence directory. No -network access is required. +ReviewMark has no transitive NuGet dependencies that propagate to the main source project; it is a +build-time tool only. diff --git a/docs/design/ots/versionmark.md b/docs/design/ots/versionmark.md index d446a23..0c18d5a 100644 --- a/docs/design/ots/versionmark.md +++ b/docs/design/ots/versionmark.md @@ -1,55 +1,45 @@ ## VersionMark OTS Design DemaConsulting.VersionMark is a .NET dotnet global tool that captures installed tool version -information during each CI job and publishes it as a Markdown document included in the Build -Notes PDF artifact. +information during each CI job and publishes it as a Markdown document included in the Build Notes +PDF artifact. ### Purpose -VersionMark provides an audit record of the exact tool versions used to produce each release. -Each CI job captures the versions of the dotnet tools and runtime components it uses; the -build-docs job then merges all captured data and publishes a Markdown document included in the -Build Notes artifact. This supports reproducibility and compliance traceability for all build -tools. +VersionMark provides an audit record of the exact tool versions used to produce each release. Each +CI job captures the versions of the dotnet tools and runtime components it uses; the build-docs job +then merges all captured data and publishes a Markdown document included in the Build Notes +artifact. This supports reproducibility and compliance traceability for all build tools. VersionMark is chosen because it operates as a local dotnet tool alongside the other pipeline tools, requires no external service, and produces Markdown output compatible with the Pandoc pipeline. -### Integration +### Features Used + +- Capture of installed tool-version metadata for a CI job via + `dotnet versionmark --capture --job-id --output `. +- Merging of captured JSON files and publication of a consolidated Markdown versions document via + `dotnet versionmark --publish --output `. +- Built-in self-validation that writes TRX evidence for ReqStream via + `dotnet versionmark --validate --results `. + +### Integration Pattern VersionMark is installed as a .NET local tool defined in `.config/dotnet-tools.json` under the -package name `demaconsulting.versionmark` and restored with `dotnet tool restore`. It is used -in two modes in the CI pipeline: +package name `demaconsulting.versionmark` and restored with `dotnet tool restore`. It is configured +entirely through command-line arguments; no external configuration files are required. + +It is used in three modes across the CI pipeline: - **Capture mode** (each CI job): invoked with `--capture --job-id --output ` to interrogate installed tool versions and write a JSON file to the artifacts folder. - **Publish mode** (build-docs job): invoked with `--publish --output ` to - merge all captured JSON files and write the consolidated Markdown versions document. + merge all captured JSON files and write the consolidated Markdown versions document, which is + included in the Build Notes document and consumed by Pandoc. - **Self-validation**: invoked with `--validate --results ` to run the built-in validation suite and write TRX evidence for ReqStream consumption. -### Configuration - -VersionMark is configured entirely through command-line arguments. In capture mode, `--job-id` -identifies the CI job and `--output` specifies the JSON output path. In publish mode, the input -JSON files and the output Markdown path are provided as positional and named arguments -respectively. No external configuration files are required. - -### Interfaces - -The project uses the following VersionMark command-line interfaces: - -| Invocation | Effect | -| :----------------------------------------------------------------------- | :------------------------------------------- | -| `dotnet versionmark --capture --job-id --output ` | Captures tool versions to a JSON file | -| `dotnet versionmark --publish --output ` | Publishes merged version info as Markdown | -| `dotnet versionmark --validate --results ` | Runs self-validation and writes TRX evidence | - -The generated Markdown file is included in the Build Notes document and consumed by Pandoc. - -### Dependencies - -VersionMark has no transitive NuGet dependencies that propagate to the main source project. It -reads installed tool metadata from the local environment and writes JSON and Markdown files. No -network access is required. +VersionMark reads installed tool metadata from the local environment and writes JSON and Markdown +files. It requires no external service or network access, and it has no transitive NuGet +dependencies that propagate to the main source project; it is a build-time tool only. diff --git a/docs/design/ots/xunit.md b/docs/design/ots/xunit.md index aece0c3..47e7a4d 100644 --- a/docs/design/ots/xunit.md +++ b/docs/design/ots/xunit.md @@ -5,61 +5,45 @@ discovery, execution, and result reporting including TRX output for requirements ### Purpose -xUnit discovers all test methods annotated with `[Fact]` in the test project, executes them, and -reports pass/fail results. The xunit.runner.visualstudio adapter generates TRX result files that -ReqStream consumes to verify requirements coverage. Passing tests provide continuous traceability -evidence that FileAssert's functional requirements are implemented correctly. +xUnit discovers the test methods declared in the test project, executes them, and reports pass/fail +results. The `xunit.runner.visualstudio` adapter generates TRX result files that ReqStream consumes +to verify requirements coverage. Passing tests provide continuous traceability evidence that +FileAssert's functional requirements are implemented correctly. -xUnit v3 is chosen because it provides a modern, self-contained test runner with -`OutputType: Exe` support for .NET 8/9/10, strong assertion APIs, and the -`xunit.runner.visualstudio` adapter for TRX output format that ReqStream requires. +xUnit v3 is chosen because it provides a modern, self-contained test runner with `OutputType: Exe` +support for .NET 8/9/10, strong assertion APIs, and the `xunit.runner.visualstudio` adapter for the +TRX output format that ReqStream requires. -### Integration +### Features Used -xUnit is integrated via NuGet package references in the test project -(`DemaConsulting.FileAssert.Tests.csproj`): - -- `xunit.v3` — the core test framework providing `[Fact]`, assertions, and test runner - infrastructure for .NET 8, 9, and 10. -- `xunit.runner.visualstudio` — the Visual Studio and `dotnet test` adapter that enables - TRX result file output. - -Tests are executed by `dotnet test` with the `--logger trx;LogFileName=.trx` argument to -produce TRX files for ReqStream. The test project targets `net8.0`, `net9.0`, and `net10.0` -matching the supported runtime targets of the main project. - -### Configuration - -xUnit behavior is controlled through `dotnet test` command-line arguments. The test project -is configured with: - -- `OutputType: Exe` — required for xUnit v3 self-contained test executables. -- `IsTestProject: true` — marks the project for MSBuild and the .NET test SDK. -- `TreatWarningsAsErrors: true` — enforces code quality at compile time. +- Test discovery and execution of methods annotated with the `[Fact]` and `[Theory]` attributes. +- The xUnit assertion library (`Assert.Equal`, `Assert.True`, `Assert.Throws`, etc.) used throughout + the test methods. +- The `[Collection]` attribute to serialize tests that share temporary file-system state. +- TRX result-file generation through the `xunit.runner.visualstudio` adapter, driven by + `dotnet test --logger trx;LogFileName=.trx`, for ReqStream consumption. -No `xunit.runner.json` file is required; default discovery and execution settings are used. -`Microsoft.NET.Test.Sdk` provides the test SDK integration layer. +### Integration Pattern -### Interfaces - -xUnit exposes the following APIs consumed by the project: - -| API | Usage | -| :----------------------------------- | :------------------------------------------------------------- | -| `[Fact]` attribute | Marks a method as a test case for discovery and execution | -| `[Collection]` attribute | Groups tests that share a fixture or must not run in parallel | -| `Assert.Equal`, `Assert.True`, etc. | Assertion methods used throughout all test methods | -| `dotnet test --logger trx` | Produces TRX output consumed by ReqStream | - -### Dependencies - -xUnit brings the following dependencies into the test project: - -- `xunit.v3.core` — the test execution engine and assertion library. -- `xunit.v3.common` — shared abstractions used by the xUnit framework. -- `xunit.runner.visualstudio` — the `dotnet test` integration adapter. -- `Microsoft.NET.Test.Sdk` — the test SDK integration layer. +xUnit is integrated via NuGet package references in the test project +(`DemaConsulting.FileAssert.Tests.csproj`): -All xUnit and runner dependencies are scoped to the test project via `PrivateAssets` settings -and do not propagate to the main `DemaConsulting.FileAssert` project or its NuGet package -consumers. +- `xunit.v3` — the core test framework providing `[Fact]`, `[Theory]`, assertions, and the test + runner infrastructure for .NET 8, 9, and 10. +- `xunit.runner.visualstudio` — the Visual Studio and `dotnet test` adapter that enables TRX result + file output. +- `Microsoft.NET.Test.Sdk` — the test SDK integration layer required by the VSTest/`dotnet test` + host for discovery. + +The test project is configured with `OutputType: Exe` (required for xUnit v3 self-contained test +executables), `IsTestProject: true` (marks the project for MSBuild and the .NET test SDK), and +`TreatWarningsAsErrors: true`. No `xunit.runner.json` file is required; default discovery and +execution settings are used. Tests target `net8.0`, `net9.0`, and `net10.0`, matching the supported +runtime targets of the main project, and are executed with +`dotnet test --logger trx;LogFileName=.trx` to produce TRX files for ReqStream. + +xUnit and its runner are confined to the test assembly by the test project boundary: the test +project references the production project, but the production `DemaConsulting.FileAssert` project and +its published NuGet package never reference the test project, so the xUnit packages cannot flow to +production consumers. This confinement is a consequence of the dependency direction between the +projects, not of any `PrivateAssets` markup on the package references. diff --git a/docs/design/ots/yamldotnet.md b/docs/design/ots/yamldotnet.md new file mode 100644 index 0000000..517f893 --- /dev/null +++ b/docs/design/ots/yamldotnet.md @@ -0,0 +1,23 @@ +## YamlDotNet OTS Design + +YamlDotNet is the YAML parsing and deserialization library used by FileAssert. + +### Purpose + +YamlDotNet is chosen to deserialize the `.fileassert.yaml` configuration into the Configuration +DTOs and to parse arbitrary YAML documents under test for `yaml:` dot-notation path assertions. It +provides a mature, well-supported YAML 1.1/1.2 implementation for .NET, removing the need for a +hand-written parser. + +### Features Used + +- Object deserialization of YAML into strongly-typed DTOs via the deserializer with member aliasing. +- Parsing of arbitrary YAML documents into a node graph for dot-notation path evaluation. +- Detection of malformed YAML, surfaced as parse exceptions that FileAssert converts into errors. + +### Integration Pattern + +YamlDotNet is referenced as a NuGet package by the main `DemaConsulting.FileAssert` project. The +Configuration subsystem constructs a deserializer to map YAML onto DTO types, and the YAML asserter +loads documents under test through the same library. Parse exceptions are caught at the asserter +boundary and reported through the context rather than propagating to the caller. diff --git a/docs/reqstream/file-assert.yaml b/docs/reqstream/file-assert.yaml index fff6a29..63ef586 100644 --- a/docs/reqstream/file-assert.yaml +++ b/docs/reqstream/file-assert.yaml @@ -114,7 +114,7 @@ sections: artifacts that should always be present. Users declare the lower bound they expect; the tool reports a violation when fewer files are found. children: - - FileAssert-FileAssertFile-CountConstraints + - FileAssert-FileAssertFile-MinCountConstraint tests: - IntegrationTest_MinCountConstraint_TooFewFiles_ReturnsNonZero @@ -127,7 +127,7 @@ sections: configuration files from silently passing validation. Users declare the upper bound they expect; the tool reports a violation when more files are found. children: - - FileAssert-FileAssertFile-CountConstraints + - FileAssert-FileAssertFile-MaxCountConstraint tests: - IntegrationTest_MaxCountConstraint_TooManyFiles_ReturnsNonZero @@ -171,7 +171,8 @@ sections: YAML configuration, deserialization, and file system inspection all integrate correctly. children: - - FileAssert-FileAssertFile-SizeConstraints + - FileAssert-FileAssertFile-MinSize + - FileAssert-FileAssertFile-MaxSize tests: - IntegrationTest_FileSizeConstraints_TooSmall_ReturnsNonZero - IntegrationTest_FileSizeConstraints_TooLarge_ReturnsNonZero @@ -276,6 +277,7 @@ sections: misleading partial results. children: - FileAssert-Modeling-FileTypeParsing + - FileAssert-Modeling-FileTypeParseError tests: - IntegrationTest_PdfAssert_InvalidFile_ReturnsNonZero - IntegrationTest_PdfAssert_FailingAssertion_ReturnsNonZero @@ -292,6 +294,7 @@ sections: parsers. An immediate failure on invalid XML prevents misleading partial results. children: - FileAssert-Modeling-FileTypeParsing + - FileAssert-Modeling-FileTypeParseError - FileAssert-Modeling-QueryAssertions tests: - IntegrationTest_XmlAssert_PassingQuery_ReturnsZero @@ -301,18 +304,19 @@ sections: title: | The FileAssert tool shall evaluate HTML document assertions declared in an `html:` block using XPath expressions and return a non-zero exit code when any assertion fails or - when the file cannot be parsed as valid HTML. + when an IO error occurs reading the file. justification: | HTML documents are common outputs of documentation generators, static site builders, and report tools. XPath-based node count assertions allow users to verify page - structure such as title presence and link counts. An immediate failure on invalid - HTML prevents misleading partial results. + structure such as title presence and link counts. Because the parser is lenient and + tolerates syntactically imperfect markup, only IO failures (not parse failures) are + treated as errors. children: - FileAssert-Modeling-FileTypeParsing - FileAssert-Modeling-QueryAssertions tests: - IntegrationTest_HtmlAssert_PassingQuery_ReturnsZero - - IntegrationTest_HtmlAssert_InvalidFile_ReturnsNonZero + - IntegrationTest_HtmlAssert_ZeroMatchingElements_ReturnsNonZero - id: FileAssert-System-YamlAssertions title: | @@ -326,6 +330,7 @@ sections: An immediate failure on invalid YAML prevents misleading partial results. children: - FileAssert-Modeling-FileTypeParsing + - FileAssert-Modeling-FileTypeParseError - FileAssert-Modeling-QueryAssertions tests: - IntegrationTest_YamlAssert_PassingQuery_ReturnsZero @@ -343,6 +348,7 @@ sections: invalid JSON prevents misleading partial results. children: - FileAssert-Modeling-FileTypeParsing + - FileAssert-Modeling-FileTypeParseError - FileAssert-Modeling-QueryAssertions tests: - IntegrationTest_JsonAssert_PassingQuery_ReturnsZero @@ -350,32 +356,42 @@ sections: - id: FileAssert-System-ZipAssertions title: | - The FileAssert tool shall evaluate zip archive entry assertions declared in a `zip:` block - and return a non-zero exit code when any entry count constraint fails or when the file - cannot be opened as a valid zip archive. + The FileAssert tool shall evaluate the full content assertion suite on matched zip archive + entries declared in a `zip:` block, enforcing count constraints and recursive nesting, and + return a non-zero exit code when any assertion fails or when the file cannot be opened as + a valid zip archive. justification: | Zip archives are a common packaging format for build outputs, distribution packages, and - compliance artifacts. Entry count constraints allow users to assert that required files - are present in the archive without unpacking it. An immediate failure on an invalid zip - archive prevents misleading partial results. + compliance artifacts. Entry count constraints allow users to assert that required files are + present in the archive without unpacking it. Content assertions (text, XML, YAML, JSON) on + individual entries allow users to validate the content of packaged files directly from the + archive. Recursive nesting lets users validate archives embedded inside other archives. An + immediate failure on an invalid zip archive prevents misleading partial results. children: - FileAssert-Modeling-FileTypeParsing + - FileAssert-Modeling-FileTypeParseError - FileAssert-FileAssertZipAssert-EntryMatching tests: - IntegrationTest_ZipAssert_PassingQuery_ReturnsZero - IntegrationTest_ZipAssert_InvalidFile_ReturnsNonZero + - IntegrationTest_ZipAssert_TextAssertionPassing_ReturnsZero + - IntegrationTest_ZipAssert_TextAssertionFailing_ReturnsNonZero + - IntegrationTest_ZipAssert_XmlAssertionPassing_ReturnsZero + - IntegrationTest_ZipAssert_NestedZipTextContent_ReturnsZero + - IntegrationTest_ZipAssert_FailingContentAssertion_ErrorContainsEntryPath - id: FileAssert-System-DefaultBehavior title: | - The FileAssert tool shall display version and copyright information when invoked - with no recognized flags. + The FileAssert tool shall load and execute the `.fileassert.yaml` configuration file + from the current working directory when invoked with no arguments. justification: | - Displaying the application banner on default invocation follows standard CLI conventions - and confirms to users that the tool is installed and running correctly. + Running the default-named configuration from the current directory with no arguments + provides a zero-configuration entry point for users and CI/CD pipelines, allowing a + project to be validated simply by invoking the tool from its root. children: - FileAssert-Program-DefaultBehavior tests: - - Program_Run_NoArguments_DisplaysDefaultBehavior + - IntegrationTest_DefaultBehavior_RunsConfigFromWorkingDirectory_ReturnsZero - id: FileAssert-System-DepthFlag title: | @@ -388,6 +404,7 @@ sections: children: - FileAssert-Context-Depth tests: + - IntegrationTest_DepthFlag_ProducesHeadingsAtSpecifiedDepth - Validation_Run_WithDepth_UsesSpecifiedHeadingDepth - id: FileAssert-System-MultiPlatform @@ -401,7 +418,9 @@ sections: - FileAssert-Platform-Linux - FileAssert-Platform-MacOS tests: - - IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero + - "windows@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" + - "ubuntu@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" + - "macos@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" - id: FileAssert-System-MultiRuntime title: | @@ -414,4 +433,6 @@ sections: - FileAssert-Platform-Net9 - FileAssert-Platform-Net10 tests: - - IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero + - "net8.0@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" + - "net9.0@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" + - "net10.0@IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero" diff --git a/docs/reqstream/file-assert/cli.yaml b/docs/reqstream/file-assert/cli.yaml index 18bc164..c42a09e 100644 --- a/docs/reqstream/file-assert/cli.yaml +++ b/docs/reqstream/file-assert/cli.yaml @@ -24,11 +24,13 @@ sections: - Cli_CreateContext_UnknownArgument_ThrowsArgumentException - id: FileAssert-Cli-ArgumentExposure - title: The Cli subsystem shall expose parsed argument values as typed properties on the Context object. + title: The Cli subsystem shall make each parsed command-line option's value observable to downstream subsystems so + that subsequent execution decisions reflect the user-supplied flags. justification: | - Exposing parsed arguments as typed properties provides a strongly-typed interface - for downstream subsystems, eliminating repeated string parsing and making argument - access self-documenting. + Downstream subsystems must be able to read the effective value of every command-line + option to decide what work to perform. Exposing parsed option values to those + subsystems eliminates repeated string parsing and ensures execution behavior matches + what the user requested. children: - FileAssert-Context-ConfigFile - FileAssert-Context-Filters @@ -64,3 +66,19 @@ sections: tests: - Cli_OutputPipeline_WithLogPathAndSilentFlag_WritesMessagesToLogFile - Cli_OutputPipeline_WithoutSilentFlag_WritesMessagesToConsole + + - id: FileAssert-Cli-ScopedContext + title: The Cli subsystem shall provide breadcrumb-style context scoping so that error messages produced inside a nested + assertion identify the enclosing container path. + justification: | + When asserting files inside a zip archive, error messages must identify both the + archive and the entry that failed. Breadcrumb-style context scoping prepends the + enclosing container path to error messages, giving users immediate context without + requiring each asserter to format breadcrumb paths explicitly. Nested scopes support + zip-in-zip scenarios. + children: + - FileAssert-IContext-OutputContract + tests: + - Context_WithPrefix_ReturnsNonNullScopedContext + - ScopedContext_WriteError_PropagatesExitCodeToRoot + - ScopedContext_Nested_WriteError_PropagatesExitCodeToRoot diff --git a/docs/reqstream/file-assert/cli/i-context.yaml b/docs/reqstream/file-assert/cli/i-context.yaml new file mode 100644 index 0000000..c27f868 --- /dev/null +++ b/docs/reqstream/file-assert/cli/i-context.yaml @@ -0,0 +1,27 @@ +--- +# Software Unit Requirements for the IContext Interface +# +# IContext is the output contract interface for reporting assertion results and +# errors. It is implemented by Context (the root context) and by +# Context.ScopedContext (a scoped wrapper that prepends a path prefix to every +# error message), enabling breadcrumb-style error reporting in nested zip assertions. + +sections: + - title: IContext Unit Requirements + requirements: + - id: FileAssert-IContext-OutputContract + title: The IContext interface shall define an output contract for reporting informational messages and errors. + justification: | + All asserters and FileAssertFile must be decoupled from the concrete Context + class so that a scoped wrapper can be passed in place of the root context. + Defining an interface rather than accepting Context directly allows + FileAssertZipAssert to supply a prefix-bearing ScopedContext to nested + asserters without those asserters requiring any knowledge of the scoping + mechanism. + tests: + - Context_WithPrefix_ReturnsNonNullScopedContext + - Context_WithPrefix_NullPrefix_ThrowsArgumentNullException + - ScopedContext_WriteError_PropagatesExitCodeToRoot + - ScopedContext_WriteLine_DoesNotSetError + - ScopedContext_Nested_WriteError_PropagatesExitCodeToRoot + - ScopedContext_MultipleErrors_AllAccumulateOnRoot diff --git a/docs/reqstream/file-assert/configuration.yaml b/docs/reqstream/file-assert/configuration.yaml index 79b252a..85dd907 100644 --- a/docs/reqstream/file-assert/configuration.yaml +++ b/docs/reqstream/file-assert/configuration.yaml @@ -48,3 +48,4 @@ sections: - FileAssert-FileAssertConfig-ResultsJUnit tests: - Configuration_Run_WithResultsFile_WritesTrxResultsFile + - Configuration_Run_WithResultsFile_WritesJUnitResultsFile diff --git a/docs/reqstream/file-assert/modeling.yaml b/docs/reqstream/file-assert/modeling.yaml index 9807a36..1781b1f 100644 --- a/docs/reqstream/file-assert/modeling.yaml +++ b/docs/reqstream/file-assert/modeling.yaml @@ -9,7 +9,8 @@ sections: - title: Modeling Subsystem Requirements requirements: - id: FileAssert-Modeling-ExecutionChain - title: The Modeling subsystem shall execute the full test-file-rule chain and pass when all constraints are met. + title: The Modeling subsystem shall report success for a test-file-rule chain only when all of its constraints are + met. justification: | The Modeling subsystem integrates three unit types (FileAssertTest, FileAssertFile, FileAssertRule) into a single execution chain. Verifying the chain end-to-end at the @@ -20,10 +21,12 @@ sections: - FileAssert-FileAssertTest-Execution - FileAssert-FileAssertTest-RunValidation - FileAssert-FileAssertFile-Creation - - FileAssert-FileAssertFile-CountConstraints + - FileAssert-FileAssertFile-MinCountConstraint + - FileAssert-FileAssertFile-MaxCountConstraint - FileAssert-FileAssertFile-ContentRules - FileAssert-FileAssertFile-ExactCount - - FileAssert-FileAssertFile-SizeConstraints + - FileAssert-FileAssertFile-MinSize + - FileAssert-FileAssertFile-MaxSize - FileAssert-FileAssertFile-FileTypeAssertDelegation - FileAssert-FileAssertRule-Factory - FileAssert-FileAssertRule-ContainsRule @@ -42,56 +45,235 @@ sections: level verifies that the full error-reporting pipeline within the subsystem works. children: - FileAssert-FileAssertTest-Execution - - FileAssert-FileAssertFile-CountConstraints + - FileAssert-FileAssertFile-MinCountConstraint + - FileAssert-FileAssertFile-MaxCountConstraint - FileAssert-FileAssertFile-ContentRules - - FileAssert-FileAssertFile-SizeConstraints + - FileAssert-FileAssertFile-MinSize + - FileAssert-FileAssertFile-MaxSize tests: - Modeling_ExecuteChain_ReportsFailuresThroughContext - - id: FileAssert-Modeling-FileTypeParsing - title: | - The Modeling subsystem shall parse matched files as structured documents (PDF, XML, - HTML, YAML, JSON, ZIP) when the corresponding assertion block is declared, and report - an immediate error if the file cannot be parsed. + - id: FileAssert-Modeling-FileTypeParsing-Text + title: The Modeling subsystem shall read matched files as plain text when a `text:` block is declared. justification: | - File-type parsing enables structured-document assertions (metadata, XPath, dot-notation - paths) that are not possible with plain-text content rules. Reporting an immediate - error on parse failure prevents misleading partial results from downstream assertions. + The text path is the simplest format and exercises the rule pipeline directly. children: - FileAssert-FileAssertTextAssert-Creation - FileAssert-FileAssertTextAssert-RuleApplication - - FileAssert-FileAssertTextAssert-IOError + tests: + - Modeling_ExecuteChain_PassesWhenAllConstraintsMet + + - id: FileAssert-Modeling-FileTypeParsing-Pdf + title: The Modeling subsystem shall parse matched files as PDF documents when a `pdf:` block is declared. + justification: | + PDF parsing is required to apply metadata, page-count, and body-text assertions. + children: - FileAssert-FileAssertPdfAssert-Creation - - FileAssert-FileAssertPdfAssert-ParseError - FileAssert-FileAssertPdfAssert-MetadataAssertions - FileAssert-FileAssertPdfAssert-PageCountAssertions - FileAssert-FileAssertPdfAssert-TextAssertions + tests: + - FileAssertPdfAssert_Run_ValidPdf_PageCountSatisfied_NoError + + - id: FileAssert-Modeling-FileTypeParsing-Xml + title: The Modeling subsystem shall parse matched files as XML documents when an `xml:` block is declared. + justification: | + XML parsing supports XPath queries against XML reports. + children: - FileAssert-FileAssertXmlAssert-Creation - - FileAssert-FileAssertXmlAssert-ParseError + - FileAssert-FileAssertXmlAssert-QueryValidation + tests: + - Modeling_QueryAssertions_XmlQueryMeetsCount_NoError + + - id: FileAssert-Modeling-FileTypeParsing-Html + title: The Modeling subsystem shall parse matched files as HTML documents when an `html:` block is declared. + justification: | + HTML parsing supports XPath queries against HTML reports (lenient parser). + children: - FileAssert-FileAssertHtmlAssert-Creation - - FileAssert-FileAssertHtmlAssert-ParseError + tests: + - FileAssertHtmlAssert_Run_ExactCount_Matches_NoError + + - id: FileAssert-Modeling-FileTypeParsing-Yaml + title: The Modeling subsystem shall parse matched files as YAML documents when a `yaml:` block is declared. + justification: | + YAML parsing supports dot-notation path queries against YAML configuration files. + children: - FileAssert-FileAssertYamlAssert-Creation - - FileAssert-FileAssertYamlAssert-ParseError + tests: + - FileAssertYamlAssert_Run_SequenceCount_Matches_NoError + + - id: FileAssert-Modeling-FileTypeParsing-Json + title: The Modeling subsystem shall parse matched files as JSON documents when a `json:` block is declared. + justification: | + JSON parsing supports dot-notation path queries against JSON outputs. + children: - FileAssert-FileAssertJsonAssert-Creation - - FileAssert-FileAssertJsonAssert-ParseError + tests: + - FileAssertJsonAssert_Run_ArrayCount_Matches_NoError + + - id: FileAssert-Modeling-FileTypeParsing-Zip + title: The Modeling subsystem shall parse matched files as ZIP archives when a `zip:` block is declared. + justification: | + ZIP parsing exposes archive entries to the IFileContainer abstraction. + children: - FileAssert-FileAssertZipAssert-Creation + tests: + - FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError + + - id: FileAssert-Modeling-FileTypeParsing + title: | + The Modeling subsystem shall parse matched files as structured documents (PDF, XML, + HTML, YAML, JSON, ZIP) when the corresponding assertion block is declared. + justification: | + Composite requirement decomposed per file format; each child carries the specific + parser binding. + children: + - FileAssert-Modeling-FileTypeParsing-Text + - FileAssert-Modeling-FileTypeParsing-Pdf + - FileAssert-Modeling-FileTypeParsing-Xml + - FileAssert-Modeling-FileTypeParsing-Html + - FileAssert-Modeling-FileTypeParsing-Yaml + - FileAssert-Modeling-FileTypeParsing-Json + - FileAssert-Modeling-FileTypeParsing-Zip + tests: + - Modeling_FileTypeParsing_ValidPdf_ParsesAndAppliesPageCount_NoError + + - id: FileAssert-Modeling-FileTypeParseError + title: | + The Modeling subsystem shall report an immediate error when a matched file cannot be + parsed as the structured-document type declared by its assertion block. + justification: | + Reporting an immediate error on parse failure prevents misleading partial results from + downstream assertions. This requirement covers content parse failures only; failures to + read a matched file due to I/O errors are a separate concern covered by the file-type + read-error requirement. + children: + - FileAssert-FileAssertPdfAssert-ParseError + - FileAssert-FileAssertXmlAssert-ParseError + - FileAssert-FileAssertHtmlAssert-ParseError + - FileAssert-FileAssertYamlAssert-ParseErrorReported + - FileAssert-FileAssertYamlAssert-ParseErrorSkipsRemaining + - FileAssert-FileAssertJsonAssert-ParseError - FileAssert-FileAssertZipAssert-ParseError tests: - Modeling_FileTypeParsing_InvalidXml_ReportsParseError - - id: FileAssert-Modeling-QueryAssertions + - id: FileAssert-Modeling-QueryAssertions-Xml title: | - The Modeling subsystem shall evaluate structured-document query assertions (XPath - for XML and HTML; dot-notation paths for YAML and JSON) and apply count constraints - to the number of matching nodes. + The Modeling subsystem shall report an XML XPath assertion as failed when the + number of nodes selected does not satisfy the declared count constraint. justification: | - Query-based assertions at the subsystem level verify that the parsing and assertion - pipeline integrates correctly across all supported structured-document formats without - requiring format-specific integration tests at the subsystem level. + XML XPath queries are the primary structured-document assertion for XML reports. children: - FileAssert-FileAssertXmlAssert-QueryAssertions + tests: + - Modeling_QueryAssertions_XmlQueryMeetsCount_NoError + + - id: FileAssert-Modeling-QueryAssertions-Html + title: | + The Modeling subsystem shall report an HTML XPath assertion as failed when the + number of nodes selected does not satisfy the declared count constraint. + justification: | + HTML XPath queries are the primary structured-document assertion for HTML reports. + children: - FileAssert-FileAssertHtmlAssert-QueryAssertions - - FileAssert-FileAssertYamlAssert-QueryAssertions + tests: + - Modeling_QueryAssertions_HtmlQueryMeetsCount_NoError + + - id: FileAssert-Modeling-QueryAssertions-Yaml + title: | + The Modeling subsystem shall report a YAML dot-notation query as failed when the + number of selected scalars/sequences does not satisfy the declared count constraint. + justification: | + YAML queries are the primary structured-document assertion for YAML configuration files. + children: + - FileAssert-FileAssertYamlAssert-QueryTraversal + - FileAssert-FileAssertYamlAssert-QueryMinCount + - FileAssert-FileAssertYamlAssert-QueryMaxCount + - FileAssert-FileAssertYamlAssert-QueryExactCount + tests: + - Modeling_QueryAssertions_YamlQueryMeetsCount_NoError + + - id: FileAssert-Modeling-QueryAssertions-Json + title: | + The Modeling subsystem shall report a JSON dot-notation query as failed when the + number of selected scalars/arrays does not satisfy the declared count constraint. + justification: | + JSON queries are the primary structured-document assertion for JSON outputs. + children: - FileAssert-FileAssertJsonAssert-QueryAssertions + tests: + - Modeling_QueryAssertions_JsonQueryMeetsCount_NoError + + - id: FileAssert-Modeling-QueryAssertions + title: | + The Modeling subsystem shall report a structured-document query assertion (XPath for XML + and HTML; dot-notation paths for YAML and JSON) as failed when the number of nodes the + query matches does not satisfy the assertion's declared count constraint. + justification: | + Composite requirement decomposed per file format. + children: + - FileAssert-Modeling-QueryAssertions-Xml + - FileAssert-Modeling-QueryAssertions-Html + - FileAssert-Modeling-QueryAssertions-Yaml + - FileAssert-Modeling-QueryAssertions-Json tests: - Modeling_QueryAssertions_XmlQueryMeetsCount_NoError + + - id: FileAssert-Modeling-ZipEntryContentAssertions-Text + title: The Modeling subsystem shall apply text content rules to matched zip archive entries. + justification: | + Verifies the simplest content asserter (text) flows through ZipFileContainer correctly. + children: + - FileAssert-FileAssertZipAssert-EntryMatching + tests: + - FileAssertZipAssert_Run_EntryContainsRequiredText_NoError + + - id: FileAssert-Modeling-ZipEntryContentAssertions-Xml + title: The Modeling subsystem shall apply XML XPath rules to matched zip archive entries. + justification: | + Verifies XML asserter integration with the IFileContainer abstraction. + tests: + - FileAssertZipAssert_Run_EntryXmlMatchesXPath_NoError + + - id: FileAssert-Modeling-ZipEntryContentAssertions-Yaml + title: The Modeling subsystem shall apply YAML query rules to matched zip archive entries. + justification: | + Verifies YAML asserter integration with the IFileContainer abstraction. + tests: + - FileAssertZipAssert_Run_EntryYamlMatchesQuery_NoError + + - id: FileAssert-Modeling-ZipEntryContentAssertions-Json + title: The Modeling subsystem shall apply JSON query rules to matched zip archive entries. + justification: | + Verifies JSON asserter integration with the IFileContainer abstraction. + tests: + - FileAssertZipAssert_Run_EntryJsonMatchesQuery_NoError + + - id: FileAssert-Modeling-ZipEntryContentAssertions + title: | + The Modeling subsystem shall apply the full content assertion suite (text, xml, yaml, + json) to matched zip archive entries. + justification: | + Composite requirement decomposed per content asserter. + children: + - FileAssert-Modeling-ZipEntryContentAssertions-Text + - FileAssert-Modeling-ZipEntryContentAssertions-Xml + - FileAssert-Modeling-ZipEntryContentAssertions-Yaml + - FileAssert-Modeling-ZipEntryContentAssertions-Json + tests: + - Modeling_ZipEntryContentAssertions_TextContentPassesWhenConstraintsMet + + - id: FileAssert-Modeling-ZipEntryBreadcrumbReporting + title: | + The Modeling subsystem shall propagate zip entry assertion failures through a scoped + context that produces breadcrumb-style error messages identifying the failing entry. + justification: | + Breadcrumb error messages must propagate correctly through the scoped context so that + users receive actionable failure output identifying both the archive and the entry that + failed, including for archives nested inside other archives. + children: + - FileAssert-FileAssertZipAssert-BreadcrumbErrors + tests: + - Modeling_ZipEntryContentAssertions_FailureReportsWithBreadcrumbs diff --git a/docs/reqstream/file-assert/modeling/file-assert-file.yaml b/docs/reqstream/file-assert/modeling/file-assert-file.yaml index 1ee977e..fe5e92d 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-file.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-file.yaml @@ -21,18 +21,22 @@ sections: - FileAssertFile_Create_NullPattern_ThrowsInvalidOperationException - FileAssertFile_Create_BlankPattern_ThrowsInvalidOperationException - - id: FileAssert-FileAssertFile-CountConstraints - title: The FileAssertFile class shall enforce optional minimum and maximum file count constraints. + - id: FileAssert-FileAssertFile-MinCountConstraint + title: The FileAssertFile class shall enforce an optional minimum file count constraint. justification: | - Count constraints allow tests to assert that a glob pattern matches the expected - number of files, catching configuration drift such as missing required artifacts - or unexpected duplicate outputs. Reporting a count mismatch before content checks - avoids misleading downstream errors. + The minimum-count constraint catches missing required artifacts (such as a build + output that should always be produced). tests: - - FileAssertFile_Run_NoMatchingFiles_NoConstraints_NoError - - FileAssertFile_Run_WithMatchingFiles_NoConstraints_NoError - FileAssertFile_Run_TooFewFiles_WritesError + - FileAssertFile_Run_NoMatchingFiles_NoConstraints_NoError + + - id: FileAssert-FileAssertFile-MaxCountConstraint + title: The FileAssertFile class shall enforce an optional maximum file count constraint. + justification: | + The maximum-count constraint catches unexpected duplicate or extra outputs. + tests: - FileAssertFile_Run_TooManyFiles_WritesError + - FileAssertFile_Run_WithMatchingFiles_NoConstraints_NoError - id: FileAssert-FileAssertFile-ContentRules title: The FileAssertFile class shall apply content rules to every matched file when rules are defined. @@ -48,25 +52,26 @@ sections: - id: FileAssert-FileAssertFile-ExactCount title: The FileAssertFile class shall enforce an exact file count constraint when declared. justification: | - The exact count constraint is a strict variant of the min/max bounds and is - needed when the user must assert that a pattern matches precisely the declared - number of files. It is checked after the min/max constraints and before per-file - rules to provide a clear, early failure when the count is wrong. + The exact-count constraint is needed when the user must assert that a pattern + matches precisely the declared number of files, catching both missing and extra + files in a single rule. tests: - FileAssertFile_Run_WrongCount_WritesError - - id: FileAssert-FileAssertFile-SizeConstraints - title: | - The FileAssertFile class shall enforce min-size and max-size constraints on - each matched file. + - id: FileAssert-FileAssertFile-MinSize + title: The FileAssertFile class shall enforce an optional minimum-size constraint on each matched file. justification: | - Size constraints guard against empty outputs (min-size) and unexpectedly large - files (max-size). Checking sizes per matched file ensures every file in the - matched set individually satisfies the declared bounds. Size checks are - performed before reading file content to avoid unnecessary I/O when a size - violation is present. + The minimum-size constraint guards against empty or near-empty outputs (for example, + a generated report that compiled successfully but produced no content). tests: - FileAssertFile_Run_TooSmall_WritesError + - FileAssertFile_Run_MultipleFiles_MultipleViolateSizeConstraints_WritesErrorForEachViolation + + - id: FileAssert-FileAssertFile-MaxSize + title: The FileAssertFile class shall enforce an optional maximum-size constraint on each matched file. + justification: | + The maximum-size constraint catches unexpectedly large outputs. + tests: - FileAssertFile_Run_TooLarge_WritesError - FileAssertFile_Run_MultipleFiles_MultipleViolateSizeConstraints_WritesErrorForEachViolation diff --git a/docs/reqstream/file-assert/modeling/file-assert-html-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-html-assert.yaml index 59479b2..b8e2319 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-html-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-html-assert.yaml @@ -26,6 +26,7 @@ sections: immediately gives users a clear, actionable error message. tests: - FileAssertHtmlAssert_Run_NonExistentFile_WritesError + - FileAssertHtmlAssert_Run_UnauthorizedAccess_WritesError - id: FileAssert-FileAssertHtmlAssert-QueryAssertions title: | @@ -40,6 +41,8 @@ sections: - FileAssertHtmlAssert_Run_ExactCount_Matches_NoError - FileAssertHtmlAssert_Run_ExactCount_Mismatch_WritesError - FileAssertHtmlAssert_Run_MinMaxCount_WithinBounds_NoError + - FileAssertHtmlAssert_Run_MinCount_BelowMinimum_WritesError + - FileAssertHtmlAssert_Run_MaxCount_ExceedsMaximum_WritesError - FileAssertHtmlAssert_Run_NonExistentFile_WritesError - FileAssertHtmlAssert_Run_InvalidXPathQuery_WritesError - FileAssertHtmlAssert_Run_XPathExactTextMatch_Matches_NoError diff --git a/docs/reqstream/file-assert/modeling/file-assert-json-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-json-assert.yaml index 373827e..a5c197d 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-json-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-json-assert.yaml @@ -19,13 +19,17 @@ sections: - id: FileAssert-FileAssertJsonAssert-ParseError title: | The FileAssertJsonAssert class shall report an immediate error and skip remaining - assertions when a matched file cannot be parsed as a valid JSON document. + assertions when a matched file cannot be parsed as a valid JSON document, and shall + report a distinct error when the file cannot be read due to an IO failure. justification: | Attempting to traverse a JSON document tree against a file that is not valid JSON - would produce meaningless or misleading results. Reporting the parse failure - immediately gives users a clear, actionable error message. + would produce meaningless or misleading results. Distinguishing parse failures from + IO failures gives users a clear, actionable error message that identifies the actual + cause. tests: - FileAssertJsonAssert_Run_InvalidFile_WritesError + - FileAssertJsonAssert_Run_InvalidJson_WritesParseError + - FileAssertJsonAssert_Run_IOError_WritesReadError - id: FileAssert-FileAssertJsonAssert-QueryAssertions title: | diff --git a/docs/reqstream/file-assert/modeling/file-assert-pdf-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-pdf-assert.yaml index 610807c..9be102f 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-pdf-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-pdf-assert.yaml @@ -15,6 +15,8 @@ sections: tests: - FileAssertPdfAssert_Create_ValidData_CreatesPdfAssert - FileAssertPdfAssert_Create_NullData_ThrowsArgumentNullException + - FileAssertPdfAssert_Create_MetadataRuleMissingField_ThrowsInvalidOperationException + - FileAssertPdfAssert_Create_MetadataRuleMissingContainsAndMatches_ThrowsInvalidOperationException - id: FileAssert-FileAssertPdfAssert-ParseError title: | @@ -59,12 +61,16 @@ sections: - id: FileAssert-FileAssertPdfAssert-TextAssertions title: | The FileAssertPdfAssert class shall extract the body text of the PDF document and - apply each configured text rule to the extracted content. + apply each configured text rule to the extracted content. Page texts shall be + joined with a `\n` separator so that text rules see clear page boundaries. justification: | Body text assertions allow users to verify that key content (such as required section headings or legal notices) is present in generated PDF outputs. Using the same FileAssertRule hierarchy as the text: block ensures consistent rule behavior. + Joining pages with `\n` prevents the last token of one page from running into the + first token of the next. tests: - FileAssertPdfAssert_Run_TextRule_ContentMissing_WritesError - FileAssertPdfAssert_Run_TextContainsRule_ContentPresent_NoError - FileAssertPdfAssert_Run_TextMatchesRule_PatternMatches_NoError + - FileAssertPdfAssert_Run_MultiPageText_PageBoundarySeparated_NoError diff --git a/docs/reqstream/file-assert/modeling/file-assert-xml-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-xml-assert.yaml index 3b6c6dd..d374aa8 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-xml-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-xml-assert.yaml @@ -27,6 +27,17 @@ sections: tests: - FileAssertXmlAssert_Run_InvalidFile_WritesError + - id: FileAssert-FileAssertXmlAssert-QueryValidation + title: | + The FileAssertXmlAssert class shall reject a blank or whitespace-only XPath query + at construction time by raising an error. + justification: | + An empty XPath query cannot express any meaningful node selection. Rejecting it at + construction time surfaces the configuration mistake before any file system or XML + parsing operations are attempted, giving the user a clear, early error. + tests: + - FileAssertXmlAssert_Create_BlankQuery_ThrowsInvalidOperationException + - id: FileAssert-FileAssertXmlAssert-QueryAssertions title: | The FileAssertXmlAssert class shall evaluate each configured XPath query against diff --git a/docs/reqstream/file-assert/modeling/file-assert-yaml-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-yaml-assert.yaml index beb16bf..d458f96 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-yaml-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-yaml-assert.yaml @@ -15,29 +15,85 @@ sections: tests: - FileAssertYamlAssert_Create_ValidData_CreatesYamlAssert - FileAssertYamlAssert_Create_NullData_ThrowsArgumentNullException + - FileAssertYamlAssert_Create_EmptyQueryList_ThrowsInvalidOperationException + - FileAssertYamlAssert_Create_QueryWithoutConstraint_ThrowsInvalidOperationException + - FileAssertYamlAssert_Create_QueryMinGreaterThanMax_ThrowsInvalidOperationException - - id: FileAssert-FileAssertYamlAssert-ParseError + - id: FileAssert-FileAssertYamlAssert-ParseErrorReported title: | - The FileAssertYamlAssert class shall report an immediate error and skip remaining - assertions when a matched file cannot be parsed as a valid YAML document. + The FileAssertYamlAssert class shall report an immediate error when a matched file + cannot be parsed as a valid YAML document. justification: | - Attempting to traverse a YAML document tree against a file that is not valid YAML - would produce meaningless or misleading results. Reporting the parse failure - immediately gives users a clear, actionable error message. + Reporting a parse failure immediately gives users a clear, actionable error message + rather than a misleading downstream traversal failure. tests: - FileAssertYamlAssert_Run_InvalidFile_WritesError - - id: FileAssert-FileAssertYamlAssert-QueryAssertions + - id: FileAssert-FileAssertYamlAssert-ParseErrorSkipsRemaining + title: | + The FileAssertYamlAssert class shall skip remaining query assertions for a file + once a YAML parse error has been reported for that file. + justification: | + Attempting to traverse a YAML document tree against a file that is not valid YAML + would produce meaningless or misleading results, so subsequent queries are + short-circuited after the parse failure. + tests: + - FileAssertYamlAssert_Run_InvalidFile_RemainingAssertionsSkipped + + - id: FileAssert-FileAssertYamlAssert-MalformedQueryRejected + title: | + The FileAssertYamlAssert class shall reject malformed dot-notation queries — empty, + starting with a dot, ending with a dot, or containing consecutive dots — at + construction time. + justification: | + Malformed queries cannot be evaluated unambiguously and indicate a configuration + error. Rejecting them up front prevents misleading "no match" results at runtime. + tests: + - FileAssertYamlAssert_Create_EmptyQuery_ThrowsInvalidOperationException + - FileAssertYamlAssert_Create_LeadingDotQuery_ThrowsInvalidOperationException + - FileAssertYamlAssert_Create_TrailingDotQuery_ThrowsInvalidOperationException + - FileAssertYamlAssert_Create_ConsecutiveDotsQuery_ThrowsInvalidOperationException + + - id: FileAssert-FileAssertYamlAssert-QueryTraversal title: | The FileAssertYamlAssert class shall evaluate each configured dot-notation path - against the parsed YAML document and apply min, max, and exact count constraints - to the number of matching nodes. + against the parsed YAML document and report the number of matching nodes. + justification: | + Dot-notation traversal allows users to address required keys inside YAML + configuration files, CI/CD pipeline definitions, and infrastructure-as-code + documents. + tests: + - FileAssertYamlAssert_Run_ScalarValue_CountsAsOne_NoError + - FileAssertYamlAssert_Run_EmptyDocument_ReportsZeroCount + + - id: FileAssert-FileAssertYamlAssert-QueryMinCount + title: | + The FileAssertYamlAssert class shall enforce a minimum-count constraint on the + number of nodes matched by a query. + justification: | + A minimum-count constraint allows users to assert that at least a given number of + configuration items are declared (for example, a minimum number of CI jobs). + tests: + - FileAssertYamlAssert_Run_MinCount_BelowMinimum_WritesError + + - id: FileAssert-FileAssertYamlAssert-QueryMaxCount + title: | + The FileAssertYamlAssert class shall enforce a maximum-count constraint on the + number of nodes matched by a query. + justification: | + A maximum-count constraint allows users to enforce limits on permitted + configuration items (for example, no more than one production deployment target). + tests: + - FileAssertYamlAssert_Run_MaxCount_ExceedsMaximum_WritesError + + - id: FileAssert-FileAssertYamlAssert-QueryExactCount + title: | + The FileAssertYamlAssert class shall enforce an exact-count constraint on the + number of nodes matched by a query. justification: | - Dot-notation path assertions allow users to verify that required configuration - keys are present and have the expected cardinality in YAML configuration files, - CI/CD pipeline definitions, and infrastructure-as-code documents. + An exact-count constraint pins the cardinality of an addressable YAML node set, + catching both missing and extra entries in a single rule. tests: - FileAssertYamlAssert_Run_SequenceCount_Matches_NoError - FileAssertYamlAssert_Run_SequenceCount_Mismatch_WritesError - FileAssertYamlAssert_Run_MinMaxCount_WithinBounds_NoError - - FileAssertYamlAssert_Run_ScalarValue_CountsAsOne_NoError diff --git a/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml b/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml index a9d4eeb..3ca5d20 100644 --- a/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml +++ b/docs/reqstream/file-assert/modeling/file-assert-zip-assert.yaml @@ -1,9 +1,10 @@ --- # Software Unit Requirements for the FileAssertZipAssert Class # -# The FileAssertZipAssert class validates zip archive contents by matching entry names against -# glob patterns and enforcing count constraints. It is invoked by FileAssertFile when a -# `zip:` assertion block is declared. +# The FileAssertZipAssert class validates zip archive contents against the full +# FileAssert assertion suite. It opens a zip entry via IFileContainer, wraps it in +# a ZipFileContainer, and runs all configured FileAssertFile assertions against the +# archive contents using a scoped IContext for breadcrumb-style error messages. sections: - title: FileAssertZipAssert Unit Requirements @@ -11,10 +12,10 @@ sections: - id: FileAssert-FileAssertZipAssert-Creation title: | The FileAssertZipAssert class shall be constructed from a zip assertion data object - containing a list of entry pattern constraints. + containing a list of file assertions. justification: | - Constructing entry constraints at creation time rather than at evaluation time avoids - repeated per-file construction overhead and allows validation errors (such as missing + Constructing file assertions at creation time rather than at evaluation time avoids + repeated per-run construction overhead and allows validation errors (such as missing patterns) to be reported before any file system operations are attempted. tests: - FileAssertZipAssert_Create_ValidData_CreatesZipAssert @@ -23,19 +24,45 @@ sections: - id: FileAssert-FileAssertZipAssert-EntryMatching title: | - The FileAssertZipAssert class shall match zip archive entry names against each - configured glob pattern and report an error when the match count violates the - declared minimum or maximum constraint. + The FileAssertZipAssert class shall apply all configured file assertions to the + contents of a zip archive entry. justification: | - Count constraints on zip entry patterns allow users to assert that required - artifacts are present in a package archive (minimum) or that unexpected extra - entries have not been included (maximum). Both bounds must be checked and reported - independently so that all violations are visible in a single pass. + Running the full assertion suite (text, xml, html, yaml, json, pdf, recursive zip) + against zip entry contents enables the same level of validation inside archives as + is possible for on-disk files. + children: + - FileAssert-FileAssertZipAssert-BreadcrumbErrors tests: - FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError - FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError - FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError - FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError + - FileAssertZipAssert_Run_EntryContainsRequiredText_NoError + - FileAssertZipAssert_Run_EntryMissingRequiredText_WritesError + - FileAssertZipAssert_Run_EntryXmlMatchesXPath_NoError + - FileAssertZipAssert_Run_EntryXmlFailsXPath_WritesError + - FileAssertZipAssert_Run_EntryHtmlMatchesXPath_NoError + - FileAssertZipAssert_Run_EntryPdfMatchesConstraint_NoError + - FileAssertZipAssert_Run_EntryYamlMatchesQuery_NoError + - FileAssertZipAssert_Run_EntryYamlFailsQuery_WritesError + - FileAssertZipAssert_Run_EntryJsonMatchesQuery_NoError + - FileAssertZipAssert_Run_EntryJsonFailsQuery_WritesError + - FileAssertZipAssert_Run_EntryMeetsMinSizeConstraint_NoError + - FileAssertZipAssert_Run_EntryBelowMinSizeConstraint_WritesError + - FileAssertZipAssert_Run_EntryMeetsMaxSizeConstraint_NoError + - FileAssertZipAssert_Run_EntryExceedsMaxSizeConstraint_WritesError + - FileAssertZipAssert_Run_NestedZipTextContent_InnerEntryContentMatches_NoError + + - id: FileAssert-FileAssertZipAssert-BreadcrumbErrors + title: | + The FileAssertZipAssert class shall prepend the archive entry's display path to + every error reported by nested file assertions, producing breadcrumb-style messages. + justification: | + A scoped IContext prepends the archive entry's display path to every error message, + giving users unambiguous breadcrumb-style context that identifies exactly which + archive entry failed which assertion. + tests: + - FileAssertZipAssert_Run_ContentAssertionFails_ErrorContainsBreadcrumbs - id: FileAssert-FileAssertZipAssert-ParseError title: | diff --git a/docs/reqstream/file-assert/program.yaml b/docs/reqstream/file-assert/program.yaml index f21dcb6..a2180da 100644 --- a/docs/reqstream/file-assert/program.yaml +++ b/docs/reqstream/file-assert/program.yaml @@ -35,11 +35,33 @@ sections: - id: FileAssert-Program-DefaultBehavior title: | - The Program class shall display version and copyright information when invoked - with no recognized flags or configuration file arguments. + The Program class shall, when invoked with no arguments, print the application banner + and then load and execute the default `.fileassert.yaml` configuration. justification: | - When no arguments are supplied the tool has no test suite to run. Displaying the - version and copyright banner gives the user a quick visual confirmation that the - tool is installed and operational without producing an unexpected error. + When no arguments are supplied the tool prints the version and copyright banner to give + the user a quick visual confirmation that the tool is installed and operational, then + proceeds to load and run the default-named configuration from the current working + directory so the project can be validated with zero configuration. tests: - Program_Run_NoArguments_DisplaysDefaultBehavior + + - id: FileAssert-Program-MissingDefaultConfig + title: | + The Program class shall, when invoked with no `--config` and no `.fileassert.yaml` + in the current directory, print user guidance and exit with success. + justification: | + Treating an absent default configuration as an informational state (rather than an + error) lets first-time users discover how to create a config without seeing a + confusing failure exit code. + tests: + - Program_Run_NoArguments_MissingDefaultConfig_WritesGuidance + + - id: FileAssert-Program-MissingExplicitConfig + title: | + The Program class shall, when an explicit `--config ` argument refers to a + non-existent file, write an error and set a non-zero exit code. + justification: | + When the user names a configuration file explicitly, a missing file is unambiguously + an error and must surface as such so CI pipelines fail fast. + tests: + - Program_Run_ExplicitConfigMissing_WritesError diff --git a/docs/reqstream/file-assert/utilities.yaml b/docs/reqstream/file-assert/utilities.yaml index c596c39..5433c4f 100644 --- a/docs/reqstream/file-assert/utilities.yaml +++ b/docs/reqstream/file-assert/utilities.yaml @@ -33,3 +33,33 @@ sections: - FileAssert-TemporaryDirectory-SafePath tests: - Utilities_TemporaryDirectory_IsolatesAndCleansUpScratchSpace + + - id: FileAssert-Utilities-FileContainerAbstraction + title: The Utilities subsystem shall provide a uniform file-access abstraction over directories and zip archives. + justification: | + Asserters must validate files whether those files reside on disk or inside a zip + archive. A shared IFileContainer interface with DirectoryFileContainer and + ZipFileContainer implementations allows all asserters to be written once and reused + against both backing stores, enabling the full assertion suite inside zip entries + without duplicating logic. + children: + - FileAssert-IFileContainer-UniformAccess + - FileAssert-DirectoryFileContainer-FileSystemAccess + - FileAssert-ZipFileContainer-EntryEnumeration + - FileAssert-ZipFileContainer-BreadcrumbDisplayPath + - FileAssert-ZipFileContainer-MissingEntryException + - FileAssert-ZipFileContainer-EntrySize + tests: + - DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes + - DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty + - DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty + - DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream + - DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException + - DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize + - DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath + - ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + - ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream + - ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException + - ZipFileContainer_GetEntrySize_ReturnsUncompressedLength + - ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath + - Utilities_FileContainerAbstraction_ZipFileContainer_EndToEnd diff --git a/docs/reqstream/file-assert/utilities/directory-file-container.yaml b/docs/reqstream/file-assert/utilities/directory-file-container.yaml new file mode 100644 index 0000000..7424619 --- /dev/null +++ b/docs/reqstream/file-assert/utilities/directory-file-container.yaml @@ -0,0 +1,90 @@ +--- +# Software Unit Requirements for the DirectoryFileContainer Class +# +# DirectoryFileContainer is the filesystem implementation of IFileContainer. It +# exposes a local directory as a container of file entries, enumerating all files +# recursively, opening them as streams, returning their sizes, and constructing +# full file-system paths for error messages. + +sections: + - title: DirectoryFileContainer Unit Requirements + requirements: + - id: FileAssert-DirectoryFileContainer-Enumeration + title: | + The DirectoryFileContainer class shall enumerate all files under the base directory + recursively, returning relative paths with forward-slash separators. + justification: | + Asserters discover candidate files by enumerating container entries. Forward-slash + normalization gives platform-independent entry names. + tests: + - DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes + - DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty + + - id: FileAssert-DirectoryFileContainer-MissingDirectory + title: | + The DirectoryFileContainer class shall return an empty entry list when the base + directory does not exist, rather than throwing. + justification: | + Returning an empty list lets zero-count constraints pass cleanly when a test + suite is misconfigured, instead of surfacing an unhandled `DirectoryNotFoundException`. + tests: + - DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty + + - id: FileAssert-DirectoryFileContainer-OpenEntry + title: | + The DirectoryFileContainer class shall open a readable stream for a named entry, + and propagate an IO exception when the entry is missing. + justification: | + Asserters need streamed access to file contents, and the exception surface for + missing files must match the rest of the I/O code path. + tests: + - DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream + - DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException + + - id: FileAssert-DirectoryFileContainer-EntrySize + title: The DirectoryFileContainer class shall report the on-disk size of a named entry. + justification: | + Size constraints (min-size, max-size) require the on-disk byte length so asserters + can validate file sizes without reading the file content. + tests: + - DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize + + - id: FileAssert-DirectoryFileContainer-DisplayPath + title: | + The DirectoryFileContainer class shall return the full file-system path of a named + entry for use in error messages. + justification: | + Error messages must identify the exact file on disk so users can locate and fix + the offending file directly. + tests: + - DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath + + - id: FileAssert-DirectoryFileContainer-NullArgumentRejection + title: | + The DirectoryFileContainer class shall reject null arguments to its constructor and + to its public entry-path methods. + justification: | + Null-argument guards prevent obscure NullReferenceException failures and document + the expected contract at the unit boundary. + tests: + - DirectoryFileContainer_Constructor_NullBasePath_ThrowsArgumentNullException + - DirectoryFileContainer_OpenEntry_NullEntryPath_ThrowsArgumentNullException + - DirectoryFileContainer_GetEntrySize_NullEntryPath_ThrowsArgumentNullException + - DirectoryFileContainer_GetDisplayPath_NullEntryPath_ThrowsArgumentNullException + + - id: FileAssert-DirectoryFileContainer-FileSystemAccess + title: | + The DirectoryFileContainer class shall expose a local directory as an IFileContainer, + composing the enumeration, missing-directory, open, size, and display-path behaviors. + justification: | + The composite behavior is what callers depend on; the children carry the precise + contracts. + children: + - FileAssert-DirectoryFileContainer-Enumeration + - FileAssert-DirectoryFileContainer-MissingDirectory + - FileAssert-DirectoryFileContainer-OpenEntry + - FileAssert-DirectoryFileContainer-EntrySize + - FileAssert-DirectoryFileContainer-DisplayPath + - FileAssert-DirectoryFileContainer-NullArgumentRejection + tests: + - DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes diff --git a/docs/reqstream/file-assert/utilities/i-file-container.yaml b/docs/reqstream/file-assert/utilities/i-file-container.yaml new file mode 100644 index 0000000..eb43c02 --- /dev/null +++ b/docs/reqstream/file-assert/utilities/i-file-container.yaml @@ -0,0 +1,32 @@ +--- +# Software Unit Requirements for the IFileContainer Interface +# +# IFileContainer is the uniform file-access abstraction used by all asserters in +# FileAssert. It decouples asserters from the filesystem by hiding whether entries +# reside on disk inside a directory or as entries inside a zip archive. + +sections: + - title: IFileContainer Unit Requirements + requirements: + - id: FileAssert-IFileContainer-UniformAccess + title: The IFileContainer interface shall provide a uniform API for enumerating, opening, sizing, and displaying entries + regardless of backing storage. + justification: | + Asserters must be able to validate files whether those files are on disk in a + directory or stored as entries in a zip archive. A single interface with four + methods (GetEntries, OpenEntry, GetEntrySize, GetDisplayPath) allows each + asserter to be written once and reused against both backing stores, eliminating + conditional logic and enabling zip-in-zip assertion without any asserter changes. + tests: + - DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes + - DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty + - DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty + - DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream + - DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException + - DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize + - DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath + - ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + - ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream + - ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException + - ZipFileContainer_GetEntrySize_ReturnsUncompressedLength + - ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath diff --git a/docs/reqstream/file-assert/utilities/path-helpers.yaml b/docs/reqstream/file-assert/utilities/path-helpers.yaml index 5267ca6..5e5b0e9 100644 --- a/docs/reqstream/file-assert/utilities/path-helpers.yaml +++ b/docs/reqstream/file-assert/utilities/path-helpers.yaml @@ -24,6 +24,18 @@ sections: - PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath - PathHelpers_SafePathCombine_DoubleDotInFilename_CombinesCorrectly + - id: FileAssert-PathHelpers-RejectRootedPaths + title: | + The PathHelpers class shall reject rooted relative paths up-front, even when the + rooted path resolves underneath the base directory. + justification: | + A rooted second argument to `Path.Combine` replaces the base entirely. Treating + such inputs as invalid prevents accidental escape from the base directory and + documents that the contract requires a true relative path. + tests: + - PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException + - PathHelpers_SafePathCombine_RootedPathInsideBase_RejectsIt + - id: FileAssert-PathHelpers-NullValidation title: The PathHelpers class shall reject null base or relative path arguments. justification: | diff --git a/docs/reqstream/file-assert/utilities/zip-file-container.yaml b/docs/reqstream/file-assert/utilities/zip-file-container.yaml new file mode 100644 index 0000000..9019a86 --- /dev/null +++ b/docs/reqstream/file-assert/utilities/zip-file-container.yaml @@ -0,0 +1,77 @@ +--- +# Software Unit Requirements for the ZipFileContainer Class +# +# ZipFileContainer is the zip archive implementation of IFileContainer. It wraps a +# ZipArchive opened from a caller-supplied Stream, exposing archive entries as a +# virtual container. It supports nested zip assertion (zip-in-zip) by accepting a +# Stream rather than a file path. + +sections: + - title: ZipFileContainer Unit Requirements + requirements: + - id: FileAssert-ZipFileContainer-EntryEnumeration + title: | + The ZipFileContainer class shall enumerate the files contained in the zip archive. + justification: | + Asserters discover candidate files by enumerating container entries. + children: + - FileAssert-ZipFileContainer-SlashNormalization + - FileAssert-ZipFileContainer-DirectoryMarkerExclusion + tests: + - ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + + - id: FileAssert-ZipFileContainer-SlashNormalization + title: | + The ZipFileContainer class shall normalize entry separators to forward slashes + when enumerating archive entries and when looking entries up by path. + justification: | + Forward-slash normalization gives platform-independent entry names and lets + callers use either separator when looking up entries (`OpenEntry`/`GetEntrySize`). + tests: + - ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + - ZipFileContainer_BackslashEntryPath_OpensAndSizesAfterNormalization + + - id: FileAssert-ZipFileContainer-DirectoryMarkerExclusion + title: | + The ZipFileContainer class shall exclude zip directory-marker entries (entries + whose names end with `/`) from the result of `GetEntries`. + justification: | + Directory markers carry no file content and would produce spurious matches if + presented to the glob matcher. + tests: + - ZipFileContainer_GetEntries_ExcludesDirectoryMarkers + + - id: FileAssert-ZipFileContainer-BreadcrumbDisplayPath + title: The ZipFileContainer class shall provide a breadcrumb display path that prefixes an entry path with the container's + display name. + justification: | + Breadcrumb display paths (displayName > entryPath) give users enough context to + identify which archive an error occurred in, which is essential for nested archives. + tests: + - ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath + + - id: FileAssert-ZipFileContainer-OpenEntry + title: The ZipFileContainer class shall open a readable stream for a named archive entry. + justification: | + Accepting a Stream rather than a file path enables zip-in-zip assertion where the + outer zip entry stream is passed directly to the inner container. + tests: + - ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream + + - id: FileAssert-ZipFileContainer-MissingEntryException + title: The ZipFileContainer class shall throw an IOException when a named entry does not exist. + justification: | + Throwing IOException for missing entries provides a consistent error type for + asserters that catch I/O failures, distinct from `FileNotFoundException` (which + implies a file-system file). + tests: + - ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException + + - id: FileAssert-ZipFileContainer-EntrySize + title: The ZipFileContainer class shall report the uncompressed size of a named archive entry. + justification: | + Size constraints (min-size, max-size) on zip entries require the uncompressed length of + each entry so asserters can validate file sizes without extracting the archive. + tests: + - ZipFileContainer_GetEntrySize_ReturnsUncompressedLength + - ZipFileContainer_BackslashEntryPath_OpensAndSizesAfterNormalization diff --git a/docs/reqstream/ots/buildmark.yaml b/docs/reqstream/ots/buildmark.yaml index 2919c18..32d36e9 100644 --- a/docs/reqstream/ots/buildmark.yaml +++ b/docs/reqstream/ots/buildmark.yaml @@ -16,5 +16,8 @@ sections: It runs as part of the same CI pipeline that produces the TRX test results, so a successful pipeline run is evidence that BuildMark executed without error. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - BuildMark_MarkdownReportGeneration diff --git a/docs/reqstream/ots/fileassert.yaml b/docs/reqstream/ots/fileassert.yaml index 4734daf..3f5ea6b 100644 --- a/docs/reqstream/ots/fileassert.yaml +++ b/docs/reqstream/ots/fileassert.yaml @@ -17,6 +17,9 @@ sections: WeasyPrint and independently confirms file assertion is functioning. Self-validation proves the tool itself is operational before ReqStream consumes the results. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - FileAssert_VersionDisplay - FileAssert_HelpDisplay diff --git a/docs/reqstream/ots/filesystemglobbing.yaml b/docs/reqstream/ots/filesystemglobbing.yaml new file mode 100644 index 0000000..a67f1e6 --- /dev/null +++ b/docs/reqstream/ots/filesystemglobbing.yaml @@ -0,0 +1,25 @@ +--- +# FileSystemGlobbing OTS Software Requirements +# +# Requirements for the Microsoft.Extensions.FileSystemGlobbing library consumed by FileAssert. + +sections: + - title: OTS Software Requirements + sections: + - title: FileSystemGlobbing Requirements + requirements: + - id: FileAssert-OTS-FileSystemGlobbing + title: FileSystemGlobbing shall match files against glob patterns including recursive wildcards. + justification: | + Microsoft.Extensions.FileSystemGlobbing provides the glob pattern matcher used by + FileAssert to resolve file-assertion patterns (for example `**/*.dll`) against the set of + candidate files in a directory or container. FileAssert relies on it for correct + wildcard, recursive-wildcard, and exact-path matching that drives count constraints. + tags: [ots] + # Test IDs below are FileAssert's own tests that exercise FileSystemGlobbing through the + # assertion pipeline. Each ID matches a TestCase Name in the test TRX output + # (artifacts/**/*.trx in CI); ReqStream consumes the artifacts/**/*.trx glob. + tests: + - FileAssertFile_Run_WithMatchingFiles_NoConstraints_NoError + - FileAssertFile_Run_TooFewFiles_WritesError + - FileAssertFile_Run_TooManyFiles_WritesError diff --git a/docs/reqstream/ots/htmlagilitypack.yaml b/docs/reqstream/ots/htmlagilitypack.yaml new file mode 100644 index 0000000..34720f5 --- /dev/null +++ b/docs/reqstream/ots/htmlagilitypack.yaml @@ -0,0 +1,41 @@ +--- +# HtmlAgilityPack OTS Software Requirements +# +# Requirements for the HtmlAgilityPack library functionality consumed by FileAssert. + +sections: + - title: OTS Software Requirements + sections: + - title: HtmlAgilityPack Requirements + requirements: + - id: FileAssert-OTS-HtmlAgilityPack-LenientParse + title: HtmlAgilityPack shall parse syntactically imperfect HTML markup without failing. + justification: | + FileAssert reads real-world HTML reports that may contain unclosed tags or + other minor issues. The parser must not fail on such markup. + tags: [ots] + tests: + - FileAssertHtmlAssert_Run_ExactCount_Matches_NoError + - FileAssertHtmlAssert_Run_MalformedHtml_ParsesAndQueriesSuccessfully_NoError + + - id: FileAssert-OTS-HtmlAgilityPack-XPath + title: HtmlAgilityPack shall evaluate XPath node-count and text queries against parsed HTML. + justification: | + FileAssert's `html:` queries rely on HtmlAgilityPack's `SelectNodes`/`InnerText` + to count matching nodes and read their text content. + tags: [ots] + tests: + - FileAssertHtmlAssert_Run_ExactCount_Matches_NoError + - FileAssertHtmlAssert_Run_MinMaxCount_WithinBounds_NoError + - FileAssertHtmlAssert_Run_XPathContainsText_Matches_NoError + + - id: FileAssert-OTS-HtmlAgilityPack + title: HtmlAgilityPack shall parse HTML documents leniently and evaluate XPath queries against them. + justification: | + Composite requirement covering lenient parsing and XPath evaluation. + tags: [ots] + children: + - FileAssert-OTS-HtmlAgilityPack-LenientParse + - FileAssert-OTS-HtmlAgilityPack-XPath + tests: + - FileAssertHtmlAssert_Run_NonExistentFile_WritesError diff --git a/docs/reqstream/ots/pandoc.yaml b/docs/reqstream/ots/pandoc.yaml index 2517f4f..e7fb328 100644 --- a/docs/reqstream/ots/pandoc.yaml +++ b/docs/reqstream/ots/pandoc.yaml @@ -17,6 +17,9 @@ sections: Passing FileAssert assertions for each document type proves Pandoc executed correctly and produced meaningful output. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - Pandoc_BuildNotesHtml - Pandoc_CodeQualityHtml diff --git a/docs/reqstream/ots/pdfpig.yaml b/docs/reqstream/ots/pdfpig.yaml new file mode 100644 index 0000000..3beee58 --- /dev/null +++ b/docs/reqstream/ots/pdfpig.yaml @@ -0,0 +1,74 @@ +--- +# PdfPig OTS Software Requirements +# +# Requirements for the PdfPig library functionality consumed by FileAssert. + +sections: + - title: OTS Software Requirements + sections: + - title: PdfPig Requirements + requirements: + - id: FileAssert-OTS-PdfPig-Parse + title: PdfPig shall open well-formed PDF documents from a byte buffer. + justification: | + FileAssert depends on PdfPig's `PdfDocument.Open(byte[])` overload to load + PDF input from any IFileContainer-supplied stream. + tags: [ots] + tests: + - FileAssertPdfAssert_Run_ValidPdf_PageCountSatisfied_NoError + + - id: FileAssert-OTS-PdfPig-PageCount + title: PdfPig shall expose the page count of an opened PDF document. + justification: | + FileAssert's `pages.min`/`pages.max` constraints rely on PdfPig's + `GetPages()` enumerable to count pages. + tags: [ots] + tests: + - FileAssertPdfAssert_Run_ValidPdf_PageCountSatisfied_NoError + - FileAssertPdfAssert_Run_ValidPdf_TooFewPages_WritesError + - FileAssertPdfAssert_Run_ValidPdf_TooManyPages_WritesError + + - id: FileAssert-OTS-PdfPig-Metadata + title: PdfPig shall expose the document information dictionary fields (Title, Author, etc.). + justification: | + FileAssert's `metadata` rules read Title/Author/Subject/Keywords/Creator/Producer + from `PdfDocument.Information`. + tags: [ots] + tests: + - FileAssertPdfAssert_Run_MetadataContainsRule_TitleMatches_NoError + - FileAssertPdfAssert_Run_MetadataContainsRule_AuthorField_NoError + - FileAssertPdfAssert_Run_MetadataMatchesRule_Matches_NoError + + - id: FileAssert-OTS-PdfPig-Text + title: PdfPig shall expose the text content of each page of an opened PDF document. + justification: | + FileAssert's `text` rules concatenate `Page.Text` across all pages and apply + text content rules to the result. + tags: [ots] + tests: + - FileAssertPdfAssert_Run_TextContainsRule_ContentPresent_NoError + - FileAssertPdfAssert_Run_TextRule_ContentMissing_WritesError + - FileAssertPdfAssert_Run_TextMatchesRule_PatternMatches_NoError + + - id: FileAssert-OTS-PdfPig-InvalidFile + title: PdfPig shall reject files that are not valid PDF documents. + justification: | + FileAssert's PDF-parse error path relies on PdfPig surfacing an exception for + malformed input so the asserter can report a clean parse-failure message. + tags: [ots] + tests: + - FileAssertPdfAssert_Run_InvalidFile_WritesError + + - id: FileAssert-OTS-PdfPig + title: PdfPig shall parse PDF documents and expose page count, metadata, and text content. + justification: | + Composite requirement covering all PdfPig features consumed by FileAssert. This is a + non-requirement grouping node; verification evidence is carried by the atomic child + requirements below. + tags: [ots] + children: + - FileAssert-OTS-PdfPig-Parse + - FileAssert-OTS-PdfPig-PageCount + - FileAssert-OTS-PdfPig-Metadata + - FileAssert-OTS-PdfPig-Text + - FileAssert-OTS-PdfPig-InvalidFile diff --git a/docs/reqstream/ots/reqstream.yaml b/docs/reqstream/ots/reqstream.yaml index 3e0f2fa..208a4d2 100644 --- a/docs/reqstream/ots/reqstream.yaml +++ b/docs/reqstream/ots/reqstream.yaml @@ -17,5 +17,8 @@ sections: making unproven requirements a build-breaking condition. A successful pipeline run with --enforce proves all requirements are covered and that ReqStream is functioning. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - ReqStream_EnforcementMode diff --git a/docs/reqstream/ots/reviewmark.yaml b/docs/reqstream/ots/reviewmark.yaml index 11f31ea..0944a46 100644 --- a/docs/reqstream/ots/reviewmark.yaml +++ b/docs/reqstream/ots/reviewmark.yaml @@ -8,14 +8,30 @@ sections: sections: - title: ReviewMark Requirements requirements: - - id: FileAssert-OTS-ReviewMark - title: ReviewMark shall generate a review plan and review report from the review configuration. + - id: FileAssert-OTS-ReviewMark-Plan + title: ReviewMark shall generate a review plan from the review configuration. justification: | - DemaConsulting.ReviewMark reads the .reviewmark.yaml configuration and the review - evidence store to produce a review plan and review report documenting file review - coverage and currency. It runs in the same CI pipeline that produces the TRX test - results, so a successful pipeline run is evidence that ReviewMark executed without error. + The plan documents which files require review and the responsible reviewers. tags: [ots] tests: - ReviewMark_ReviewPlanGeneration + + - id: FileAssert-OTS-ReviewMark-Report + title: ReviewMark shall generate a review report documenting review coverage and currency. + justification: | + The report is the auditable record showing which files have been reviewed and + whether reviews are current with the source. + tags: [ots] + tests: - ReviewMark_ReviewReportGeneration + + - id: FileAssert-OTS-ReviewMark + title: ReviewMark shall generate a review plan and review report from the review configuration. + justification: | + Composite requirement covering plan and report generation. This is a non-requirement + grouping node; verification evidence is carried by the atomic child requirements, + each of which covers one behavior (plan generation and report generation). + tags: [ots] + children: + - FileAssert-OTS-ReviewMark-Plan + - FileAssert-OTS-ReviewMark-Report diff --git a/docs/reqstream/ots/sarifmark.yaml b/docs/reqstream/ots/sarifmark.yaml index 37b27c3..fc9aad1 100644 --- a/docs/reqstream/ots/sarifmark.yaml +++ b/docs/reqstream/ots/sarifmark.yaml @@ -16,6 +16,9 @@ sections: It runs in the same CI pipeline that produces the TRX test results, so a successful pipeline run is evidence that SarifMark executed without error. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - SarifMark_SarifReading - SarifMark_MarkdownReportGeneration diff --git a/docs/reqstream/ots/sonarmark.yaml b/docs/reqstream/ots/sonarmark.yaml index c0bab3c..3199252 100644 --- a/docs/reqstream/ots/sonarmark.yaml +++ b/docs/reqstream/ots/sonarmark.yaml @@ -16,6 +16,9 @@ sections: same CI pipeline that produces the TRX test results, so a successful pipeline run is evidence that SonarMark executed without error. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - SonarMark_QualityGateRetrieval - SonarMark_IssuesRetrieval diff --git a/docs/reqstream/ots/versionmark.yaml b/docs/reqstream/ots/versionmark.yaml index 22a017e..6f5071f 100644 --- a/docs/reqstream/ots/versionmark.yaml +++ b/docs/reqstream/ots/versionmark.yaml @@ -8,14 +8,31 @@ sections: sections: - title: VersionMark Requirements requirements: - - id: FileAssert-OTS-VersionMark - title: VersionMark shall capture and publish tool-version information. + - id: FileAssert-OTS-VersionMark-Capture + title: VersionMark shall capture tool-version information for each configured tool. justification: | - DemaConsulting.VersionMark reads version metadata for each dotnet tool used in the - pipeline and writes a versions markdown document included in the release artifacts. - It runs in the same CI pipeline that produces the TRX test results, so a successful - pipeline run is evidence that VersionMark executed without error. + VersionMark reads version metadata for each dotnet tool used in the pipeline so + the release artifact records the exact tools that produced it. tags: [ots] tests: - VersionMark_CapturesVersions + + - id: FileAssert-OTS-VersionMark-Publish + title: VersionMark shall publish the captured version information as a markdown report. + justification: | + The published markdown report is included in the release artifacts and serves as + the human-readable evidence of tool versions used. + tags: [ots] + tests: - VersionMark_GeneratesMarkdownReport + + - id: FileAssert-OTS-VersionMark + title: VersionMark shall capture and publish tool-version information. + justification: | + Composite requirement covering capture and publish behaviors. This is a non-requirement + grouping node; verification evidence is carried by the atomic child requirements, each + of which covers one behavior (capture and publish). + tags: [ots] + children: + - FileAssert-OTS-VersionMark-Capture + - FileAssert-OTS-VersionMark-Publish diff --git a/docs/reqstream/ots/weasyprint.yaml b/docs/reqstream/ots/weasyprint.yaml index 751218b..d132e45 100644 --- a/docs/reqstream/ots/weasyprint.yaml +++ b/docs/reqstream/ots/weasyprint.yaml @@ -17,6 +17,9 @@ sections: content in the rendered text. Passing FileAssert assertions for each document type proves WeasyPrint executed correctly and produced meaningful output. tags: [ots] + # Test IDs below are sourced from the tool's `--validate` self-validation TRX output + # (artifacts/-self-validation*.trx in CI). Each ID matches a TestCase Name in + # the TRX; ReqStream consumes the artifacts/**/*.trx glob. tests: - WeasyPrint_BuildNotesPdf - WeasyPrint_CodeQualityPdf diff --git a/docs/reqstream/ots/xunit.yaml b/docs/reqstream/ots/xunit.yaml index 1382b8f..1a95ef7 100644 --- a/docs/reqstream/ots/xunit.yaml +++ b/docs/reqstream/ots/xunit.yaml @@ -8,14 +8,26 @@ sections: sections: - title: xUnit Requirements requirements: + - id: FileAssert-OTS-xUnit-Discover + title: xUnit shall discover all test methods annotated with the `[Fact]` and `[Theory]` attributes. + justification: | + The framework must locate every test the project has declared so that none are + silently skipped. Successful test runs that report the expected number of cases + are evidence of correct discovery. + tags: [ots] + tests: + - Context_Create_NoArguments_ReturnsDefaultContext + - PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly + - id: FileAssert-OTS-xUnit-Execute - title: xUnit shall discover and execute all unit tests. + title: xUnit shall execute the discovered tests and report each result. justification: | - xUnit (xunit.v3 and xunit.runner.visualstudio) is the unit-testing framework used - by the project. It discovers and runs all test methods annotated with [Fact] and - writes TRX result files that feed into coverage reporting and requirements - traceability. Passing tests confirm the framework is functioning correctly. + The framework must execute each discovered test and produce a pass/fail result + that downstream tooling can consume. tags: [ots] + # Test IDs below are FileAssert's own xUnit tests that exercise the framework. Each ID + # matches a TestCase Name in the test TRX output (artifacts/**/*.trx in CI); ReqStream + # consumes the artifacts/**/*.trx glob. tests: - Context_Create_NoArguments_ReturnsDefaultContext - Context_Create_VersionFlag_SetsVersionTrue @@ -32,6 +44,9 @@ sections: consumed by ReqStream to verify requirements coverage. Each passing test provides evidence for the requirements it covers, providing continuous compliance traceability. tags: [ots] + # Test IDs below are FileAssert's own xUnit tests that exercise the framework. Each ID + # matches a TestCase Name in the test TRX output (artifacts/**/*.trx in CI); ReqStream + # consumes the artifacts/**/*.trx glob. tests: - Context_Create_NoArguments_ReturnsDefaultContext - Context_Create_VersionFlag_SetsVersionTrue diff --git a/docs/reqstream/ots/yamldotnet.yaml b/docs/reqstream/ots/yamldotnet.yaml new file mode 100644 index 0000000..9640fa5 --- /dev/null +++ b/docs/reqstream/ots/yamldotnet.yaml @@ -0,0 +1,26 @@ +--- +# YamlDotNet OTS Software Requirements +# +# Requirements for the YamlDotNet library functionality consumed by FileAssert. + +sections: + - title: OTS Software Requirements + sections: + - title: YamlDotNet Requirements + requirements: + - id: FileAssert-OTS-YamlDotNet + title: YamlDotNet shall deserialize YAML documents into the configuration and assertion data model. + justification: | + YamlDotNet is the YAML parser used by FileAssert to deserialize the `.fileassert.yaml` + configuration into DTOs and to parse YAML documents under test for `yaml:` dot-notation + path assertions. FileAssert relies on YamlDotNet for correct YAML parsing, scalar and + sequence handling, and detection of malformed documents. + tags: [ots] + # Test IDs below are FileAssert's own tests that exercise YamlDotNet through the + # assertion pipeline. Each ID matches a TestCase Name in the test TRX output + # (artifacts/**/*.trx in CI); ReqStream consumes the artifacts/**/*.trx glob. + tests: + - FileAssertYamlAssert_Run_SequenceCount_Matches_NoError + - FileAssertYamlAssert_Run_ScalarValue_CountsAsOne_NoError + - FileAssertYamlAssert_Run_MinMaxCount_WithinBounds_NoError + - FileAssertYamlAssert_Run_InvalidFile_WritesError diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 66cb315..bd2b757 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -267,7 +267,7 @@ tests: files: - pattern: "output/package.zip" zip: - entries: + files: - pattern: "**/*.dll" min: 1 ``` diff --git a/docs/verification/file-assert.md b/docs/verification/file-assert.md index 36b623a..7b36086 100644 --- a/docs/verification/file-assert.md +++ b/docs/verification/file-assert.md @@ -134,6 +134,14 @@ a positional filter argument. **Expected**: Exit code non-zero. +### IntegrationTest_DefaultBehavior_RunsConfigFromWorkingDirectory_ReturnsZero + +**Scenario**: The tool is invoked with no arguments from a working directory containing a +`.fileassert.yaml` file whose assertions are satisfied. + +**Expected**: Exit code 0; the default-named configuration is loaded and executed from the +current working directory without an explicit `--config` argument. + ### IntegrationTest_PassingAssertions_WritesTrxWithPassedResults **Scenario**: All assertions pass and `--results .trx` is specified. @@ -236,6 +244,13 @@ a positional filter argument. **Expected**: Exit code non-zero. +### IntegrationTest_PdfAssert_FailingAssertion_ReturnsNonZero + +**Scenario**: A PDF assertion is configured against a valid PDF, but the asserted condition +(e.g., page count or text content) is not satisfied. + +**Expected**: Exit code non-zero. + ### IntegrationTest_ZipAssert_PassingQuery_ReturnsZero **Scenario**: A zip assertion is configured and the archive contains entries that satisfy @@ -249,7 +264,42 @@ the declared constraints. **Expected**: Exit code non-zero. -### IntegrationTest_HtmlAssert_InvalidFile_ReturnsNonZero +### IntegrationTest_ZipAssert_TextAssertionPassing_ReturnsZero + +**Scenario**: A zip assertion declares a text content rule against an entry whose content +satisfies the rule. + +**Expected**: Exit code 0. + +### IntegrationTest_ZipAssert_TextAssertionFailing_ReturnsNonZero + +**Scenario**: A zip assertion declares a text content rule against an entry whose content does +not satisfy the rule. + +**Expected**: Exit code non-zero. + +### IntegrationTest_ZipAssert_XmlAssertionPassing_ReturnsZero + +**Scenario**: A zip assertion declares an XML XPath assertion against a zip entry whose XML +satisfies the query constraint. + +**Expected**: Exit code 0. + +### IntegrationTest_ZipAssert_NestedZipTextContent_ReturnsZero + +**Scenario**: A zip assertion targets an entry that is itself a zip archive (zip-in-zip), and a +text content rule against the nested entry is satisfied. + +**Expected**: Exit code 0. + +### IntegrationTest_ZipAssert_FailingContentAssertion_ErrorContainsEntryPath + +**Scenario**: A zip assertion's content rule fails for an entry inside the archive. + +**Expected**: Exit code non-zero; the error message contains the breadcrumb-style entry path +identifying the enclosing archive and the failing entry. + +### IntegrationTest_HtmlAssert_ZeroMatchingElements_ReturnsNonZero **Scenario**: An HTML assertion is configured with an XPath query that yields no matching elements and a `min: 1` constraint. Note: HtmlAgilityPack is intentionally lenient and does not raise a @@ -270,39 +320,14 @@ elements) rather than a parse-failure path. **Expected**: Exit code non-zero. -## Requirements Coverage - -- **Version display**: IntegrationTest_VersionFlag_OutputsVersion -- **Help display**: IntegrationTest_HelpFlag_OutputsUsageInformation -- **Self-validation**: IntegrationTest_ValidateFlag_RunsValidation, - IntegrationTest_ValidateWithResults_GeneratesTrxFile, - IntegrationTest_ValidateWithResults_GeneratesJUnitFile -- **Silent mode**: IntegrationTest_SilentFlag_SuppressesOutput -- **Log file output**: IntegrationTest_LogFlag_WritesOutputToFile -- **Invalid argument rejection**: IntegrationTest_UnknownArgument_ReturnsError -- **Test filtering**: IntegrationTest_TestFiltering_OnlyRunsMatchingTests -- **File assertions**: IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero, - IntegrationTest_ValidConfig_FailingAssertions_ReturnsNonZero -- **Results output**: IntegrationTest_PassingAssertions_WritesTrxWithPassedResults, - IntegrationTest_FailingAssertions_WritesJUnitWithFailedResults -- **Count/size constraints**: IntegrationTest_MinCountConstraint_TooFewFiles_ReturnsNonZero, - IntegrationTest_MaxCountConstraint_TooManyFiles_ReturnsNonZero, - IntegrationTest_ExactCountConstraint_WrongCount_ReturnsNonZero, - IntegrationTest_FileSizeConstraints_TooSmall_ReturnsNonZero, - IntegrationTest_FileSizeConstraints_TooLarge_ReturnsNonZero -- **Text rules**: IntegrationTest_RegexRule_MatchingContent_ReturnsZero, - IntegrationTest_RegexRule_NonMatchingContent_ReturnsNonZero, - IntegrationTest_DoesNotContainRule_ForbiddenTextPresent_ReturnsNonZero, - IntegrationTest_DoesNotContainRegexRule_ForbiddenPatternMatches_ReturnsNonZero -- **Structured file assertions**: IntegrationTest_XmlAssert_PassingQuery_ReturnsZero, - IntegrationTest_XmlAssert_InvalidFile_ReturnsNonZero, - IntegrationTest_HtmlAssert_PassingQuery_ReturnsZero, - IntegrationTest_HtmlAssert_InvalidFile_ReturnsNonZero, - IntegrationTest_YamlAssert_PassingQuery_ReturnsZero, - IntegrationTest_YamlAssert_InvalidFile_ReturnsNonZero, - IntegrationTest_JsonAssert_PassingQuery_ReturnsZero, - IntegrationTest_JsonAssert_InvalidFile_ReturnsNonZero, - IntegrationTest_PdfAssert_InvalidFile_ReturnsNonZero, - IntegrationTest_PdfAssert_FailingAssertion_ReturnsNonZero -- **Zip archive assertions**: IntegrationTest_ZipAssert_PassingQuery_ReturnsZero, - IntegrationTest_ZipAssert_InvalidFile_ReturnsNonZero +### IntegrationTest_DepthFlag_ProducesHeadingsAtSpecifiedDepth + +**Scenario**: The tool is invoked with `--validate --depth 3` so that the self-validation report is +emitted with its headings shifted to the requested depth. The combined output is captured. Detailed +unit-level coverage for the heading-depth behavior is provided by +`Validation_Run_WithDepth_UsesSpecifiedHeadingDepth` in the SelfTest verification set +(`docs/verification/file-assert/selftest/validation.md`). + +**Expected**: Exit code 0; the combined output contains the top-level validation heading at the +requested depth (`### DEMA Consulting FileAssert`), confirming the `--depth` flag is recognized at +the system boundary and applied to the validation output. diff --git a/docs/verification/file-assert/cli.md b/docs/verification/file-assert/cli.md index ed2d229..cc3c62b 100644 --- a/docs/verification/file-assert/cli.md +++ b/docs/verification/file-assert/cli.md @@ -4,12 +4,13 @@ This document describes the subsystem-level verification design for the `Cli` su defines the integration test approach, subsystem boundary, mocking strategy, and test scenarios that together verify the `Cli` subsystem requirements. -### Verification Strategy +### Verification Approach -The `Cli` subsystem boundary at `Program` is verified by integration tests defined in -`CliTests.cs`. Each test exercises `Context.Create` and `Program.Run` together, treating the pair -as the observable subsystem interface. Tests pass controlled argument arrays and assert on captured -console output, file system side-effects, and exit codes. +The `Cli` subsystem boundary is verified by integration tests defined in `CliTests.cs`. Each +test exercises the `Cli` subsystem's public surface — primarily `Context.Create` and the +`Context` instance methods (`WriteLine`, `WriteError`, `WithPrefix`) — rather than +`Program.Run`. Tests pass controlled argument arrays and assert on captured console output, +file system side-effects, and exit codes. ### Dependencies and Mocking Strategy @@ -34,7 +35,7 @@ this document execute and pass in the CI pipeline without any test failures, une exceptions, or assertion errors. Each named scenario must pass on all supported runtime and platform combinations. -### Integration Test Scenarios +### Test Scenarios The following integration test scenarios are defined in `CliTests.cs`. @@ -90,13 +91,23 @@ called with a message. **Expected**: The message appears on standard output; exit code is 0. -### Requirements Coverage - -- **Argument parsing**: Cli_CreateContext_ParsesSilentValidateAndLogFlags, - Cli_CreateContext_ParsesVersionHelpConfigResultsFlags, - Cli_CreateContext_WithFilters_ParsesPositionalArguments -- **Unknown argument rejection**: Cli_CreateContext_UnknownArgument_ThrowsArgumentException -- **Typed property exposure (depth)**: Cli_CreateContext_ParsesDepthFlag -- **Error exit code**: Cli_WriteError_AfterSuccessfulCreate_ChangesExitCodeToOne -- **Log file output**: Cli_OutputPipeline_WithLogPathAndSilentFlag_WritesMessagesToLogFile -- **Console output**: Cli_OutputPipeline_WithoutSilentFlag_WritesMessagesToConsole +### ScopedContext Verification + +The `ScopedContext` implementation (returned by `Context.WithPrefix`) is verified by unit tests +defined in `ScopedContextTests.cs`. Each test exercises prefix creation, error propagation, and +multi-level nesting. + +#### ScopedContext Test Scenarios + +- **Context_WithPrefix_ReturnsNonNullScopedContext** – confirms that `WithPrefix` returns a + non-null `IContext` instance. +- **Context_WithPrefix_NullPrefix_ThrowsArgumentNullException** – confirms `ArgumentNullException` + for a null prefix. +- **ScopedContext_WriteError_PropagatesExitCodeToRoot** – confirms that an error written via a + scoped context increments the root context's `ExitCode` and `ErrorCount`. +- **ScopedContext_WriteLine_DoesNotSetError** – confirms that informational output via a scoped + context does not set any error state on the root context. +- **ScopedContext_Nested_WriteError_PropagatesExitCodeToRoot** – confirms that errors propagate + through two levels of `WithPrefix` nesting to the root context. +- **ScopedContext_MultipleErrors_AllAccumulateOnRoot** – confirms that errors from two separate + scoped contexts and a direct root `WriteError` call all accumulate on the root `ErrorCount`. diff --git a/docs/verification/file-assert/cli/context.md b/docs/verification/file-assert/cli/context.md index dd60a73..a8e2774 100644 --- a/docs/verification/file-assert/cli/context.md +++ b/docs/verification/file-assert/cli/context.md @@ -18,9 +18,10 @@ special hardware, peripherals, or environment configuration is required. #### Acceptance Criteria -N/A – Acceptance criteria are managed at the subsystem and system integration levels. -Unit tests provide fine-grained coverage evidence; formal acceptance is declared at the -subsystem level when all unit tests supporting a subsystem requirement pass. +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Every public method on +`Context` exercised by the listed scenarios returns the documented value or sets `ExitCode` +to `1` when an error is reported. #### Dependencies @@ -35,80 +36,60 @@ no mocking is needed at this level. **Expected**: All boolean flags are false; `ResultsFile` is null; exit code is 0. -**Requirement coverage**: Default context creation requirement. - ##### Context_Create_VersionFlag_SetsVersionTrue **Scenario**: `Context.Create` is called with `["--version"]`. **Expected**: `Version` property is true. -**Requirement coverage**: Version flag parsing requirement. - ##### Context_Create_ShortVersionFlag_SetsVersionTrue **Scenario**: `Context.Create` is called with `["-v"]`. **Expected**: `Version` property is true. -**Requirement coverage**: Short version flag parsing requirement. - ##### Context_Create_HelpFlag_SetsHelpTrue **Scenario**: `Context.Create` is called with `["--help"]`. **Expected**: `Help` property is true. -**Requirement coverage**: Help flag (long form) parsing requirement. - ##### Context_Create_ShortHelpFlag_H_SetsHelpTrue **Scenario**: `Context.Create` is called with `["-h"]`. **Expected**: `Help` property is true. -**Requirement coverage**: Help flag (-h) parsing requirement. - ##### Context_Create_ShortHelpFlag_Question_SetsHelpTrue **Scenario**: `Context.Create` is called with `["-?"]`. **Expected**: `Help` property is true. -**Requirement coverage**: Help flag (-?) parsing requirement. - ##### Context_Create_SilentFlag_SetsSilentTrue **Scenario**: `Context.Create` is called with `["--silent"]`. **Expected**: `Silent` property is true. -**Requirement coverage**: Silent flag parsing requirement. - ##### Context_Create_ValidateFlag_SetsValidateTrue **Scenario**: `Context.Create` is called with `["--validate"]`. **Expected**: `Validate` property is true. -**Requirement coverage**: Validate flag parsing requirement. - ##### Context_Create_ResultsFlag_SetsResultsFile **Scenario**: `Context.Create` is called with `["--results", "output.trx"]`. **Expected**: `ResultsFile` property equals `"output.trx"`. -**Requirement coverage**: Results file path parsing requirement. - ##### Context_Create_ResultAliasFlag_SetsResultsFile **Scenario**: `Context.Create` is called with `["--result", "output.trx"]` (legacy alias). **Expected**: `ResultsFile` property equals `"output.trx"`, identical to the `--results` flag. -**Requirement coverage**: Results alias flag parsing requirement. - ##### Context_Create_LogFlag_OpensLogFile **Scenario**: `Context.Create` is called with `["--log", ".log"]`; `WriteLine` is then called @@ -116,72 +97,54 @@ with a test message. **Expected**: The log file is created; the test message is written to it. -**Requirement coverage**: Log file opening and writing requirement. - ##### Context_Create_UnknownArgument_ThrowsArgumentException **Scenario**: `Context.Create` is called with an unrecognized argument (e.g., `["--unknown"]`). **Expected**: An `ArgumentException` is thrown containing the text "Unsupported argument". -**Requirement coverage**: Unknown argument rejection requirement. - ##### Context_Create_LogFlag_WithoutValue_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--log"]` (value missing). **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Log flag missing-value validation requirement. - ##### Context_Create_ResultsFlag_WithoutValue_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--results"]` (value missing). **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Results flag missing-value validation requirement. - ##### Context_WriteLine_NotSilent_WritesToConsole **Scenario**: A non-silent `Context` is created and `WriteLine` is called with a test message. **Expected**: The test message appears on standard output. -**Requirement coverage**: Normal output writing requirement. - ##### Context_WriteLine_Silent_DoesNotWriteToConsole **Scenario**: A silent `Context` (created with `["--silent"]`) calls `WriteLine`. **Expected**: Standard output receives nothing. -**Requirement coverage**: Silent mode suppression requirement. - ##### Context_WriteError_Silent_DoesNotWriteToConsole **Scenario**: A silent `Context` calls `WriteError`. **Expected**: Standard error receives nothing. -**Requirement coverage**: Silent mode error suppression requirement. - ##### Context_WriteError_SetsErrorExitCode **Scenario**: A `Context` calls `WriteError`. **Expected**: `ExitCode` is 1 after the call. -**Requirement coverage**: Error exit code setting requirement. - ##### Context_WriteError_NotSilent_WritesToConsole **Scenario**: A non-silent `Context` calls `WriteError` with a test message. **Expected**: The test message appears on standard error. -**Requirement coverage**: Error output writing requirement. - ##### Context_WriteError_WritesToLogFile **Scenario**: A `Context` created with `["--silent", "--log", ".log"]` calls `WriteError` @@ -189,154 +152,86 @@ with a test message. **Expected**: The test message appears in the log file. -**Requirement coverage**: Error log writing requirement. - ##### Context_ErrorCount_IncrementsOnEachWriteError **Scenario**: `WriteError` is called multiple times on the same `Context`. **Expected**: `ErrorCount` increments by one for each call. -**Requirement coverage**: Error count tracking requirement. - ##### Context_Create_DepthFlag_SetsDepth **Scenario**: `Context.Create` is called with `["--depth", "3"]`. **Expected**: `Depth` property equals 3. -**Requirement coverage**: Depth flag parsing requirement. - ##### Context_Create_NoArguments_DepthDefaultsToOne **Scenario**: `Context.Create` is called with an empty argument array. **Expected**: `Depth` property equals 1 (the default). -**Requirement coverage**: Default heading depth requirement. - ##### Context_Create_DepthFlag_WithoutValue_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--depth"]` (value missing). **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Depth flag missing-value validation requirement. - ##### Context_Create_DepthFlag_NonNumeric_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--depth", "abc"]`. **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Depth flag non-integer validation requirement. - ##### Context_Create_DepthFlag_Zero_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--depth", "0"]` (below minimum of 1). **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Depth flag minimum-value validation requirement. - ##### Context_Create_DepthFlag_AboveSix_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--depth", "7"]` (above maximum of 6). **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Depth flag maximum-value validation requirement. - ##### Context_Create_NoArguments_ConfigFileHasDefaultValue **Scenario**: `Context.Create` is called with an empty argument array. **Expected**: `ConfigFile` property has the default value. -**Requirement coverage**: Default config file path requirement. - ##### Context_Create_NoArguments_FiltersIsEmpty **Scenario**: `Context.Create` is called with an empty argument array. **Expected**: `Filters` collection is empty. -**Requirement coverage**: Default filters requirement. - ##### Context_Create_ConfigFlag_SetsConfigFile **Scenario**: `Context.Create` is called with `["--config", "my.yaml"]`. **Expected**: `ConfigFile` property equals `"my.yaml"`. -**Requirement coverage**: Config file flag parsing requirement. - ##### Context_Create_PositionalArguments_AddedToFilters **Scenario**: `Context.Create` is called with positional arguments (e.g., `["TestA", "TestB"]`). **Expected**: `Filters` contains `["TestA", "TestB"]`. -**Requirement coverage**: Test filter parsing requirement. - ##### Context_Create_MixedArguments_ParsesCorrectly **Scenario**: `Context.Create` is called with a mix of flags and positional arguments. **Expected**: All flags and positional arguments are correctly parsed. -**Requirement coverage**: Mixed argument parsing requirement. - ##### Context_Create_UnknownFlagWithDash_ThrowsArgumentException **Scenario**: `Context.Create` is called with an unrecognized flag starting with `--`. **Expected**: An `ArgumentException` is thrown. -**Requirement coverage**: Unknown flag rejection requirement. - ##### Context_Create_ConfigFlag_WithoutValue_ThrowsArgumentException **Scenario**: `Context.Create` is called with `["--config"]` (value missing). **Expected**: An `ArgumentException` is thrown. - -**Requirement coverage**: Config flag missing-value validation requirement. - -#### Requirements Coverage - -| Requirement | Test Scenario | -|--------------------------------|-----------------------------------------------------------------| -| Default context creation | Context_Create_NoArguments_ReturnsDefaultContext | -| --version flag parsing | Context_Create_VersionFlag_SetsVersionTrue | -| -v flag parsing | Context_Create_ShortVersionFlag_SetsVersionTrue | -| --help flag parsing | Context_Create_HelpFlag_SetsHelpTrue | -| -h flag parsing | Context_Create_ShortHelpFlag_H_SetsHelpTrue | -| -? flag parsing | Context_Create_ShortHelpFlag_Question_SetsHelpTrue | -| --silent flag parsing | Context_Create_SilentFlag_SetsSilentTrue | -| --validate flag parsing | Context_Create_ValidateFlag_SetsValidateTrue | -| --results flag parsing | Context_Create_ResultsFlag_SetsResultsFile | -| --result alias parsing | Context_Create_ResultAliasFlag_SetsResultsFile | -| --log flag and file writing | Context_Create_LogFlag_OpensLogFile | -| Unknown argument rejection | Context_Create_UnknownArgument_ThrowsArgumentException | -| --log missing value | Context_Create_LogFlag_WithoutValue_ThrowsArgumentException | -| --results missing value | Context_Create_ResultsFlag_WithoutValue_ThrowsArgumentException | -| Normal output writing | Context_WriteLine_NotSilent_WritesToConsole | -| Silent mode output suppression | Context_WriteLine_Silent_DoesNotWriteToConsole | -| Silent mode error suppression | Context_WriteError_Silent_DoesNotWriteToConsole | -| Error exit code | Context_WriteError_SetsErrorExitCode | -| Error output to stderr | Context_WriteError_NotSilent_WritesToConsole | -| Error writing to log file | Context_WriteError_WritesToLogFile | -| Error count tracking | Context_ErrorCount_IncrementsOnEachWriteError | -| --depth flag parsing | Context_Create_DepthFlag_SetsDepth | -| Default heading depth | Context_Create_NoArguments_DepthDefaultsToOne | -| --depth missing value | Context_Create_DepthFlag_WithoutValue_ThrowsArgumentException | -| --depth non-integer value | Context_Create_DepthFlag_NonNumeric_ThrowsArgumentException | -| --depth zero value (min 1) | Context_Create_DepthFlag_Zero_ThrowsArgumentException | -| --depth exceeds maximum (max 6)| Context_Create_DepthFlag_AboveSix_ThrowsArgumentException | -| --config flag parsing | Context_Create_ConfigFlag_SetsConfigFile | -| Default config file path | Context_Create_NoArguments_ConfigFileHasDefaultValue | -| Test filter parsing | Context_Create_PositionalArguments_AddedToFilters | -| Mixed argument parsing | Context_Create_MixedArguments_ParsesCorrectly | -| Unknown flag rejection | Context_Create_UnknownFlagWithDash_ThrowsArgumentException | -| --config missing value | Context_Create_ConfigFlag_WithoutValue_ThrowsArgumentException | diff --git a/docs/verification/file-assert/cli/i-context.md b/docs/verification/file-assert/cli/i-context.md new file mode 100644 index 0000000..67af67d --- /dev/null +++ b/docs/verification/file-assert/cli/i-context.md @@ -0,0 +1,70 @@ +### IContext Verification + +This document describes the unit-level verification design for the `IContext` interface and its +`ScopedContext` implementation. It defines the test scenarios, dependency usage, and requirement +coverage for `Cli/IContext.cs` and the nested `ScopedContext` class inside `Cli/Context.cs`. + +#### Verification Approach + +`IContext` and `ScopedContext` are verified with unit tests defined in `ScopedContextTests.cs`. +Tests exercise `Context.WithPrefix`, error propagation from scoped contexts to the root context, +and multi-level nesting. No mocking or test doubles are needed because the tests operate directly +on a `Context` instance created with `["--silent"]` to suppress console output. + +#### Test Environment + +Tests execute in the standard CI pipeline environment using the xUnit test runner. The test +collection is marked `[Collection("Sequential")]` to prevent parallel execution of tests that +share `Console` state. No special hardware, peripherals, or environment configuration is required +beyond the standard build toolchain. + +#### Acceptance Criteria + +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Code coverage for `IContext.cs` +meets the project minimum threshold. + +#### Dependencies + +`ScopedContext` depends on `Context` for error accumulation. No external dependencies +require mocking at this level. + +#### Test Scenarios + +##### Context_WithPrefix_ReturnsNonNullScopedContext + +**Scenario**: `context.WithPrefix("archive.zip")` is called on a valid root context. + +**Expected**: The returned `IContext` instance is not null. + +##### Context_WithPrefix_NullPrefix_ThrowsArgumentNullException + +**Scenario**: `context.WithPrefix(null!)` is called on a valid root context. + +**Expected**: An `ArgumentNullException` is thrown. + +**Boundary / error path**: Null argument guard. + +##### ScopedContext_WriteError_PropagatesExitCodeToRoot + +**Scenario**: An error is written via a scoped context derived from a root context. + +**Expected**: `context.ExitCode` is `1` and `context.ErrorCount` is `1`. + +##### ScopedContext_WriteLine_DoesNotSetError + +**Scenario**: An informational message is written via a scoped context. + +**Expected**: `context.ExitCode` is `0` and `context.ErrorCount` is `0`. + +##### ScopedContext_Nested_WriteError_PropagatesExitCodeToRoot + +**Scenario**: Two levels of `WithPrefix` are applied; an error is written via the deepest scope. + +**Expected**: `context.ExitCode` is `1` and `context.ErrorCount` is `1`. + +##### ScopedContext_MultipleErrors_AllAccumulateOnRoot + +**Scenario**: Two separate scoped contexts and the root context each write one error. + +**Expected**: `context.ErrorCount` is `3` and `context.ExitCode` is `1`. diff --git a/docs/verification/file-assert/configuration.md b/docs/verification/file-assert/configuration.md index b6f8839..ecfdcc7 100644 --- a/docs/verification/file-assert/configuration.md +++ b/docs/verification/file-assert/configuration.md @@ -29,7 +29,7 @@ this document execute and pass in the CI pipeline without any test failures, une exceptions, or assertion errors. Each named scenario must pass on all supported runtime and platform combinations. -### Integration Test Scenarios +### Test Scenarios The following integration test scenarios are defined in `ConfigurationTests.cs`. @@ -63,9 +63,22 @@ extension is provided to the context via `--results`. **Expected**: `FileAssertConfig.Run` completes and a TRX results file is written to the specified path. -### Requirements Coverage +#### Configuration_Run_WithResultsFile_WritesJUnitResultsFile -- **YAML loading and hierarchy construction**: Configuration_LoadYaml_BuildsCompleteTestHierarchy -- **Test name filtering**: Configuration_RunWithFilter_ExecutesOnlyMatchingTests -- **Tag filtering**: Configuration_RunWithTagFilter_ExecutesOnlyMatchingTests -- **Results file output (TRX/JUnit XML)**: Configuration_Run_WithResultsFile_WritesTrxResultsFile +**Scenario**: A configuration file with one test is loaded. A results file path with an `.xml` +extension is provided to the context via `--results`. + +**Expected**: `FileAssertConfig.Run` completes and a JUnit XML results file (containing a +`]` +where `` refers to a file that does not exist. -### Requirements Coverage +**Expected**: Output contains "Configuration file not found"; exit code is 1. -- **Version display**: Program_Run_WithVersionFlag_DisplaysVersionOnly, - Program_Version_ReturnsNonEmptyString -- **Help display**: Program_Run_WithHelpFlag_DisplaysUsageInformation -- **Self-validation**: Program_Run_WithValidateFlag_RunsValidation -- **Default behavior**: Program_Run_NoArguments_DisplaysDefaultBehavior +#### Program_Version_ReturnsNonEmptyString + +**Scenario**: The `Program.Version` static property is read. + +**Expected**: The returned string is non-empty and non-null. diff --git a/docs/verification/file-assert/selftest.md b/docs/verification/file-assert/selftest.md index 67bbe1d..59935ff 100644 --- a/docs/verification/file-assert/selftest.md +++ b/docs/verification/file-assert/selftest.md @@ -4,7 +4,7 @@ This document describes the subsystem-level verification design for the `SelfTes defines the integration test approach, subsystem boundary, mocking strategy, and test scenarios that together verify the `SelfTest` subsystem requirements. -### Verification Strategy +### Verification Approach The `SelfTest` subsystem is verified by integration tests defined in `SelfTestTests.cs`. Each test exercises the `Validation.Run` method with a real `Context` to confirm that the subsystem @@ -54,10 +54,11 @@ including "Total Tests:". **Expected**: A TRX file is created at the specified path; the file contains a ` entryPath"`. diff --git a/docs/verification/file-assert/utilities/directory-file-container.md b/docs/verification/file-assert/utilities/directory-file-container.md new file mode 100644 index 0000000..7b5847f --- /dev/null +++ b/docs/verification/file-assert/utilities/directory-file-container.md @@ -0,0 +1,84 @@ +### DirectoryFileContainer Verification + +This document describes the unit-level verification design for the `DirectoryFileContainer` class. +It defines the test scenarios, dependency usage, and requirement coverage for +`Utilities/DirectoryFileContainer.cs`. + +#### Verification Approach + +`DirectoryFileContainer` is verified with unit tests defined in `IFileContainerTests.cs`. Tests +exercise all four `IFileContainer` interface members against a real filesystem directory created +by `TemporaryDirectory`. No mocking or test doubles are needed because the class interacts only +with the local file system. + +#### Test Environment + +Tests execute in the standard CI pipeline environment using the xUnit test runner. The test +collection is marked `[Collection("Sequential")]` to serialize tests that create and tear down +shared temporary directories. `TemporaryDirectory` creates these under +`Environment.CurrentDirectory` (not `Path.GetTempPath()`) to avoid OS symlink resolution issues +such as `/tmp` resolving to `/private/tmp` on macOS. No special hardware, peripherals, or +environment configuration is required beyond the standard build toolchain. + +#### Acceptance Criteria + +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Code coverage for `DirectoryFileContainer.cs` +meets the project minimum threshold. + +#### Dependencies + +`DirectoryFileContainer` depends on .NET BCL types (`File`, `Directory`, `Path`, `FileInfo`, +`SearchOption`). No mocking is needed at this level. + +#### Test Scenarios + +##### DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes + +**Scenario**: A directory tree with two files (`a.txt` and `sub/b.txt`) is created; `GetEntries()` +is called. + +**Expected**: Returns exactly 2 entries: `"a.txt"` and `"sub/b.txt"` with forward slashes. + +##### DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty + +**Scenario**: An empty directory is created; `GetEntries()` is called. + +**Expected**: Returns an empty list. + +##### DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty + +**Scenario**: A path that does not exist on disk is supplied to the constructor; `GetEntries()` +is called. + +**Expected**: Returns an empty list without throwing any exception. + +**Boundary / error path**: Non-existent base directory treated as empty container. + +##### DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream + +**Scenario**: A file `"data.txt"` containing `"hello"` is written to a temporary directory; +`OpenEntry("data.txt")` is called. + +**Expected**: The returned stream reads `"hello"`. + +##### DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException + +**Scenario**: `OpenEntry("missing.txt")` is called on an empty temporary directory. + +**Expected**: A `FileNotFoundException` (subclass of `IOException`) is thrown by `File.OpenRead`. + +**Boundary / error path**: Missing file throws appropriate exception. + +##### DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize + +**Scenario**: A file `"size.txt"` containing exactly 5 ASCII bytes is written; `GetEntrySize("size.txt")` +is called. + +**Expected**: Returns `5L`. + +##### DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath + +**Scenario**: `GetDisplayPath("report.pdf")` is called on a container with a known base path. + +**Expected**: Returns `Path.Combine(basePath, "report.pdf")` — the full absolute file-system path. diff --git a/docs/verification/file-assert/utilities/i-file-container.md b/docs/verification/file-assert/utilities/i-file-container.md new file mode 100644 index 0000000..f0c85cb --- /dev/null +++ b/docs/verification/file-assert/utilities/i-file-container.md @@ -0,0 +1,117 @@ +### IFileContainer Verification + +This document describes the unit-level verification design for the `IFileContainer` interface. +It defines the test scenarios, dependency usage, and requirement coverage for +`Utilities/IFileContainer.cs` and both of its implementations. + +#### Verification Approach + +`IFileContainer` is verified through the concrete implementations `DirectoryFileContainer` and +`ZipFileContainer`, whose tests are defined in `IFileContainerTests.cs`. Each test exercises the +full set of interface members against both a real filesystem directory and an in-memory zip archive. +No mocking or test doubles are used — the interface contract is verified by exercising both +implementations. + +#### Test Environment + +Tests execute in the standard CI pipeline environment using the xUnit test runner. The test +collection is marked `[Collection("Sequential")]` to prevent parallel execution of tests that +share `Console` state. No special hardware, peripherals, or environment configuration is required +beyond the standard build toolchain. + +#### Acceptance Criteria + +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Code coverage for `IFileContainer.cs` +meets the project minimum threshold. + +#### Dependencies + +`DirectoryFileContainer` depends on .NET BCL file-system APIs. `ZipFileContainer` depends on +`System.IO.Compression.ZipArchive`. No mocking is needed at this level. + +#### Test Scenarios + +##### DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes + +**Scenario**: A `DirectoryFileContainer` is created over a small directory tree containing +`a.txt` and `sub/b.txt`. + +**Expected**: `GetEntries()` returns exactly 2 entries: `"a.txt"` and `"sub/b.txt"` (forward +slashes regardless of platform). + +##### DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty + +**Scenario**: A `DirectoryFileContainer` is created over an empty directory. + +**Expected**: `GetEntries()` returns an empty list. + +##### DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty + +**Scenario**: A `DirectoryFileContainer` is created over a path that does not exist on disk. + +**Expected**: `GetEntries()` returns an empty list without throwing. + +**Boundary / error path**: Non-existent directory treated as empty container. + +##### DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream + +**Scenario**: A file with known content is written to a temporary directory; `OpenEntry` is +called with its filename. + +**Expected**: The returned stream contains the expected content. + +##### DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException + +**Scenario**: `OpenEntry` is called for a filename that does not exist in the container. + +**Expected**: A `FileNotFoundException` (subclass of `IOException`) is thrown. + +**Boundary / error path**: Missing entry error handling. + +##### DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize + +**Scenario**: A file containing exactly 5 ASCII bytes is written; `GetEntrySize` is called. + +**Expected**: Returns `5L`. + +##### DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath + +**Scenario**: `GetDisplayPath("report.pdf")` is called on a `DirectoryFileContainer`. + +**Expected**: Returns `Path.Combine(basePath, "report.pdf")` — the full file-system path. + +##### ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + +**Scenario**: A `ZipFileContainer` is created from an in-memory zip containing two entries +`"lib/a.dll"` and `"lib/b.dll"`. + +**Expected**: `GetEntries()` returns exactly 2 entries with forward slashes. + +##### ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream + +**Scenario**: A `ZipFileContainer` wraps a zip containing `"readme.txt"` with known content; +`OpenEntry("readme.txt")` is called. + +**Expected**: The returned stream contains the expected content. + +##### ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException + +**Scenario**: `OpenEntry("missing.txt")` is called on a `ZipFileContainer` wrapping an empty zip. + +**Expected**: An `IOException` is thrown. + +**Boundary / error path**: Missing zip entry error handling. + +##### ZipFileContainer_GetEntrySize_ReturnsUncompressedLength + +**Scenario**: A `ZipFileContainer` wraps a zip containing a 5-byte entry; `GetEntrySize` is called. + +**Expected**: Returns `5L` (uncompressed length). + +##### ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath + +**Scenario**: A `ZipFileContainer` with display name `"outer.zip"` has `GetDisplayPath("inner.txt")` +called. + +**Expected**: Returns `"outer.zip > inner.txt"`. diff --git a/docs/verification/file-assert/utilities/path-helpers.md b/docs/verification/file-assert/utilities/path-helpers.md index e7f3fe4..fac181d 100644 --- a/docs/verification/file-assert/utilities/path-helpers.md +++ b/docs/verification/file-assert/utilities/path-helpers.md @@ -17,9 +17,9 @@ special hardware, peripherals, or environment configuration is required. #### Acceptance Criteria -N/A – Acceptance criteria are managed at the subsystem and system integration levels. -Unit tests provide fine-grained coverage evidence; formal acceptance is declared at the -subsystem level when all unit tests supporting a subsystem requirement pass. +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Each scenario asserts the +exact return value or the exact exception type produced by `SafePathCombine` for its inputs. #### Dependencies @@ -34,8 +34,6 @@ subsystem level when all unit tests supporting a subsystem requirement pass. **Expected**: The returned path equals the expected combined result; no exception is thrown. -**Requirement coverage**: Valid path combination requirement. - ##### PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException **Scenario**: A relative path starting with `"../"` is passed to `SafePathCombine`. @@ -44,8 +42,6 @@ subsystem level when all unit tests supporting a subsystem requirement pass. **Boundary / error path**: Directory traversal attempt via leading `../`. -**Requirement coverage**: Traversal rejection requirement. - ##### PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException **Scenario**: A relative path containing `"subfolder/../../../etc/passwd"` is passed to @@ -55,8 +51,6 @@ subsystem level when all unit tests supporting a subsystem requirement pass. **Boundary / error path**: Directory traversal attempt via embedded `../` sequence. -**Requirement coverage**: Embedded traversal rejection requirement. - ##### PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException **Scenario**: An absolute path is passed as the relative argument to `SafePathCombine`. @@ -69,8 +63,6 @@ Sub-cases: **Boundary / error path**: Absolute path used where a relative path is required. -**Requirement coverage**: Absolute path rejection requirement. - ##### PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly **Scenario**: A relative path starting with `"./"` (e.g., `"./subfolder/file.txt"`) is combined @@ -78,8 +70,6 @@ with a base path. **Expected**: The returned path equals the expected combined result; no exception is thrown. -**Requirement coverage**: Current-directory prefix requirement. - ##### PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly **Scenario**: A deeply nested relative path (e.g., `"a/b/c/d/file.txt"`) is combined with a @@ -87,8 +77,6 @@ base path. **Expected**: The returned path equals the expected combined result; no exception is thrown. -**Requirement coverage**: Nested path combination requirement. - ##### PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath **Scenario**: An empty string is passed as the relative path argument. @@ -97,8 +85,6 @@ base path. **Boundary / error path**: Empty relative path edge case. -**Requirement coverage**: Empty relative path requirement. - ##### PathHelpers_SafePathCombine_DoubleDotInFilename_CombinesCorrectly **Scenario**: A relative path whose filename starts with `".."` but is not a traversal sequence @@ -108,8 +94,6 @@ base path. **Boundary / error path**: Filename beginning with `".."` must not be misidentified as a traversal. -**Requirement coverage**: Dot-dot-prefixed filename requirement. - ##### PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException **Scenario**: `null` is passed as the `basePath` argument to `SafePathCombine`. @@ -118,8 +102,6 @@ base path. **Boundary / error path**: Null guard on `basePath`. -**Requirement coverage**: Null input rejection requirement. - ##### PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException **Scenario**: `null` is passed as the `relativePath` argument to `SafePathCombine`. @@ -128,20 +110,14 @@ base path. **Boundary / error path**: Null guard on `relativePath`. -**Requirement coverage**: Null input rejection requirement. +##### PathHelpers_SafePathCombine_RootedPathInsideBase_RejectsIt -#### Requirements Coverage +**Scenario**: An absolute path that resolves underneath the base directory (e.g., a child of +`Path.GetTempPath()` while `basePath` is `Path.GetTempPath()` itself) is passed as the +`relativePath` argument to `SafePathCombine`. -- **FileAssert-PathHelpers-SafeCombine** (safe path combination): - - PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly - - PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException - - PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException - - PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException - - PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly - - PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly - - PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath - - PathHelpers_SafePathCombine_DoubleDotInFilename_CombinesCorrectly +**Expected**: An `ArgumentException` containing `"Invalid path component"` is thrown. -- **FileAssert-PathHelpers-NullValidation** (null input rejection): - - PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException - - PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException +**Boundary / error path**: Rooted relative paths are rejected even when the resolved location +is inside `basePath`, because `Path.Combine` discards `basePath` whenever the second argument +is rooted. diff --git a/docs/verification/file-assert/utilities/temporary-directory.md b/docs/verification/file-assert/utilities/temporary-directory.md index 1d2d004..47cac76 100644 --- a/docs/verification/file-assert/utilities/temporary-directory.md +++ b/docs/verification/file-assert/utilities/temporary-directory.md @@ -20,9 +20,9 @@ beyond the standard build toolchain. #### Acceptance Criteria -N/A – Acceptance criteria are managed at the subsystem and system integration levels. -Unit tests provide fine-grained coverage evidence; formal acceptance is declared at the -subsystem level when all unit tests supporting a subsystem requirement pass. +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. Code coverage for `TemporaryDirectory.cs` +meets the project minimum threshold. #### Dependencies @@ -39,8 +39,6 @@ operations. No mocking is needed at this level. **Expected**: `Directory.Exists(tmpDir.DirectoryPath)` returns `true` immediately after construction. -**Requirement coverage**: Lifecycle creation requirement. - ##### TemporaryDirectory_Constructor_CreatesUniqueDirectories **Scenario**: Two `TemporaryDirectory` instances are constructed sequentially without disposal @@ -50,8 +48,6 @@ between them. **Boundary / error path**: Ensures uniqueness under rapid successive construction. -**Requirement coverage**: Lifecycle uniqueness requirement. - ##### TemporaryDirectory_GetFilePath_SimpleFile_ReturnsPathUnderDirectory **Scenario**: `GetFilePath("output.md")` is called on a live `TemporaryDirectory` instance. @@ -59,8 +55,6 @@ between them. **Expected**: The returned path starts with `tmpDir.DirectoryPath` and ends with `"output.md"`. No exception is thrown. -**Requirement coverage**: Safe path construction requirement. - ##### TemporaryDirectory_GetFilePath_NestedPath_CreatesIntermediateDirectories **Scenario**: `GetFilePath(Path.Combine("sub", "nested", "output.md"))` is called on a live @@ -69,8 +63,6 @@ No exception is thrown. **Expected**: `Directory.Exists` on the parent directory of the returned path returns `true`, confirming that intermediate subdirectories were created automatically. No exception is thrown. -**Requirement coverage**: Intermediate subdirectory creation requirement. - ##### TemporaryDirectory_GetFilePath_TraversalAttempt_ThrowsArgumentException **Scenario**: `GetFilePath("../escaped.txt")` is called on a live `TemporaryDirectory` instance. @@ -80,8 +72,6 @@ directory. **Boundary / error path**: Path-traversal attempt using leading `../`. -**Requirement coverage**: Traversal rejection requirement. - ##### TemporaryDirectory_Dispose_DeletesDirectory **Scenario**: A `TemporaryDirectory` is constructed, a file is written inside it via @@ -90,8 +80,6 @@ directory. **Expected**: `Directory.Exists` on the captured `DirectoryPath` returns `false` after disposal, confirming that the directory and its contents were deleted. -**Requirement coverage**: Lifecycle cleanup requirement. - ##### TemporaryDirectory_Dispose_AlreadyDeleted_DoesNotThrow **Scenario**: The underlying directory is manually deleted before `Dispose()` is called on the @@ -100,15 +88,3 @@ confirming that the directory and its contents were deleted. **Expected**: `Dispose()` completes without throwing any exception. **Boundary / error path**: Cleanup error suppression when the directory no longer exists. - -**Requirement coverage**: Resilient disposal requirement. - -#### Requirements Coverage - -- (directory created on construction): TemporaryDirectory_Constructor_CreatesDirectory -- (unique directory per instance): TemporaryDirectory_Constructor_CreatesUniqueDirectories -- (directory deleted on disposal): TemporaryDirectory_Dispose_DeletesDirectory -- (disposal safe when already deleted): TemporaryDirectory_Dispose_AlreadyDeleted_DoesNotThrow -- (simple file path under directory): TemporaryDirectory_GetFilePath_SimpleFile_ReturnsPathUnderDirectory -- (nested path creates subdirectories): TemporaryDirectory_GetFilePath_NestedPath_CreatesIntermediateDirectories -- (traversal attempt rejected): TemporaryDirectory_GetFilePath_TraversalAttempt_ThrowsArgumentException diff --git a/docs/verification/file-assert/utilities/zip-file-container.md b/docs/verification/file-assert/utilities/zip-file-container.md new file mode 100644 index 0000000..714821b --- /dev/null +++ b/docs/verification/file-assert/utilities/zip-file-container.md @@ -0,0 +1,91 @@ +### ZipFileContainer Verification + +This document describes the unit-level verification design for the `ZipFileContainer` class. +It defines the test scenarios, dependency usage, and requirement coverage for +`Utilities/ZipFileContainer.cs`. + +#### Verification Approach + +`ZipFileContainer` is verified with unit tests defined in `IFileContainerTests.cs`. Tests +exercise all four `IFileContainer` interface members against in-memory zip archives constructed +with `System.IO.Compression.ZipArchive` using `MemoryStream`. No mocking or test doubles are +needed because the class interacts only with the in-memory `ZipArchive` API. + +#### Test Environment + +Tests execute in the standard CI pipeline environment using the xUnit test runner. The test +collection is marked `[Collection("Sequential")]` to serialize tests that share temporary +directory and stream resources. No special hardware, peripherals, or environment configuration +is required beyond the standard build toolchain. + +#### Acceptance Criteria + +All listed unit test scenarios pass on every supported platform and runtime combination. No +test failures, unhandled exceptions, or assertion errors occur. The IO behaviors documented in +the design (entry enumeration, lookup, size reporting, display-path formatting) all return the +documented value or raise the documented exception type for each scenario. + +#### Dependencies + +`ZipFileContainer` depends on `System.IO.Compression.ZipArchive` and `ZipArchiveEntry`. No +mocking is needed at this level. + +#### Test Scenarios + +##### ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes + +**Scenario**: A `ZipFileContainer` is created from an in-memory zip containing two entries +`"lib/a.dll"` and `"lib/b.dll"`. + +**Expected**: `GetEntries()` returns exactly 2 entries with forward slashes; directory marker +entries (names ending in `/`) are excluded. + +##### ZipFileContainer_GetEntries_ExcludesDirectoryMarkers + +**Scenario**: A `ZipFileContainer` is created from an in-memory zip containing a directory +marker entry `"lib/"` and a file entry `"lib/a.dll"`. + +**Expected**: `GetEntries()` returns only `"lib/a.dll"`; the directory marker entry (name ending +in `/`) is excluded. + +**Boundary / error path**: Directory marker exclusion during entry enumeration. + +##### ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream + +**Scenario**: A zip containing `"readme.txt"` with content `"zip content"` is opened; `OpenEntry("readme.txt")` +is called. + +**Expected**: The returned stream reads `"zip content"`. + +##### ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException + +**Scenario**: `OpenEntry("missing.txt")` is called on a `ZipFileContainer` wrapping an empty zip. + +**Expected**: An `IOException` is thrown with a message containing the missing entry name. + +**Boundary / error path**: Missing zip entry uses `IOException` rather than `FileNotFoundException`, +since zip entries are not file-system files. + +##### ZipFileContainer_GetEntrySize_ReturnsUncompressedLength + +**Scenario**: A zip containing a single entry with 5 ASCII characters is created; `GetEntrySize` is called. + +**Expected**: Returns `5L` (the uncompressed `ZipArchiveEntry.Length` value). + +##### ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath + +**Scenario**: A `ZipFileContainer` is constructed with display name `"outer.zip"`; `GetDisplayPath("inner.txt")` +is called. + +**Expected**: Returns `"outer.zip > inner.txt"`. + +##### ZipFileContainer_BackslashEntryPath_OpensAndSizesAfterNormalization + +**Scenario**: A zip stores entry `"lib/a.dll"` (forward slash). The test calls +`OpenEntry("lib\\a.dll")` and `GetEntrySize("lib\\a.dll")` using a backslash separator. + +**Expected**: `OpenEntry` returns a stream that reads the entry's content; `GetEntrySize` +returns the uncompressed length of the entry. Both APIs locate the entry by normalizing +backslashes to forward slashes before calling `ZipArchive.GetEntry(...)`. + +**Boundary / error path**: Backslash-separator normalization in `OpenEntry` and `GetEntrySize`. diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 46fcac9..57612a4 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -20,6 +20,7 @@ This document covers the verification design for the same software items describ - **Program** — entry point and execution orchestrator - **Cli** — command-line interface subsystem - **Context** — argument parser and I/O owner + - **IContext** — output and error-reporting contract for assertion context - **Configuration** — configuration loading subsystem - **FileAssertConfig** — configuration file reader - **FileAssertData** — deserialization data transfer objects @@ -37,6 +38,9 @@ This document covers the verification design for the same software items describ - **Utilities** — shared utility subsystem - **PathHelpers** — safe path combination utilities - **TemporaryDirectory** — temporary workspace lifecycle utility + - **IFileContainer** — uniform file-access abstraction over directories and zip archives + - **DirectoryFileContainer** — filesystem-backed file container + - **ZipFileContainer** — zip-archive-backed file container - **SelfTest** — self-validation subsystem - **Validation** — self-validation test runner @@ -57,6 +61,10 @@ The following OTS items are also covered: - **VersionMark** — tool-version documentation tool - **WeasyPrint** — HTML-to-PDF conversion tool - **xUnit** — unit-testing framework +- **YamlDotNet** — YAML parsing and deserialization library +- **PdfPig** — PDF parsing library +- **HtmlAgilityPack** — HTML parsing library +- **FileSystemGlobbing** — glob pattern-matching library ## Software Structure @@ -66,7 +74,8 @@ The following tree shows the software items covered by this document: FileAssert (System) ├── Program (Unit) ├── Cli (Subsystem) -│ └── Context (Unit) +│ ├── Context (Unit) +│ └── IContext (Unit) ├── Configuration (Subsystem) │ ├── FileAssertConfig (Unit) │ └── FileAssertData (Unit) @@ -83,7 +92,10 @@ FileAssert (System) │ └── FileAssertZipAssert (Unit) ├── Utilities (Subsystem) │ ├── PathHelpers (Unit) -│ └── TemporaryDirectory (Unit) +│ ├── TemporaryDirectory (Unit) +│ ├── IFileContainer (Unit) +│ ├── DirectoryFileContainer (Unit) +│ └── ZipFileContainer (Unit) └── SelfTest (Subsystem) └── Validation (Unit) @@ -97,7 +109,11 @@ OTS Items ├── SonarMark ├── VersionMark ├── WeasyPrint -└── xUnit +├── xUnit +├── YamlDotNet +├── PdfPig +├── HtmlAgilityPack +└── FileSystemGlobbing ``` ## Companion Artifact Structure @@ -113,6 +129,7 @@ In-house items have corresponding artifacts in parallel directory trees: OTS items have parallel artifacts in: - Requirements: `docs/reqstream/ots/{ots-name}.yaml` +- Design: `docs/design/ots/{ots-name}.md` - Verification: `docs/verification/ots/{ots-name}.md` Review-sets: defined in `.reviewmark.yaml` diff --git a/docs/verification/ots/buildmark.md b/docs/verification/ots/buildmark.md index 1866b24..c6fc3a2 100644 --- a/docs/verification/ots/buildmark.md +++ b/docs/verification/ots/buildmark.md @@ -27,14 +27,8 @@ BuildMark did not produce the required output. **Expected**: BuildMark exits without error and produces a non-empty markdown build-notes document in the release artifacts. -**Requirement coverage**: `FileAssert-OTS-BuildMark`. - -### Requirements Coverage - -- **`FileAssert-OTS-BuildMark`**: BuildMark_MarkdownReportGeneration - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/fileassert.md b/docs/verification/ots/fileassert.md index 83c7dfd..39abf02 100644 --- a/docs/verification/ots/fileassert.md +++ b/docs/verification/ots/fileassert.md @@ -30,16 +30,12 @@ pipeline. **Expected**: Exits 0 and displays a version string. -**Requirement coverage**: `FileAssert-OTS-FileAssert`. - #### FileAssert_HelpDisplay **Scenario**: FileAssert self-validation exercises the `--help` flag. **Expected**: Exits 0 and displays usage information. -**Requirement coverage**: `FileAssert-OTS-FileAssert`. - #### FileAssert_Results **Scenario**: FileAssert self-validation exercises the `--results` flag by running a configuration with one @@ -47,8 +43,6 @@ passing test and one deliberately failing test, then verifying that a TRX result **Expected**: Exits non-zero (due to the failing test) and creates a TRX results file at the specified path. -**Requirement coverage**: `FileAssert-OTS-FileAssert`. - #### FileAssert_Exists **Scenario**: FileAssert self-validation exercises file-existence checking by matching a glob pattern against @@ -56,8 +50,6 @@ a temporary directory containing a single `.txt` file. **Expected**: Exits 0, confirming that the glob-based file-existence assertion passes. -**Requirement coverage**: `FileAssert-OTS-FileAssert`. - #### FileAssert_Contains **Scenario**: FileAssert self-validation exercises file-content checking by asserting that a temporary `.txt` @@ -65,15 +57,24 @@ file contains a known string. **Expected**: Exits 0, confirming that the text `contains` assertion passes. -**Requirement coverage**: `FileAssert-OTS-FileAssert`. +#### FileAssert_StructuralValidity + +**Scenario**: FileAssert self-validation exercises structural validity by parsing a temporary +HTML document with `html:` queries and a temporary PDF document with `pdf:` page-count +constraints. + +**Expected**: Exits 0, confirming that both documents parse successfully and that the +structured-document queries evaluate against the parsed model. + +#### FileAssert_Metadata -### Requirements Coverage +**Scenario**: FileAssert self-validation exercises metadata assertions by reading PDF metadata +fields (Title, Author) and matching them against expected values. -- **`FileAssert-OTS-FileAssert`**: FileAssert_VersionDisplay, FileAssert_HelpDisplay, FileAssert_Results, - FileAssert_Exists, FileAssert_Contains +**Expected**: Exits 0, confirming that the metadata assertions resolve and pass. ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/filesystemglobbing.md b/docs/verification/ots/filesystemglobbing.md new file mode 100644 index 0000000..98a3fbd --- /dev/null +++ b/docs/verification/ots/filesystemglobbing.md @@ -0,0 +1,58 @@ +## FileSystemGlobbing Verification + +This document provides the verification evidence for the FileSystemGlobbing OTS software item. +Requirements for this OTS item are defined in the FileSystemGlobbing OTS Software Requirements +document. + +### Required Functionality + +Microsoft.Extensions.FileSystemGlobbing provides the glob pattern matcher used by FileAssert to +resolve file-assertion patterns against candidate files. Correct wildcard, recursive-wildcard, and +exact-path matching that drives count constraints is required. + +### Verification Approach + +FileSystemGlobbing is verified indirectly through FileAssert's own test suite. Each scenario names a +specific FileAssert test that exercises the glob matcher through the file-assertion pipeline and +records its result in a TRX file. A passing CI run for all scenarios constitutes evidence that the +requirement is satisfied. + +### Test Environment + +The standard `dotnet test` runner on the supported .NET runtimes (net8.0, net9.0, net10.0); no +additional environment setup is required. + +### Acceptance Criteria + +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered +verified when the test scenarios that exercise its functionality pass in the CI pipeline. + +### Test Scenarios + +#### FileAssertFile_Run_WithMatchingFiles_NoConstraints_NoError + +**Scenario**: FileAssert resolves a glob pattern with FileSystemGlobbing and finds matching files. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertFile_Run_TooFewFiles_WritesError + +**Scenario**: FileAssert resolves a glob pattern with FileSystemGlobbing and reports an error when +fewer files match than the minimum constraint allows. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertFile_Run_TooManyFiles_WritesError + +**Scenario**: FileAssert resolves a glob pattern with FileSystemGlobbing and reports an error when +more files match than the maximum constraint allows. + +**Expected**: The test passes and the result appears in the TRX output. + +#### IntegrationTest_RecursiveGlob_MatchesFilesAcrossSubdirectories + +**Scenario**: FileAssert resolves a recursive glob pattern of the form `**/...` (for example +`**/*.txt`) against a directory tree that contains matching files at multiple nesting levels. + +**Expected**: All files at every level are matched and the resulting count satisfies the +configured constraint; the test passes and the result appears in the TRX output. diff --git a/docs/verification/ots/htmlagilitypack.md b/docs/verification/ots/htmlagilitypack.md new file mode 100644 index 0000000..5db9658 --- /dev/null +++ b/docs/verification/ots/htmlagilitypack.md @@ -0,0 +1,65 @@ +## HtmlAgilityPack Verification + +This document provides the verification evidence for the HtmlAgilityPack OTS software item. +Requirements for this OTS item are defined in the HtmlAgilityPack OTS Software Requirements document. + +### Required Functionality + +HtmlAgilityPack is the HTML parser used by FileAssert to read HTML documents under test for `html:` +XPath assertions. Lenient parsing of syntactically imperfect markup and evaluation of XPath +node-count and text queries are required. + +### Verification Approach + +HtmlAgilityPack is verified indirectly through FileAssert's own test suite. Each scenario names a +specific FileAssert test that exercises HtmlAgilityPack through the HTML assertion pipeline and +records its result in a TRX file. A passing CI run for all scenarios constitutes evidence that the +requirement is satisfied. + +### Test Environment + +The standard `dotnet test` runner on the supported .NET runtimes (net8.0, net9.0, net10.0); no +additional environment setup is required. + +### Acceptance Criteria + +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered +verified when the test scenarios that exercise its functionality pass in the CI pipeline. + +### Test Scenarios + +#### FileAssertHtmlAssert_Run_ExactCount_Matches_NoError + +**Scenario**: FileAssert parses an HTML document with HtmlAgilityPack and asserts an exact XPath +node count that matches. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertHtmlAssert_Run_MalformedHtml_ParsesAndQueriesSuccessfully_NoError + +**Scenario**: FileAssert hands HtmlAgilityPack syntactically imperfect markup (missing closing +``, ``, ``, and `` tags). The lenient parser repairs the document and +FileAssert then evaluates an XPath node-count query against the repaired tree. + +**Expected**: The parser does not fail on the malformed markup, the XPath query returns the +expected node count, and the test passes with its result recorded in the TRX output. + +#### FileAssertHtmlAssert_Run_MinMaxCount_WithinBounds_NoError + +**Scenario**: FileAssert parses an HTML document with HtmlAgilityPack and asserts an XPath node +count within min/max bounds. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertHtmlAssert_Run_XPathContainsText_Matches_NoError + +**Scenario**: FileAssert evaluates an XPath text query via HtmlAgilityPack and confirms the matched +node contains the expected text. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertHtmlAssert_Run_NonExistentFile_WritesError + +**Scenario**: FileAssert attempts to read a missing HTML file; the resulting IO error is reported. + +**Expected**: The test passes and the result appears in the TRX output. diff --git a/docs/verification/ots/pandoc.md b/docs/verification/ots/pandoc.md index b59e9b7..07cec76 100644 --- a/docs/verification/ots/pandoc.md +++ b/docs/verification/ots/pandoc.md @@ -24,8 +24,6 @@ a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the build-notes HTML document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_CodeQualityHtml **Scenario**: FileAssert asserts the code-quality HTML file exists, is non-trivially sized, contains @@ -33,8 +31,6 @@ a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the code-quality HTML document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_ReviewPlanHtml **Scenario**: FileAssert asserts the review plan HTML file exists, is non-trivially sized, contains @@ -42,8 +38,6 @@ a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the review plan HTML document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_ReviewReportHtml **Scenario**: FileAssert asserts the review report HTML file exists, is non-trivially sized, @@ -51,8 +45,6 @@ contains a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the review report HTML document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_DesignHtml **Scenario**: FileAssert asserts the design document HTML file exists, is non-trivially sized, @@ -60,8 +52,6 @@ contains a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the design document HTML. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_VerificationHtml **Scenario**: FileAssert asserts the verification HTML file exists, is non-trivially sized, contains @@ -69,8 +59,6 @@ a valid HTML title element, and includes expected verification document content. **Expected**: FileAssert exits 0 for the verification document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - #### Pandoc_UserGuideHtml **Scenario**: FileAssert asserts the user guide HTML file exists, is non-trivially sized, contains @@ -78,16 +66,8 @@ a valid HTML title element, and includes expected document content. **Expected**: FileAssert exits 0 for the user guide HTML document. -**Requirement coverage**: `FileAssert-OTS-Pandoc`. - -### Requirements Coverage - -- **`FileAssert-OTS-Pandoc`**: Pandoc_BuildNotesHtml, Pandoc_CodeQualityHtml, - Pandoc_ReviewPlanHtml, Pandoc_ReviewReportHtml, Pandoc_DesignHtml, Pandoc_VerificationHtml, - Pandoc_UserGuideHtml - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/pdfpig.md b/docs/verification/ots/pdfpig.md new file mode 100644 index 0000000..e79c221 --- /dev/null +++ b/docs/verification/ots/pdfpig.md @@ -0,0 +1,97 @@ +## PdfPig Verification + +This document provides the verification evidence for the PdfPig OTS software item. Requirements for +this OTS item are defined in the PdfPig OTS Software Requirements document. + +### Required Functionality + +PdfPig is the PDF parsing library used by FileAssert to read PDF documents under test for `pdf:` +assertions. Extraction of page counts, document metadata, and page text, and detection of files that +are not valid PDF documents, are required. + +### Verification Approach + +PdfPig is verified indirectly through FileAssert's own test suite. Each scenario names a specific +FileAssert test that exercises PdfPig through the PDF assertion pipeline and records its result in a +TRX file. A passing CI run for all scenarios constitutes evidence that the requirement is satisfied. + +### Test Environment + +The standard `dotnet test` runner on the supported .NET runtimes (net8.0, net9.0, net10.0); no +additional environment setup is required. + +### Acceptance Criteria + +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered +verified when the test scenarios that exercise its functionality pass in the CI pipeline. + +### Test Scenarios + +#### FileAssertPdfAssert_Run_ValidPdf_PageCountSatisfied_NoError + +**Scenario**: FileAssert parses a valid PDF with PdfPig and asserts a page count that is satisfied. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_ValidPdf_TooFewPages_WritesError + +**Scenario**: FileAssert counts the pages of a valid PDF via PdfPig and reports an error when the +document has fewer pages than the configured minimum. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_ValidPdf_TooManyPages_WritesError + +**Scenario**: FileAssert counts the pages of a valid PDF via PdfPig and reports an error when the +document has more pages than the configured maximum. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_MetadataContainsRule_FieldMissing_WritesError + +**Scenario**: FileAssert reads PDF metadata via PdfPig and reports an error when a `contains` rule +targets a metadata field that is not present in the document. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_MetadataMatchesRule_NoMatch_WritesError + +**Scenario**: FileAssert reads PDF metadata via PdfPig and reports an error when a `matches` regex +rule does not match the field value. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_TextContainsRule_ContentPresent_NoError + +**Scenario**: FileAssert extracts page text via PdfPig and confirms that a `contains` rule is +satisfied when the required content is present. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_TextMatchesRule_PatternMatches_NoError + +**Scenario**: FileAssert extracts page text via PdfPig and confirms that a `matches` regex rule is +satisfied when the extracted text matches the pattern. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_MetadataContainsRule_TitleMatches_NoError + +**Scenario**: FileAssert reads PDF metadata via PdfPig and asserts that the title contains an +expected value. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_TextRule_ContentMissing_WritesError + +**Scenario**: FileAssert extracts page text via PdfPig and reports an error when required content is +missing. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertPdfAssert_Run_InvalidFile_WritesError + +**Scenario**: FileAssert attempts to parse a file that is not a valid PDF; PdfPig raises a parse +error that FileAssert reports. + +**Expected**: The test passes and the result appears in the TRX output. diff --git a/docs/verification/ots/reqstream.md b/docs/verification/ots/reqstream.md index bb4dea5..f745460 100644 --- a/docs/verification/ots/reqstream.md +++ b/docs/verification/ots/reqstream.md @@ -29,14 +29,8 @@ test coverage. **Expected**: Exits 0; all requirements have passing test evidence; requirements documents are generated. -**Requirement coverage**: `FileAssert-OTS-ReqStream`. - -### Requirements Coverage - -- **`FileAssert-OTS-ReqStream`**: ReqStream_EnforcementMode - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/reviewmark.md b/docs/verification/ots/reviewmark.md index 7557fd1..62adfe7 100644 --- a/docs/verification/ots/reviewmark.md +++ b/docs/verification/ots/reviewmark.md @@ -28,8 +28,6 @@ evidence that ReviewMark did not produce the required review documents. **Expected**: Exits 0 and produces a non-empty review plan markdown file. -**Requirement coverage**: `FileAssert-OTS-ReviewMark`. - #### ReviewMark_ReviewReportGeneration **Scenario**: ReviewMark is invoked with `--report` to generate a review report from the @@ -37,14 +35,8 @@ evidence that ReviewMark did not produce the required review documents. **Expected**: Exits 0 and produces a non-empty review report. -**Requirement coverage**: `FileAssert-OTS-ReviewMark`. - -### Requirements Coverage - -- **`FileAssert-OTS-ReviewMark`**: ReviewMark_ReviewPlanGeneration, ReviewMark_ReviewReportGeneration - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/sarifmark.md b/docs/verification/ots/sarifmark.md index b2f7fa0..803e938 100644 --- a/docs/verification/ots/sarifmark.md +++ b/docs/verification/ots/sarifmark.md @@ -26,8 +26,6 @@ failure at any step is evidence that SarifMark did not produce the required outp **Expected**: Exits 0 and successfully reads the SARIF content. -**Requirement coverage**: `FileAssert-OTS-SarifMark`. - #### SarifMark_MarkdownReportGeneration **Scenario**: SarifMark renders the SARIF input as a markdown report included in the release @@ -35,14 +33,8 @@ artifacts. **Expected**: Exits 0 and produces a non-empty markdown report. -**Requirement coverage**: `FileAssert-OTS-SarifMark`. - -### Requirements Coverage - -- **`FileAssert-OTS-SarifMark`**: SarifMark_SarifReading, SarifMark_MarkdownReportGeneration - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/sonarmark.md b/docs/verification/ots/sonarmark.md index 91a091d..7b15957 100644 --- a/docs/verification/ots/sonarmark.md +++ b/docs/verification/ots/sonarmark.md @@ -26,39 +26,26 @@ failure at any step is evidence that SonarMark did not retrieve and render quali **Expected**: Exits 0 and retrieves quality-gate data. -**Requirement coverage**: `FileAssert-OTS-SonarMark`. - #### SonarMark_IssuesRetrieval **Scenario**: SonarMark queries the SonarCloud API for issues. **Expected**: Exits 0 and retrieves issues data. -**Requirement coverage**: `FileAssert-OTS-SonarMark`. - #### SonarMark_HotSpotsRetrieval **Scenario**: SonarMark queries the SonarCloud API for hot spots. **Expected**: Exits 0 and retrieves hot-spots data. -**Requirement coverage**: `FileAssert-OTS-SonarMark`. - #### SonarMark_MarkdownReportGeneration **Scenario**: SonarMark renders quality-gate, issues, and hot-spots data as a markdown report. **Expected**: Exits 0 and produces a non-empty markdown quality report. -**Requirement coverage**: `FileAssert-OTS-SonarMark`. - -### Requirements Coverage - -- **`FileAssert-OTS-SonarMark`**: SonarMark_QualityGateRetrieval, SonarMark_IssuesRetrieval, - SonarMark_HotSpotsRetrieval, SonarMark_MarkdownReportGeneration - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/versionmark.md b/docs/verification/ots/versionmark.md index f6c0820..cbbdad8 100644 --- a/docs/verification/ots/versionmark.md +++ b/docs/verification/ots/versionmark.md @@ -13,10 +13,12 @@ The published document is included in the Build Notes release artifact. VersionMark is verified by two complementary layers of evidence. Each CI job runs `versionmark --capture` to collect tool-version JSON files, and the build-docs job runs `versionmark --publish` to produce `docs/build_notes/generated/versions.md`. This file is included -in the Build Notes document compiled by Pandoc. If VersionMark failed to produce the versions -document, the Build Notes compilation would be incomplete. WeasyPrint renders the result to PDF -and FileAssert asserts its content (`WeasyPrint_BuildNotesPdf`). A CI build failure at any step is -evidence that VersionMark did not execute correctly. +in the Build Notes document compiled by Pandoc. WeasyPrint renders the result to PDF and FileAssert +asserts its content (`WeasyPrint_BuildNotesPdf`). A failure that is directly attributable to +VersionMark output validation — a missing or malformed `versions.md`, or a failed VersionMark +self-validation result — is evidence that VersionMark did not execute correctly. Unrelated pipeline +failures (for example, a Pandoc template error or a network outage) are not attributed to +VersionMark. ### Test Scenarios @@ -26,22 +28,14 @@ evidence that VersionMark did not execute correctly. **Expected**: Exits 0 and captures version data for every tool. -**Requirement coverage**: `FileAssert-OTS-VersionMark`. - #### VersionMark_GeneratesMarkdownReport **Scenario**: VersionMark writes a versions markdown document to the release artifacts. **Expected**: Exits 0 and produces a non-empty versions markdown file. -**Requirement coverage**: `FileAssert-OTS-VersionMark`. - -### Requirements Coverage - -- **`FileAssert-OTS-VersionMark`**: VersionMark_CapturesVersions, VersionMark_GeneratesMarkdownReport - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/weasyprint.md b/docs/verification/ots/weasyprint.md index 0672203..3e75915 100644 --- a/docs/verification/ots/weasyprint.md +++ b/docs/verification/ots/weasyprint.md @@ -26,8 +26,6 @@ at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the build-notes PDF document. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_CodeQualityPdf **Scenario**: FileAssert asserts the code-quality PDF file exists, is non-trivially sized, contains @@ -35,8 +33,6 @@ at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the code-quality PDF document. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_ReviewPlanPdf **Scenario**: FileAssert asserts the review plan PDF file exists, is non-trivially sized, contains @@ -44,8 +40,6 @@ at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the review plan PDF document. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_ReviewReportPdf **Scenario**: FileAssert asserts the review report PDF file exists, is non-trivially sized, contains @@ -53,8 +47,6 @@ at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the review report PDF document. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_DesignPdf **Scenario**: FileAssert asserts the design document PDF file exists, is non-trivially sized, @@ -62,8 +54,6 @@ contains at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the design document PDF. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_VerificationPdf **Scenario**: FileAssert asserts the verification PDF file exists, is non-trivially sized, contains @@ -71,8 +61,6 @@ at least one page, and includes expected verification document content. **Expected**: FileAssert exits 0 for the verification PDF. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - #### WeasyPrint_UserGuidePdf **Scenario**: FileAssert asserts the user guide PDF file exists, is non-trivially sized, contains @@ -80,16 +68,8 @@ at least one page, and includes expected document content. **Expected**: FileAssert exits 0 for the user guide PDF document. -**Requirement coverage**: `FileAssert-OTS-WeasyPrint`. - -### Requirements Coverage - -- **`FileAssert-OTS-WeasyPrint`**: WeasyPrint_BuildNotesPdf, WeasyPrint_CodeQualityPdf, - WeasyPrint_ReviewPlanPdf, WeasyPrint_ReviewReportPdf, WeasyPrint_DesignPdf, - WeasyPrint_VerificationPdf, WeasyPrint_UserGuidePdf - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/xunit.md b/docs/verification/ots/xunit.md index a0fcd3d..df7987d 100644 --- a/docs/verification/ots/xunit.md +++ b/docs/verification/ots/xunit.md @@ -24,8 +24,6 @@ run for all scenarios constitutes evidence that both requirements are satisfied. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Context_Create_VersionFlag_SetsVersionTrue **Scenario**: xUnit discovers and runs this test; the test verifies that passing the --version flag @@ -33,8 +31,6 @@ sets the Version property to true. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Context_Create_SilentFlag_SetsSilentTrue **Scenario**: xUnit discovers and runs this test; the test verifies that passing the --silent flag @@ -42,8 +38,6 @@ sets the Silent property to true. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Context_Create_LogFlag_OpensLogFile **Scenario**: xUnit discovers and runs this test; the test verifies that passing the --log flag @@ -51,8 +45,6 @@ opens a log file. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Context_Create_UnknownArgument_ThrowsArgumentException **Scenario**: xUnit discovers and runs this test; the test verifies that an unrecognized argument @@ -60,8 +52,6 @@ raises an exception. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly **Scenario**: xUnit discovers and runs this test; the test verifies that SafePathCombine correctly @@ -69,8 +59,6 @@ joins valid path segments. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Program_Run_WithVersionFlag_DisplaysVersionOnly **Scenario**: xUnit discovers and runs this test; the test verifies that the program prints only @@ -78,8 +66,6 @@ version information when invoked with the --version flag. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - #### Validation_Run_WithSilentContext_PrintsSummary **Scenario**: xUnit discovers and runs this test; the test verifies that Validation.Run prints a @@ -87,23 +73,8 @@ summary even when the context is configured for silent operation. **Expected**: xUnit executes the test, the test passes, and the result appears in the TRX output. -**Requirement coverage**: `FileAssert-OTS-xUnit-Execute`, `FileAssert-OTS-xUnit-Report`. - -### Requirements Coverage - -- **`FileAssert-OTS-xUnit-Execute`**: Context_Create_NoArguments_ReturnsDefaultContext, - Context_Create_VersionFlag_SetsVersionTrue, Context_Create_SilentFlag_SetsSilentTrue, - Context_Create_LogFlag_OpensLogFile, Context_Create_UnknownArgument_ThrowsArgumentException, - PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly, - Program_Run_WithVersionFlag_DisplaysVersionOnly, Validation_Run_WithSilentContext_PrintsSummary -- **`FileAssert-OTS-xUnit-Report`**: Context_Create_NoArguments_ReturnsDefaultContext, - Context_Create_VersionFlag_SetsVersionTrue, Context_Create_SilentFlag_SetsSilentTrue, - Context_Create_LogFlag_OpensLogFile, Context_Create_UnknownArgument_ThrowsArgumentException, - PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly, - Program_Run_WithVersionFlag_DisplaysVersionOnly, Validation_Run_WithSilentContext_PrintsSummary - ### Acceptance Criteria -N/A – Acceptance criteria are managed at the system integration level. This OTS item is +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered verified when the integration test scenarios that exercise its functionality pass in the CI pipeline. diff --git a/docs/verification/ots/yamldotnet.md b/docs/verification/ots/yamldotnet.md new file mode 100644 index 0000000..87b4e39 --- /dev/null +++ b/docs/verification/ots/yamldotnet.md @@ -0,0 +1,57 @@ +## YamlDotNet Verification + +This document provides the verification evidence for the YamlDotNet OTS software item. Requirements +for this OTS item are defined in the YamlDotNet OTS Software Requirements document. + +### Required Functionality + +YamlDotNet is the YAML parser used by FileAssert to deserialize the `.fileassert.yaml` configuration +and to parse YAML documents under test for `yaml:` dot-notation path assertions. Correct scalar and +sequence handling and detection of malformed documents are required. + +### Verification Approach + +YamlDotNet is verified indirectly through FileAssert's own test suite. Each scenario names a specific +FileAssert test that exercises YamlDotNet through the YAML assertion pipeline and records its result +in a TRX file. A passing CI run for all scenarios constitutes evidence that the requirement is +satisfied. + +### Test Environment + +The standard `dotnet test` runner on the supported .NET runtimes (net8.0, net9.0, net10.0); no +additional environment setup is required. + +### Acceptance Criteria + +N/A - Acceptance criteria are managed at the system integration level. This OTS item is considered +verified when the test scenarios that exercise its functionality pass in the CI pipeline. + +### Test Scenarios + +#### FileAssertYamlAssert_Run_SequenceCount_Matches_NoError + +**Scenario**: FileAssert parses a YAML document with YamlDotNet and asserts a sequence's element +count, which matches the constraint. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertYamlAssert_Run_ScalarValue_CountsAsOne_NoError + +**Scenario**: FileAssert parses a YAML document with YamlDotNet and treats a scalar value at a path +as a single match. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertYamlAssert_Run_MinMaxCount_WithinBounds_NoError + +**Scenario**: FileAssert parses a YAML document with YamlDotNet and asserts a match count within +min/max bounds. + +**Expected**: The test passes and the result appears in the TRX output. + +#### FileAssertYamlAssert_Run_InvalidFile_WritesError + +**Scenario**: FileAssert attempts to parse a malformed YAML document; YamlDotNet raises a parse +error that FileAssert reports. + +**Expected**: The test passes and the result appears in the TRX output. diff --git a/requirements.yaml b/requirements.yaml index 42cb09e..0f53f4f 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -5,6 +5,7 @@ includes: - docs/reqstream/file-assert/program.yaml - docs/reqstream/file-assert/cli.yaml - docs/reqstream/file-assert/cli/context.yaml + - docs/reqstream/file-assert/cli/i-context.yaml - docs/reqstream/file-assert/configuration.yaml - docs/reqstream/file-assert/configuration/file-assert-config.yaml - docs/reqstream/file-assert/configuration/file-assert-data.yaml @@ -22,6 +23,9 @@ includes: - docs/reqstream/file-assert/utilities.yaml - docs/reqstream/file-assert/utilities/path-helpers.yaml - docs/reqstream/file-assert/utilities/temporary-directory.yaml + - docs/reqstream/file-assert/utilities/i-file-container.yaml + - docs/reqstream/file-assert/utilities/directory-file-container.yaml + - docs/reqstream/file-assert/utilities/zip-file-container.yaml - docs/reqstream/file-assert/selftest.yaml - docs/reqstream/file-assert/selftest/validation.yaml - docs/reqstream/file-assert/platform-requirements.yaml @@ -35,3 +39,8 @@ includes: - docs/reqstream/ots/pandoc.yaml - docs/reqstream/ots/weasyprint.yaml - docs/reqstream/ots/fileassert.yaml + # cspell:ignore yamldotnet pdfpig htmlagilitypack filesystemglobbing + - docs/reqstream/ots/yamldotnet.yaml + - docs/reqstream/ots/pdfpig.yaml + - docs/reqstream/ots/htmlagilitypack.yaml + - docs/reqstream/ots/filesystemglobbing.yaml diff --git a/src/DemaConsulting.FileAssert/Cli/Context.cs b/src/DemaConsulting.FileAssert/Cli/Context.cs index 0dbc548..b21ac9c 100644 --- a/src/DemaConsulting.FileAssert/Cli/Context.cs +++ b/src/DemaConsulting.FileAssert/Cli/Context.cs @@ -23,7 +23,7 @@ namespace DemaConsulting.FileAssert.Cli; /// /// Context class that handles command-line arguments and program output. /// -internal sealed class Context : IDisposable +internal sealed class Context : IContext, IDisposable { /// /// Log file stream writer (if logging is enabled). @@ -107,6 +107,7 @@ private Context() /// /// Command-line arguments. /// A new Context instance. + /// Thrown when is null. /// Thrown when arguments are invalid. public static Context Create(string[] args) { @@ -358,6 +359,26 @@ public void WriteError(string message) _logWriter?.WriteLine(message); } + /// + /// Returns a new scoped context that prepends "{prefix} > " to every + /// message. + /// + /// + /// ScopedContext is used by FileAssertZipAssert to route nested asserter errors + /// through a breadcrumb prefix that identifies the zip archive entry being tested. + /// The scoped context delegates state (error flag and counter) to the root Context + /// so that error accumulation remains consistent across nested contexts. + /// + /// The prefix to prepend to error messages. Must not be null. + /// A new that prepends to errors. + /// Thrown when is null. + public IContext WithPrefix(string prefix) + { + // Validate the prefix before constructing the scoped context + ArgumentNullException.ThrowIfNull(prefix); + return new ScopedContext(this, prefix); + } + /// /// Disposes resources used by the Context. /// @@ -367,4 +388,81 @@ public void Dispose() _logWriter?.Dispose(); _logWriter = null; } + + /// + /// A scoped context wrapper that prepends a path prefix to every + /// message. + /// + /// + /// ScopedContext delegates all output and state to the parent IContext, ensuring + /// that error counters and exit-code logic remain in the root Context. It is used + /// by FileAssertZipAssert to inject breadcrumb context into error messages without + /// requiring the calling asserter to know about the scoping mechanism. + /// + private sealed class ScopedContext : IContext + { + /// + /// The parent context that owns the error flag and counter. + /// + private readonly IContext _parent; + + /// + /// The prefix prepended to every error message. + /// + private readonly string _prefix; + + /// + /// Initializes a new instance of the class. + /// + /// The parent context. Must not be null. + /// The prefix to prepend to error messages. Must not be null. + /// + /// Thrown when or is null. + /// + internal ScopedContext(IContext parent, string prefix) + { + // Validate required dependencies before storing them + ArgumentNullException.ThrowIfNull(parent); + ArgumentNullException.ThrowIfNull(prefix); + + _parent = parent; + _prefix = prefix; + } + + /// + /// Writes a line of informational output by delegating to the parent context. + /// + /// The message to write. + public void WriteLine(string message) + { + // Delegate informational output unchanged — prefix applies only to errors + _parent.WriteLine(message); + } + + /// + /// Writes an error message to the parent context, prepending the path prefix. + /// + /// The error message to write. + public void WriteError(string message) + { + // Prepend the path prefix to give the error a navigation breadcrumb + _parent.WriteError($"{_prefix} > {message}"); + } + + /// + /// Returns a new scoped context that nests this context's prefix with + /// an additional level. + /// + /// The additional prefix segment. Must not be null. + /// A new with the combined prefix. + /// Thrown when is null. + public IContext WithPrefix(string prefix) + { + // Validate before chaining a new level + ArgumentNullException.ThrowIfNull(prefix); + + // Chain a new ScopedContext that builds on this context's already-prefixed output + return new ScopedContext(this, prefix); + } + } } diff --git a/src/DemaConsulting.FileAssert/Cli/IContext.cs b/src/DemaConsulting.FileAssert/Cli/IContext.cs new file mode 100644 index 0000000..47695e6 --- /dev/null +++ b/src/DemaConsulting.FileAssert/Cli/IContext.cs @@ -0,0 +1,54 @@ +// 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. + +namespace DemaConsulting.FileAssert.Cli; + +/// +/// Defines the output contract for reporting assertion results and errors. +/// +/// +/// IContext is implemented by Context (the root context) and Context.ScopedContext (a +/// scoped wrapper that prepends a path prefix to all error messages). Accepting IContext +/// in Run methods allows FileAssertZipAssert to pass a scoped context to nested asserters +/// without requiring those asserters to know about the scoping mechanism. +/// +internal interface IContext +{ + /// + /// Writes a line of informational output. + /// + /// The message to write. + void WriteLine(string message); + + /// + /// Writes an error message, marking the context as having errors. + /// + /// The error message to write. + void WriteError(string message); + + /// + /// Returns a new scoped context that prepends "{prefix} > " to every + /// message. + /// + /// The prefix to prepend to all error messages. Must not be null. + /// A new scoped context delegating state to this context. + /// Thrown when is null. + IContext WithPrefix(string prefix); +} diff --git a/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs b/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs index 1b4723d..e4488a0 100644 --- a/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs +++ b/src/DemaConsulting.FileAssert/Configuration/FileAssertData.cs @@ -202,40 +202,18 @@ internal sealed class FileAssertPdfData public List? Text { get; set; } } -/// -/// YAML data transfer object representing a single zip archive entry pattern with count constraints. -/// -internal sealed class FileAssertZipEntryData -{ - /// - /// Gets or sets the glob pattern used to match zip archive entry names. - /// - [YamlMember(Alias = "pattern")] - public string? Pattern { get; set; } - - /// - /// Gets or sets the minimum number of entries that must match the pattern. - /// - [YamlMember(Alias = "min")] - public int? Min { get; set; } - - /// - /// Gets or sets the maximum number of entries that may match the pattern. - /// - [YamlMember(Alias = "max")] - public int? Max { get; set; } -} - /// /// YAML data transfer object for the zip archive assertion block. /// internal sealed class FileAssertZipData { /// - /// Gets or sets the list of entry pattern constraints to validate against the zip archive. + /// Gets or sets the list of file assertions to validate against the files inside the zip archive. + /// Each file uses the same schema as top-level file assertions, + /// enabling the full assertion suite (text, xml, html, yaml, json, pdf, nested zip) inside archives. /// - [YamlMember(Alias = "entries")] - public List? Entries { get; set; } + [YamlMember(Alias = "files")] + public List? Files { get; set; } } /// diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs index 6738972..0251a4b 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertFile.cs @@ -20,8 +20,8 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; namespace DemaConsulting.FileAssert.Modeling; @@ -176,20 +176,30 @@ internal static FileAssertFile Create(FileAssertFileData data) } /// - /// Executes the file assertion against the specified base directory, reporting any violations. + /// Executes the file assertion against the provided container, reporting any violations. /// + /// + /// The glob pattern is matched against all entries exposed by + /// using Matcher.Match(".", entries), which works uniformly for both directory-backed + /// and zip-backed containers. Per-file type asserters receive the scoped container and the + /// relative entry path so they can open the entry as a stream without needing to know + /// whether the content lives on disk or inside an archive. + /// /// The context used for reporting errors. - /// The directory path in which to evaluate the glob pattern. - internal void Run(Context context, string basePath) + /// The container in which to evaluate the glob pattern. + internal void Run(IContext context, IFileContainer container) { - // Validate required parameters before performing any file system operations + // Validate required parameters before performing any container operations ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(basePath); + ArgumentNullException.ThrowIfNull(container); - // Perform the glob match to discover files matching the pattern + // Perform the glob match against the container entries. + // Normalize the pattern to forward slashes so that user-supplied patterns with + // backslash separators (common on Windows) match the normalized entry paths from GetEntries(). var matcher = new Matcher(); - matcher.AddInclude(Pattern); - var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(basePath))); + matcher.AddInclude(Pattern.Replace('\\', '/')); + var allEntries = container.GetEntries(); + var result = matcher.Match(".", allEntries); var files = result.Files.Select(f => f.Path).ToList(); var count = files.Count; @@ -218,7 +228,7 @@ internal void Run(Context context, string basePath) } // Skip the per-file loop entirely when no size or file-type assertions are configured, - // avoiding unnecessary file system reads for purely count-constrained patterns. + // avoiding unnecessary container reads for purely count-constrained patterns. var hasPerFileChecks = MinSize.HasValue || MaxSize.HasValue || TextAssert != null || PdfAssert != null || XmlAssert != null || HtmlAssert != null || @@ -227,34 +237,35 @@ internal void Run(Context context, string basePath) if (hasPerFileChecks) { - foreach (var fullPath in files.Select(file => Path.Combine(basePath, file))) + foreach (var entryPath in files) { // Enforce size constraints when specified if (MinSize.HasValue || MaxSize.HasValue) { - var size = new FileInfo(fullPath).Length; + var size = container.GetEntrySize(entryPath); + var displayPath = container.GetDisplayPath(entryPath); if (MinSize.HasValue && size < MinSize.Value) { context.WriteError( - $"File '{fullPath}' is {size} byte(s), which is less than the minimum {MinSize.Value} bytes"); + $"File '{displayPath}' is {size} byte(s), which is less than the minimum {MinSize.Value} bytes"); } if (MaxSize.HasValue && size > MaxSize.Value) { context.WriteError( - $"File '{fullPath}' is {size} byte(s), which exceeds the maximum {MaxSize.Value} bytes"); + $"File '{displayPath}' is {size} byte(s), which exceeds the maximum {MaxSize.Value} bytes"); } } // Delegate to each file-type assert unit when declared - TextAssert?.Run(context, fullPath); - PdfAssert?.Run(context, fullPath); - XmlAssert?.Run(context, fullPath); - HtmlAssert?.Run(context, fullPath); - YamlAssert?.Run(context, fullPath); - JsonAssert?.Run(context, fullPath); - ZipAssert?.Run(context, fullPath); + TextAssert?.Run(context, container, entryPath); + PdfAssert?.Run(context, container, entryPath); + XmlAssert?.Run(context, container, entryPath); + HtmlAssert?.Run(context, container, entryPath); + YamlAssert?.Run(context, container, entryPath); + JsonAssert?.Run(context, container, entryPath); + ZipAssert?.Run(context, container, entryPath); } } } diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertHtmlAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertHtmlAssert.cs index 8bf99f0..8ca6d9d 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertHtmlAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertHtmlAssert.cs @@ -21,6 +21,7 @@ using System.Xml.XPath; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; using HtmlAgilityPack; namespace DemaConsulting.FileAssert.Modeling; @@ -78,28 +79,34 @@ internal static FileAssertHtmlAssert Create(IEnumerable dat } /// - /// Parses the HTML file and evaluates all configured XPath queries, reporting violations. + /// Parses the HTML entry and evaluates all configured XPath queries, reporting violations. /// HtmlAgilityPack is intentionally lenient (as browsers are): syntactically imperfect /// HTML is still parsed into a DOM and queries are evaluated against it. - /// I/O errors (file not found, access denied) are reported as parse failures. + /// I/O errors (entry not found, access denied) are reported as parse failures. /// /// The context used for reporting errors. - /// The full path to the HTML file to validate. - internal void Run(Context context, string fileName) + /// The container from which the entry is opened. + /// The relative path of the entry to validate. + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); + + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); // Load the HTML document using HtmlAgilityPack; HAP is lenient by design so only // I/O failures are treated as parse errors var doc = new HtmlDocument(); try { - doc.Load(fileName); + using var stream = container.OpenEntry(entryPath); + doc.Load(stream); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be parsed as an HTML document"); + context.WriteError($"File '{displayPath}' could not be parsed as an HTML document"); return; } @@ -113,11 +120,11 @@ internal void Run(Context context, string fileName) } catch (XPathException) { - context.WriteError($"File '{fileName}' query '{q.Query}' is not a valid XPath expression"); + context.WriteError($"File '{displayPath}' query '{q.Query}' is not a valid XPath expression"); continue; } - ApplyConstraints(context, fileName, q.Query, q.Count, q.Min, q.Max, n); + ApplyConstraints(context, displayPath, q.Query, q.Count, q.Min, q.Max, n); } } @@ -132,7 +139,7 @@ internal void Run(Context context, string fileName) /// The maximum count constraint, or null. /// The actual node count returned by the query. private static void ApplyConstraints( - Context context, string fileName, string query, + IContext context, string fileName, string query, int? count, int? min, int? max, int n) { if (count.HasValue && n != count.Value) diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertJsonAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertJsonAssert.cs index 8aea3b3..f84affa 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertJsonAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertJsonAssert.cs @@ -21,6 +21,7 @@ using System.Text.Json; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Modeling; @@ -84,25 +85,37 @@ internal static FileAssertJsonAssert Create(IEnumerable dat } /// - /// Parses the JSON file and evaluates all configured dot-notation path queries, reporting violations. + /// Parses the JSON entry and evaluates all configured dot-notation path queries, reporting violations. /// /// The context used for reporting errors. - /// The full path to the JSON file to validate. - internal void Run(Context context, string fileName) + /// The container from which the entry is opened. + /// The relative path of the entry to validate. + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); - // Attempt to parse the file as a JSON document + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); + + // Attempt to parse the entry as a JSON document JsonDocument document; try { - var json = File.ReadAllText(fileName); + using var stream = container.OpenEntry(entryPath); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8); + var json = reader.ReadToEnd(); document = JsonDocument.Parse(json); } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + catch (JsonException) + { + context.WriteError($"File '{displayPath}' could not be parsed as a JSON document"); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be parsed as a JSON document"); + context.WriteError($"File '{displayPath}' could not be read"); return; } @@ -112,7 +125,7 @@ internal void Run(Context context, string fileName) foreach (var q in _queries) { var n = CountJsonNodes(document.RootElement, q.Query); - ApplyConstraints(context, fileName, q.Query, q.Count, q.Min, q.Max, n); + ApplyConstraints(context, displayPath, q.Query, q.Count, q.Min, q.Max, n); } } } @@ -178,7 +191,7 @@ private static int CountJsonNodes(JsonElement root, string query) /// The maximum count constraint, or null. /// The actual element count returned by the query. private static void ApplyConstraints( - Context context, string fileName, string query, + IContext context, string fileName, string query, int? count, int? min, int? max, int n) { if (count.HasValue && n != count.Value) diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs index 5448e22..1ee603f 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertPdfAssert.cs @@ -22,6 +22,7 @@ using System.Text.RegularExpressions; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; using UglyToad.PdfPig; namespace DemaConsulting.FileAssert.Modeling; @@ -96,7 +97,7 @@ internal static PdfMetadataRule FromData(FileAssertPdfMetadataRuleData data) /// The context used for reporting errors. /// The file being validated. /// The metadata field value, or null if not present. - internal void Apply(Context context, string fileName, string? value) + internal void Apply(IContext context, string fileName, string? value) { ArgumentNullException.ThrowIfNull(context); @@ -156,7 +157,7 @@ internal static PdfPages FromData(FileAssertPdfPagesData data) /// The context used for reporting errors. /// The file being validated. /// The actual page count. - internal void Apply(Context context, string fileName, int n) + internal void Apply(IContext context, string fileName, int n) { ArgumentNullException.ThrowIfNull(context); @@ -229,24 +230,46 @@ internal static FileAssertPdfAssert Create(FileAssertPdfData data) } /// - /// Opens the PDF file and applies all configured assertions, reporting violations. + /// Opens the PDF entry and applies all configured assertions, reporting violations. /// /// The context used for reporting errors. - /// The full path to the PDF file to validate. - internal void Run(Context context, string fileName) + /// The container from which the entry is opened. + /// The relative path of the entry to validate. + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); - // Attempt to open the file as a PDF document + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); + + // Attempt to open the entry as a PDF document + // PdfPig does not support opening from a Stream directly, so bytes are read first PdfDocument document; try { - document = PdfDocument.Open(fileName); + using var stream = container.OpenEntry(entryPath); + var bytes = ReadAllBytes(stream); + document = PdfDocument.Open(bytes); + } + catch (IOException ex) + { + context.WriteError($"File '{displayPath}' could not be read: {ex.Message}"); + return; + } + catch (UnauthorizedAccessException ex) + { + context.WriteError($"File '{displayPath}' could not be read: {ex.Message}"); + return; } catch (Exception) { - context.WriteError($"File '{fileName}' could not be parsed as a PDF document"); + // Fallback: PdfPig surfaces a wide variety of exception types for malformed + // PDF input (PdfDocumentFormatException, InvalidOperationException, + // ArgumentException, etc.). Treat any unrecognized parse exception as an + // invalid PDF so behavior degrades gracefully. + context.WriteError($"File '{displayPath}' could not be parsed as a PDF document"); return; } @@ -256,34 +279,57 @@ internal void Run(Context context, string fileName) foreach (var rule in _metadata) { var value = GetMetadataField(document, rule.Field); - rule.Apply(context, fileName, value); + rule.Apply(context, displayPath, value); } // Apply page count constraints and collect pages only when needed if (_pages != null || _text.Count > 0) { var pageList = document.GetPages().ToList(); - _pages?.Apply(context, fileName, pageList.Count); + _pages?.Apply(context, displayPath, pageList.Count); // Apply text rules to the extracted body text when rules are defined if (_text.Count > 0) { var sb = new StringBuilder(); - foreach (var page in pageList) + for (var i = 0; i < pageList.Count; i++) { - sb.Append(page.Text); + if (i > 0) + { + // Separate page text with newlines so that text rules don't + // see two adjacent words from different pages glued together. + sb.Append('\n'); + } + + sb.Append(pageList[i].Text); } var content = sb.ToString(); foreach (var rule in _text) { - rule.Apply(context, fileName, content); + rule.Apply(context, displayPath, content); } } } } } + /// + /// Reads all bytes from a stream into a byte array. + /// + /// + /// PdfPig does not expose a Stream-based Open overload, so bytes are buffered + /// first. This helper is used to read the entry contents from any IFileContainer. + /// + /// The stream to read. + /// A byte array containing the full stream contents. + private static byte[] ReadAllBytes(Stream stream) + { + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + /// /// Retrieves a named metadata field value from the PDF document. /// diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertRule.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertRule.cs index 44b797c..69b280a 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertRule.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertRule.cs @@ -88,7 +88,7 @@ internal static FileAssertRule Create(FileAssertRuleData data) /// The context used for reporting errors. /// The name of the file being validated, used in error messages. /// The full text content of the file to validate. - internal abstract void Apply(Context context, string fileName, string content); + internal abstract void Apply(IContext context, string fileName, string content); } /// @@ -117,7 +117,7 @@ internal FileAssertContainsRule(string value) /// The context used for reporting errors. /// The name of the file being validated. /// The full text content of the file to validate. - internal override void Apply(Context context, string fileName, string content) + internal override void Apply(IContext context, string fileName, string content) { // Validate that we have a context to report errors to ArgumentNullException.ThrowIfNull(context); @@ -164,7 +164,7 @@ internal FileAssertMatchesRule(string pattern) /// The context used for reporting errors. /// The name of the file being validated. /// The full text content of the file to validate. - internal override void Apply(Context context, string fileName, string content) + internal override void Apply(IContext context, string fileName, string content) { // Validate that we have a context to report errors to ArgumentNullException.ThrowIfNull(context); @@ -203,7 +203,7 @@ internal FileAssertDoesNotContainRule(string value) /// The context used for reporting errors. /// The name of the file being validated. /// The full text content of the file to validate. - internal override void Apply(Context context, string fileName, string content) + internal override void Apply(IContext context, string fileName, string content) { // Validate that we have a context to report errors to ArgumentNullException.ThrowIfNull(context); @@ -250,7 +250,7 @@ internal FileAssertDoesNotMatchRule(string pattern) /// The context used for reporting errors. /// The name of the file being validated. /// The full text content of the file to validate. - internal override void Apply(Context context, string fileName, string content) + internal override void Apply(IContext context, string fileName, string content) { // Validate that we have a context to report errors to ArgumentNullException.ThrowIfNull(context); diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertTest.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertTest.cs index ffa9b19..51636fa 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertTest.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertTest.cs @@ -20,6 +20,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Modeling; @@ -122,20 +123,28 @@ internal bool MatchesFilter(IEnumerable filters) /// /// Executes all file assertions in this test against the specified base directory. /// + /// + /// The base directory is wrapped in a to provide + /// a uniform file-access abstraction. This allows all file assertions to work identically + /// whether they are applied to a directory or (when called recursively) to a zip archive. + /// /// The context used for reporting errors. /// The base directory path against which file patterns are evaluated. /// Thrown when is null. /// Thrown when is null. - internal void Run(Context context, string basePath) + internal void Run(IContext context, string basePath) { // Validate required parameters ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(basePath); + // Wrap the base directory in a container to provide uniform file-access semantics + using var container = new DirectoryFileContainer(basePath); + // Execute each file assertion in sequence foreach (var file in Files) { - file.Run(context, basePath); + file.Run(context, container); } } } diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertTextAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertTextAssert.cs index 593f080..97081d6 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertTextAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertTextAssert.cs @@ -20,6 +20,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Modeling; @@ -56,34 +57,42 @@ internal static FileAssertTextAssert Create(IEnumerable data } /// - /// Reads the file as UTF-8 text and applies all configured rules, reporting violations. + /// Reads the entry content as UTF-8 text and applies all configured rules, reporting violations. /// /// The context used for reporting errors. - /// The full path to the file to validate. + /// The container from which the entry is opened. + /// The relative path of the entry to validate. /// - /// Thrown when or is null. + /// Thrown when , , or + /// is null. /// - internal void Run(Context context, string fileName) + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); - // Read the file content as UTF-8 text for rule evaluation + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); + + // Read the entry content as UTF-8 text for rule evaluation string content; try { - content = File.ReadAllText(fileName, System.Text.Encoding.UTF8); + using var stream = container.OpenEntry(entryPath); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8); + content = reader.ReadToEnd(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be read as text"); + context.WriteError($"File '{displayPath}' could not be read as text"); return; } // Apply each rule to validate the file content foreach (var rule in Rules) { - rule.Apply(context, fileName, content); + rule.Apply(context, displayPath, content); } } } diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertXmlAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertXmlAssert.cs index 33760c7..ae1b6d4 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertXmlAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertXmlAssert.cs @@ -23,6 +23,7 @@ using System.Xml.XPath; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Modeling; @@ -79,24 +80,30 @@ internal static FileAssertXmlAssert Create(IEnumerable data } /// - /// Parses the XML file and evaluates all configured XPath queries, reporting violations. + /// Parses the XML entry and evaluates all configured XPath queries, reporting violations. /// /// The context used for reporting errors. - /// The full path to the XML file to validate. - internal void Run(Context context, string fileName) + /// The container from which the entry is opened. + /// The relative path of the entry to validate. + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); - // Attempt to parse the file as an XML document + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); + + // Attempt to parse the entry as an XML document XDocument document; try { - document = XDocument.Load(fileName); + using var stream = container.OpenEntry(entryPath); + document = XDocument.Load(stream); } catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be parsed as an XML document"); + context.WriteError($"File '{displayPath}' could not be parsed as an XML document"); return; } @@ -110,11 +117,11 @@ internal void Run(Context context, string fileName) } catch (XPathException) { - context.WriteError($"File '{fileName}' query '{q.Query}' is not a valid XPath expression"); + context.WriteError($"File '{displayPath}' query '{q.Query}' is not a valid XPath expression"); continue; } - ApplyConstraints(context, fileName, q.Query, q.Count, q.Min, q.Max, n); + ApplyConstraints(context, displayPath, q.Query, q.Count, q.Min, q.Max, n); } } @@ -129,7 +136,7 @@ internal void Run(Context context, string fileName) /// The maximum count constraint, or null. /// The actual node count returned by the query. private static void ApplyConstraints( - Context context, string fileName, string query, + IContext context, string fileName, string query, int? count, int? min, int? max, int n) { if (count.HasValue && n != count.Value) diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertYamlAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertYamlAssert.cs index c24adf3..2a8146b 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertYamlAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertYamlAssert.cs @@ -20,6 +20,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; +using DemaConsulting.FileAssert.Utilities; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; @@ -33,6 +34,10 @@ internal sealed class FileAssertYamlAssert /// /// Represents a single dot-notation path assertion with count constraints. /// + /// The dot-notation path to evaluate against the YAML document. + /// The exact number of matching nodes required, or null if no exact-count check. + /// The minimum number of matching nodes required, or null if no lower bound. + /// The maximum number of matching nodes permitted, or null if no upper bound. private sealed record YamlQuery(string Query, int? Count, int? Min, int? Max); /// The list of configured dot-notation path assertions to evaluate against each matched YAML file. @@ -54,8 +59,9 @@ private FileAssertYamlAssert(IReadOnlyList queries) /// A new instance. /// Thrown when is null. /// - /// Thrown when a query does not specify a query string, or when the query path is malformed - /// (contains leading/trailing dots or consecutive dots). + /// Thrown when no queries are specified, when a query does not specify a query string, when + /// the query path is malformed (contains leading/trailing dots or consecutive dots), when a + /// query specifies none of count/min/max, or when a query's min exceeds its max. /// internal static FileAssertYamlAssert Create(IEnumerable data) { @@ -74,32 +80,55 @@ internal static FileAssertYamlAssert Create(IEnumerable dat $"YAML query assertion has malformed path '{d.Query}'"); } + if (d.Count == null && d.Min == null && d.Max == null) + { + throw new InvalidOperationException( + $"YAML query '{d.Query}' must specify count, min, or max"); + } + + if (d.Min.HasValue && d.Max.HasValue && d.Min.Value > d.Max.Value) + { + throw new InvalidOperationException( + $"YAML query '{d.Query}' has min greater than max"); + } + return new YamlQuery(d.Query, d.Count, d.Min, d.Max); }).ToList(); + if (queries.Count == 0) + { + throw new InvalidOperationException("YAML assertion must specify at least one query"); + } + return new FileAssertYamlAssert(queries.AsReadOnly()); } /// - /// Parses the YAML file and evaluates all configured dot-notation path queries, reporting violations. + /// Parses the YAML entry and evaluates all configured dot-notation path queries, reporting violations. /// /// The context used for reporting errors. - /// The full path to the YAML file to validate. - internal void Run(Context context, string fileName) + /// The container from which the entry is opened. + /// The relative path of the entry to validate. + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); + + // Compute the display path once for use in error messages + var displayPath = container.GetDisplayPath(entryPath); - // Attempt to parse the file as a YAML document + // Attempt to parse the entry as a YAML document var yaml = new YamlStream(); try { - using var reader = new StreamReader(fileName); + using var stream = container.OpenEntry(entryPath); + using var reader = new StreamReader(stream); yaml.Load(reader); } catch (Exception ex) when (ex is YamlException or IOException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be parsed as a YAML document"); + context.WriteError($"File '{displayPath}' could not be parsed as a YAML document"); return; } @@ -108,7 +137,7 @@ internal void Run(Context context, string fileName) { foreach (var q in _queries) { - ApplyConstraints(context, fileName, q.Query, q.Count, q.Min, q.Max, 0); + ApplyConstraints(context, displayPath, q.Query, q.Count, q.Min, q.Max, 0); } return; @@ -120,7 +149,7 @@ internal void Run(Context context, string fileName) foreach (var q in _queries) { var n = CountYamlNodes(root, q.Query); - ApplyConstraints(context, fileName, q.Query, q.Count, q.Min, q.Max, n); + ApplyConstraints(context, displayPath, q.Query, q.Count, q.Min, q.Max, n); } } @@ -187,7 +216,7 @@ private static int CountYamlNodes(YamlNode root, string query) /// The maximum count constraint, or null. /// The actual node count returned by the query. private static void ApplyConstraints( - Context context, string fileName, string query, + IContext context, string fileName, string query, int? count, int? min, int? max, int n) { if (count.HasValue && n != count.Value) diff --git a/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs b/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs index 1dc5e10..c499da6 100644 --- a/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs +++ b/src/DemaConsulting.FileAssert/Modeling/FileAssertZipAssert.cs @@ -21,66 +21,43 @@ using System.IO.Compression; using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; -using Microsoft.Extensions.FileSystemGlobbing; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Modeling; /// -/// Validates zip archive contents by matching entry names against glob patterns and enforcing -/// count constraints. Invoked by when a zip: assertion +/// Validates zip archive contents by running the full file assertion suite against each +/// matching entry. Invoked by when a zip: assertion /// block is declared in the YAML configuration. /// +/// +/// Unlike the previous implementation that only checked entry counts, this implementation +/// opens the zip entry as a and runs each +/// assertion against the virtual file system exposed by the +/// archive. This enables the full assertion suite — text, XML, HTML, YAML, JSON, PDF, +/// and recursively nested zip — to be applied to zip entries without any asserter needing +/// to know whether the file lives on disk or inside an archive. +/// internal sealed class FileAssertZipAssert { /// - /// Represents a single glob-pattern entry constraint for a zip archive, carrying the - /// pattern and optional minimum and maximum match counts. + /// The list of file assertions to apply to the zip archive contents. /// - internal sealed class Entry - { - /// - /// Initializes a new instance of the class. - /// - /// The glob pattern used to match zip entry names. - /// The minimum number of entries that must match, or null for no lower bound. - /// The maximum number of entries that may match, or null for no upper bound. - internal Entry(string pattern, int? min, int? max) - { - // Store the validated pattern and count constraints for use during zip inspection - Pattern = pattern; - Min = min; - Max = max; - } - - /// - /// Gets the glob pattern used to match zip entry names. - /// - internal string Pattern { get; } - - /// - /// Gets the minimum number of entries that must match the pattern, or null for no constraint. - /// - internal int? Min { get; } - - /// - /// Gets the maximum number of entries that may match the pattern, or null for no constraint. - /// - internal int? Max { get; } - } + private readonly IReadOnlyList _files; /// /// Initializes a new instance of the class. /// - /// The list of entry constraints to apply to the zip archive. - private FileAssertZipAssert(IReadOnlyList entries) + /// The list of file assertions to apply to the zip archive. + private FileAssertZipAssert(IReadOnlyList files) { - Entries = entries; + _files = files; } /// - /// Gets the list of entry constraints applied to the zip archive. + /// Gets the list of file assertions applied to the zip archive. /// - internal IReadOnlyList Entries { get; } + internal IReadOnlyList Files => _files; /// /// Creates a new from the provided YAML data. @@ -94,90 +71,78 @@ internal static FileAssertZipAssert Create(FileAssertZipData data) // Validate that data was provided ArgumentNullException.ThrowIfNull(data); - // Convert each entry DTO into a validated Entry domain object - var entries = (data.Entries ?? []) - .Select(e => - { - // Require every entry to specify a glob pattern before any I/O is attempted - if (string.IsNullOrWhiteSpace(e.Pattern)) - { - throw new InvalidOperationException("Zip entry assertion must specify a pattern"); - } + // Require at least one file assertion; a zip: block with no files: list is always a + // misconfiguration — it would silently pass every zip without checking any content. + if (data.Files is not { Count: > 0 }) + { + throw new InvalidOperationException("Zip assertion must specify at least one entry under 'files:'"); + } - return new Entry(e.Pattern, e.Min, e.Max); - }) + // Convert each entry DTO into a FileAssertFile domain object using the shared factory. + var files = data.Files + .Select(FileAssertFile.Create) .ToList(); - return new FileAssertZipAssert(entries.AsReadOnly()); + return new FileAssertZipAssert(files.AsReadOnly()); } /// - /// Opens the zip archive at , enumerates its entries, and - /// applies all configured entry constraints, reporting violations via the context. + /// Opens the zip entry identified by inside + /// , wraps its contents in a , + /// and runs all configured file assertions against it. /// /// - /// Directory entries (whose names end with /) are excluded from matching because - /// they represent containers rather than file content. Entry names are normalized to - /// forward slashes so that glob patterns work consistently across platforms. - /// - /// If the file cannot be opened as a zip archive, a single error is written and the - /// method returns immediately without evaluating any entry constraints. + /// Each file assertion is run with a scoped context that prepends the zip display path + /// to every error message, providing breadcrumb-style context for nested archives. + /// If the entry cannot be opened or parsed as a zip archive, a single error is written + /// and no further assertions are evaluated. /// /// The context used for reporting errors. Must not be null. - /// The full path to the zip file to validate. Must not be null. + /// The container from which the zip entry is opened. Must not be null. + /// The relative path of the zip entry inside the container. Must not be null. /// - /// Thrown when or is null. + /// Thrown when , , or + /// is null. /// - internal void Run(Context context, string fileName) + internal void Run(IContext context, IFileContainer container, string entryPath) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(entryPath); + + // Compute the display path for error messages and context scoping + var displayPath = container.GetDisplayPath(entryPath); - // Attempt to open the zip archive; report and abort on any I/O or format error - ZipArchive archive; + // Attempt to open the entry and wrap it as a ZipFileContainer + // The stream must be disposed on ZipArchive constructor failure to avoid file locks + ZipFileContainer zipContainer; try { - archive = ZipFile.OpenRead(fileName); + var stream = container.OpenEntry(entryPath); + try + { + zipContainer = new ZipFileContainer(stream, displayPath); + } + catch + { + // Ensure the stream is released even if ZipFileContainer construction fails + stream.Dispose(); + throw; + } } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) { - context.WriteError($"File '{fileName}' could not be read as a zip archive"); + context.WriteError($"File '{displayPath}' could not be read as a zip archive"); return; } - using (archive) + using (zipContainer) { - // Collect all file entry names, normalizing to forward slashes and excluding directory markers - var allEntries = archive.Entries - .Select(e => e.FullName.Replace('\\', '/')) - .Where(name => !name.EndsWith('/')) - .ToList(); - - // Evaluate each entry constraint against the complete list of zip file entries - foreach (var entry in Entries) + // Run each file assertion against the zip container; breadcrumb context is + // already baked into every display path produced by ZipFileContainer.GetDisplayPath + foreach (var file in _files) { - // Use the FileSystemGlobbing Matcher with a virtual root "." so patterns are applied - // directly to the normalized entry names without any filesystem path manipulation - var matcher = new Matcher(); - matcher.AddInclude(entry.Pattern); - var result = matcher.Match(".", allEntries); - var count = result.Files.Count(); - - // Enforce the minimum entry count constraint if specified - if (entry.Min.HasValue && count < entry.Min.Value) - { - context.WriteError( - $"Zip '{fileName}' entry pattern '{entry.Pattern}' matched {count} " + - $"entry(s), but expected at least {entry.Min.Value}"); - } - - // Enforce the maximum entry count constraint if specified - if (entry.Max.HasValue && count > entry.Max.Value) - { - context.WriteError( - $"Zip '{fileName}' entry pattern '{entry.Pattern}' matched {count} " + - $"entry(s), but expected at most {entry.Max.Value}"); - } + file.Run(context, zipContainer); } } } diff --git a/src/DemaConsulting.FileAssert/Utilities/DirectoryFileContainer.cs b/src/DemaConsulting.FileAssert/Utilities/DirectoryFileContainer.cs new file mode 100644 index 0000000..c72a85a --- /dev/null +++ b/src/DemaConsulting.FileAssert/Utilities/DirectoryFileContainer.cs @@ -0,0 +1,137 @@ +// 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. + +namespace DemaConsulting.FileAssert.Utilities; + +/// +/// Implements over a local file-system directory. +/// +/// +/// DirectoryFileContainer is the top-level container used by FileAssertFile.Run when +/// evaluating assertions against a base directory. It lists all files recursively, +/// opens them via their absolute paths, and returns their on-disk sizes. +/// It implements IDisposable for symmetry with ZipFileContainer but holds no disposable +/// resources itself — Dispose is a no-op. +/// +internal sealed class DirectoryFileContainer : IFileContainer, IDisposable +{ + /// + /// Gets the absolute path of the root directory. + /// + internal string BasePath { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The absolute path of the directory to expose as a container. Must not be null. + /// + /// Thrown when is null. + internal DirectoryFileContainer(string basePath) + { + // Validate the base path before storing it for use in all subsequent operations + ArgumentNullException.ThrowIfNull(basePath); + BasePath = basePath; + } + + /// + /// Returns all file paths under the directory, relative to + /// and using forward slashes as the separator. + /// + /// A read-only list of forward-slash-separated relative file paths. + public IReadOnlyList GetEntries() + { + // Return an empty list when the directory does not exist, consistent with + // the zero-match behavior expected by glob-based count constraints + if (!Directory.Exists(BasePath)) + { + return Array.Empty(); + } + + // Enumerate all files recursively, producing paths relative to BasePath + // and normalizing to forward slashes for cross-platform consistency + return Directory + .EnumerateFiles(BasePath, "*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(BasePath, f).Replace('\\', '/')) + .ToList() + .AsReadOnly(); + } + + /// + /// Opens the file at the specified relative path for reading. + /// + /// Relative path of the file to open. Must not be null. + /// A readable positioned at the start of the file. + /// Thrown when is null. + /// Thrown when the file cannot be opened. + public Stream OpenEntry(string entryPath) + { + // Validate the entry path before constructing the full file-system path + ArgumentNullException.ThrowIfNull(entryPath); + + // Combine the base path with the relative entry path to get the full on-disk location + var fullPath = Path.Combine(BasePath, entryPath); + return File.OpenRead(fullPath); + } + + /// + /// Returns the size of the file at the specified relative path in bytes. + /// + /// Relative path of the file. Must not be null. + /// The file size in bytes. + /// Thrown when is null. + public long GetEntrySize(string entryPath) + { + // Validate the entry path before constructing the full path + ArgumentNullException.ThrowIfNull(entryPath); + + // Combine to get the full path and query the size via FileInfo + var fullPath = Path.Combine(BasePath, entryPath); + return new FileInfo(fullPath).Length; + } + + /// + /// Returns the full file-system path of the specified entry for use in error messages. + /// + /// Relative path of the entry. Must not be null. + /// The absolute file-system path of the entry. + /// Thrown when is null. + public string GetDisplayPath(string entryPath) + { + // Validate the entry path before combining + ArgumentNullException.ThrowIfNull(entryPath); + + // Return the full path so error messages identify the exact file on disk + return Path.Combine(BasePath, entryPath); + } + + /// + /// Releases resources held by this container. + /// + /// + /// DirectoryFileContainer holds no disposable resources; this method is a no-op + /// provided for symmetry with ZipFileContainer so that both can be used in + /// using statements. + /// + public void Dispose() + { + // No disposable resources held — no-op for IDisposable symmetry with ZipFileContainer + } +} diff --git a/src/DemaConsulting.FileAssert/Utilities/IFileContainer.cs b/src/DemaConsulting.FileAssert/Utilities/IFileContainer.cs new file mode 100644 index 0000000..32b9ce0 --- /dev/null +++ b/src/DemaConsulting.FileAssert/Utilities/IFileContainer.cs @@ -0,0 +1,82 @@ +// 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. + +namespace DemaConsulting.FileAssert.Utilities; + +/// +/// Defines a uniform file-access abstraction over a directory or a zip archive entry set. +/// +/// +/// IFileContainer decouples the asserters (FileAssertTextAssert, FileAssertXmlAssert, etc.) +/// from the underlying storage mechanism. FileAssertFile.Run passes either a +/// DirectoryFileContainer (for top-level assertions) or a ZipFileContainer (when a +/// zip: block is declared) through the same interface so that every asserter can open, +/// size, and display entries without knowing whether the content lives on disk or inside +/// a zip archive. +/// +internal interface IFileContainer +{ + /// + /// Returns the relative entry paths for all file entries in this container. + /// + /// + /// Directory entries (e.g., zip directory markers ending with '/') are excluded. + /// Paths use forward slashes as the separator for cross-platform consistency. + /// + /// A read-only list of relative forward-slash-separated entry paths. + IReadOnlyList GetEntries(); + + /// + /// Opens the entry at the given path and returns a readable stream. + /// + /// + /// The relative path of the entry to open. Must match a value returned by + /// . Must not be null. + /// + /// A positioned at the start of the entry content. + /// Thrown when is null. + /// Thrown when the entry cannot be opened. + Stream OpenEntry(string entryPath); + + /// + /// Returns the uncompressed size of the specified entry in bytes. + /// + /// + /// The relative path of the entry to measure. Must match a value returned by + /// . Must not be null. + /// + /// The uncompressed size of the entry in bytes. + /// Thrown when is null. + long GetEntrySize(string entryPath); + + /// + /// Returns a human-readable display path for use in error messages. + /// + /// + /// The relative path of the entry. Must not be null. + /// + /// + /// A string suitable for inclusion in error messages. For a directory container this is + /// the full file-system path; for a zip container this includes the archive name as a + /// breadcrumb prefix. + /// + /// Thrown when is null. + string GetDisplayPath(string entryPath); +} diff --git a/src/DemaConsulting.FileAssert/Utilities/PathHelpers.cs b/src/DemaConsulting.FileAssert/Utilities/PathHelpers.cs index 094fbcc..8b9fb1c 100644 --- a/src/DemaConsulting.FileAssert/Utilities/PathHelpers.cs +++ b/src/DemaConsulting.FileAssert/Utilities/PathHelpers.cs @@ -63,6 +63,15 @@ internal static string SafePathCombine(string basePath, string relativePath) ArgumentNullException.ThrowIfNull(basePath); ArgumentNullException.ThrowIfNull(relativePath); + // Reject rooted relative paths up-front. A rooted path supplied as the + // second argument to Path.Combine replaces basePath entirely, which can + // happen even when the rooted path resolves underneath basePath. Such + // inputs are never legitimate "relative" paths. + if (Path.IsPathRooted(relativePath)) + { + throw new ArgumentException($"Invalid path component: {relativePath}", nameof(relativePath)); + } + // Combine the paths (preserves the caller's relative/absolute style) var combinedPath = Path.Combine(basePath, relativePath); diff --git a/src/DemaConsulting.FileAssert/Utilities/ZipFileContainer.cs b/src/DemaConsulting.FileAssert/Utilities/ZipFileContainer.cs new file mode 100644 index 0000000..da37f68 --- /dev/null +++ b/src/DemaConsulting.FileAssert/Utilities/ZipFileContainer.cs @@ -0,0 +1,169 @@ +// 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 System.IO.Compression; + +namespace DemaConsulting.FileAssert.Utilities; + +/// +/// Implements over a . +/// +/// +/// ZipFileContainer is constructed by FileAssertZipAssert.Run to expose the contents +/// of a zip archive entry as a container that nested FileAssertFile instances can +/// query and open. It wraps a ZipArchive and must be disposed when file assertions +/// are complete so that the underlying stream is released. +/// +internal sealed class ZipFileContainer : IFileContainer, IDisposable +{ + /// + /// The underlying zip archive opened from the provided stream. + /// + private readonly ZipArchive _archive; + + /// + /// The display name of this container used in error message breadcrumbs. + /// + private readonly string _displayName; + + /// + /// Initializes a new instance of the class from a stream. + /// + /// + /// Opening from a stream (rather than a file path) supports the zip-in-zip scenario + /// where FileAssertZipAssert opens an entry stream from a parent ZipFileContainer. + /// The archive takes ownership of the stream and closes it on disposal. + /// + /// The stream containing zip archive data. Must not be null. + /// + /// The display name of this container, used as a breadcrumb prefix in error messages. + /// Must not be null. + /// + /// + /// Thrown when or is null. + /// + /// Thrown when the stream is not a valid zip archive. + internal ZipFileContainer(Stream stream, string displayName) + { + // Validate required parameters before opening the archive + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(displayName); + + // Open the archive in read mode; leaveOpen: false so the stream is closed with the archive + _archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + _displayName = displayName; + } + + /// + /// Returns all file entry paths in the zip archive, normalized to forward slashes and + /// excluding directory markers. + /// + /// + /// A read-only list of forward-slash-separated entry paths. Directory entries (whose + /// names end with '/') are excluded. + /// + public IReadOnlyList GetEntries() + { + // Collect file entry names, normalizing to forward slashes and excluding directory markers + return _archive.Entries + .Select(e => e.FullName.Replace('\\', '/')) + .Where(name => !name.EndsWith('/')) + .ToList() + .AsReadOnly(); + } + + /// + /// Opens the zip archive entry at the specified path and returns a readable stream. + /// + /// + /// The relative path of the entry to open. Must match a value returned by + /// . Must not be null. + /// + /// A readable over the entry content. + /// Thrown when is null. + /// Thrown when the entry cannot be found or opened. + public Stream OpenEntry(string entryPath) + { + // Validate the entry path before searching the archive + ArgumentNullException.ThrowIfNull(entryPath); + + // Normalize backslashes to forward slashes so that callers using + // either separator can locate entries; mirrors GetEntries normalization. + var normalized = entryPath.Replace('\\', '/'); + + // Locate the entry by its normalized name within the archive + var entry = _archive.GetEntry(normalized) + ?? throw new IOException($"Zip entry '{entryPath}' not found in '{_displayName}'"); + + return entry.Open(); + } + + /// + /// Returns the uncompressed size of the specified zip archive entry in bytes. + /// + /// + /// The relative path of the entry. Must match a value returned by . + /// Must not be null. + /// + /// The uncompressed size of the entry in bytes. + /// Thrown when is null. + /// Thrown when the entry cannot be found. + public long GetEntrySize(string entryPath) + { + // Validate the entry path before searching the archive + ArgumentNullException.ThrowIfNull(entryPath); + + // Normalize backslashes to forward slashes so that callers using + // either separator can locate entries; mirrors GetEntries normalization. + var normalized = entryPath.Replace('\\', '/'); + + // Locate the entry and return its uncompressed length + var entry = _archive.GetEntry(normalized) + ?? throw new IOException($"Zip entry '{entryPath}' not found in '{_displayName}'"); + + return entry.Length; + } + + /// + /// Returns a display path for the specified entry for use in error messages. + /// + /// The relative path of the entry. Must not be null. + /// + /// A breadcrumb-style display path of the form "{displayName} > {entryPath}". + /// + /// Thrown when is null. + public string GetDisplayPath(string entryPath) + { + // Validate the entry path before constructing the display string + ArgumentNullException.ThrowIfNull(entryPath); + + // Prefix the entry path with the archive display name as a navigation breadcrumb + return $"{_displayName} > {entryPath}"; + } + + /// + /// Disposes the underlying and its associated stream. + /// + public void Dispose() + { + // Dispose the archive to release the underlying stream and any unmanaged resources + _archive.Dispose(); + } +} diff --git a/test/DemaConsulting.FileAssert.Tests/Cli/ScopedContextTests.cs b/test/DemaConsulting.FileAssert.Tests/Cli/ScopedContextTests.cs new file mode 100644 index 0000000..7e878c2 --- /dev/null +++ b/test/DemaConsulting.FileAssert.Tests/Cli/ScopedContextTests.cs @@ -0,0 +1,135 @@ +// 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.FileAssert.Cli; + +namespace DemaConsulting.FileAssert.Tests.Cli; + +/// +/// Unit tests for and the nested ScopedContext class. +/// +[Collection("Sequential")] +public sealed class ScopedContextTests +{ + /// + /// Verifies that WithPrefix returns a non-null scoped context. + /// + [Fact] + public void Context_WithPrefix_ReturnsNonNullScopedContext() + { + // Arrange + using var context = Context.Create(["--silent"]); + + // Act + var scoped = context.WithPrefix("archive.zip"); + + // Assert + Assert.NotNull(scoped); + } + + /// + /// Verifies that WithPrefix throws when prefix is null. + /// + [Fact] + public void Context_WithPrefix_NullPrefix_ThrowsArgumentNullException() + { + // Arrange + using var context = Context.Create(["--silent"]); + + // Act & Assert + Assert.Throws(() => context.WithPrefix(null!)); + } + + /// + /// Verifies that errors written via a scoped context propagate to the root context exit code. + /// + [Fact] + public void ScopedContext_WriteError_PropagatesExitCodeToRoot() + { + // Arrange - create a root context; derive a scoped context from it + using var context = Context.Create(["--silent"]); + var scoped = context.WithPrefix("archive.zip"); + + // Act - write an error via the scoped context + scoped.WriteError("some error"); + + // Assert - the root context must reflect the error + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + + /// + /// Verifies that WriteLine written via a scoped context does not set an error on the root. + /// + [Fact] + public void ScopedContext_WriteLine_DoesNotSetError() + { + // Arrange + using var context = Context.Create(["--silent"]); + var scoped = context.WithPrefix("archive.zip"); + + // Act + scoped.WriteLine("informational message"); + + // Assert - no error should be recorded + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + + /// + /// Verifies that nested scoped contexts chain prefixes correctly and still propagate errors. + /// + [Fact] + public void ScopedContext_Nested_WriteError_PropagatesExitCodeToRoot() + { + // Arrange - two levels of scoping + using var context = Context.Create(["--silent"]); + var level1 = context.WithPrefix("outer.zip"); + var level2 = level1.WithPrefix("inner.zip"); + + // Act + level2.WriteError("nested error"); + + // Assert - root reflects the error + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + + /// + /// Verifies that multiple errors from different scoped contexts all accumulate on the root. + /// + [Fact] + public void ScopedContext_MultipleErrors_AllAccumulateOnRoot() + { + // Arrange + using var context = Context.Create(["--silent"]); + var scoped1 = context.WithPrefix("zip1.zip"); + var scoped2 = context.WithPrefix("zip2.zip"); + + // Act + scoped1.WriteError("error in zip1"); + scoped2.WriteError("error in zip2"); + context.WriteError("direct error"); + + // Assert - all three errors must be counted + Assert.Equal(3, context.ErrorCount); + Assert.Equal(1, context.ExitCode); + } +} diff --git a/test/DemaConsulting.FileAssert.Tests/Configuration/ConfigurationTests.cs b/test/DemaConsulting.FileAssert.Tests/Configuration/ConfigurationTests.cs index bb00232..a840e2b 100644 --- a/test/DemaConsulting.FileAssert.Tests/Configuration/ConfigurationTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Configuration/ConfigurationTests.cs @@ -174,4 +174,51 @@ public void Configuration_Run_WithResultsFile_WritesTrxResultsFile() Assert.True(File.Exists(resultsPath), "TRX results file should be written when --results is provided."); } + + /// + /// Verifies that the Configuration subsystem writes a JUnit XML results file + /// when a results path with an `.xml` extension is provided to the context. + /// + [Fact] + public void Configuration_Run_WithResultsFile_WritesJUnitResultsFile() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + var configPath = tempDir.GetFilePath("config.yaml"); + var resultsPath = tempDir.GetFilePath("results.xml"); + File.WriteAllText(configPath, """ + tests: + - name: "Exists Check" + files: + - pattern: "*.yaml" + min: 1 + """); + + var config = FileAssertConfig.ReadFromFile(configPath); + using var context = Context.Create(["--silent", "--results", resultsPath]); + + // Act + config.Run(context, []); + + // Assert - a JUnit XML results file was written with the expected root element + Assert.True(File.Exists(resultsPath), + "JUnit results file should be written when --results with an .xml path is provided."); + var contents = File.ReadAllText(resultsPath); + Assert.Contains(" + /// Verifies that ReadFromFile throws when the YAML is syntactically invalid. + /// + [Fact] + public void Configuration_ReadFromFile_InvalidYaml_ThrowsException() + { + // Arrange - write syntactically broken YAML + using var tempDir = new TemporaryDirectory(); + var configPath = tempDir.GetFilePath("bad.yaml"); + File.WriteAllText(configPath, "tests: [unclosed bracket"); + + // Act / Assert - invalid YAML must not silently produce an empty config + Assert.ThrowsAny(() => FileAssertConfig.ReadFromFile(configPath)); + } } diff --git a/test/DemaConsulting.FileAssert.Tests/IntegrationTests.cs b/test/DemaConsulting.FileAssert.Tests/IntegrationTests.cs index 03281c2..c8830c9 100644 --- a/test/DemaConsulting.FileAssert.Tests/IntegrationTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/IntegrationTests.cs @@ -148,6 +148,26 @@ public void IntegrationTest_ValidateWithResults_GeneratesTrxFile() } } + /// + /// Test that the --depth flag controls the markdown heading depth of the --validate output. + /// + [Fact] + public void IntegrationTest_DepthFlag_ProducesHeadingsAtSpecifiedDepth() + { + // Act - run self-validation with a heading depth of 3 + var exitCode = Runner.Run( + out var output, + "dotnet", + _dllPath, + "--validate", + "--depth", + "3"); + + // Assert - validation passes and the top heading is emitted at the requested depth (###) + Assert.Equal(0, exitCode); + Assert.Contains("### DEMA Consulting FileAssert", output); + } + /// /// Test that silent flag suppresses output. /// @@ -338,7 +358,50 @@ public void IntegrationTest_ValidConfig_PassingAssertions_ReturnsZero() } /// - /// Test that a configuration file with a failing assertion causes the tool to return non-zero. + /// Test that, when invoked with no arguments, the tool loads and executes the + /// .fileassert.yaml file from the current working directory. + /// + [Fact] + public void IntegrationTest_DefaultBehavior_RunsConfigFromWorkingDirectory_ReturnsZero() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + // Create a file that satisfies the assertion + File.WriteAllText(tempDir.GetFilePath("sample.txt"), "Copyright (c) DEMA Consulting"); + + // Write the default-named config in the working directory (no --config argument) + File.WriteAllText(tempDir.GetFilePath(".fileassert.yaml"), """ + tests: + - name: "License Check" + files: + - pattern: "*.txt" + min: 1 + text: + - contains: "Copyright" + """); + + var originalDirectory = Directory.GetCurrentDirectory(); + try + { + // Act: run with no arguments from the temporary working directory + Directory.SetCurrentDirectory(tempDir.DirectoryPath); + var exitCode = Runner.Run( + out var _, + "dotnet", + _dllPath); + + // Assert + Assert.Equal(0, exitCode); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + } + } + + /// + /// Test that the tool returns a non-zero exit code when a valid configuration's + /// assertions fail. /// [Fact] public void IntegrationTest_ValidConfig_FailingAssertions_ReturnsNonZero() @@ -951,7 +1014,7 @@ public void IntegrationTest_ZipAssert_PassingQuery_ReturnsZero() files: - pattern: "*.zip" zip: - entries: + files: - pattern: "*.txt" min: 1 """); @@ -982,7 +1045,7 @@ public void IntegrationTest_ZipAssert_InvalidFile_ReturnsNonZero() files: - pattern: "*.zip" zip: - entries: + files: - pattern: "*.txt" min: 1 """); @@ -996,10 +1059,12 @@ public void IntegrationTest_ZipAssert_InvalidFile_ReturnsNonZero() } /// - /// Test that an HTML assert with a non-existent XPath query and min:1 returns a non-zero exit code. + /// Test that an HTML assert whose XPath query matches zero elements with min:1 returns a + /// non-zero exit code. HtmlAgilityPack parses leniently, so this exercises the zero-match + /// assertion-failure path rather than a parse-failure path. /// [Fact] - public void IntegrationTest_HtmlAssert_InvalidFile_ReturnsNonZero() + public void IntegrationTest_HtmlAssert_ZeroMatchingElements_ReturnsNonZero() { // Arrange using var tempDir = new TemporaryDirectory(); @@ -1147,4 +1212,218 @@ public void IntegrationTest_PdfAssert_InvalidFile_ReturnsNonZero() Assert.NotEqual(0, exitCode); } + + /// + /// Test that a ZIP assert with a passing text-content rule on a zip entry returns a + /// zero exit code. + /// + [Fact] + public void IntegrationTest_ZipAssert_TextAssertionPassing_ReturnsZero() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("readme.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("hello world"); + } + + var configPath = tempDir.GetFilePath(".fileassert.yaml"); + File.WriteAllText(configPath, """ + tests: + - name: ZipTextCheck + files: + - pattern: "*.zip" + zip: + files: + - pattern: "readme.txt" + text: + - contains: "hello" + """); + + // Act + var exitCode = Runner.Run(out var _, "dotnet", _dllPath, "--silent", "--config", configPath); + + // Assert + Assert.Equal(0, exitCode); + + } + + /// + /// Test that a ZIP assert with a failing text-content rule on a zip entry returns a + /// non-zero exit code. + /// + [Fact] + public void IntegrationTest_ZipAssert_TextAssertionFailing_ReturnsNonZero() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("readme.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("goodbye world"); + } + + var configPath = tempDir.GetFilePath(".fileassert.yaml"); + File.WriteAllText(configPath, """ + tests: + - name: ZipTextFail + files: + - pattern: "*.zip" + zip: + files: + - pattern: "readme.txt" + text: + - contains: "not-present" + """); + + // Act + var exitCode = Runner.Run(out var _, "dotnet", _dllPath, "--silent", "--config", configPath); + + // Assert + Assert.NotEqual(0, exitCode); + + } + + /// + /// Test that a ZIP assert with a passing XML XPath query on a zip entry returns a + /// zero exit code. + /// + [Fact] + public void IntegrationTest_ZipAssert_XmlAssertionPassing_ReturnsZero() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("config.xml"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("""onetwo"""); + } + + var configPath = tempDir.GetFilePath(".fileassert.yaml"); + File.WriteAllText(configPath, """ + tests: + - name: ZipXmlCheck + files: + - pattern: "*.zip" + zip: + files: + - pattern: "config.xml" + xml: + - query: "//item" + count: 2 + """); + + // Act + var exitCode = Runner.Run(out var _, "dotnet", _dllPath, "--silent", "--config", configPath); + + // Assert + Assert.Equal(0, exitCode); + + } + + /// + /// Test that a nested zip-in-zip with a passing text-content assertion returns a zero + /// exit code. + /// + [Fact] + public void IntegrationTest_ZipAssert_NestedZipTextContent_ReturnsZero() + { + // Arrange - build inner.zip in memory containing readme.txt, then wrap it in outer.zip + using var tempDir = new TemporaryDirectory(); + var outerZipPath = tempDir.GetFilePath("outer.zip"); + + using var innerZipStream = new MemoryStream(); + using (var innerArchive = new ZipArchive(innerZipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var innerEntry = innerArchive.CreateEntry("readme.txt"); + using var innerStream = innerEntry.Open(); + using var innerWriter = new StreamWriter(innerStream, System.Text.Encoding.UTF8); + innerWriter.Write("hello world"); + } + + var innerZipBytes = innerZipStream.ToArray(); + + using (var outerArchive = ZipFile.Open(outerZipPath, ZipArchiveMode.Create)) + { + var outerEntry = outerArchive.CreateEntry("inner.zip"); + using var outerEntryStream = outerEntry.Open(); + outerEntryStream.Write(innerZipBytes, 0, innerZipBytes.Length); + } + + var configPath = tempDir.GetFilePath(".fileassert.yaml"); + File.WriteAllText(configPath, """ + tests: + - name: NestedZipCheck + files: + - pattern: "outer.zip" + zip: + files: + - pattern: "inner.zip" + min: 1 + zip: + files: + - pattern: "readme.txt" + min: 1 + text: + - contains: "hello" + """); + + // Act + var exitCode = Runner.Run(out var _, "dotnet", _dllPath, "--silent", "--config", configPath); + + // Assert + Assert.Equal(0, exitCode); + + } + + /// + /// Test that when a text assertion fails inside a zip entry, the error output contains + /// both the zip file name and the failing entry path as breadcrumbs. + /// + [Fact] + public void IntegrationTest_ZipAssert_FailingContentAssertion_ErrorContainsEntryPath() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("readme.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("goodbye world"); + } + + var configPath = tempDir.GetFilePath(".fileassert.yaml"); + File.WriteAllText(configPath, """ + tests: + - name: ZipErrorBreadcrumb + files: + - pattern: "archive.zip" + zip: + files: + - pattern: "readme.txt" + text: + - contains: "not-present" + """); + + // Act - run without --silent so that error messages are written to stderr and captured + var exitCode = Runner.Run(out var output, "dotnet", _dllPath, "--config", configPath); + + // Assert - error output must carry both the zip filename and the entry path as breadcrumbs + Assert.NotEqual(0, exitCode); + Assert.Contains("archive.zip", output); + Assert.Contains("readme.txt", output); + + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertFileTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertFileTests.cs index 1e36689..ad97234 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertFileTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertFileTests.cs @@ -102,7 +102,8 @@ public void FileAssertFile_Run_NoMatchingFiles_NoConstraints_NoError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(0, context.ExitCode); @@ -123,7 +124,8 @@ public void FileAssertFile_Run_WithMatchingFiles_NoConstraints_NoError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(0, context.ExitCode); @@ -143,7 +145,8 @@ public void FileAssertFile_Run_TooFewFiles_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -165,7 +168,8 @@ public void FileAssertFile_Run_TooManyFiles_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -190,7 +194,8 @@ public void FileAssertFile_Run_WithContentRule_ContentContainsValue_NoError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(0, context.ExitCode); @@ -215,7 +220,8 @@ public void FileAssertFile_Run_WithContentRule_ContentMissingValue_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -237,7 +243,8 @@ public void FileAssertFile_Run_WrongCount_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -258,7 +265,8 @@ public void FileAssertFile_Run_TooSmall_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -279,7 +287,8 @@ public void FileAssertFile_Run_TooLarge_WritesError() using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert Assert.Equal(1, context.ExitCode); @@ -303,7 +312,8 @@ public void FileAssertFile_Run_MultipleFiles_MultipleViolateSizeConstraints_Writ using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert - both invalid files should trigger errors regardless of enumeration order Assert.Equal(1, context.ExitCode); @@ -332,11 +342,35 @@ public void FileAssertFile_Run_MultipleFiles_MultipleFailContentRule_WritesError using var context = Context.Create(["--silent"]); // Act - file.Run(context, tempDir.DirectoryPath); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); // Assert - both bad files should trigger errors regardless of enumeration order Assert.Equal(1, context.ExitCode); Assert.Equal(2, context.ErrorCount); + } + + /// + /// Verifies that a glob pattern containing backslash separators (common on Windows) still + /// matches container entries whose paths are normalized to forward slashes. + /// + [Fact] + public void FileAssertFile_Run_BackslashPattern_MatchesForwardSlashEntries_NoError() + { + // Arrange - a file in a subdirectory; the pattern uses backslash separators + using var tempDir = new TemporaryDirectory(); + var subDir = Path.Combine(tempDir.DirectoryPath, "sub"); + Directory.CreateDirectory(subDir); + File.WriteAllText(Path.Combine(subDir, "file.txt"), "content"); + var data = new FileAssertFileData { Pattern = @"sub\file.txt", Count = 1 }; + var file = FileAssertFile.Create(data); + using var context = Context.Create(["--silent"]); + // Act + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + file.Run(context, container); + + // Assert - backslash pattern must match the forward-slash normalized entry + Assert.Equal(0, context.ExitCode); } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs index 7b55b25..36d50b6 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertHtmlAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -87,8 +88,12 @@ public void FileAssertHtmlAssert_Run_ExactCount_Matches_NoError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -114,8 +119,12 @@ public void FileAssertHtmlAssert_Run_ExactCount_Mismatch_WritesError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -141,8 +150,12 @@ public void FileAssertHtmlAssert_Run_MinMaxCount_WithinBounds_NoError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -153,20 +166,61 @@ public void FileAssertHtmlAssert_Run_MinMaxCount_WithinBounds_NoError() } } + /// + /// Verifies that Run parses syntactically imperfect HTML (missing closing tags) leniently + /// and still evaluates XPath queries successfully. + /// + [Fact] + public void FileAssertHtmlAssert_Run_MalformedHtml_ParsesAndQueriesSuccessfully_NoError() + { + // Arrange - HTML with missing closing , , , and tags + const string malformedHtml = """ + + +
    +
  • Item one +
  • Item two +
  • Item three + """; + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, malformedHtml); + var data = new List { new() { Query = "//li", Count = 3 } }; + var htmlAssert = FileAssertHtmlAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act - the lenient parser repairs the markup so the XPath query can run + htmlAssert.Run(context, container, fileName); + + // Assert - all three list items are found despite the missing closing tags + Assert.Equal(0, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + /// /// Verifies that Run reports an error when the file does not exist and cannot be parsed. /// [Fact] public void FileAssertHtmlAssert_Run_NonExistentFile_WritesError() { - // Arrange - use a path that does not exist to trigger a parse failure - var missingFile = Path.Combine(Path.GetTempPath(), $"does_not_exist_{Guid.NewGuid():N}.html"); + // Arrange - use a filename that does not exist inside the temp directory to trigger a parse failure + var missingFileName = $"does_not_exist_{Guid.NewGuid():N}.html"; var data = new List { new() { Query = "//p", Count = 1 } }; var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + using var container = new DirectoryFileContainer(Path.GetTempPath()); // Act - htmlAssert.Run(context, missingFile); + htmlAssert.Run(context, container, missingFileName); // Assert Assert.Equal(1, context.ExitCode); @@ -187,8 +241,12 @@ public void FileAssertHtmlAssert_Run_InvalidXPathQuery_WritesError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -214,8 +272,12 @@ public void FileAssertHtmlAssert_Run_XPathExactTextMatch_Matches_NoError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -241,8 +303,12 @@ public void FileAssertHtmlAssert_Run_XPathExactTextMatch_NoMatch_WritesError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -268,8 +334,12 @@ public void FileAssertHtmlAssert_Run_XPathContainsText_Matches_NoError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -295,8 +365,74 @@ public void FileAssertHtmlAssert_Run_XPathContainsText_NoMatch_WritesError() var htmlAssert = FileAssertHtmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + htmlAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run reports an error when the node count is below the minimum. + /// + [Fact] + public void FileAssertHtmlAssert_Run_MinCount_BelowMinimum_WritesError() + { + // Arrange - sample HTML has 2 paragraphs; assert min = 5 + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, SampleHtml); + var data = new List { new() { Query = "//p", Min = 5 } }; + var htmlAssert = FileAssertHtmlAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + htmlAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run reports an error when the node count exceeds the maximum. + /// + [Fact] + public void FileAssertHtmlAssert_Run_MaxCount_ExceedsMaximum_WritesError() + { + // Arrange - sample HTML has 2 paragraphs; assert max = 1 + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, SampleHtml); + var data = new List { new() { Query = "//p", Max = 1 } }; + var htmlAssert = FileAssertHtmlAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - htmlAssert.Run(context, tempFile); + htmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -306,4 +442,63 @@ public void FileAssertHtmlAssert_Run_XPathContainsText_NoMatch_WritesError() File.Delete(tempFile); } } + + /// + /// Verifies that Run reports an IO error when the entry cannot be opened. + /// + [Fact] + public void FileAssertHtmlAssert_Run_UnauthorizedAccess_WritesError() + { + // Arrange - a container whose OpenEntry raises an access-denied failure + var data = new List { new() { Query = "//p", Count = 1 } }; + var htmlAssert = FileAssertHtmlAssert.Create(data); + var context = new CapturingContext(); + var container = new ThrowingFileContainer(); + + // Act + htmlAssert.Run(context, container, "page.html"); + + // Assert: the IO failure is reported + Assert.Single(context.Errors); + Assert.Contains("could not be parsed as an HTML document", context.Errors[0]); + } + + /// + /// Captures error messages written via for assertion in tests. + /// + private sealed class CapturingContext : IContext + { + private readonly List _errors = []; + + /// Gets all error messages captured since this context was created. + public IReadOnlyList Errors => _errors.AsReadOnly(); + + /// + public void WriteLine(string message) { } + + /// + public void WriteError(string message) => _errors.Add(message); + + /// + public IContext WithPrefix(string prefix) => this; + } + + /// + /// A file container whose raises an + /// to simulate an IO failure while reading an entry. + /// + private sealed class ThrowingFileContainer : IFileContainer + { + /// + public IReadOnlyList GetEntries() => ["page.html"]; + + /// + public Stream OpenEntry(string entryPath) => throw new UnauthorizedAccessException("denied"); + + /// + public long GetEntrySize(string entryPath) => 0; + + /// + public string GetDisplayPath(string entryPath) => entryPath; + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs index d556926..309f708 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertJsonAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -85,8 +86,12 @@ public void FileAssertJsonAssert_Run_InvalidFile_WritesError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -112,8 +117,12 @@ public void FileAssertJsonAssert_Run_ArrayCount_Matches_NoError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -139,8 +148,12 @@ public void FileAssertJsonAssert_Run_ArrayCount_Mismatch_WritesError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -166,8 +179,12 @@ public void FileAssertJsonAssert_Run_MinMaxCount_WithinBounds_NoError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -193,8 +210,12 @@ public void FileAssertJsonAssert_Run_ScalarValue_CountsAsOne_NoError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -220,8 +241,12 @@ public void FileAssertJsonAssert_Run_MinCount_BelowMinimum_WritesError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert - min violation produces an error Assert.Equal(1, context.ExitCode); @@ -247,8 +272,12 @@ public void FileAssertJsonAssert_Run_MaxCount_ExceedsMaximum_WritesError() var jsonAssert = FileAssertJsonAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - jsonAssert.Run(context, tempFile); + jsonAssert.Run(context, container, fileName); // Assert - max violation produces an error Assert.Equal(1, context.ExitCode); @@ -310,4 +339,95 @@ public void FileAssertJsonAssert_Create_ConsecutiveDotsQuery_ThrowsInvalidOperat // Act & Assert Assert.Throws(() => FileAssertJsonAssert.Create(data)); } + + /// + /// Verifies that Run reports a parse-specific error message when the file is not valid JSON. + /// + [Fact] + public void FileAssertJsonAssert_Run_InvalidJson_WritesParseError() + { + // Arrange - create a non-JSON file + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "this is not JSON"); + var data = new List { new() { Query = "tools", Count = 1 } }; + var jsonAssert = FileAssertJsonAssert.Create(data); + var context = new CapturingContext(); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + jsonAssert.Run(context, container, fileName); + + // Assert: the error identifies a parse failure, not an IO failure + Assert.Single(context.Errors); + Assert.Contains("could not be parsed as a JSON document", context.Errors[0]); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run reports an IO-specific error message when the entry cannot be read. + /// + [Fact] + public void FileAssertJsonAssert_Run_IOError_WritesReadError() + { + // Arrange - a container whose OpenEntry raises an IO failure + var data = new List { new() { Query = "tools", Count = 1 } }; + var jsonAssert = FileAssertJsonAssert.Create(data); + var context = new CapturingContext(); + var container = new ThrowingFileContainer(); + + // Act + jsonAssert.Run(context, container, "data.json"); + + // Assert: the error identifies an IO failure, not a parse failure + Assert.Single(context.Errors); + Assert.Contains("could not be read", context.Errors[0]); + } + + /// + /// Captures error messages written via for assertion in tests. + /// + private sealed class CapturingContext : IContext + { + private readonly List _errors = []; + + /// Gets all error messages captured since this context was created. + public IReadOnlyList Errors => _errors.AsReadOnly(); + + /// + public void WriteLine(string message) { } + + /// + public void WriteError(string message) => _errors.Add(message); + + /// + public IContext WithPrefix(string prefix) => this; + } + + /// + /// A file container whose raises an + /// to simulate an IO failure while reading an entry. + /// + private sealed class ThrowingFileContainer : IFileContainer + { + /// + public IReadOnlyList GetEntries() => ["data.json"]; + + /// + public Stream OpenEntry(string entryPath) => throw new UnauthorizedAccessException("denied"); + + /// + public long GetEntrySize(string entryPath) => 0; + + /// + public string GetDisplayPath(string entryPath) => entryPath; + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertPdfAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertPdfAssertTests.cs index bdd4b99..e8bf224 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertPdfAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertPdfAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; using UglyToad.PdfPig.Content; using UglyToad.PdfPig.Core; using UglyToad.PdfPig.Fonts.Standard14Fonts; @@ -78,8 +79,12 @@ public void FileAssertPdfAssert_Run_InvalidFile_WritesError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -111,8 +116,12 @@ public void FileAssertPdfAssert_Run_ValidPdf_PageCountSatisfied_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -144,8 +153,12 @@ public void FileAssertPdfAssert_Run_ValidPdf_TooFewPages_WritesError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -179,8 +192,12 @@ public void FileAssertPdfAssert_Run_ValidPdf_TooManyPages_WritesError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -215,8 +232,12 @@ public void FileAssertPdfAssert_Run_MetadataContainsRule_FieldMissing_WritesErro var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -251,8 +272,12 @@ public void FileAssertPdfAssert_Run_TextRule_ContentMissing_WritesError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -288,8 +313,12 @@ public void FileAssertPdfAssert_Run_MetadataContainsRule_TitleMatches_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -325,8 +354,12 @@ public void FileAssertPdfAssert_Run_MetadataContainsRule_AuthorField_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -362,8 +395,12 @@ public void FileAssertPdfAssert_Run_MetadataMatchesRule_Matches_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -399,8 +436,12 @@ public void FileAssertPdfAssert_Run_MetadataMatchesRule_NoMatch_WritesError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -437,8 +478,12 @@ public void FileAssertPdfAssert_Run_TextContainsRule_ContentPresent_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -475,8 +520,12 @@ public void FileAssertPdfAssert_Run_TextMatchesRule_PatternMatches_NoError() var pdfAssert = FileAssertPdfAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - pdfAssert.Run(context, tempFile); + pdfAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -486,4 +535,98 @@ public void FileAssertPdfAssert_Run_TextMatchesRule_PatternMatches_NoError() File.Delete(tempFile); } } + + /// + /// Verifies that text extracted from adjacent pages is not concatenated across the page + /// boundary: the \n separator inserted between page texts prevents the trailing token + /// of one page from merging with the leading token of the next. + /// + [Fact] + public void FileAssertPdfAssert_Run_MultiPageText_PageBoundarySeparated_NoError() + { + // Arrange - build a two-page PDF where page one ends with "concat" and page two starts + // with "enation"; without the page separator these would merge into "concatenation" + var tempFile = Path.GetTempFileName(); + try + { + using var builder = new PdfDocumentBuilder(); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + + var page1 = builder.AddPage(PageSize.A4); + page1.AddText("concat", 12, new PdfPoint(50, 700), font); + + var page2 = builder.AddPage(PageSize.A4); + page2.AddText("enation", 12, new PdfPoint(50, 700), font); + + File.WriteAllBytes(tempFile, builder.Build()); + + var data = new FileAssertPdfData + { + Text = + [ + // Each page's token is present in the joined text + new FileAssertRuleData { Contains = "concat" }, + // But the merged word must NOT appear because the pages are separated by '\n' + new FileAssertRuleData { DoesNotContain = "concatenation" } + ] + }; + var pdfAssert = FileAssertPdfAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + pdfAssert.Run(context, container, fileName); + + // Assert - both rules pass, proving the page boundary prevents cross-page concatenation + Assert.Equal(0, context.ExitCode); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Create throws when a metadata rule + /// does not specify a field name (negative path of PdfMetadataRule.FromData). + /// + [Fact] + public void FileAssertPdfAssert_Create_MetadataRuleMissingField_ThrowsInvalidOperationException() + { + // Arrange - a metadata rule with a constraint but no field name + var data = new FileAssertPdfData + { + Metadata = + [ + new FileAssertPdfMetadataRuleData { Contains = "Test" } + ] + }; + + // Act & Assert + Assert.Throws(() => FileAssertPdfAssert.Create(data)); + } + + /// + /// Verifies that Create throws when a metadata rule + /// specifies neither contains nor matches (negative path of + /// PdfMetadataRule.FromData). + /// + [Fact] + public void FileAssertPdfAssert_Create_MetadataRuleMissingContainsAndMatches_ThrowsInvalidOperationException() + { + // Arrange - a metadata rule with a field name but no contains/matches constraint + var data = new FileAssertPdfData + { + Metadata = + [ + new FileAssertPdfMetadataRuleData { Field = "Title" } + ] + }; + + // Act & Assert + Assert.Throws(() => FileAssertPdfAssert.Create(data)); + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTestTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTestTests.cs index e844e7d..d931242 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTestTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTestTests.cs @@ -206,13 +206,18 @@ public void FileAssertTest_MatchesFilter_CaseInsensitiveTag_ReturnsTrue() [Fact] public void FileAssertTest_Run_RunsAllFiles() { - // Arrange - create a temp directory with a file matching the pattern + // Arrange - create a temp directory with files matching two patterns using var tempDir = new TemporaryDirectory(); File.WriteAllText(tempDir.GetFilePath("sample.txt"), "content"); + File.WriteAllText(tempDir.GetFilePath("sample.log"), "log content"); var data = new FileAssertTestData { Name = "Run Test", - Files = [new FileAssertFileData { Pattern = "*.txt", Min = 1 }] + Files = + [ + new FileAssertFileData { Pattern = "*.txt", Min = 1 }, + new FileAssertFileData { Pattern = "*.log", Min = 1 } + ] }; var test = FileAssertTest.Create(data); using var context = Context.Create(["--silent"]); @@ -220,10 +225,23 @@ public void FileAssertTest_Run_RunsAllFiles() // Act test.Run(context, tempDir.DirectoryPath); - // Assert - min=1 would have produced an error if the file had not been found + // Assert - min=1 on each file assertion would have produced an error if not found Assert.Equal(0, context.ExitCode); } + /// + /// Verifies that MatchesFilter throws when filters is null. + /// + [Fact] + public void FileAssertTest_Create_NullFilters_ThrowsArgumentNullException() + { + // Arrange + var test = FileAssertTest.Create(new FileAssertTestData { Name = "Test" }); + + // Act & Assert + Assert.Throws(() => test.MatchesFilter(null!)); + } + /// /// Verifies that Run throws when context is null. /// diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTextAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTextAssertTests.cs index 7e2aa1b..67eb4f2 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTextAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertTextAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -77,8 +78,12 @@ public void FileAssertTextAssert_Run_FileContainsText_NoError() var textAssert = FileAssertTextAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - textAssert.Run(context, tempFile); + textAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -104,8 +109,12 @@ public void FileAssertTextAssert_Run_FileMissingText_WritesError() var textAssert = FileAssertTextAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - textAssert.Run(context, tempFile); + textAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -122,14 +131,15 @@ public void FileAssertTextAssert_Run_FileMissingText_WritesError() [Fact] public void FileAssertTextAssert_Run_NonExistentFile_WritesError() { - // Arrange - use a path that does not exist to trigger an I/O failure - var missingFile = Path.Combine(Path.GetTempPath(), $"does_not_exist_{Guid.NewGuid():N}.txt"); + // Arrange - use a filename that does not exist inside the temp directory to trigger an I/O failure + var missingFileName = $"does_not_exist_{Guid.NewGuid():N}.txt"; var data = new List { new() { Contains = "hello" } }; var textAssert = FileAssertTextAssert.Create(data); using var context = Context.Create(["--silent"]); + using var container = new DirectoryFileContainer(Path.GetTempPath()); // Act - textAssert.Run(context, missingFile); + textAssert.Run(context, container, missingFileName); // Assert - an error was reported Assert.Equal(1, context.ExitCode); @@ -156,8 +166,12 @@ public void FileAssertTextAssert_Run_MultipleRulesMultipleViolations_WritesMulti var textAssert = FileAssertTextAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - textAssert.Run(context, tempFile); + textAssert.Run(context, container, fileName); // Assert - both rules must have been evaluated (no short-circuit) Assert.Equal(2, context.ErrorCount); diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertXmlAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertXmlAssertTests.cs index 07a56d0..2ac81cc 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertXmlAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertXmlAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -68,6 +69,22 @@ public void FileAssertXmlAssert_Create_NullData_ThrowsArgumentNullException() Assert.Throws(() => FileAssertXmlAssert.Create(null!)); } + /// + /// Verifies that Create throws when a query is blank. + /// + [Fact] + public void FileAssertXmlAssert_Create_BlankQuery_ThrowsInvalidOperationException() + { + // Arrange: query data whose query string is blank (whitespace) + var data = new List + { + new() { Query = " ", Count = 1 } + }; + + // Act / Assert: a blank query is rejected + Assert.Throws(() => FileAssertXmlAssert.Create(data)); + } + /// /// Verifies that Run reports an error when the file cannot be parsed as XML. /// @@ -83,8 +100,12 @@ public void FileAssertXmlAssert_Run_InvalidFile_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -110,8 +131,12 @@ public void FileAssertXmlAssert_Run_ExactCount_Matches_NoError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -137,8 +162,12 @@ public void FileAssertXmlAssert_Run_ExactCount_Mismatch_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -164,8 +193,12 @@ public void FileAssertXmlAssert_Run_MinMaxCount_WithinBounds_NoError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -191,8 +224,12 @@ public void FileAssertXmlAssert_Run_InvalidXPathQuery_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -218,8 +255,12 @@ public void FileAssertXmlAssert_Run_XPathExactTextMatch_Matches_NoError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -245,8 +286,12 @@ public void FileAssertXmlAssert_Run_MaxCount_Exceeded_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -272,8 +317,12 @@ public void FileAssertXmlAssert_Run_MinCount_NotMet_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -299,8 +348,12 @@ public void FileAssertXmlAssert_Run_XPathExactTextMatch_NoMatch_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -326,8 +379,12 @@ public void FileAssertXmlAssert_Run_XPathContainsText_Matches_NoError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -353,8 +410,12 @@ public void FileAssertXmlAssert_Run_XPathContainsText_NoMatch_WritesError() var xmlAssert = FileAssertXmlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - xmlAssert.Run(context, tempFile); + xmlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs index a486ffb..fd4cc89 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertYamlAssertTests.cs @@ -21,6 +21,7 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -82,8 +83,12 @@ public void FileAssertYamlAssert_Run_InvalidFile_WritesError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -109,8 +114,12 @@ public void FileAssertYamlAssert_Run_SequenceCount_Matches_NoError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -136,8 +145,12 @@ public void FileAssertYamlAssert_Run_SequenceCount_Mismatch_WritesError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -163,8 +176,12 @@ public void FileAssertYamlAssert_Run_MinMaxCount_WithinBounds_NoError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -190,8 +207,12 @@ public void FileAssertYamlAssert_Run_ScalarValue_CountsAsOne_NoError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -217,8 +238,12 @@ public void FileAssertYamlAssert_Run_MinCount_BelowMinimum_WritesError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert - min violation produces an error Assert.Equal(1, context.ExitCode); @@ -244,8 +269,12 @@ public void FileAssertYamlAssert_Run_MaxCount_ExceedsMaximum_WritesError() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert - max violation produces an error Assert.Equal(1, context.ExitCode); @@ -308,6 +337,47 @@ public void FileAssertYamlAssert_Create_ConsecutiveDotsQuery_ThrowsInvalidOperat Assert.Throws(() => FileAssertYamlAssert.Create(data)); } + /// + /// Verifies that Create throws when the query list is empty. + /// + [Fact] + public void FileAssertYamlAssert_Create_EmptyQueryList_ThrowsInvalidOperationException() + { + // Arrange - no queries declared at all + var data = new List(); + + // Act & Assert + Assert.Throws(() => FileAssertYamlAssert.Create(data)); + } + + /// + /// Verifies that Create throws when a query specifies + /// none of count, min, or max. + /// + [Fact] + public void FileAssertYamlAssert_Create_QueryWithoutConstraint_ThrowsInvalidOperationException() + { + // Arrange - a valid path but no count/min/max constraint + var data = new List { new() { Query = "tools" } }; + + // Act & Assert + Assert.Throws(() => FileAssertYamlAssert.Create(data)); + } + + /// + /// Verifies that Create throws when a query's min + /// constraint exceeds its max constraint. + /// + [Fact] + public void FileAssertYamlAssert_Create_QueryMinGreaterThanMax_ThrowsInvalidOperationException() + { + // Arrange - min is greater than max + var data = new List { new() { Query = "tools", Min = 5, Max = 2 } }; + + // Act & Assert + Assert.Throws(() => FileAssertYamlAssert.Create(data)); + } + /// /// Verifies that Run reports zero matches for all queries when the YAML file has no documents. /// @@ -323,8 +393,12 @@ public void FileAssertYamlAssert_Run_EmptyDocument_ReportsZeroCount() var yamlAssert = FileAssertYamlAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - yamlAssert.Run(context, tempFile); + yamlAssert.Run(context, container, fileName); // Assert - no documents means 0 matches; min=1 constraint is violated Assert.Equal(1, context.ExitCode); @@ -334,4 +408,61 @@ public void FileAssertYamlAssert_Run_EmptyDocument_ReportsZeroCount() File.Delete(tempFile); } } + + /// + /// Verifies that when the YAML file cannot be parsed, only the parse error is reported + /// and the remaining configured query assertions are skipped. + /// + [Fact] + public void FileAssertYamlAssert_Run_InvalidFile_RemainingAssertionsSkipped() + { + // Arrange - malformed YAML with two configured queries + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "key: [unclosed"); + var data = new List + { + new() { Query = "tools", Count = 3 }, + new() { Query = "version", Count = 1 } + }; + var yamlAssert = FileAssertYamlAssert.Create(data); + var context = new CapturingContext(); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + yamlAssert.Run(context, container, fileName); + + // Assert - exactly one error (the parse failure); the queries are not evaluated + Assert.Single(context.Errors); + Assert.Contains("could not be parsed", context.Errors[0]); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Captures error messages written via for assertion in tests. + /// + private sealed class CapturingContext : IContext + { + private readonly List _errors = []; + + /// Gets all error messages captured since this context was created. + public IReadOnlyList Errors => _errors.AsReadOnly(); + + /// + public void WriteLine(string message) { } + + /// + public void WriteError(string message) => _errors.Add(message); + + /// + public IContext WithPrefix(string prefix) => this; + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs index d17cffa..37c3a60 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/FileAssertZipAssertTests.cs @@ -22,6 +22,11 @@ using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; +using DemaConsulting.FileAssert.Utilities; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.Fonts.Standard14Fonts; +using UglyToad.PdfPig.Writer; namespace DemaConsulting.FileAssert.Tests.Modeling; @@ -31,6 +36,38 @@ namespace DemaConsulting.FileAssert.Tests.Modeling; [Collection("Sequential")] public sealed class FileAssertZipAssertTests { + /// + /// Sample XML content used across multiple XML assertion tests. + /// + private const string SampleXmlContent = """ + + + one + two + + """; + + /// + /// Sample YAML content used across multiple YAML assertion tests. + /// + private const string SampleYamlContent = """ + server: + host: localhost + port: 8080 + """; + + /// + /// Sample JSON content used across multiple JSON assertion tests. + /// + private const string SampleJsonContent = """ + { + "server": { + "host": "localhost", + "port": 8080 + } + } + """; + /// /// Creates a zip file at containing the specified entry names, /// each with a single placeholder byte of content. @@ -54,6 +91,133 @@ private static void CreateZipFile(string path, IEnumerable entries) } } + /// + /// Creates a zip file at containing entries with the specified + /// names and text content. Content is written as UTF-8 without BOM so that + /// reflects the exact byte count. + /// + /// Destination path for the zip file. Any existing file is removed first. + /// Entry names and text content to add to the zip archive. + private static void CreateZipFileWithContent(string path, IEnumerable<(string name, string content)> entries) + { + // Remove the file first because ZipFile.Open in Create mode requires a non-existent path + File.Delete(path); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var (name, content) in entries) + { + var archiveEntry = archive.CreateEntry(name); + var entryStream = archiveEntry.Open(); + + // StreamWriter takes ownership of the stream and closes it on disposal; + // UTF-8 without BOM ensures the uncompressed size equals the exact character count + using var writer = new StreamWriter(entryStream, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(content); + } + } + + /// + /// Creates an outer zip file at containing a single inner + /// zip entry named . The inner zip is built in memory + /// and contains the specified text entries. + /// + /// Destination path for the outer zip. Any existing file is removed first. + /// Name of the inner zip entry within the outer archive. + /// Entry names and text content within the inner zip archive. + private static void CreateNestedZipFile( + string outerPath, + string innerEntryName, + IEnumerable<(string name, string content)> innerEntries) + { + // Build the inner zip entirely in memory before writing it to the outer archive + using var innerZipStream = new MemoryStream(); + using (var innerArchive = new ZipArchive(innerZipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in innerEntries) + { + var innerEntry = innerArchive.CreateEntry(name); + var innerStream = innerEntry.Open(); + + // StreamWriter takes ownership of the stream (leaveOpen: false default) + using var writer = new StreamWriter(innerStream, new System.Text.UTF8Encoding(false)); + writer.Write(content); + } + } + + var innerZipBytes = innerZipStream.ToArray(); + + // Write the outer zip with the in-memory inner zip as a single binary entry + File.Delete(outerPath); + using var outerArchive = ZipFile.Open(outerPath, ZipArchiveMode.Create); + var outerEntry = outerArchive.CreateEntry(innerEntryName); + using var outerEntryStream = outerEntry.Open(); + outerEntryStream.Write(innerZipBytes, 0, innerZipBytes.Length); + } + + /// + /// Creates a zip file at containing a single entry named + /// whose content is the supplied raw bytes. Used for + /// binary entries such as PDF documents that cannot be written as text. + /// + /// Destination path for the zip file. Any existing file is removed first. + /// Name of the entry to add to the archive. + /// The raw bytes to write as the entry content. + private static void CreateZipFileWithBinaryEntry(string path, string entryName, byte[] content) + { + // Remove the file first because ZipFile.Open in Create mode requires a non-existent path + File.Delete(path); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var archiveEntry = archive.CreateEntry(entryName); + using var stream = archiveEntry.Open(); + stream.Write(content, 0, content.Length); + } + + /// + /// A test-only implementation that captures all error messages + /// written via for inspection in breadcrumb tests. + /// chains a scoped wrapper that mirrors the behavior of + /// Context.ScopedContext so that the full breadcrumb path is accumulated. + /// + private sealed class CapturingContext : IContext + { + private readonly List _errors = []; + + /// Gets all error messages captured since this context was created. + public IReadOnlyList Errors => _errors.AsReadOnly(); + + /// + public void WriteLine(string message) { } + + /// + public void WriteError(string message) => _errors.Add(message); + + /// + public IContext WithPrefix(string prefix) => new PrefixedContext(this, prefix); + + /// + /// Scoped wrapper that prepends a prefix to each error before delegating to the + /// parent context. Mirrors the behavior of Context.ScopedContext. + /// + private sealed class PrefixedContext : IContext + { + private readonly IContext _parent; + private readonly string _prefix; + + internal PrefixedContext(IContext parent, string prefix) + { + _parent = parent; + _prefix = prefix; + } + + public void WriteLine(string message) => _parent.WriteLine(message); + + public void WriteError(string message) => _parent.WriteError($"{_prefix} > {message}"); + + public IContext WithPrefix(string prefix) => new PrefixedContext(this, prefix); + } + } + /// /// Verifies that Create succeeds given valid data. /// @@ -63,9 +227,9 @@ public void FileAssertZipAssert_Create_ValidData_CreatesZipAssert() // Arrange var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + new FileAssertFileData { Pattern = "lib/**/*.dll", Min = 1 } ] }; @@ -74,10 +238,38 @@ public void FileAssertZipAssert_Create_ValidData_CreatesZipAssert() // Assert Assert.NotNull(zipAssert); - Assert.Single(zipAssert.Entries); - Assert.Equal("lib/**/*.dll", zipAssert.Entries[0].Pattern); - Assert.Equal(1, zipAssert.Entries[0].Min); - Assert.Null(zipAssert.Entries[0].Max); + Assert.Single(zipAssert.Files); + Assert.Equal("lib/**/*.dll", zipAssert.Files[0].Pattern); + Assert.Equal(1, zipAssert.Files[0].Min); + Assert.Null(zipAssert.Files[0].Max); + } + + /// + /// Verifies that Create throws when Files is null, + /// preventing a silent pass on a zip block with no file assertions. + /// + [Fact] + public void FileAssertZipAssert_Create_NullFiles_ThrowsInvalidOperationException() + { + // Arrange - a zip data block with no files list (would silently pass everything if allowed) + var data = new FileAssertZipData { Files = null }; + + // Act / Assert + Assert.Throws(() => FileAssertZipAssert.Create(data)); + } + + /// + /// Verifies that Create throws when Files is empty, + /// preventing a silent pass on a zip block with an empty file assertion list. + /// + [Fact] + public void FileAssertZipAssert_Create_EmptyFiles_ThrowsInvalidOperationException() + { + // Arrange - a zip data block with an empty files list + var data = new FileAssertZipData { Files = [] }; + + // Act / Assert + Assert.Throws(() => FileAssertZipAssert.Create(data)); } /// @@ -100,7 +292,7 @@ public void FileAssertZipAssert_Create_EntryMissingPattern_ThrowsInvalidOperatio // Arrange var data = new FileAssertZipData { - Entries = [new FileAssertZipEntryData { Min = 1 }] + Files = [new FileAssertFileData { Min = 1 }] }; // Act / Assert @@ -121,16 +313,20 @@ public void FileAssertZipAssert_Run_MatchingEntriesMeetConstraints_NoError() CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll"]); var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "lib/net8.0/MyLib.dll", Min = 1, Max = 1 } + new FileAssertFileData { Pattern = "lib/net8.0/MyLib.dll", Min = 1, Max = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - zipAssert.Run(context, tempFile); + zipAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -156,16 +352,20 @@ public void FileAssertZipAssert_Run_GlobPatternMatchesMultipleEntries_NoError() CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll", "lib/net8.0/MyOther.dll"]); var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + new FileAssertFileData { Pattern = "lib/**/*.dll", Min = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - zipAssert.Run(context, tempFile); + zipAssert.Run(context, container, fileName); // Assert Assert.Equal(0, context.ExitCode); @@ -191,16 +391,20 @@ public void FileAssertZipAssert_Run_TooFewMatchingEntries_WritesError() CreateZipFile(tempFile, []); var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Min = 1 } + new FileAssertFileData { Pattern = "lib/**/*.dll", Min = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - zipAssert.Run(context, tempFile); + zipAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -226,16 +430,20 @@ public void FileAssertZipAssert_Run_TooManyMatchingEntries_WritesError() CreateZipFile(tempFile, ["lib/net8.0/MyLib.dll", "lib/net8.0/MyOther.dll"]); var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "lib/**/*.dll", Max = 1 } + new FileAssertFileData { Pattern = "lib/**/*.dll", Max = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - zipAssert.Run(context, tempFile); + zipAssert.Run(context, container, fileName); // Assert Assert.Equal(1, context.ExitCode); @@ -260,16 +468,20 @@ public void FileAssertZipAssert_Run_InvalidZipFile_WritesError() File.WriteAllBytes(tempFile, [0x00, 0x01, 0x02, 0x03]); var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "**/*.dll", Min = 1 } + new FileAssertFileData { Pattern = "**/*.dll", Min = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + // Act - zipAssert.Run(context, tempFile); + zipAssert.Run(context, container, fileName); // Assert - a single parse error should be reported Assert.Equal(1, context.ExitCode); @@ -287,23 +499,727 @@ public void FileAssertZipAssert_Run_InvalidZipFile_WritesError() [Fact] public void FileAssertZipAssert_Run_NonExistentFile_WritesError() { - // Arrange - use a path guaranteed not to exist - var missingFile = Path.Combine(Path.GetTempPath(), $"does_not_exist_{Guid.NewGuid():N}.zip"); + // Arrange - use a filename guaranteed not to exist in the temp directory + var missingFileName = $"does_not_exist_{Guid.NewGuid():N}.zip"; var data = new FileAssertZipData { - Entries = + Files = [ - new FileAssertZipEntryData { Pattern = "**/*.dll", Min = 1 } + new FileAssertFileData { Pattern = "**/*.dll", Min = 1 } ] }; var zipAssert = FileAssertZipAssert.Create(data); using var context = Context.Create(["--silent"]); + using var dirContainer = new DirectoryFileContainer(Path.GetTempPath()); // Act - zipAssert.Run(context, missingFile); + zipAssert.Run(context, dirContainer, missingFileName); // Assert - a single I/O error should be reported Assert.Equal(1, context.ExitCode); Assert.Equal(1, context.ErrorCount); } + + // ----------------------------------------------------------------------- + // Content assertion tests — text, XML, YAML, JSON, size, nested zip, breadcrumbs + // ----------------------------------------------------------------------- + + /// + /// Verifies that Run produces no error when a zip entry contains the required text. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryContainsRequiredText_NoError() + { + // Arrange - create a zip archive whose text entry satisfies the contains rule + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("readme.txt", "hello world")]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "readme.txt", + Text = [new FileAssertRuleData { Contains = "hello" }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry does not contain the required text. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryMissingRequiredText_WritesError() + { + // Arrange - create a zip archive whose text entry does NOT satisfy the contains rule + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("readme.txt", "goodbye world")]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "readme.txt", + Text = [new FileAssertRuleData { Contains = "not-present" }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry contains XML that satisfies + /// the XPath count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryXmlMatchesXPath_NoError() + { + // Arrange - create a zip with an XML entry containing exactly 2 item elements + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.xml", SampleXmlContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.xml", + Xml = [new FileAssertQueryData { Query = "//item", Count = 2 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry contains XML that does not satisfy + /// the XPath count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryXmlFailsXPath_WritesError() + { + // Arrange - XML has 2 items but we assert count = 5 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.xml", SampleXmlContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.xml", + Xml = [new FileAssertQueryData { Query = "//item", Count = 5 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry contains HTML that satisfies + /// the XPath count constraint of the html: asserter. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryHtmlMatchesXPath_NoError() + { + // Arrange - create a zip with an HTML entry containing exactly one element + const string sampleHtml = """ + <!DOCTYPE html> + <html> + <head><title>Report +

    one

    two

    + + """; + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("report.html", sampleHtml)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "report.html", + Html = [new FileAssertQueryData { Query = "//p", Count = 2 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry contains a PDF that satisfies + /// the page-count constraint of the pdf: asserter. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryPdfMatchesConstraint_NoError() + { + // Arrange - build a single-page PDF with body text and store it as a zip entry + byte[] pdfBytes; + using (var builder = new PdfDocumentBuilder()) + { + var page = builder.AddPage(PageSize.A4); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + page.AddText("Hello World", 12, new PdfPoint(50, 700), font); + pdfBytes = builder.Build(); + } + + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithBinaryEntry(tempFile, "report.pdf", pdfBytes); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "report.pdf", + Pdf = new FileAssertPdfData + { + Pages = new FileAssertPdfPagesData { Min = 1, Max = 1 }, + Text = [new FileAssertRuleData { Contains = "Hello" }] + } + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry contains YAML that satisfies + /// the dot-notation query count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryYamlMatchesQuery_NoError() + { + // Arrange - create a zip with a YAML entry whose server.host key matches count = 1 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.yaml", SampleYamlContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.yaml", + Yaml = [new FileAssertQueryData { Query = "server.host", Count = 1 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry contains YAML that does not satisfy + /// the dot-notation query count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryYamlFailsQuery_WritesError() + { + // Arrange - YAML has one server.host value but we assert count = 5 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.yaml", SampleYamlContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.yaml", + Yaml = [new FileAssertQueryData { Query = "server.host", Count = 5 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry contains JSON that satisfies + /// the dot-notation query count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryJsonMatchesQuery_NoError() + { + // Arrange - create a zip with a JSON entry whose "server" key matches count = 1 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.json", SampleJsonContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.json", + Json = [new FileAssertQueryData { Query = "server", Count = 1 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry contains JSON that does not satisfy + /// the dot-notation query count constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryJsonFailsQuery_WritesError() + { + // Arrange - JSON has no "missing" key but we assert count = 1 + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("config.json", SampleJsonContent)]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "config.json", + Json = [new FileAssertQueryData { Query = "missing", Count = 1 }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry's uncompressed size meets + /// the minimum-size constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryMeetsMinSizeConstraint_NoError() + { + // Arrange - entry content is "hello world" (11 bytes UTF-8 without BOM) + // and min-size is 5, which is satisfied by 11 bytes + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("data.bin", "hello world")]); + var data = new FileAssertZipData + { + Files = [new FileAssertFileData { Pattern = "data.bin", MinSize = 5 }] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry's uncompressed size is below + /// the minimum-size constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryBelowMinSizeConstraint_WritesError() + { + // Arrange - entry content is "hello world" (11 bytes UTF-8 without BOM) + // and min-size is 20, which is NOT satisfied by 11 bytes + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("data.bin", "hello world")]); + var data = new FileAssertZipData + { + Files = [new FileAssertFileData { Pattern = "data.bin", MinSize = 20 }] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run produces no error when a zip entry's uncompressed size is within + /// the maximum-size constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryMeetsMaxSizeConstraint_NoError() + { + // Arrange - entry content is "hello world" (11 bytes UTF-8 without BOM) + // and max-size is 20, which is satisfied by 11 bytes + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("data.bin", "hello world")]); + var data = new FileAssertZipData + { + Files = [new FileAssertFileData { Pattern = "data.bin", MaxSize = 20 }] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run writes an error when a zip entry's uncompressed size exceeds + /// the maximum-size constraint. + /// + [Fact] + public void FileAssertZipAssert_Run_EntryExceedsMaxSizeConstraint_WritesError() + { + // Arrange - entry content is "hello world" (11 bytes UTF-8 without BOM) + // and max-size is 5, which is NOT satisfied by 11 bytes + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("data.bin", "hello world")]); + var data = new FileAssertZipData + { + Files = [new FileAssertFileData { Pattern = "data.bin", MaxSize = 5 }] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(1, context.ExitCode); + Assert.Equal(1, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that Run recursively evaluates a text-content assertion on an entry inside + /// a zip that is itself an entry in another zip (nested zip-in-zip). + /// + [Fact] + public void FileAssertZipAssert_Run_NestedZipTextContent_InnerEntryContentMatches_NoError() + { + // Arrange - outer zip contains inner.zip which contains readme.txt with "hello world" + var tempFile = Path.GetTempFileName(); + try + { + CreateNestedZipFile(tempFile, "inner.zip", [("readme.txt", "hello world")]); + + // The inner zip assert checks the text entry and the outer zip assert locates inner.zip + var innerZipData = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "readme.txt", + Min = 1, + Text = [new FileAssertRuleData { Contains = "hello" }] + } + ] + }; + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "inner.zip", + Min = 1, + Zip = innerZipData + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + using var context = Context.Create(["--silent"]); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(context, container, fileName); + + // Assert + Assert.Equal(0, context.ExitCode); + Assert.Equal(0, context.ErrorCount); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Verifies that when a content assertion fails inside a zip entry the error message + /// carries breadcrumbs that identify both the zip file and the failing entry path. + /// + [Fact] + public void FileAssertZipAssert_Run_ContentAssertionFails_ErrorContainsBreadcrumbs() + { + // Arrange - create a zip with a text entry whose content will NOT satisfy the rule + var tempFile = Path.GetTempFileName(); + try + { + CreateZipFileWithContent(tempFile, [("readme.txt", "goodbye world")]); + var data = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "readme.txt", + Text = [new FileAssertRuleData { Contains = "not-present" }] + } + ] + }; + var zipAssert = FileAssertZipAssert.Create(data); + var capturingContext = new CapturingContext(); + + var dir = Path.GetDirectoryName(tempFile)!; + var fileName = Path.GetFileName(tempFile)!; + using var container = new DirectoryFileContainer(dir); + + // Act + zipAssert.Run(capturingContext, container, fileName); + + // Assert - the error contains the full breadcrumb path and the zip name is not doubled + Assert.NotEmpty(capturingContext.Errors); + var error = capturingContext.Errors[0]; + Assert.Contains($"{fileName} > readme.txt", error); + Assert.Equal(1, error.Split(fileName).Length - 1); + } + finally + { + File.Delete(tempFile); + } + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Modeling/ModelingTests.cs b/test/DemaConsulting.FileAssert.Tests/Modeling/ModelingTests.cs index b33ad96..ff9ea6b 100644 --- a/test/DemaConsulting.FileAssert.Tests/Modeling/ModelingTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Modeling/ModelingTests.cs @@ -18,12 +18,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.IO.Compression; + using DemaConsulting.FileAssert.Cli; using DemaConsulting.FileAssert.Configuration; using DemaConsulting.FileAssert.Modeling; using DemaConsulting.FileAssert.Utilities; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.Fonts.Standard14Fonts; +using UglyToad.PdfPig.Writer; + namespace DemaConsulting.FileAssert.Tests.Modeling; /// @@ -188,4 +195,286 @@ public void Modeling_QueryAssertions_XmlQueryMeetsCount_NoError() Assert.Equal(0, context.ExitCode); } + + /// + /// Verifies that the Modeling subsystem applies a text content assertion to a zip archive + /// entry and reports no error when the constraint is satisfied. + /// + [Fact] + public void Modeling_ZipEntryContentAssertions_TextContentPassesWhenConstraintsMet() + { + // Arrange - create a zip file containing a text entry with known content + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("entry.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("hello world"); + } + + var testData = new FileAssertTestData + { + Name = "ZipTextContentCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.zip", + Zip = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "entry.txt", + Text = + [ + new FileAssertRuleData { Contains = "hello world" } + ] + } + ] + } + } + ] + }; + + var test = FileAssertTest.Create(testData); + using var context = Context.Create(["--silent"]); + + // Act + test.Run(context, tempDir.DirectoryPath); + + // Assert - no errors reported; the text constraint was satisfied by the zip entry content + Assert.Equal(0, context.ExitCode); + + } + + /// + /// Verifies that the Modeling subsystem reports a failure with breadcrumb-style error + /// messages when a text assertion on a zip entry does not pass. + /// + [Fact] + public void Modeling_ZipEntryContentAssertions_FailureReportsWithBreadcrumbs() + { + // Arrange - create a zip file containing a text entry; the assertion will fail + using var tempDir = new TemporaryDirectory(); + var zipPath = tempDir.GetFilePath("archive.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("entry.txt"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8); + writer.Write("hello world"); + } + + var testData = new FileAssertTestData + { + Name = "ZipBreadcrumbCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.zip", + Zip = new FileAssertZipData + { + Files = + [ + new FileAssertFileData + { + Pattern = "entry.txt", + Text = + [ + new FileAssertRuleData { Contains = "not-present" } + ] + } + ] + } + } + ] + }; + + var test = FileAssertTest.Create(testData); + var logPath = tempDir.GetFilePath("errors.log"); + + // Act - run inside an explicit scope so the context (and its log writer) is disposed + // before the log file is read; the exit code is captured before disposal + int exitCode; + using (var context = Context.Create(["--silent", "--log", logPath])) + { + test.Run(context, tempDir.DirectoryPath); + exitCode = context.ExitCode; + } + + // Assert - an error was reported, exit code is non-zero, and the log message carries + // both the zip filename and the entry name as breadcrumbs + Assert.NotEqual(0, exitCode); + var logContents = File.ReadAllText(logPath); + Assert.Contains("archive.zip", logContents); + Assert.Contains("entry.txt", logContents); + + } + + /// + /// Verifies that the Modeling subsystem parses a matched file as a PDF document (a file-type + /// parse distinct from a query assertion) and applies a page-count constraint successfully. + /// + [Fact] + public void Modeling_FileTypeParsing_ValidPdf_ParsesAndAppliesPageCount_NoError() + { + // Arrange - generate a single-page PDF so the pdf: block exercises real PDF parsing + using var tempDir = new TemporaryDirectory(); + using (var builder = new PdfDocumentBuilder()) + { + var page = builder.AddPage(PageSize.A4); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + page.AddText("Report Body", 12, new PdfPoint(50, 700), font); + File.WriteAllBytes(tempDir.GetFilePath("report.pdf"), builder.Build()); + } + + var testData = new FileAssertTestData + { + Name = "PdfParseCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.pdf", + Pdf = new FileAssertPdfData + { + Pages = new FileAssertPdfPagesData { Min = 1, Max = 1 } + } + } + ] + }; + + var test = FileAssertTest.Create(testData); + using var context = Context.Create(["--silent"]); + + // Act + test.Run(context, tempDir.DirectoryPath); + + // Assert - the file parsed as a PDF and satisfied the page-count constraint + Assert.Equal(0, context.ExitCode); + } + + /// + /// Verifies that the Modeling subsystem parses an HTML document and evaluates an XPath node + /// count query, reporting no error when the count constraint is satisfied. + /// + [Fact] + public void Modeling_QueryAssertions_HtmlQueryMeetsCount_NoError() + { + // Arrange - a valid HTML file with two paragraph elements + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("report.html"), """ + + + Report +

    one

    two

    + + """); + + var testData = new FileAssertTestData + { + Name = "HtmlQueryCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.html", + Html = [new FileAssertQueryData { Query = "//p", Count = 2 }] + } + ] + }; + + var test = FileAssertTest.Create(testData); + using var context = Context.Create(["--silent"]); + + // Act + test.Run(context, tempDir.DirectoryPath); + + // Assert + Assert.Equal(0, context.ExitCode); + } + + /// + /// Verifies that the Modeling subsystem parses a YAML document and evaluates a dot-notation + /// query, reporting no error when the count constraint is satisfied. + /// + [Fact] + public void Modeling_QueryAssertions_YamlQueryMeetsCount_NoError() + { + // Arrange - a valid YAML file with a server mapping + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("config.yaml"), """ + server: + host: localhost + port: 8080 + """); + + var testData = new FileAssertTestData + { + Name = "YamlQueryCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.yaml", + Yaml = [new FileAssertQueryData { Query = "server.host", Count = 1 }] + } + ] + }; + + var test = FileAssertTest.Create(testData); + using var context = Context.Create(["--silent"]); + + // Act + test.Run(context, tempDir.DirectoryPath); + + // Assert + Assert.Equal(0, context.ExitCode); + } + + /// + /// Verifies that the Modeling subsystem parses a JSON document and evaluates a dot-notation + /// query, reporting no error when the count constraint is satisfied. + /// + [Fact] + public void Modeling_QueryAssertions_JsonQueryMeetsCount_NoError() + { + // Arrange - a valid JSON file with a server object + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("config.json"), """ + { + "server": { + "host": "localhost", + "port": 8080 + } + } + """); + + var testData = new FileAssertTestData + { + Name = "JsonQueryCheck", + Files = + [ + new FileAssertFileData + { + Pattern = "*.json", + Json = [new FileAssertQueryData { Query = "server.host", Count = 1 }] + } + ] + }; + + var test = FileAssertTest.Create(testData); + using var context = Context.Create(["--silent"]); + + // Act + test.Run(context, tempDir.DirectoryPath); + + // Assert + Assert.Equal(0, context.ExitCode); + } } diff --git a/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs b/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs index 2a9906f..375e8ad 100644 --- a/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/ProgramTests.cs @@ -146,6 +146,74 @@ public void Program_Run_NoArguments_DisplaysDefaultBehavior() } } + /// + /// Test that Run with no arguments and no default config file present prints + /// the "no configuration file found" guidance and exits cleanly. + /// + [Fact] + public void Program_Run_NoArguments_MissingDefaultConfig_WritesGuidance() + { + // Arrange + var originalOut = Console.Out; + var originalCwd = Directory.GetCurrentDirectory(); + var tempDir = Directory.CreateTempSubdirectory("FileAssertProgramTests-"); + try + { + Directory.SetCurrentDirectory(tempDir.FullName); + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + using var context = Context.Create([]); + + // Act + Program.Run(context); + + // Assert + var output = outWriter.ToString(); + Assert.Contains("No configuration file found", output); + Assert.Equal(0, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Directory.SetCurrentDirectory(originalCwd); + tempDir.Delete(recursive: true); + } + } + + /// + /// Test that Run with an explicit --config pointing at a non-existent file writes + /// a "Configuration file not found" error and sets a non-zero exit code. + /// + [Fact] + public void Program_Run_ExplicitConfigMissing_WritesError() + { + // Arrange + var originalOut = Console.Out; + var originalErr = Console.Error; + try + { + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); + Console.SetOut(outWriter); + Console.SetError(errWriter); + var missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.yaml"); + using var context = Context.Create(["--config", missing]); + + // Act + Program.Run(context); + + // Assert + var combined = outWriter.ToString() + errWriter.ToString(); + Assert.Contains("Configuration file not found", combined); + Assert.Equal(1, context.ExitCode); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + } + } + /// /// Test that version property returns non-empty version string. /// diff --git a/test/DemaConsulting.FileAssert.Tests/Utilities/IFileContainerTests.cs b/test/DemaConsulting.FileAssert.Tests/Utilities/IFileContainerTests.cs new file mode 100644 index 0000000..b6d4569 --- /dev/null +++ b/test/DemaConsulting.FileAssert.Tests/Utilities/IFileContainerTests.cs @@ -0,0 +1,378 @@ +// 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 System.IO.Compression; +using DemaConsulting.FileAssert.Utilities; + +namespace DemaConsulting.FileAssert.Tests.Utilities; + +/// +/// Unit tests for and . +/// +[Collection("Sequential")] +public sealed class IFileContainerTests +{ + // --------------------------------------------------------------------------- + // DirectoryFileContainer tests + // --------------------------------------------------------------------------- + + /// + /// Verifies that GetEntries returns all files recursively with forward-slash paths. + /// + [Fact] + public void DirectoryFileContainer_GetEntries_ReturnsAllFilesWithForwardSlashes() + { + // Arrange - create a small directory tree + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("a.txt"), "a"); + var subDir = Directory.CreateDirectory(Path.Combine(tempDir.DirectoryPath, "sub")); + File.WriteAllText(Path.Combine(subDir.FullName, "b.txt"), "b"); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act + var entries = container.GetEntries().ToList(); + + // Assert - both files are returned with forward slashes + Assert.Equal(2, entries.Count); + Assert.Contains("a.txt", entries); + Assert.Contains("sub/b.txt", entries); + } + + /// + /// Verifies that GetEntries returns an empty list when the directory is empty. + /// + [Fact] + public void DirectoryFileContainer_GetEntries_EmptyDirectory_ReturnsEmpty() + { + // Arrange - create an empty directory + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act + var entries = container.GetEntries().ToList(); + + // Assert + Assert.Empty(entries); + } + + /// + /// Verifies that GetEntries returns an empty list when the directory does not exist. + /// + [Fact] + public void DirectoryFileContainer_GetEntries_NonExistentDirectory_ReturnsEmpty() + { + // Arrange - a directory that does not exist + var missingDir = Path.Combine(Path.GetTempPath(), $"no_such_dir_{Guid.NewGuid():N}"); + using var container = new DirectoryFileContainer(missingDir); + + // Act + var entries = container.GetEntries().ToList(); + + // Assert + Assert.Empty(entries); + } + + /// + /// Verifies that OpenEntry returns a readable stream for an existing file. + /// + [Fact] + public void DirectoryFileContainer_OpenEntry_ExistingFile_ReturnsStream() + { + // Arrange - write a file with known content + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("data.txt"), "hello"); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act + using var stream = container.OpenEntry("data.txt"); + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd(); + + // Assert + Assert.Equal("hello", text); + } + + /// + /// Verifies that OpenEntry throws for a non-existent entry. + /// + [Fact] + public void DirectoryFileContainer_OpenEntry_NonExistentFile_ThrowsIOException() + { + // Arrange - empty directory; entry does not exist + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act & Assert + Assert.Throws(() => container.OpenEntry("missing.txt")); + } + + /// + /// Verifies that GetEntrySize returns the byte length of an existing file. + /// + [Fact] + public void DirectoryFileContainer_GetEntrySize_ReturnsCorrectSize() + { + // Arrange - write a file with 5 ASCII bytes + using var tempDir = new TemporaryDirectory(); + File.WriteAllText(tempDir.GetFilePath("size.txt"), "hello"); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act + var size = container.GetEntrySize("size.txt"); + + // Assert + Assert.Equal(5L, size); + } + + /// + /// Verifies that GetDisplayPath returns the full file-system path for a root-level entry. + /// + [Fact] + public void DirectoryFileContainer_GetDisplayPath_RootEntry_ReturnsFullPath() + { + // Arrange + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + + // Act + var displayPath = container.GetDisplayPath("report.pdf"); + + // Assert - the display path is the full file-system path, useful in error messages + var expectedPath = Path.Combine(tempDir.DirectoryPath, "report.pdf"); + Assert.Equal(expectedPath, displayPath); + } + + // --------------------------------------------------------------------------- + // ZipFileContainer tests + // --------------------------------------------------------------------------- + + /// + /// Creates a zip archive byte array in memory containing the specified entries. + /// + /// Entry name/content pairs. + /// A byte array containing the zip archive. + private static byte[] CreateZipBytes(IEnumerable<(string name, string content)> entries) + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = zip.CreateEntry(name); + using var w = new StreamWriter(entry.Open()); + w.Write(content); + } + } + + return ms.ToArray(); + } + + /// + /// Verifies that GetEntries returns entry names with forward slashes, excluding directory entries. + /// + [Fact] + public void ZipFileContainer_GetEntries_ReturnsFileEntriesWithForwardSlashes() + { + // Arrange - build a zip in memory with two file entries + var bytes = CreateZipBytes([("lib/a.dll", "a"), ("lib/b.dll", "b")]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act + var entries = container.GetEntries().ToList(); + + // Assert + Assert.Equal(2, entries.Count); + Assert.Contains("lib/a.dll", entries); + Assert.Contains("lib/b.dll", entries); + } + + /// + /// Verifies that OpenEntry returns a readable stream for an existing zip entry. + /// + [Fact] + public void ZipFileContainer_OpenEntry_ExistingEntry_ReturnsStream() + { + // Arrange - zip containing one entry with known content + var bytes = CreateZipBytes([("readme.txt", "zip content")]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act + using var entryStream = container.OpenEntry("readme.txt"); + using var reader = new StreamReader(entryStream); + var text = reader.ReadToEnd(); + + // Assert + Assert.Equal("zip content", text); + } + + /// + /// Verifies that OpenEntry throws for a missing entry. + /// + [Fact] + public void ZipFileContainer_OpenEntry_NonExistentEntry_ThrowsIOException() + { + // Arrange - empty zip + var bytes = CreateZipBytes([]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act & Assert + Assert.Throws(() => container.OpenEntry("missing.txt")); + } + + /// + /// Verifies that GetEntrySize returns the uncompressed length of a zip entry. + /// + [Fact] + public void ZipFileContainer_GetEntrySize_ReturnsUncompressedLength() + { + // Arrange - zip containing an entry with 5 ASCII chars + var bytes = CreateZipBytes([("data.txt", "hello")]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act + var size = container.GetEntrySize("data.txt"); + + // Assert + Assert.Equal(5L, size); + } + + /// + /// Verifies that GetDisplayPath returns the display name prefixed path for a zip entry. + /// + [Fact] + public void ZipFileContainer_GetDisplayPath_ReturnsDisplayNamePrefixedPath() + { + // Arrange - zip container with display name "outer.zip" + var bytes = CreateZipBytes([("inner.txt", "x")]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "outer.zip"); + + // Act + var displayPath = container.GetDisplayPath("inner.txt"); + + // Assert + Assert.Equal("outer.zip > inner.txt", displayPath); + } + + /// + /// Verifies that GetEntries excludes zip directory marker entries (names ending in '/'). + /// + [Fact] + public void ZipFileContainer_GetEntries_ExcludesDirectoryMarkers() + { + // Arrange - build a zip containing a directory marker entry and a file entry + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + // Directory marker entry (name ends in '/', no content) + zip.CreateEntry("lib/"); + + // Regular file entry within that directory + var fileEntry = zip.CreateEntry("lib/a.dll"); + using var w = new StreamWriter(fileEntry.Open()); + w.Write("a"); + } + + var bytes = ms.ToArray(); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act + var entries = container.GetEntries().ToList(); + + // Assert - only the file entry is returned; the directory marker is excluded + Assert.Single(entries); + Assert.Contains("lib/a.dll", entries); + Assert.DoesNotContain("lib/", entries); + } + + /// + /// Verifies that OpenEntry and GetEntrySize accept entry paths that use backslash + /// separators by normalizing them to forward slashes before lookup. + /// + [Fact] + public void ZipFileContainer_BackslashEntryPath_OpensAndSizesAfterNormalization() + { + // Arrange - zip stores the entry with a forward-slash separator + var bytes = CreateZipBytes([("lib/a.dll", "abc")]); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "archive.zip"); + + // Act - look the entry up using a backslash path + using var entryStream = container.OpenEntry("lib\\a.dll"); + using var reader = new StreamReader(entryStream); + var content = reader.ReadToEnd(); + var size = container.GetEntrySize("lib\\a.dll"); + + // Assert - both APIs resolve the entry through backslash normalization + Assert.Equal("abc", content); + Assert.Equal(3L, size); + } + + // --------------------------------------------------------------------------- + // DirectoryFileContainer null-input tests + // --------------------------------------------------------------------------- + + /// + /// Verifies that the constructor rejects a null base path. + /// + [Fact] + public void DirectoryFileContainer_Constructor_NullBasePath_ThrowsArgumentNullException() + { + Assert.Throws(() => new DirectoryFileContainer(null!)); + } + + /// + /// Verifies that OpenEntry rejects a null entry path. + /// + [Fact] + public void DirectoryFileContainer_OpenEntry_NullEntryPath_ThrowsArgumentNullException() + { + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + Assert.Throws(() => container.OpenEntry(null!)); + } + + /// + /// Verifies that GetEntrySize rejects a null entry path. + /// + [Fact] + public void DirectoryFileContainer_GetEntrySize_NullEntryPath_ThrowsArgumentNullException() + { + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + Assert.Throws(() => container.GetEntrySize(null!)); + } + + /// + /// Verifies that GetDisplayPath rejects a null entry path. + /// + [Fact] + public void DirectoryFileContainer_GetDisplayPath_NullEntryPath_ThrowsArgumentNullException() + { + using var tempDir = new TemporaryDirectory(); + using var container = new DirectoryFileContainer(tempDir.DirectoryPath); + Assert.Throws(() => container.GetDisplayPath(null!)); + } +} diff --git a/test/DemaConsulting.FileAssert.Tests/Utilities/PathHelpersTests.cs b/test/DemaConsulting.FileAssert.Tests/Utilities/PathHelpersTests.cs index 60eea7e..732b7f5 100644 --- a/test/DemaConsulting.FileAssert.Tests/Utilities/PathHelpersTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Utilities/PathHelpersTests.cs @@ -199,4 +199,27 @@ public void PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullExcep Assert.Throws(() => PathHelpers.SafePathCombine(basePath, relativePath!)); } + + /// + /// Test that SafePathCombine rejects a rooted relative path even when its resolved + /// form would lie within the base directory. A rooted second argument to + /// replaces the base entirely, so any + /// such input is treated as invalid by the helper. + /// + [Fact] + public void PathHelpers_SafePathCombine_RootedPathInsideBase_RejectsIt() + { + // Arrange - use the platform's temp directory as a base and an absolute path + // that lives inside that base. Both expressions are rooted on either OS. + var basePath = Path.GetFullPath(Path.GetTempPath()); + var relativePath = Path.Combine(basePath, "child", "file.txt"); + + // Pre-condition: the resolved combined location IS inside basePath. + Assert.True(Path.IsPathRooted(relativePath)); + + // Act & Assert - rooted relative paths are rejected unconditionally + var exception = Assert.Throws(() => + PathHelpers.SafePathCombine(basePath, relativePath)); + Assert.Contains("Invalid path component", exception.Message); + } } diff --git a/test/DemaConsulting.FileAssert.Tests/Utilities/UtilitiesTests.cs b/test/DemaConsulting.FileAssert.Tests/Utilities/UtilitiesTests.cs index a1613be..816bec9 100644 --- a/test/DemaConsulting.FileAssert.Tests/Utilities/UtilitiesTests.cs +++ b/test/DemaConsulting.FileAssert.Tests/Utilities/UtilitiesTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.IO.Compression; using DemaConsulting.FileAssert.Utilities; namespace DemaConsulting.FileAssert.Tests.Utilities; @@ -71,4 +72,54 @@ public void Utilities_TemporaryDirectory_IsolatesAndCleansUpScratchSpace() Assert.False(File.Exists(filePath), "Scratch file should be removed after the temporary directory is disposed."); } + + /// + /// Verifies the file-container abstraction end-to-end by building a real zip archive with + /// multiple entries and exercising the full surface: + /// GetEntries, OpenEntry, GetEntrySize, and GetDisplayPath. + /// + [Fact] + public void Utilities_FileContainerAbstraction_ZipFileContainer_EndToEnd() + { + // Arrange: build a zip archive in memory with multiple entries + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var readme = zip.CreateEntry("docs/readme.txt"); + using (var w = new StreamWriter(readme.Open())) + { + w.Write("hello"); + } + + var data = zip.CreateEntry("lib/data.bin"); + using (var w = new StreamWriter(data.Open())) + { + w.Write("12345678"); + } + } + + var bytes = ms.ToArray(); + using var stream = new MemoryStream(bytes); + using var container = new ZipFileContainer(stream, "package.zip"); + + // Act & Assert: GetEntries returns both file entries + var entries = container.GetEntries().ToList(); + Assert.Equal(2, entries.Count); + Assert.Contains("docs/readme.txt", entries); + Assert.Contains("lib/data.bin", entries); + + // Act & Assert: OpenEntry returns the entry content + using (var entryStream = container.OpenEntry("docs/readme.txt")) + using (var reader = new StreamReader(entryStream)) + { + Assert.Equal("hello", reader.ReadToEnd()); + } + + // Act & Assert: GetEntrySize returns the uncompressed length + Assert.Equal(5L, container.GetEntrySize("docs/readme.txt")); + Assert.Equal(8L, container.GetEntrySize("lib/data.bin")); + + // Act & Assert: GetDisplayPath includes the archive breadcrumb prefix + Assert.Equal("package.zip > lib/data.bin", container.GetDisplayPath("lib/data.bin")); + } }