Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import org.specs2.mutable.Specification
*
* '''2026-04-29 consolidation.''' 5 → 1 named block. Each case's fire + message-projection is
* still witnessed; the spec frame collapses.
*
* '''2026-09-18 consolidation.''' The `IndexOutOfRange` fire scenario is dropped: `at(5)` on a
* one-element array is one point of what [[JsonIndexBoundsSpec]]'s oracle property now asserts
* over the whole index range, against an expectation derived from the backing `Vector`. Verified
* by a mutation re-run — no mutant flipped Killed → Survived.
*/
class JsonFailureSpec extends Specification:

Expand All @@ -25,8 +30,8 @@ class JsonFailureSpec extends Specification:
// NotAnObject.message contains "expected JSON object";
// NotAnArray fires when parent is not a JSON array for an Index step,
// NotAnArray.message contains "expected JSON array";
// IndexOutOfRange fires when index past end of array (Ior.Both with size in chain),
// IndexOutOfRange.message contains "size=N";
// IndexOutOfRange.message contains "size=N" (its FIRE scenario is subsumed by
// JsonIndexBoundsSpec's oracle property — every out-of-range index class, not one constant);
// DecodeFailed fires when leaf Json doesn't decode (Ior.Left with DecodeFailed in chain)
"JsonFailure: every case fires from a triggering input + message-projection holds" >> {
// ---- PathMissing ----
Expand Down Expand Up @@ -62,12 +67,10 @@ class JsonFailureSpec extends Specification:
val naMessageOk = naFailure.message must contain("expected JSON array")

// ---- IndexOutOfRange ----
val basket = Basket("Alice", Vector(Order("X")))
val iorResult = codecPrism[Basket].items.at(5).modify(identity)(basket.asJson)
val iorFireOk = iorResult match
case Ior.Both(chain, _) =>
chain.headOption.get === JsonFailure.IndexOutOfRange(PathStep.Index(5), 1)
case _ => ko(s"expected Ior.Both, got $iorResult")
// The FIRE scenario moved to JsonIndexBoundsSpec's oracle property, which asserts the exact
// `Ior.Both(Chain.one(IndexOutOfRange(Index(i), size)), json)` for every out-of-range index
// class (negative, == length, > length) instead of the single hard-coded `at(5)` here. Only
// the message projection — which that property does not look at — stays.
val iorFailure: JsonFailure = JsonFailure.IndexOutOfRange(PathStep.Index(7), 3)
val iorMessageOk = iorFailure.message must contain("size=3")

Expand All @@ -91,7 +94,6 @@ class JsonFailureSpec extends Specification:
.and(noMessageOk)
.and(naFireOk)
.and(naMessageOk)
.and(iorFireOk)
.and(iorMessageOk)
.and(dfFireOk)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,47 @@ package dev.constructive.eo.circe

import scala.language.implicitConversions

import cats.data.Ior
import io.circe.syntax.*
import org.specs2.mutable.Specification
import org.scalacheck.Gen
import org.scalacheck.Prop.forAll
import org.specs2.ScalaCheck

/** Index-bounds discrimination for the array walk: the existing specs exercise out-of-range-high on
* a 1-element array and index 0 on non-arrays, but never a SUCCESSFUL walk at indices 0 and >0 of
* one array, nor a negative index — so operator mutants on the `idx < 0 || idx >= arr.length`
* check survived.
/** Index-bounds discrimination for the array walk.
*
* `JsonWalk` carries TWO copies of `idx < 0 || idx >= arr.length` — one in `readPath`, one in
* `modifyPath`. The previous version of this spec drove only `.modify(…)`, so every operator
* mutant on the `readPath` copy survived. The property below drives the read, the Ior write and
* the silent write from one index, against an oracle DERIVED from the backing `Vector` rather than
* hard-coded, so a guard that mis-fires is caught whichever direction it mis-fires in.
*/
class JsonIndexBoundsSpec extends Specification:
class JsonIndexBoundsSpec extends JsonSpecBase with ScalaCheck:

import JsonSpecFixtures.*

// covers: JsonWalk.walkStep Index bounds (JsonWalk.scala:62), every operator variant —
// `idx < 0` weakened to `> 0` breaks at(1), to `<= 0` breaks at(0); a weakened
// `idx >= length` lets at(3)/at(-1) walk off the array.
"indices 0 and 1 read their elements; -1 and length fail IndexOutOfRange" >> {
val basket = Basket("Alice", Vector(Order("A"), Order("B"), Order("C"))).asJson
def run(i: Int) = codecPrism[Basket].items.at(i).modify(identity)(basket)
val valid = (run(0), run(1)) match
case (Ior.Right(_), Ior.Right(_)) => true
case _ => false
def oob(i: Int) = run(i) match
case Ior.Both(chain, _) =>
chain.headOption.get == JsonFailure.IndexOutOfRange(PathStep.Index(i), 3)
case _ => false
(valid, oob(-1), oob(3)) must beEqualTo((true, true, true))
// covers: JsonWalk.scala:56 readPath Index bounds, every operator variant (:20 `→false`,
// :24 `<`→`<=`, :24 `<`→`==`, :28 `||`→`&&`, :35 `>=`→`>`, :35 `>=`→`==`) and the
// already-covered twin at JsonWalk.scala:81 (modifyPath).
// Range -2..5 against a FIXED size-3 array straddles all five discriminating classes:
// i<0, i=0 (low boundary), 0<i<3, i=3 (exactly length), i>3 (strictly past length).
// i=3 alone kills `>=`→`>`; i>=4 alone kills `>=`→`==`; i=0 alone kills both `<` variants.
"read / Ior write / silent write agree with the Vector oracle at every index class" >> {
val items = Vector(Order("A"), Order("B"), Order("C"))
val basket: Json = Basket("Alice", items).asJson
forAll(Gen.chooseNum(-2, 5)) { (i: Int) =>
val p = codecPrism[Basket].items.at(i)
val hit = i >= 0 && i < items.length
val oor: JsonFailure = JsonFailure.IndexOutOfRange(PathStep.Index(i), items.length)

val read: Ior[Chain[JsonFailure], Order] = p.get(basket)
val expectedRead: Ior[Chain[JsonFailure], Order] =
if hit then Ior.Right(items(i)) else Ior.Left(Chain.one(oor))

val write: Ior[Chain[JsonFailure], Json] = p.modify(identity)(basket)
val expectedWrite: Ior[Chain[JsonFailure], Json] =
if hit then Ior.Right(basket) else Ior.Both(Chain.one(oor), basket)

val silent: Json = p.modifyUnsafe(identity)(basket)

(read == expectedRead) && (write == expectedWrite) && (silent == basket)
}
}
24 changes: 2 additions & 22 deletions circe/src/test/scala/dev/constructive/eo/circe/JsonPrismSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,8 @@ class JsonPrismSpec extends Specification with ScalaCheck:
// covers: at(i) on root-level Vector modify the i-th element + leave siblings byte-identical,
// at(i) on nested Basket.items modifies the right element + leaves others alone,
// nested at(i) getOptionUnsafe returns the element,
// *Unsafe out-of-range index leaves input unchanged,
// default-Ior out-of-range surfaces Ior.Both(IndexOutOfRange, inputJson),
// *Unsafe negative index leaves input unchanged
"at(i) on Vector focus: index modify + sibling preservation + OOR / negative index handling" >> {
// (out-of-range / negative index: see JsonIndexBoundsSpec's oracle property)
"at(i) on Vector focus: index modify + sibling preservation" >> {
val orders = Vector(Order("A"), Order("B"), Order("C"))
val json = orders.asJson
val outAt1 = codecPrism[Vector[Order]].at(1).name.modifyUnsafe(_.toUpperCase)(json)
Expand All @@ -176,21 +174,6 @@ class JsonPrismSpec extends Specification with ScalaCheck:
basket.copy(items = Vector(Order("X".toUpperCase), Order("Y"))).asJson
val r4 = codecPrism[Basket].items.at(1).getOptionUnsafe(basket.asJson) === Some(Order("Y"))

// ---- OOR / negative index branches ----
val basket1 = Basket(owner = "Alice", items = Vector(Order("X")))
val json1 = basket1.asJson
val unsafeOOR =
codecPrism[Basket].items.at(5).name.modifyUnsafe(_.toUpperCase)(json1) === json1
val defaultOOR = codecPrism[Basket].items.at(5).name.modify(_.toUpperCase)(json1) match
case Ior.Both(chain, out) =>
(out === json1)
.and(chain.length === 1L)
.and(chain.headOption.get === JsonFailure.IndexOutOfRange(PathStep.Index(5), 1))
case _ => org.specs2.execute.Failure("expected Ior.Both"): org.specs2.execute.Result
val negIndex =
codecPrism[Basket].items.at(-1).name.modifyUnsafe(_.toUpperCase)(basket.asJson) ===
basket.asJson

// ---- .each Unsafe surface (absorbed) ----
// covers: .each modifyUnsafe applies to every element, transformUnsafe applies to each
// raw Json leaf, getAllUnsafe collects every focus, modifyUnsafe on empty array no-op,
Expand Down Expand Up @@ -228,9 +211,6 @@ class JsonPrismSpec extends Specification with ScalaCheck:
r1.and(r2)
.and(r3)
.and(r4)
.and(unsafeOOR)
.and(defaultOOR)
.and(negIndex)
.and(rEach1)
.and(rEach2)
.and(rEach3)
Expand Down
Loading