Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ name: Draft changelog
# drafts the "[Unreleased]" section of ONE release train's changelog from the pull
# requests merged since that train's last tag, then opens a pull request so a human
# can review it (and delete any benefit the model inferred rather than found)
# before merging. The two trains version independently and keep separate changelogs:
# before merging. The trains version independently and keep separate changelogs:
# lib -> CHANGELOG.md (FirstClassErrors + FirstClassErrors.Testing)
# cli -> FirstClassErrors.Cli/CHANGELOG.md (the fce .NET tool)
# dum -> Dummies/CHANGELOG.md (the standalone Dummies library)
on:
workflow_dispatch:
inputs:
Expand All @@ -16,6 +17,7 @@ on:
options:
- lib
- cli
- dum
required: true
from_ref:
description: "Previous tag to diff from. Leave blank to auto-detect the train's latest tag; if the train has no tag yet, the whole history is used."
Expand Down Expand Up @@ -90,7 +92,7 @@ jobs:
tools/changelog/collect-prs.sh "$COMPONENT" > prs.json
echo "count=$(jq 'length' prs.json)" >> "$GITHUB_OUTPUT"
echo "Collected $(jq 'length' prs.json) ${COMPONENT}-facing pull request(s) for this changelog."
# COMPONENT is a choice input (lib|cli only), safe to pass straight through.
# COMPONENT is a choice input (lib|cli|dum only), safe to pass straight through.
# from_ref is free text, so it travels via the environment (never inlined
# into the script) and is only ever handed to `git log`.

Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/release-dryrun.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: release-dryrun

# Continuously rehearses the SIDE-EFFECT-FREE portion of the release pipeline — build, pack, and embed the
# SBOM for the two published projects — on every push to main and every pull request. release.yml itself
# SBOM for the published trains — on every push to main and every pull request. release.yml itself
# runs only on a tag or a manual dispatch, so its packaging path is otherwise exercised for the first time
# in production, on a tag, once; this catches packaging/SBOM regressions in ordinary CI instead.
#
Expand Down Expand Up @@ -63,17 +63,19 @@ jobs:

# The SAME script release.yml packs with — it packs each train's projects with their SBOM and then
# asserts the SBOM is actually embedded. Sharing it is the point: this rehearsal cannot drift from the
# real release, because there is only one definition of "pack the release artifacts". Both trains are
# rehearsed so neither packaging path first runs in production.
# real release, because there is only one definition of "pack the release artifacts". All trains are
# rehearsed so no packaging path first runs in production.
# (The unit/integration tests run in ci.yml; this job's unique contribution is the packaging.)
- name: Pack with SBOM (build artifacts, no publish)
run: |
tools/packaging/pack.sh "$DRYRUN_VERSION" lib
tools/packaging/pack.sh "$DRYRUN_VERSION" cli
tools/packaging/pack.sh "$DRYRUN_VERSION" dum

# Rehearse the train-scoped release-notes generation too (release.yml's other production-only path),
# so a bug in it surfaces here instead of during a real release. Prints to the log; publishes nothing.
- name: Rehearse release notes (both trains, no publish)
- name: Rehearse release notes (all trains, no publish)
run: |
echo "----- lib notes -----"; tools/packaging/release-notes.sh lib HEAD
echo "----- cli notes -----"; tools/packaging/release-notes.sh cli HEAD
echo "----- dum notes -----"; tools/packaging/release-notes.sh dum HEAD
16 changes: 10 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ name: release

on:
push:
# Publish on a train-prefixed semantic-version tag. The two trains version independently:
# Publish on a train-prefixed semantic-version tag. The trains version independently:
# lib-v1.2.3 -> FirstClassErrors + FirstClassErrors.Testing
# cli-v1.2.3 -> FirstClassErrors.Cli (the fce .NET tool)
# dum-v1.2.3 -> Dummies (the standalone arbitrary-test-value library)
tags:
- 'lib-v*.*.*'
- 'cli-v*.*.*'
- 'dum-v*.*.*'
workflow_dispatch:
inputs:
component:
Expand All @@ -16,6 +18,7 @@ on:
options:
- lib
- cli
- dum
required: true
version:
description: 'Package version, without any prefix (e.g. 1.2.3; a dry run can use any SemVer, e.g. 0.0.0-dry.1)'
Expand Down Expand Up @@ -99,12 +102,13 @@ jobs:
case "$REF_NAME" in
lib-v*) COMPONENT="lib"; VERSION="${REF_NAME#lib-v}" ;;
cli-v*) COMPONENT="cli"; VERSION="${REF_NAME#cli-v}" ;;
*) echo "::error::Tag '$REF_NAME' is not a release tag; expected lib-v*.*.* or cli-v*.*.*."; exit 1 ;;
dum-v*) COMPONENT="dum"; VERSION="${REF_NAME#dum-v}" ;;
*) echo "::error::Tag '$REF_NAME' is not a release tag; expected lib-v*.*.*, cli-v*.*.* or dum-v*.*.*."; exit 1 ;;
esac
fi
case "$COMPONENT" in
lib|cli) ;;
*) echo "::error::Invalid component '$COMPONENT'; expected 'lib' or 'cli'."; exit 1 ;;
lib|cli|dum) ;;
*) echo "::error::Invalid component '$COMPONENT'; expected 'lib', 'cli' or 'dum'."; exit 1 ;;
esac
# Build metadata (+...) is deliberately REJECTED even though SemVer allows it: NuGet strips it
# from the package identity, so a tag like lib-v1.2.3+build5 packs as FirstClassErrors.1.2.3.nupkg.
Expand Down Expand Up @@ -182,8 +186,8 @@ jobs:
fi
echo "ok: GenDoc's error catalog has a breaking change since $PREVIOUS_TAG, matched by the major version bump ($PREVIOUS_MAJOR -> $NEW_MAJOR)."

# Pack only the train this release targets (lib -> FirstClassErrors + .Testing; cli -> the fce tool),
# so a lib release never republishes the CLI and vice versa. The analyzer is bundled inside the main
# Pack only the train this release targets (lib -> FirstClassErrors + .Testing; cli -> the fce
# tool; dum -> Dummies), so a lib release never republishes the CLI and vice versa. The analyzer is bundled inside the main
# package and the GenDoc worker inside the CLI tool; the samples are not published.
# GenerateSBOM activates Microsoft.Sbom.Targets in each packable project: each package embeds its SPDX
# inventory at _manifest/spdx_2.2/manifest.spdx.json. tools/packaging/pack.sh is the single source of
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read:
* Follow `.github/pull_request_template.md` for every pull request.
* Do not open a pull request unless I explicitly ask for one.
* PR titles, descriptions, commits, and branch names must be written in English.
* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, dummies, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
* Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title.
* Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request.
* In PR descriptions, do not invent testing results. Only check items that were actually run.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ The scope MAY be provided. When present it MUST be lowercase and MUST be one of:
| `analyzers` | `FirstClassErrors.Analyzers` — the Roslyn analyzers and their `FCExxx` diagnostics |
| `binder` | `FirstClassErrors.RequestBinder` — the request binder for the primary-adapter boundary |
| `cli` | `FirstClassErrors.Cli` — the command-line tool |
| `dummies` | `Dummies` — the standalone arbitrary-test-value generator |
| `gendoc` | `FirstClassErrors.GenDoc` and its worker — the documentation generator |
| `testing` | `FirstClassErrors.Testing` — the test-support package |

Expand Down
206 changes: 206 additions & 0 deletions Dummies.UnitTests/AnyInt32Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace Dummies.UnitTests;

[TestSubject(typeof(AnyInt32))]
public sealed class AnyInt32Tests {

private const int SampleCount = 200;

#region Statics members declarations

private static IEnumerable<int> Samples(IAny<int> generator) {
for (int i = 0; i < SampleCount; i++) {
yield return generator.Generate();
}
}

#endregion

[Fact(DisplayName = "An unconstrained Int32 generates without failing.")]
public void UnconstrainedGenerates() {
Check.ThatCode(() => Any.Int32().Generate()).DoesNotThrow();
}

[Fact(DisplayName = "Positive yields values strictly greater than zero.")]
public void PositiveIsStrictlyPositive() {
foreach (int value in Samples(Any.Int32().Positive())) {
Check.That(value).IsStrictlyGreaterThan(0);
}
}

[Fact(DisplayName = "Negative yields values strictly less than zero.")]
public void NegativeIsStrictlyNegative() {
foreach (int value in Samples(Any.Int32().Negative())) {
Check.That(value).IsStrictlyLessThan(0);
}
}

[Fact(DisplayName = "Zero yields exactly zero.")]
public void ZeroIsZero() {
Check.That(Any.Int32().Zero().Generate()).IsEqualTo(0);
}

[Fact(DisplayName = "NonZero never yields zero.")]
public void NonZeroIsNeverZero() {
foreach (int value in Samples(Any.Int32().NonZero().Between(-2, 2))) {
Check.That(value).IsNotEqualTo(0);
}
}

[Fact(DisplayName = "Between yields values within the inclusive bounds.")]
public void BetweenStaysWithinBounds() {
foreach (int value in Samples(Any.Int32().Between(10, 20))) {
Check.That(value).IsGreaterOrEqualThan(10);
Check.That(value).IsLessOrEqualThan(20);
}
}

[Fact(DisplayName = "Between with equal bounds pins the value.")]
public void BetweenWithEqualBoundsPins() {
Check.That(Any.Int32().Between(5, 5).Generate()).IsEqualTo(5);
}

[Fact(DisplayName = "Between eventually reaches both inclusive bounds.")]
public void BetweenReachesItsBounds() {
HashSet<int> seen = new(Samples(Any.Int32().Between(1, 3)));

Check.That(seen.Contains(1)).IsTrue();
Check.That(seen.Contains(3)).IsTrue();
}

[Fact(DisplayName = "GreaterThan is exclusive, GreaterThanOrEqualTo is inclusive.")]
public void LowerBoundsAreExactlyExclusiveOrInclusive() {
foreach (int value in Samples(Any.Int32().GreaterThan(10).LessThanOrEqualTo(12))) {
Check.That(value).IsGreaterOrEqualThan(11);
}

HashSet<int> seen = new(Samples(Any.Int32().GreaterThanOrEqualTo(10).LessThanOrEqualTo(11)));
Check.That(seen.Contains(10)).IsTrue();
}

[Fact(DisplayName = "LessThan is exclusive, LessThanOrEqualTo is inclusive.")]
public void UpperBoundsAreExactlyExclusiveOrInclusive() {
foreach (int value in Samples(Any.Int32().LessThan(10).GreaterThanOrEqualTo(8))) {
Check.That(value).IsLessOrEqualThan(9);
}

HashSet<int> seen = new(Samples(Any.Int32().LessThanOrEqualTo(10).GreaterThanOrEqualTo(9)));
Check.That(seen.Contains(10)).IsTrue();
}

[Fact(DisplayName = "The extreme bounds of the Int32 range are generable.")]
public void ExtremeBoundsAreGenerable() {
Check.That(Any.Int32().LessThanOrEqualTo(int.MinValue).Generate()).IsEqualTo(int.MinValue);
Check.That(Any.Int32().GreaterThanOrEqualTo(int.MaxValue).Generate()).IsEqualTo(int.MaxValue);
}

[Fact(DisplayName = "OneOf yields only the supplied values.")]
public void OneOfStaysWithinTheSuppliedValues() {
int[] allowed = [1, 5, 9];
foreach (int value in Samples(Any.Int32().OneOf(allowed))) {
Check.That(allowed.Contains(value)).IsTrue();
}
}

[Fact(DisplayName = "Except never yields an excluded value.")]
public void ExceptNeverYieldsAnExcludedValue() {
foreach (int value in Samples(Any.Int32().Between(1, 3).Except(2))) {
Check.That(value).IsNotEqualTo(2);
}
}

[Fact(DisplayName = "DifferentFrom never yields the excluded value.")]
public void DifferentFromNeverYieldsTheValue() {
foreach (int value in Samples(Any.Int32().Between(7, 8).DifferentFrom(7))) {
Check.That(value).IsEqualTo(8);
}
}

[Fact(DisplayName = "Positive then Negative conflicts, naming both constraints.")]
public void PositiveThenNegativeConflicts() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Int32().Positive().Negative());

Check.That(conflict.Message).Contains("Negative()");
Check.That(conflict.Message).Contains("Positive()");
}

[Fact(DisplayName = "GreaterThan then an impossible LessThan conflicts, naming both constraints.")]
public void CrossedBoundsConflict() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Int32().GreaterThan(100).LessThan(10));

Check.That(conflict.Message).Contains("LessThan(10)");
Check.That(conflict.Message).Contains("GreaterThan(100)");
}

[Fact(DisplayName = "Zero then NonZero conflicts: the pinned value is excluded.")]
public void ZeroThenNonZeroConflicts() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Int32().Zero().NonZero());

Check.That(conflict.Message).Contains("NonZero()");
Check.That(conflict.Message).Contains("Zero()");
}

[Fact(DisplayName = "GreaterThan int.MaxValue conflicts: no Int32 satisfies it.")]
public void GreaterThanMaxValueConflicts() {
Check.ThatCode(() => Any.Int32().GreaterThan(int.MaxValue)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "OneOf then a bound excluding every allowed value conflicts.")]
public void OneOfEmptiedByABoundConflicts() {
ConflictingAnyConstraintException conflict = Assert.Throws<ConflictingAnyConstraintException>(
() => Any.Int32().OneOf(1, 2).GreaterThan(5));

Check.That(conflict.Message).Contains("GreaterThan(5)");
Check.That(conflict.Message).Contains("OneOf(1, 2)");
}

[Fact(DisplayName = "A second OneOf conflicts: the allow-list is declared once.")]
public void SecondOneOfConflicts() {
Check.ThatCode(() => Any.Int32().OneOf(1, 2).OneOf(3, 4)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "Except exhausting the whole interval conflicts.")]
public void ExceptExhaustingTheIntervalConflicts() {
Check.ThatCode(() => Any.Int32().Between(1, 2).Except(1, 2)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "Except exhausting the allow-list conflicts.")]
public void ExceptExhaustingTheAllowListConflicts() {
Check.ThatCode(() => Any.Int32().OneOf(1, 2).Except(1).Except(2)).Throws<ConflictingAnyConstraintException>();
}

[Fact(DisplayName = "Between with crossed arguments is an argument error, not a conflict.")]
public void BetweenWithCrossedArgumentsIsAnArgumentError() {
Check.ThatCode(() => Any.Int32().Between(10, 1)).Throws<ArgumentException>();
}

[Fact(DisplayName = "OneOf and Except reject null or empty value lists.")]
public void OneOfAndExceptRejectNullOrEmpty() {
Check.ThatCode(() => Any.Int32().OneOf()).Throws<ArgumentException>();
Check.ThatCode(() => Any.Int32().OneOf(null!)).Throws<ArgumentNullException>();
Check.ThatCode(() => Any.Int32().Except()).Throws<ArgumentException>();
Check.ThatCode(() => Any.Int32().Except(null!)).Throws<ArgumentNullException>();
}

[Fact(DisplayName = "A constrained generator is a new instance: the original is unchanged.")]
public void ConstrainingReturnsANewGenerator() {
AnyInt32 original = Any.Int32().Between(1, 10);
AnyInt32 constrained = original.GreaterThanOrEqualTo(10);

Check.That(ReferenceEquals(constrained, original)).IsFalse();
// The original still generates over its own, wider domain.
HashSet<int> seen = new(Samples(original));
Check.That(seen.Count).IsStrictlyGreaterThan(1);
}

}
Loading