Skip to content
Draft
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
31 changes: 31 additions & 0 deletions core/Common/Reflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,37 @@ public static function fetchTraits(
return \array_unique($traits);
}

/**
* Fetch the attributes of every case of an enum.
*
* Every case is present in the result, in declaration order, so a caller can walk the enum once and
* fall back to a default where a case carries nothing.
*
* @template T of object
*
* @param \ReflectionEnum|class-string<\UnitEnum> $enum
* @param class-string<T>|null $attributeClass If provided, only attributes of this class will be returned.
* @param int $flags Flags to pass to {@see \ReflectionEnumUnitCase::getAttributes()}.
*
* @return ($attributeClass is null
* ? array<non-empty-string, list<\ReflectionAttribute>>
* : array<non-empty-string, list<\ReflectionAttribute<T>>>) Attributes keyed by case name.
*/
public static function fetchEnumCaseAttributes(
\ReflectionEnum|string $enum,
?string $attributeClass = null,
int $flags = 0,
): array {
\is_string($enum) and $enum = new \ReflectionEnum($enum);

$attributes = [];
foreach ($enum->getCases() as $case) {
$attributes[$case->name] = $case->getAttributes($attributeClass, $flags);
}

return $attributes;
}

/**
* Find all methods in a class that have a specific attribute.
*
Expand Down
34 changes: 34 additions & 0 deletions core/Core/Metric/Memory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Metric;

use Testo\Metric\Dimension;
use Testo\Metric\Factor;
use Testo\Metric\Unit;
use Testo\Metric\UnitConversion;

/**
* An amount of memory, in bytes or one of their binary (IEC) multiples — the scale PHP's own memory
* figures and the tools around it use, named so a kibibyte is never mistaken for a thousand bytes.
*
* @api
*/
#[Dimension('memory')]
enum Memory: string implements Unit
{
use UnitConversion;

#[Factor(1)]
case Bytes = 'B';

#[Factor(1024)]
case Kibibytes = 'KiB';

#[Factor(1024 ** 2)]
case Mebibytes = 'MiB';

#[Factor(1024 ** 3)]
case Gibibytes = 'GiB';
}
21 changes: 21 additions & 0 deletions core/Core/Metric/Percent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Metric;

use Testo\Metric\Dimension;
use Testo\Metric\Unit;
use Testo\Metric\UnitConversion;

/**
* A ratio already expressed out of 100.
*
* @api
*/
#[Dimension('percent')]
enum Percent: string implements Unit
{
use UnitConversion;
case Percent = 'percent';
}
21 changes: 21 additions & 0 deletions core/Core/Metric/Scalar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Metric;

use Testo\Metric\Dimension;
use Testo\Metric\Unit;
use Testo\Metric\UnitConversion;

/**
* A dimensionless quantity — a count of iterations or calls, a rank.
*
* @api
*/
#[Dimension('number')]
enum Scalar: string implements Unit
{
use UnitConversion;
case Number = 'number';
}
33 changes: 33 additions & 0 deletions core/Core/Metric/Time.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Testo\Core\Metric;

use Testo\Metric\Dimension;
use Testo\Metric\Factor;
use Testo\Metric\Unit;
use Testo\Metric\UnitConversion;

/**
* A duration, in one of the units it may be measured in.
*
* @api
*/
#[Dimension('time')]
enum Time: string implements Unit
{
use UnitConversion;

#[Factor(1)]
case Nanoseconds = 'ns';

#[Factor(1_000)]
case Microseconds = 'us';

#[Factor(1_000_000)]
case Milliseconds = 'ms';

#[Factor(1_000_000_000)]
case Seconds = 's';
}
25 changes: 25 additions & 0 deletions core/Metric/Dimension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Testo\Metric;

/**
* Names the physical quantity a {@see Unit} enum measures.
*
* Placed on the enum itself. The name is what a reporter shows or keys on when it does not care about
* the particular unit — `time`, `memory` — and what an error names when two units are mixed. An enum
* without it is still a valid unit family: the name falls back to the enum's short class name in lower case.
*
* @api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class Dimension
{
/**
* @param non-empty-string $name
*/
public function __construct(
public string $name,
) {}
}
26 changes: 26 additions & 0 deletions core/Metric/Exception/IncompatibleUnits.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Testo\Metric\Exception;

use Testo\Metric\Unit;

/**
* Thrown when a value is converted between units of different dimensions — time into bytes.
*
* @api
*/
final class IncompatibleUnits extends \InvalidArgumentException
{
public static function between(Unit $from, Unit $to): self
{
return new self(\sprintf(
'Cannot convert between units of different dimensions: %s::%s and %s::%s.',
$from::class,
$from->name,
$to::class,
$to->name,
));
}
}
30 changes: 30 additions & 0 deletions core/Metric/Exception/InvalidUnitDeclaration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Testo\Metric\Exception;

use Testo\Metric\Unit;

/**
* Thrown when a {@see Unit} enum is declared in a way the metric core cannot read — a non-enum class,
* a class that does not implement the interface, or a non-positive factor.
*
* @api
*/
final class InvalidUnitDeclaration extends \LogicException
{
public static function notAUnitEnum(string $class): self
{
return new self(\sprintf('`%s` must be an enum implementing `%s`.', $class, Unit::class));
}

/**
* @param class-string $class
* @param non-empty-string $case
*/
public static function nonPositiveFactor(string $class, string $case, int|float $factor): self
{
return new self(\sprintf('Factor of `%s::%s` must be positive, got `%s`.', $class, $case, (string) $factor));
}
}
26 changes: 26 additions & 0 deletions core/Metric/Factor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Testo\Metric;

/**
* How many base units one unit of this case is worth.
*
* Placed on a {@see Unit} enum case. The base is the case with factor `1` — a case without the attribute
* has that factor, so a single-case family such as {@see \Testo\Core\Metric\Percent} declares nothing. Converting between
* two cases is a ratio of their factors, and the compact form of a value is the case whose factor is the
* largest one the value still exceeds.
*
* @api
*/
#[\Attribute(\Attribute::TARGET_CLASS_CONSTANT)]
final readonly class Factor
{
/**
* @param int|float $value A positive multiplier to the family's base unit.
*/
public function __construct(
public int|float $value,
) {}
}
52 changes: 52 additions & 0 deletions core/Metric/Metric.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Testo\Metric;

use Testo\Metric\Exception\IncompatibleUnits;

/**
* One number a test produced, together with the unit it was measured in.
*
* The unit travels with the value rather than baked into a name, so each reporter renders it its own way
* — a TeamCity number `type`, a JUnit property-name suffix — instead of parsing it back out of a key. The
* unit's family is a type parameter, so a `Metric<Percent>` is distinct from a `Metric<Time>` and a
* consumer keyed to a family cannot be handed the wrong one.
*
* @template-covariant TUnit of Unit
* @api
*/
final readonly class Metric
{
/**
* @param TUnit $unit
*/
public function __construct(
public int|float $value,
public Unit $unit,
) {}

/**
* The same amount in another unit of the same family.
*
* @template TTo of Unit
* @param TTo $unit
* @return self<TTo>
* @throws IncompatibleUnits When `$unit` belongs to a different family.
*/
public function to(Unit $unit): self
{
return new self(Units::convert($this->value, $this->unit, $unit), $unit);
}

/**
* The same amount in the unit of its family that reads most compactly; see {@see UnitFamily::fit()}.
*
* @return self<TUnit>
*/
public function compact(): self
{
return Units::compact($this->value, $this->unit);
}
}
33 changes: 33 additions & 0 deletions core/Metric/Unit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Testo\Metric;

use Testo\Metric\Exception\IncompatibleUnits;

/**
* The unit a {@see Metric} was measured in.
*
* The parent of every dimension enum ({@see \Testo\Core\Metric\Time}, {@see \Testo\Core\Metric\Memory}, {@see \Testo\Core\Metric\Percent}, {@see \Testo\Core\Metric\Scalar}), so a
* metric can name a unit from any family while a reporter still handles all of them through one type.
* Each family is one backed enum whose cases scale within it: the scale is declared with {@see Factor}
* on the cases and read by {@see Units}, and the {@see UnitConversion} trait implements this interface
* for such an enum. Converting between families is a reporter's job at its own boundary.
*
* @api
*/
interface Unit extends \BackedEnum
{
/**
* How many base units of the family one of this unit is worth.
*/
public function factor(): int|float;

/**
* An amount in this unit expressed in another unit of the same family.
*
* @throws IncompatibleUnits When `$to` belongs to a different family.
*/
public function convert(int|float $value, Unit $to): int|float;
}
25 changes: 25 additions & 0 deletions core/Metric/UnitConversion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Testo\Metric;

/**
* The {@see Unit} arithmetic for an enum that declares itself through {@see Dimension} and {@see Factor}.
*
* Everything is delegated to {@see Units}, so the enum contributes only its cases and attributes.
*
* @api
*/
trait UnitConversion
{
public function factor(): int|float
{
return Units::factor($this);
}

public function convert(int|float $value, Unit $to): int|float
{
return Units::convert($value, $this, $to);
}
}
Loading
Loading