Skip to content

feat: test metadata reporting and a unit-aware metric engine - #316

Draft
roxblnfk wants to merge 7 commits into
1.xfrom
feature/sandbox-test-metadata
Draft

feat: test metadata reporting and a unit-aware metric engine#316
roxblnfk wants to merge 7 commits into
1.xfrom
feature/sandbox-test-metadata

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Sep 3, 2026

Copy link
Copy Markdown
Member

🔍 What was changed

  • #[TestMetadata] in testo/test: a repeatable attribute that attaches a testMetadata value (number, text, link, image, artifact) to a test. The interceptor is registered by the default TestPlugin, so any suite reports it to TeamCity out of the box. Relative image and artifact paths resolve against the test file's directory.
  • Bench metrics are reported as a bench.<case>.<columnGroup>.<column> table instead of a flat list, with the same column groups the terminal table prints. TeamCity and JUnit output of bench metrics changes; the HTML and JSON structured map does not.
  • New metric engine in Testo\Metric: #[Dimension] on a unit enum names what it measures, #[Factor] on a case gives its multiplier to the base unit. Units reflects both once per enum and does the conversion; Metric::to() and Metric::compact() sit on top. The concrete families Time, Memory, Percent, Scalar stay in Testo\Core\Metric.
  • Memory uses binary units with honest names: Bytes, Kibibytes, Mebibytes, Gibibytes with symbols B, KiB, MiB, GiB. The factors were already 1024-based.
  • Reflection::fetchEnumCaseAttributes() returns the attributes of every enum case keyed by case name.

How it works

  • A unit enum implements Unit via the UnitConversion trait and declares its cases with #[Factor]; the case without one is the base. Conversion is a ratio of factors and stays integral while every operand is an integer.
  • compact() picks the largest unit the value still reaches at least one of, derived from the factors alone, so a family needs no hook of its own.
  • Reporters convert at their own boundary: TeamCity scales every time unit to ms and memory to bytes, JUnit appends the unit symbol to the flat property name (…mean.us, …memory.B).

Why?

A value should carry its unit instead of hiding it in a key name. Reporters were each parsing units back out of leaf names, and the bench mapper rounded once for everyone. With the unit on the Metric, each consumer renders it natively and the source keeps its precision.

Checklist

  • How was this tested:
    • Tested manually
    • Unit tests added
    • composer psalm passes locally; composer test:ci -- --json --type=!bench passes except BenchAttr::rangeSum, which trips the local Xdebug nesting limit on the recursive bench and is unrelated to this branch

Review notes

  • Unit families are one enum per physical dimension. Splitting time into two enums for sub-second and wall-clock ranges was considered and rejected, since values could no longer convert between them.
  • Skills do not yet describe Testo\Metric or #[TestMetadata]; that is left for a follow-up.

Add a sandbox playground that emits ##teamcity[testMetadata] messages for
every TeamCity metadata type. A #[TestMetadata] attribute declares the values;
a TestMetadataInterceptor (registered by TestMetadataPlugin on the sandbox
suite) writes them straight to the stream after the test body runs, nested
between testStarted and testFinished like the built-in bench metrics.

Covers numbers (a chartable table), text, links, and images/artifacts sourced
from both local files and URLs; relative file references resolve against the
test directory.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bench metrics now spell `bench.<case>.<columnGroup>.<column>` instead of the
flat `bench.<case>.<metric>`, so a consumer that understands the four-segment
`testMetadata` convention rebuilds them as a sortable table with spanning
column-group bands — the same grouping the terminal's ASCII table prints:
Benchmark setup, Time results, Filtered results, Summary. Every case emits every
column so the grid stays rectangular, and the standalone `bench.iterations`
counter folds into each row's Benchmark setup group to keep the key depth
uniform — a mixed depth would drop a consumer back to a flat list.

The unit no longer hides in the leaf name. A value is a `Testo\Core\Metric\Metric`
— a generic `Metric<TUnit>` carrying its number in the unit it was measured in.
Units are split into family enums (Time, Memory, Percent, Scalar) under a shared
`Unit` interface, so a `Metric<Percent>` cannot be handed to a consumer keyed to
`Metric<Time>`. Each reporter renders the unit its own way: JUnit — no type
attribute — suffixes the flat property name with the native unit (`…mean.us`,
`…memory.bytes`); TeamCity converts at its boundary via `TeamcityMetricType` to
the four number types it charts, scaling every time unit to milliseconds and
every byte unit to bytes. The source keeps native precision for every consumer
instead of rounding once at the mapper.

Affects the TeamCity `testMetadata` and JUnit `<property>` output; the HTML/JSON
structured map is unchanged. The sandbox metadata playground gains ms/bytes/
percent examples.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A three-level `<prefix>.<row>.<column>` table (no column group) whose values all
share one type, so the metadata consumer can plot it as a graph rather than a
grid: rows as the x-axis, columns as the series, milliseconds as the y-axis.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `#[TestMetadata]` attribute and its interceptor move out of the sandbox and
into `testo/test`, so any suite can attach `testMetadata` to a test — the
interceptor is registered by the default TestPlugin, and the attribute lives
beside `#[Test]` as `Testo\Test\TestMetadata`.

The interceptor builds the `##teamcity[testMetadata …]` service message itself
rather than through the core formatter, so the plugin depends on nothing
internal; the format is TeamCity's own stable protocol. The sandbox keeps only
the showcase test, now importing the attribute from the plugin, and its bespoke
plugin/interceptor are gone.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `metadata` group to the bench self-test and the sandbox showcase so the
metadata-reporting tests can be run as a set, rename the showcase's image
entries, point the remote-image example at a reachable asset, and extend the
Assert sandbox's logging demo.

Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every case is present in the result, in declaration order, so a caller can walk an enum once and fall back to a default where a case carries nothing.

Assisted-By: Claude Fable 5.1
…en units

refactor(output): build the JUnit property suffix in the writer instead of on the unit

The metric engine moves to `Testo\Metric`: `#[Dimension]` names what an enum measures, `#[Factor]` on a case gives its multiplier to the family's base unit, and `Units` reflects both once per enum and does the arithmetic. `Testo\Core\Metric` keeps only the concrete families. A conversion stays integral while every operand is an integer, so `2 KiB` is `2048` bytes rather than `2048.0`. `compact()` derives the most readable unit from the factors alone, so no per-family hook is needed.

`Memory` is renamed to its binary units (`KiB`, `MiB`, `GiB`): the factors were already 1024-based, and the SI names misrepresented them. Backing values are the unit symbols, which the JUnit writer now appends itself; the `suffix()` method was a single reporter's concern living in the core contract.

Assisted-By: Claude Fable 5.1
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

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.

1 participant