diff --git a/frontend/src/__tests__/fixtures/axis.ts b/frontend/src/__tests__/fixtures/axis.ts new file mode 100644 index 0000000..00ae258 --- /dev/null +++ b/frontend/src/__tests__/fixtures/axis.ts @@ -0,0 +1,85 @@ +import type { MaskBuffer } from "../../lib/pipeline/types.js"; + +function blankMask(width: number, height: number): MaskBuffer { + return new Uint8Array(width * height); +} + +function fillMaskRect( + mask: MaskBuffer, + width: number, + x0: number, + y0: number, + x1: number, + y1: number, +): void { + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + mask[y * width + x] = 255; + } + } +} + +/** + * TV-4: axis-aligned slab 96×24 px → with lengthIn=96, scale=1 px/in. + * Constant width 24" at every 6" station. + */ +export function tv4RectMask(): { + mask: MaskBuffer; + width: number; + height: number; + lengthIn: number; + expectedWidthIn: number; + expectedStations: number[]; +} { + const width = 120; + const height = 48; + const mask = blankMask(width, height); + fillMaskRect(mask, width, 12, 12, 108, 36); + const expectedStations: number[] = []; + for (let s = 6; s < 96; s += 6) expectedStations.push(s); + return { + mask, + width, + height, + lengthIn: 96, + expectedWidthIn: 24, + expectedStations, + }; +} + +/** + * TV-5: 96×24 slab with square left end and 45° cut on the right end. + * Prevailing length remains horizontal; diagonal of the cut must not win. + */ +export function tv5AngledEndMask(): { + mask: MaskBuffer; + width: number; + height: number; + lengthIn: number; + bodyWidthIn: number; +} { + const width = 120; + const height = 48; + const mask = blankMask(width, height); + const x0 = 12; + const x1 = 108; // 96 px long + const y0 = 12; + const y1 = 36; // 24 px wide + const cutStart = x1 - 24; // 45° cut over last 24" of length + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + if (x >= cutStart) { + const cutY = y0 + (x - cutStart); + if (y < cutY) continue; + } + mask[y * width + x] = 255; + } + } + return { + mask, + width, + height, + lengthIn: 96, + bodyWidthIn: 24, + }; +} diff --git a/frontend/src/__tests__/tv4-tv5-axis.test.ts b/frontend/src/__tests__/tv4-tv5-axis.test.ts new file mode 100644 index 0000000..b4e5ba0 --- /dev/null +++ b/frontend/src/__tests__/tv4-tv5-axis.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + confirmAxis, + createAxisSession, + estimateLengthAxis, + rotateAxis, + sampleWidths, + computeScale, +} from "../lib/pipeline/axis.js"; +import { WIDTH_SAMPLE_IN } from "../lib/pipeline/constants.js"; +import { tv4RectMask, tv5AngledEndMask } from "./fixtures/axis.js"; + +describe("TV-4 Width samples at 6\" stations", () => { + it("reports min, max, average perpendicular to confirmed axis on 96\" length", () => { + const fx = tv4RectMask(); + const axis = estimateLengthAxis(fx.mask, fx.width, fx.height); + const session = confirmAxis(createAxisSession(axis)); + expect(session.state).toBe("confirmed"); + + const scale = computeScale(session.axis.lengthPx, fx.lengthIn); + const stats = sampleWidths( + fx.mask, + fx.width, + fx.height, + session.axis, + fx.lengthIn, + ); + + console.log( + JSON.stringify({ + case: "TV-4", + lengthPx: session.axis.lengthPx, + widthPx: session.axis.widthPx, + scale, + stations: stats.samples.map((s) => s.stationIn), + widths: stats.samples.map((s) => s.widthIn), + min: stats.min, + max: stats.max, + average: stats.average, + expectedWidthIn: fx.expectedWidthIn, + }), + ); + + expect(WIDTH_SAMPLE_IN).toBe(6); + expect(scale).toBeCloseTo(1, 5); + expect(stats.samples.map((s) => s.stationIn)).toEqual(fx.expectedStations); + expect(stats.samples.length).toBe(15); + for (const s of stats.samples) { + expect(s.widthIn).toBeCloseTo(fx.expectedWidthIn, 0); + } + expect(stats.min).toBeCloseTo(fx.expectedWidthIn, 0); + expect(stats.max).toBeCloseTo(fx.expectedWidthIn, 0); + expect(stats.average).toBeCloseTo(fx.expectedWidthIn, 0); + }); +}); + +describe("TV-5 Angled-end length axis", () => { + it("follows prevailing length; widths stay perpendicular after confirm", () => { + const fx = tv5AngledEndMask(); + const axis = estimateLengthAxis(fx.mask, fx.width, fx.height); + let session = createAxisSession(axis); + expect(session.state).toBe("pending"); + + expect(Math.abs(session.axis.direction.x)).toBeGreaterThan( + Math.abs(session.axis.direction.y), + ); + expect(session.axis.lengthPx).toBeGreaterThan(session.axis.widthPx); + + session = confirmAxis(session); + expect(session.state).toBe("confirmed"); + + const stats = sampleWidths( + fx.mask, + fx.width, + fx.height, + session.axis, + fx.lengthIn, + ); + + const bodySamples = stats.samples.filter((s) => s.stationIn <= 72); + expect(bodySamples.length).toBeGreaterThan(0); + for (const s of bodySamples) { + expect(s.widthIn).toBeCloseTo(fx.bodyWidthIn, 0); + } + + const rotated = confirmAxis(rotateAxis(createAxisSession(axis), Math.PI / 2)); + expect(Math.abs(rotated.axis.direction.y)).toBeGreaterThan( + Math.abs(rotated.axis.direction.x) - 1e-9, + ); + + console.log( + JSON.stringify({ + case: "TV-5", + direction: session.axis.direction, + lengthPx: session.axis.lengthPx, + widthPx: session.axis.widthPx, + bodySampleCount: bodySamples.length, + bodyWidths: bodySamples.map((s) => s.widthIn), + min: stats.min, + max: stats.max, + average: stats.average, + rotatedDirection: rotated.axis.direction, + }), + ); + }); +}); diff --git a/frontend/src/lib/pipeline/axis.ts b/frontend/src/lib/pipeline/axis.ts new file mode 100644 index 0000000..19b48b2 --- /dev/null +++ b/frontend/src/lib/pipeline/axis.ts @@ -0,0 +1,316 @@ +import { WIDTH_SAMPLE_IN } from "./constants.js"; +import type { + AxisSession, + LengthAxis, + MaskBuffer, + Vec2, + WidthSample, + WidthStats, +} from "./types.js"; + +const MASK_ON = 128; + +function isOn(mask: MaskBuffer, width: number, x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= width) return false; + const h = mask.length / width; + if (y >= h) return false; + return mask[y * width + x]! >= MASK_ON; +} + +function collectPoints(mask: MaskBuffer, width: number, height: number): Vec2[] { + const pts: Vec2[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (mask[y * width + x]! >= MASK_ON) { + // Pixel as unit square so axis-aligned span matches pixel count. + pts.push({ x, y }, { x: x + 1, y }, { x, y: y + 1 }, { x: x + 1, y: y + 1 }); + } + } + } + return pts; +} + +function cross(o: Vec2, a: Vec2, b: Vec2): number { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); +} + +/** Andrew's monotone chain. */ +function convexHull(points: Vec2[]): Vec2[] { + if (points.length <= 1) return points.slice(); + const sorted = points.slice().sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)); + const lower: Vec2[] = []; + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) { + lower.pop(); + } + lower.push(p); + } + const upper: Vec2[] = []; + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i]!; + while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) { + upper.pop(); + } + upper.push(p); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +function norm(v: Vec2): number { + return Math.hypot(v.x, v.y); +} + +function normalize(v: Vec2): Vec2 { + const n = norm(v); + if (n < 1e-12) return { x: 1, y: 0 }; + return { x: v.x / n, y: v.y / n }; +} + +function perp(v: Vec2): Vec2 { + return { x: -v.y, y: v.x }; +} + +function dot(a: Vec2, b: Vec2): number { + return a.x * b.x + a.y * b.y; +} + +function add(a: Vec2, b: Vec2): Vec2 { + return { x: a.x + b.x, y: a.y + b.y }; +} + +function scale(v: Vec2, s: number): Vec2 { + return { x: v.x * s, y: v.y * s }; +} + +function sub(a: Vec2, b: Vec2): Vec2 { + return { x: a.x - b.x, y: a.y - b.y }; +} + +/** Min-area bounding rect via edge-aligned projections on the hull. */ +function minAreaRect(hull: Vec2[]): LengthAxis { + if (hull.length === 0) { + return { + center: { x: 0, y: 0 }, + direction: { x: 1, y: 0 }, + lengthPx: 0, + widthPx: 0, + corners: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ], + }; + } + if (hull.length === 1) { + const c = hull[0]!; + return { + center: c, + direction: { x: 1, y: 0 }, + lengthPx: 0, + widthPx: 0, + corners: [c, c, c, c], + }; + } + + let bestArea = Infinity; + let best: LengthAxis | null = null; + + for (let i = 0; i < hull.length; i++) { + const a = hull[i]!; + const b = hull[(i + 1) % hull.length]!; + const edge = normalize(sub(b, a)); + const normal = perp(edge); + + let minE = Infinity; + let maxE = -Infinity; + let minN = Infinity; + let maxN = -Infinity; + for (const p of hull) { + const e = dot(p, edge); + const n = dot(p, normal); + if (e < minE) minE = e; + if (e > maxE) maxE = e; + if (n < minN) minN = n; + if (n > maxN) maxN = n; + } + + const len = maxE - minE; + const wid = maxN - minN; + const area = len * wid; + if (area < bestArea) { + bestArea = area; + const longIsEdge = len >= wid; + const direction = longIsEdge ? edge : normal; + const lengthPx = longIsEdge ? len : wid; + const widthPx = longIsEdge ? wid : len; + const center = { + x: ((minE + maxE) / 2) * edge.x + ((minN + maxN) / 2) * normal.x, + y: ((minE + maxE) / 2) * edge.y + ((minN + maxN) / 2) * normal.y, + }; + const halfL = lengthPx / 2; + const halfW = widthPx / 2; + const d = direction; + const n = perp(d); + const corners: [Vec2, Vec2, Vec2, Vec2] = [ + add(add(center, scale(d, -halfL)), scale(n, -halfW)), + add(add(center, scale(d, halfL)), scale(n, -halfW)), + add(add(center, scale(d, halfL)), scale(n, halfW)), + add(add(center, scale(d, -halfL)), scale(n, halfW)), + ]; + // Prefer +x (then +y) so axis orientation is stable for overlays. + let dir = direction; + if (dir.x < -1e-9 || (Math.abs(dir.x) < 1e-9 && dir.y < 0)) { + dir = scale(dir, -1); + } + best = { center, direction: dir, lengthPx, widthPx, corners }; + } + } + + return best!; +} + +/** + * TECH-SPEC §4.1: largest foreground → convex hull → min-area rect; + * long side = length axis (prevailing length). + */ +export function estimateLengthAxis( + mask: MaskBuffer, + width: number, + height: number, +): LengthAxis { + const pts = collectPoints(mask, width, height); + if (pts.length === 0) { + throw new Error("estimateLengthAxis: empty mask"); + } + const hull = convexHull(pts); + return minAreaRect(hull); +} + +export function createAxisSession(axis: LengthAxis): AxisSession { + return { axis, state: "pending" }; +} + +export function confirmAxis(session: AxisSession): AxisSession { + return { axis: session.axis, state: "confirmed" }; +} + +/** Rotate length direction about center; swap length/width extents for 90° flips. */ +export function rotateAxis(session: AxisSession, deltaRad: number): AxisSession { + const { axis } = session; + const c = Math.cos(deltaRad); + const s = Math.sin(deltaRad); + const d = axis.direction; + let direction = normalize({ x: c * d.x - s * d.y, y: s * d.x + c * d.y }); + if (direction.x < -1e-9 || (Math.abs(direction.x) < 1e-9 && direction.y < 0)) { + direction = scale(direction, -1); + } + const quarterTurns = Math.round(deltaRad / (Math.PI / 2)); + const swap = Math.abs(quarterTurns) % 2 === 1; + const lengthPx = swap ? axis.widthPx : axis.lengthPx; + const widthPx = swap ? axis.lengthPx : axis.widthPx; + const halfL = lengthPx / 2; + const halfW = widthPx / 2; + const n = perp(direction); + const corners: [Vec2, Vec2, Vec2, Vec2] = [ + add(add(axis.center, scale(direction, -halfL)), scale(n, -halfW)), + add(add(axis.center, scale(direction, halfL)), scale(n, -halfW)), + add(add(axis.center, scale(direction, halfL)), scale(n, halfW)), + add(add(axis.center, scale(direction, -halfL)), scale(n, halfW)), + ]; + return { + axis: { center: axis.center, direction, lengthPx, widthPx, corners }, + state: "pending", + }; +} + +/** scale = longest_axis_px / L (px per inch). */ +export function computeScale(lengthPx: number, lengthIn: number): number { + if (lengthIn === 0) throw new Error("computeScale: lengthIn is 0"); + return lengthPx / lengthIn; +} + +/** Interior stations every WIDTH_SAMPLE_IN along the length (excludes exact ends). */ +export function widthStations(lengthIn: number): number[] { + const out: number[] = []; + for (let s = WIDTH_SAMPLE_IN; s < lengthIn; s += WIDTH_SAMPLE_IN) { + out.push(s); + } + return out; +} + +function rayExtent( + mask: MaskBuffer, + width: number, + height: number, + origin: Vec2, + dir: Vec2, + maxT: number, +): number { + let lo = 0; + let hi = maxT; + let t = 1; + while (t < maxT && isOn(mask, width, Math.floor(origin.x + dir.x * t), Math.floor(origin.y + dir.y * t))) { + t *= 2; + } + hi = Math.min(t, maxT); + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + const x = Math.floor(origin.x + dir.x * mid); + const y = Math.floor(origin.y + dir.y * mid); + if (isOn(mask, width, x, y)) lo = mid; + else hi = mid; + } + return lo; +} + +/** + * TECH-SPEC §4.4: sample every 6" along confirmed length axis, perpendicular widths. + */ +export function sampleWidths( + mask: MaskBuffer, + width: number, + height: number, + axis: LengthAxis, + lengthIn: number, +): WidthStats { + const scalePx = computeScale(axis.lengthPx, lengthIn); + const origin = sub(axis.center, scale(axis.direction, axis.lengthPx / 2)); + const n = perp(axis.direction); + const maxT = Math.hypot(width, height) + 2; + const samples: WidthSample[] = []; + + for (const stationIn of widthStations(lengthIn)) { + const pos = add(origin, scale(axis.direction, stationIn * scalePx)); + let probe = pos; + if (!isOn(mask, width, Math.floor(probe.x), Math.floor(probe.y))) { + const inward = scale(axis.direction, Math.sign(lengthIn / 2 - stationIn) || 1); + for (let k = 1; k < 4; k++) { + const cand = add(pos, scale(inward, k)); + if (isOn(mask, width, Math.floor(cand.x), Math.floor(cand.y))) { + probe = cand; + break; + } + } + } + const plus = rayExtent(mask, width, height, probe, n, maxT); + const minus = rayExtent(mask, width, height, probe, scale(n, -1), maxT); + const widthPx = plus + minus; + samples.push({ stationIn, widthIn: widthPx / scalePx }); + } + + if (samples.length === 0) { + return { min: 0, max: 0, average: 0, samples }; + } + let min = samples[0]!.widthIn; + let max = samples[0]!.widthIn; + let sum = 0; + for (const s of samples) { + if (s.widthIn < min) min = s.widthIn; + if (s.widthIn > max) max = s.widthIn; + sum += s.widthIn; + } + return { min, max, average: sum / samples.length, samples }; +} diff --git a/frontend/src/lib/pipeline/index.ts b/frontend/src/lib/pipeline/index.ts index 523acae..4812bc2 100644 --- a/frontend/src/lib/pipeline/index.ts +++ b/frontend/src/lib/pipeline/index.ts @@ -2,3 +2,4 @@ export * from "./constants.js"; export * from "./types.js"; export * from "./mask.js"; export * from "./units.js"; +export * from "./axis.js"; diff --git a/frontend/src/lib/pipeline/types.ts b/frontend/src/lib/pipeline/types.ts index 07c8f59..212e1c6 100644 --- a/frontend/src/lib/pipeline/types.ts +++ b/frontend/src/lib/pipeline/types.ts @@ -37,3 +37,38 @@ export interface MaskSession { result: MaskResult; state: MaskConfirmState; } + + +export interface Vec2 { + x: number; + y: number; +} + +/** Min-area long-side axis (TECH-SPEC §4.1). */ +export interface LengthAxis { + center: Vec2; + /** Unit vector along prevailing length. */ + direction: Vec2; + lengthPx: number; + widthPx: number; + corners: [Vec2, Vec2, Vec2, Vec2]; +} + +export type AxisConfirmState = "pending" | "confirmed"; + +export interface AxisSession { + axis: LengthAxis; + state: AxisConfirmState; +} + +export interface WidthSample { + stationIn: number; + widthIn: number; +} + +export interface WidthStats { + min: number; + max: number; + average: number; + samples: WidthSample[]; +} diff --git a/test-results/gate-b-all.txt b/test-results/gate-b-all.txt new file mode 100644 index 0000000..068027c --- /dev/null +++ b/test-results/gate-b-all.txt @@ -0,0 +1,24 @@ + + RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend + +stdout | src/__tests__/tv3-tv10-units.test.ts > TV-3 Scale, sqft, bdft > bdft = sqft × thickness_in with no divide-by-12 +{"case":"TV-3","lengthIn":96,"thicknessIn":1.5,"sqft":0.64,"bdft":0.96,"expectedBdft":0.96,"identity_1sqft_1in":1} + +stdout | src/__tests__/tv3-tv10-units.test.ts > TV-10 Rounding > half-up / nearest-step matches TECH-SPEC vectors +{"case":"TV-10","length":{"raw":96.04,"rounded":96,"expected":96},"sqft":{"raw":0.641,"rounded":0.64,"expected":0.64},"bdft":{"raw":11.525,"rounded":11.53,"expected":11.53},"price":{"raw":12.345,"rounded":12.35,"expected":12.35}} + + ✓ src/__tests__/tv3-tv10-units.test.ts (3 tests) 5ms +stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length +{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24} + +stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm +{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}} + + ✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 29ms + ✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 35ms + + Test Files 3 passed (3) + Tests 10 passed (10) + Start at 06:06:43 + Duration 460ms (transform 206ms, setup 0ms, collect 319ms, tests 69ms, environment 1ms, prepare 212ms) + diff --git a/test-results/tv4-tv5-green.txt b/test-results/tv4-tv5-green.txt new file mode 100644 index 0000000..4f3e089 --- /dev/null +++ b/test-results/tv4-tv5-green.txt @@ -0,0 +1,16 @@ + + RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend + +stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length +{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24} + +stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm +{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}} + + ✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 25ms + + Test Files 1 passed (1) + Tests 2 passed (2) + Start at 06:06:32 + Duration 445ms (transform 84ms, setup 0ms, collect 86ms, tests 25ms, environment 0ms, prepare 61ms) + diff --git a/test-results/tv4-tv5-red.txt b/test-results/tv4-tv5-red.txt new file mode 100644 index 0000000..f3af027 --- /dev/null +++ b/test-results/tv4-tv5-red.txt @@ -0,0 +1,18 @@ + + RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend + + ❯ src/__tests__/tv4-tv5-axis.test.ts (0 test) + +⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL src/__tests__/tv4-tv5-axis.test.ts [ src/__tests__/tv4-tv5-axis.test.ts ] +Error: Failed to load url ../lib/pipeline/axis.js (resolved id: ../lib/pipeline/axis.js) in /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend/src/__tests__/tv4-tv5-axis.test.ts. Does the file exist? + ❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:51969:17 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests no tests + Start at 06:05:51 + Duration 423ms (transform 53ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 72ms) +