Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 14:15
· 41 commits to master since this release
90a21e1

Every open issue of the backlog in one release. All additive — nothing
removed, nothing renamed; a minor because Arg::rest(), Arg::captor(),
Understudy::delegate(), Understudy::lean() and rendered property hooks
are new public API, and on 0.x that is the boundary Composer's caret already
treats as breaking. The perf ritual ran before the tag: three full-harness
runs a side against the commit the published figures were taken at — the
per-call marginal cost is unchanged (0.90µs), double creation moved +1.4-2.4%
while the competitors moved ±3-4% in the same runs, which is inside the noise
floor.

  • Interface-declared property hooks are rendered, so a modern contract can
    be doubled at all
    (#36). public string $name { get; }
    on an interface — or an abstract hooked property on a class — no longer
    refuses the target: the generated class declares the property with the
    dispatcher inside the hook, which no __get-based library can do (__get
    fires only for an inaccessible property). Reads answer the forwarding
    target's value, else what the code under test wrote (a { get; set; }
    property behaves like a plain one), else the mode's type-safe default —
    Understudy::defaults() registrations and the depth-1 nested double
    included. Exactly the declared hooks are rendered: a get-only property
    refuses a write with PHP's own error. A property read is not a call — not
    recorded, not specifiable, not judged by strict mode; stubbing and verifying
    reads is future work by design. Still refused, each with its reason: a
    readonly class target carrying an abstract hook (a hooked property cannot be
    readonly, and a readonly class only extends readonly), and a by-reference
    &get hook. Concrete hooks on class targets stay inherited, and on PHP 8.3
    nothing changes — the language cannot express a hooked property there.

  • Understudy::lean(): a call log that does not retain returned values
    (#63). The transcript keeps every invocation with its
    outcome until reset() — which the runner adapters call after the test's
    own teardown, so a value a double returned is still referenced while
    teardown runs. Real incident: a forwarding double returned file streams and
    FileHelper::removeDirectory() failed with "Directory not empty" — on
    Windows only, since POSIX unlinks open files. A lean double keeps the
    invocation (method, arguments, sequence), so matching, verify(),
    transcript() and nothingElse() work unchanged, but not the value:
    Invocation::returned() raises OutcomeUnavailable the way it already does
    for a call that threw. Also caps per-call memory growth in hot loops. The
    retention interaction — and Understudy::scope() as the other remedy — is
    now documented in both READMEs and llms.txt.

  • The pre-0.1.0 double-creation regression is now a stated decision, not an
    open question
    (#56). perf/README.md records that the
    ~0.3µs per double bisected to #23 bought bounded registration/reset/verify
    accounting and closed the Fiber hole where an unmet expect() in a Fiber
    passed silently — a false pass, the one failure mode a verification library
    must not have. The trade is accepted; reopening it requires the full-harness
    A/B the file describes. No code change.

  • Arg::captor(): a typed argument captor (#62). The
    typed replacement for reading args[N] out of the call log:
    $options = Arg::captor(DeliveryOptions::class), $options->capture() in
    the specification where the argument goes, then $options->last() /
    $options->all() — typed through the class-string generic, so no
    instanceof narrowing ritual at the read site. The typed form matches like
    Arg::instanceOf(), the untyped like Arg::any(); the value is recorded
    only once the whole specification matched, so a call the other arguments
    rejected captures nothing. Works in when(), expect() and verify().
    last() on an empty captor raises the new NothingCaptured; captured
    values are dropped with the context, like the call log.

  • Understudy::delegate(Contract::class, $real): a forwarding double in one
    expression
    (#61). Builds the double, turns forwarding
    on and returns it — the for() + forwarding() pair that suites leaning on
    forwarding repeated at every site. The target is validated the way
    forwarding() validates it; both existing forwarding() forms stay.

  • Arg::rest(): "the arguments before me matter, the rest of the arity does
    not"
    (#60). The one matcher that lets a specification
    stop before the method's required parameters run out —
    when(fn () => $storage->recordOutcome('svc', Arg::rest())) instead of an
    Arg::any() per remaining parameter. To make the shortened call physically
    possible, every required parameter of a generated method now defaults to an
    internal sentinel; a real call that omits a required argument still fails
    with ArgumentCountError, raised by the dispatcher instead of the engine,
    so a double is no more permissive about arity than the contract. A
    specification that stops early without ending in Arg::rest() is refused
    with the reason — including Arg::remaining()/Arg::none() in that
    position, which describe a variadic tail, not omitted parameters. Layering
    is untouched: a later, narrower specification still wins over the broad
    prefix stub.