Skip to content

Refactor Imperium.UnitTests spec framework for clarity and consistency#139

Merged
bartul merged 16 commits into
masterfrom
reorganize-unittests-package-ready-spec
May 25, 2026
Merged

Refactor Imperium.UnitTests spec framework for clarity and consistency#139
bartul merged 16 commits into
masterfrom
reorganize-unittests-package-ready-spec

Conversation

@bartul
Copy link
Copy Markdown
Owner

@bartul bartul commented May 25, 2026

Closes #137.

Summary

Continues the structural cleanup tracked by issue #137 with a sweep across the Imperium.UnitTests spec framework and per-BC test infrastructure. All renames are mechanical; no behavior changes. 130 tests pass throughout.

  • Spec framework: Both SpecRunner and Specification now use the canonical F# namespace + type + companion module shape (matches Option, Result, Map). SpecMarkdownMarkdown (drops redundant namespace-repeating prefix), MarkdownRenderOptionsMarkdown.RenderOptions. SpecFilter.T record collapsed to a Predicate = string list -> bool type alias. runActions, prepareContext, and toMarkdownDocument privatized.
  • Spec tests split per module: SpecificationTests.fs previously mixed three modules under a generic "Spec" testList. Split into SpecificationTests, SpecRunnerTests, CollectionAssertTests — each file maps 1:1 to a framework module.
  • BC test contexts: AccountingContext/RondelContext types renamed to Context, createContext factory renamed to Context.create, both BCs promoted to namespace + type + companion module shape. accountingSpecs/rondelSpecsspecifications. The SpecRunner value moved from each Context.fs into the only file that uses it (Specs.fs) as a private value.
  • Rondel StateFormatting: Renamed to Board.render — module name describes what it produces (a board diagram) and matches the framework's noun-module + verb-function pattern.
  • Misc: renderSpecMarkdown function → renderMarkdown (CLI flag preserved for CI). Dropped 5 unused open statements. AGENTS.md synced to reflect all renames.

Summary by CodeRabbit

  • Tests
    • Reorganized test suite with a new, modular specification DSL and runner for clearer, more consistent tests.
    • Added rich test helpers, collection/assertion utilities, and readable state/board renderers.
    • Improved test filtering, Expecto integration, and CLI markdown rendering for spec outputs.
  • Documentation
    • Updated guidelines and architecture docs to reflect the current spec/testing layout and coverage.

Review Change Stack

bartul added 15 commits May 25, 2026 19:54
Split the flat test project into a source-mirror layout:

- Support/Spec → Imperium.Testing.Spec namespace (Specification, Runner,
  Filter, CollectionAssert, Markdown) ready for future package extraction
- Support/Spec.Tests → split SpecTests.fs into per-area files
- Imperium/{Accounting,Rondel} → Context/Assertions/Specs (+ Rondel
  StateFormatting); BC specs use module abbreviations in Main.fs to keep
  Accounting/Rondel short names
- Imperium/Contract → contract transformation tests
- Imperium/Gameplay → placeholder test module
- Imperium.Terminal → mirrors src/Imperium.Terminal subfolders

Compile order in the fsproj is explicit and topological. All 130 tests
pass; dotnet test, native runner, and --render-spec-markdown filters
verified.
Update the test project layout section to reflect the new source-mirror
structure introduced by #137 (Support/Spec, Imperium/{BC}/{Context,
Assertions,Specs}, Imperium.Terminal mirrors). Document the
Imperium.Testing.Spec namespace and the opens pattern consumer files
use. Refresh all test-coverage snapshot paths to the new file locations.
Drop CLAUDE.md so AGENTS.md remains the single agent guide.
Mark runActions and prepareContext as private in Runner.fs; they are
implementation details, not part of the spec framework's public surface
(SpecRunner, runExpectation, toExpecto).

Rewrite the on/specOn factory tests in SpecificationTests.fs to assert
on the context inside an expect block and drive via runExpectation,
removing their dependency on prepareContext.
Replace the single-field record `type T = { MatchExpectation: ... }`
with `type Predicate = string list -> bool`. The wrapping record only
served to label one function; the alias keeps the documented signature
and lets call sites apply the predicate directly (`filter path` instead
of `filter.MatchExpectation path`).

Also renames `T` to `Predicate` to match the codebase's descriptive
type-naming convention (no other module uses `type T`).
Convert Runner.fs from a wrapper module (module Imperium.Testing.Spec.Runner
containing type SpecRunner + nested module SpecRunner) to the canonical F#
shape: namespace Imperium.Testing.Spec with the SpecRunner type and a
companion SpecRunner module side-by-side.

The private helpers runActions and prepareContext move inside the
SpecRunner module so they live next to the public functions that use them.
Consumers drop the now-redundant `open Imperium.Testing.Spec.Runner` line.

Also renames toExpecto to toExpectoTestList to better convey its return
type, and removes an unused Specification open in Rondel/Context.fs.
Convert Specification.fs from a wrapper module (module Imperium.Testing.Spec.Specification
containing types + a same-named nested Specification module) to the canonical
F# shape: namespace Imperium.Testing.Spec with types and a companion
Specification module side-by-side.

The companion module now holds only the spec/specOn CE factories. The
previously-nested withGivenState/withActions/preserve helpers were unused
across the codebase and have been removed; they can be reintroduced if a
real consumer needs to compose specs outside the CE syntax.

This mirrors the SpecRunner refactor: same shape applied to the framework's
other primary type, eliminating the Specification.Specification.X redundancy.
SpecificationTests.fs previously held tests for three different modules
under a generic 'Spec' testList. Split into focused files that mirror the
framework module layout:

- SpecificationTests.fs ('Specification') — 5 tests for the CE factories
  (spec/specOn) and Expecto-assertion compatibility.
- SpecRunnerTests.fs ('SpecRunner') — 5 tests for runExpectation outcomes
  (assertion/action failures, state snapshots, AssertException) and the
  preserve flag.
- CollectionAssertTests.fs ('CollectionAssert') — 2 tests for HasAny/HasNone
  failure message contents.

Also fixes 4 testCase descriptions that were inadvertently prefixed with
'SpecRunner.' during an earlier mass rename, and capitalizes the two
collection assertion descriptions.

After the split, each Spec.Tests file maps 1:1 to a Spec framework module
(SpecificationTests ↔ Specification, SpecRunnerTests ↔ SpecRunner, etc.).
The module SpecMarkdown lives in the Imperium.Testing.Spec namespace, so
the Spec prefix repeats the namespace. Rename to Markdown to match the
codebase convention (Rondel, Accounting, Gameplay, Primitives — namespaces
convey scope, modules don't repeat it).

Also renames MarkdownRenderOptions to RenderOptions: the Markdown prefix
existed only to disambiguate from the module's previous SpecMarkdown name,
and now produces the redundant Markdown.RenderOptions read.

testList in MarkdownTests.fs updated to match the new module name.

The function renderSpecMarkdown is left intact — it's a domain action name
tied to the --render-spec-markdown CLI flag, not a module reference.
For Accounting and Rondel test contexts, switch from a file-module wrapper
to the canonical F# namespace + type + companion module shape (matching
SpecRunner and Specification):

- File module Imperium.UnitTests.{BC}.Context becomes namespace
  Imperium.UnitTests.{BC} with type Context + module Context containing
  the create factory.
- AccountingContext / RondelContext drop the redundant BC prefix and
  become just Context (namespace conveys the BC).
- createContext drops the redundant verb and becomes Context.create.

The runner SpecRunner value, previously top-level in each Context.fs, now
lives in the only file that uses it (Specs.fs) as a private value. The
Context type carries no responsibility for test execution wiring.

Also drops three now-redundant open statements (Assertions and Specs files
implicitly see their parent namespace via their module declaration path).
Two redundant-prefix renames in the BC test files:

- accountingSpecs / rondelSpecs → specifications. The BC prefix repeats
  the file's namespace and the plural reads honestly as a Specification
  list.
- renderSpecMarkdown → renderMarkdown. The function name no longer needs
  the Spec prefix now that the SpecMarkdown module is just Markdown; the
  namespace and the spec arguments convey "render specs as markdown."

The CLI flag --render-spec-markdown stays as-is (used by CI and docs).
docs/architecture.md updated to mirror the function name and pick up an
earlier-missed SpecMarkdown.render → Markdown.render reference.
Updates the test-framework sections of AGENTS.md to match the recent
restructure on this branch:

- Spec framework file listing: Runner.fs → SpecRunner.fs; reflects the
  type + companion module shape now used by SpecRunner and Specification;
  renames SpecMarkdown → Markdown.
- Spec.Tests file listing now lists all five files (SpecRunnerTests and
  CollectionAssertTests added after the split).
- BC test layout: Context.fs now declares type Context + Context.create
  in namespace Imperium.UnitTests.{BC}; runner moved from Context.fs to
  Specs.fs as a private value.
- Consumer-open guidance: opening Imperium.Testing.Spec now suffices for
  the bulk of the framework; Imperium.Testing.Spec.Specification only
  needed to unqualify the spec/specOn CE factories. The defunct
  Imperium.Testing.Spec.Runner open removed.
- Usage-pattern code example updated: specOn createContext →
  specOn Context.create, toExpecto runner → SpecRunner.toExpectoTestList
  runner, accountingSpecs → specifications.
- Key design decision references promoted to the qualified
  SpecRunner.runExpectation / SpecRunner.toExpectoTestList names.
- Test coverage snapshot for Spec.Tests reorganized into per-file bullets
  (one per module being tested); replaces the stale 31-tests-in-3-files
  description.

Last verified date bumped to 2026-05-25.
The module produces an ASCII rondel board diagram with nation tokens
placed on spaces — that's a noun (Board), not a process (StateFormatting).
Renames to match the framework's noun-module + verb-function pattern
(Markdown.render, SpecFilter.apply, CollectionAssert.forAccessor):

- File: StateFormatting.fs → Board.fs
- Module: module Imperium.UnitTests.Rondel.StateFormatting →
  [<RequireQualifiedAccess>] module Imperium.UnitTests.Rondel.Board
  (adds RequireQualifiedAccess for consistency with the other flat
  utility modules in the spec framework)
- Function: format → render (parallels Markdown.render)
- Call site: Some StateFormatting.format → Some Board.render

AGENTS.md and the fsproj compile order updated accordingly.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bd5db9d6-e5b5-48e8-b0a0-0aea7ec411fe

📥 Commits

Reviewing files that changed from the base of the PR and between 7296e35 and dc3f6a7.

📒 Files selected for processing (1)
  • docs/architecture.md
✅ Files skipped from review due to trivial changes (1)
  • docs/architecture.md

📝 Walkthrough

Walkthrough

This PR reorganizes the test specification framework: it replaces the monolithic Spec module with a modular Imperium.Testing.Spec (Specification DSL, SpecRunner, SpecFilter, Markdown, CollectionAssert), migrates Accounting and Rondel test suites to the new framework (contexts, assertions, renderers), updates the test project compile ordering and test entrypoint, and refreshes docs and examples.

Changes

Spec Framework Restructuring and Test Migration

Layer / File(s) Summary
Specification DSL and Types
tests/Imperium.UnitTests/Support/Spec/Specification.fs
Introduces Specification<'ctx,'seed,'cmd,'evt>, Action<'cmd,'evt>, Expectation<'ctx>, ExpectationOutcome/ExpectationRunResult<'state>, and a SpecificationBuilder (CE) with operations on, state, given_command, given_event, preserve, when_command, when_event, expect; exposes spec and specOn.
SpecRunner Record-based Execution
tests/Imperium.UnitTests/Support/Spec/SpecRunner.fs
Adds SpecRunner<'ctx,'seed,'state,'cmd,'evt> record, SpecRunner.empty, SpecRunner.runExpectation (state capture + outcome), and SpecRunner.toExpectoTestList (converts expectations to Expecto tests, rethrowing original exceptions).
Filtering, Markdown, and Collection Assertions
tests/Imperium.UnitTests/Support/Spec/Filter.fs, tests/Imperium.UnitTests/Support/Spec/Markdown.fs, tests/Imperium.UnitTests/Support/Spec/CollectionAssert.fs
SpecFilter parses CLI flags into Predicate and filters spec expectations; Markdown moved to Imperium.Testing.Spec.Markdown with RenderOptions and uses SpecRunner.runExpectation for rendering; CollectionAssert provides Accessor and forAccessor for detailed Expecto collection assertions.
Specification Framework Test Coverage
tests/Imperium.UnitTests/Support/Spec.Tests/*
New tests: SpecificationTests, SpecRunnerTests, FilterTests, MarkdownTests, CollectionAssertTests validating builder semantics, runner behavior, filtering logic, markdown output, and assertion messages.
Accounting Test Suite Migration
tests/Imperium.UnitTests/Imperium/Accounting/Context.fs, tests/Imperium.UnitTests/Imperium/Accounting/Assertions.fs, tests/Imperium.UnitTests/Imperium/Accounting/Specs.fs
Adds Accounting test Context with in-memory Events buffer and Context.create; new assertion helpers in Assertions.fs; Specs.fs now uses specOn Context.create, SpecRunner.toExpectoTestList, and Markdown.render via renamed renderMarkdown.
Rondel Test Suite Migration
tests/Imperium.UnitTests/Imperium/Rondel/Context.fs, tests/Imperium.UnitTests/Imperium/Rondel/Board.fs, tests/Imperium.UnitTests/Imperium/Rondel/Assertions.fs, tests/Imperium.UnitTests/Imperium/Rondel/Specs.fs
Adds Rondel test Context with in-memory Store, Board.render ASCII renderer, many Rondel-specific assertion helpers, and Specs.fs refactored to use specOn Context.create, Board.render state formatting, and SpecRunner.toExpectoTestList.
Project Configuration, Test Entry, and Documentation
tests/Imperium.UnitTests/Imperium.UnitTests.fsproj, tests/Imperium.UnitTests/Main.fs, tests/Imperium.UnitTests/Imperium/Gameplay/GameplayTests.fs, AGENTS.md, docs/architecture.md
fsproj compile include reordered for new layout; Main.fs uses Markdown.RenderOptions, expanded allTests and --render-spec-markdown routing; removed old Spec.fs/SpecTests.fs; docs updated to reference new modules and usage examples; minor Gameplay import cleanup.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • bartul/imperium#100: Related migration from interface-based runner to the SpecRunner record-of-functions used here.
  • bartul/imperium#104: Related changes to spec seeded-state naming/flow (GivenState) reflected in this PR.
  • bartul/imperium#120: Related migration of the spec framework and markdown rendering pipeline to the assertion-native model used here.

🐰 I hopped through specs and stitched them neat,
From one big nest to modules tidy and sweet.
Tests now sprout roots where runners take flight,
Markdown and filters all humming just right.
Thump — the refactor finished, bright as a carrot bite.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: a refactor of the spec framework structure and naming across the Imperium.UnitTests project.
Linked Issues check ✅ Passed All coding-related objectives from issue #137 are met: spec support isolated under Support/Spec with canonical F# naming, BC contexts/helpers reorganized locally, contract/terminal tests separated, fsproj ordering preserved, and all build/test/render commands functional.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the refactoring objectives: spec framework reorganization, context/assertion restruturing, file reorganization, documentation updates, and project file compilation ordering adjustments.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reorganize-unittests-package-ready-spec

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/Imperium.UnitTests/Imperium/Accounting/Assertions.fs (1)

10-10: ⚡ Quick win

Make collection accessor module-private.

Use let private events = ... so only assertion helpers are exposed from this module.

As per coding guidelines: tests/Imperium.UnitTests/**/{Context,Assertions,Specs}.fs: “for repeated collection checks in assertions, define accessors like let private events = CollectionAssert.forAccessor (...)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Imperium.UnitTests/Imperium/Accounting/Assertions.fs` at line 10, The
binding events in Assertions.fs is public but should be module-private; change
the declaration of events (the result of forAccessor taking Context ->
ctx.Events) to use a private binding (e.g., let private events = forAccessor
(fun (ctx: Context) -> ctx.Events :> seq<_>)) so only the assertion helpers are
exposed; update the events symbol accordingly and leave forAccessor and Context
usage as-is.
tests/Imperium.UnitTests/Imperium/Rondel/Assertions.fs (1)

12-14: ⚡ Quick win

Keep accessor helpers private.

Please mark both accessors private (let private events, let private commands) to avoid widening module surface unnecessarily.

As per coding guidelines: tests/Imperium.UnitTests/**/{Context,Assertions,Specs}.fs: “for repeated collection checks in assertions, define accessors like let private events = CollectionAssert.forAccessor (...)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Imperium.UnitTests/Imperium/Rondel/Assertions.fs` around lines 12 - 14,
The two accessor bindings are public but should be private; change the
declarations for events and commands to be private (e.g. make the bindings named
events and commands use "let private") so the accessors created with forAccessor
(used with Context -> ctx.Events and Context -> ctx.Commands) are not exposed
from the module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/architecture.md`:
- Line 112: Documentation refers to the wrong symbol: replace the incorrect
`Markdown.toMarkdownDocument` reference with the actual function name
`Markdown.render` (as implemented in `Markdown.fs` and referenced elsewhere) so
the description matches the code and the AI summary; ensure the sentence still
describes that `Markdown.render` calls `runExpectation` for every expectation
and renders all results without aborting on failures.

---

Nitpick comments:
In `@tests/Imperium.UnitTests/Imperium/Accounting/Assertions.fs`:
- Line 10: The binding events in Assertions.fs is public but should be
module-private; change the declaration of events (the result of forAccessor
taking Context -> ctx.Events) to use a private binding (e.g., let private events
= forAccessor (fun (ctx: Context) -> ctx.Events :> seq<_>)) so only the
assertion helpers are exposed; update the events symbol accordingly and leave
forAccessor and Context usage as-is.

In `@tests/Imperium.UnitTests/Imperium/Rondel/Assertions.fs`:
- Around line 12-14: The two accessor bindings are public but should be private;
change the declarations for events and commands to be private (e.g. make the
bindings named events and commands use "let private") so the accessors created
with forAccessor (used with Context -> ctx.Events and Context -> ctx.Commands)
are not exposed from the module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 14dd2fce-16ee-4223-b9be-c2ee48093203

📥 Commits

Reviewing files that changed from the base of the PR and between a197bad and 7296e35.

📒 Files selected for processing (31)
  • AGENTS.md
  • docs/architecture.md
  • tests/Imperium.UnitTests/Imperium.Terminal/Accounting/HostTests.fs
  • tests/Imperium.UnitTests/Imperium.Terminal/BusTests.fs
  • tests/Imperium.UnitTests/Imperium.Terminal/Rondel/DirectCommitTests.fs
  • tests/Imperium.UnitTests/Imperium.Terminal/Rondel/HostTests.fs
  • tests/Imperium.UnitTests/Imperium.Terminal/Rondel/StoreTests.fs
  • tests/Imperium.UnitTests/Imperium.UnitTests.fsproj
  • tests/Imperium.UnitTests/Imperium/Accounting/Assertions.fs
  • tests/Imperium.UnitTests/Imperium/Accounting/Context.fs
  • tests/Imperium.UnitTests/Imperium/Accounting/Specs.fs
  • tests/Imperium.UnitTests/Imperium/Contract/AccountingContractTests.fs
  • tests/Imperium.UnitTests/Imperium/Contract/RondelContractTests.fs
  • tests/Imperium.UnitTests/Imperium/Gameplay/GameplayTests.fs
  • tests/Imperium.UnitTests/Imperium/Rondel/Assertions.fs
  • tests/Imperium.UnitTests/Imperium/Rondel/Board.fs
  • tests/Imperium.UnitTests/Imperium/Rondel/Context.fs
  • tests/Imperium.UnitTests/Imperium/Rondel/Specs.fs
  • tests/Imperium.UnitTests/Main.fs
  • tests/Imperium.UnitTests/Spec.fs
  • tests/Imperium.UnitTests/SpecTests.fs
  • tests/Imperium.UnitTests/Support/Spec.Tests/CollectionAssertTests.fs
  • tests/Imperium.UnitTests/Support/Spec.Tests/FilterTests.fs
  • tests/Imperium.UnitTests/Support/Spec.Tests/MarkdownTests.fs
  • tests/Imperium.UnitTests/Support/Spec.Tests/SpecRunnerTests.fs
  • tests/Imperium.UnitTests/Support/Spec.Tests/SpecificationTests.fs
  • tests/Imperium.UnitTests/Support/Spec/CollectionAssert.fs
  • tests/Imperium.UnitTests/Support/Spec/Filter.fs
  • tests/Imperium.UnitTests/Support/Spec/Markdown.fs
  • tests/Imperium.UnitTests/Support/Spec/SpecRunner.fs
  • tests/Imperium.UnitTests/Support/Spec/Specification.fs
💤 Files with no reviewable changes (3)
  • tests/Imperium.UnitTests/SpecTests.fs
  • tests/Imperium.UnitTests/Imperium/Gameplay/GameplayTests.fs
  • tests/Imperium.UnitTests/Spec.fs

Comment thread docs/architecture.md Outdated
@bartul bartul merged commit 8abb4e8 into master May 25, 2026
5 checks passed
@bartul bartul deleted the reorganize-unittests-package-ready-spec branch May 25, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

type = improvement: Reorganize Imperium.UnitTests with package-ready spec support

1 participant