Skip to content
Merged
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
56 changes: 56 additions & 0 deletions src/studio/__tests__/adapters/featureRecordsToCounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// KC-06 regression: the Validity panel reported `0 parts · 0 joints` for a
// model the Scene tab was listing, because `reviewToValidity` hardcoded both
// counts. These cover the counts recovered from the loaded model's records
// for BOTH joint vocabularies (joint primitives and mates).
import { describe, expect, it } from 'vitest';
import type { FeatureRecord } from '../../../shared/intent/featureRecord';
import { countModelTopology } from '../../adapters/featureRecordsToCounts';
import {
jointPrimitiveModelRecords,
mateModelRecords,
} from '../fixtures/assemblyFeatureRecordFixtures';

describe('countModelTopology', () => {
it('empty / null records → 0 parts, 0 joints', () => {
expect(countModelTopology([])).toEqual({ partCount: 0, jointCount: 0 });
expect(countModelTopology(null)).toEqual({ partCount: 0, jointCount: 0 });
});

it('joint-primitive model (2 parts, 1 revolute) reports 2 and 1', () => {
expect(countModelTopology(jointPrimitiveModelRecords())).toEqual({
partCount: 2,
jointCount: 1,
});
});

it('mate-built model (2 parts, 1 revolute mate) reports 2 and 1', () => {
expect(countModelTopology(mateModelRecords())).toEqual({
partCount: 2,
jointCount: 1,
});
});

it('does not double count a mate repeated across two solvedAssembly records', () => {
const records = mateModelRecords();
const solved = records.find((r) => r.kind === 'solvedAssembly');
if (solved === undefined) throw new Error('fixture has no solvedAssembly record');
const repeated: FeatureRecord[] = [...records, { ...solved, id: 'solved-2' }];
expect(countModelTopology(repeated).jointCount).toBe(1);
});

it('counts same-named parts in two assemblies separately', () => {
const records: FeatureRecord[] = [
...jointPrimitiveModelRecords(),
{
id: 'other-base',
kind: 'assemblyPart',
params: {},
inputs: {},
metadata: { assemblyName: 'gripper', partName: 'base' },
},
];
expect(countModelTopology(records).partCount).toBe(3);
});
});
36 changes: 36 additions & 0 deletions src/studio/__tests__/adapters/reviewToValidity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,42 @@ describe('reviewToValidity', () => {
expect(v?.diagnostics[0].severity).toBe('error');
});

// KC-06: counts and provenance.
it('a bare ok:true placeholder is NOT marked validated', () => {
// `{ ok: true, diagnostics: [] }` is what GeometryContext substitutes
// for a missing `review` block and what the dev endpoint's `live=1`
// short-circuit returns. Nothing validated; the flag must say so.
expect(reviewToValidity({ ok: true })?.validated).toBe(false);
expect(reviewToValidity({ ok: true, diagnostics: [], live: true })?.validated).toBe(false);
});

it('a review carrying validator / fitness / mechanism evidence IS validated', () => {
expect(
reviewToValidity({ ok: true, validator: { partCount: 2, jointCount: 1 } })?.validated,
).toBe(true);
expect(reviewToValidity({ ok: true, fitness: { functional: true } })?.validated).toBe(true);
expect(reviewToValidity({ ok: true, mechanism: 'real' })?.validated).toBe(true);
expect(reviewToValidity({ ok: true, mechanism: 'unverified' })?.validated).toBe(false);
expect(
reviewToValidity({ ok: false, diagnostics: [{ message: 'x' }] })?.validated,
).toBe(true);
});

it('counts come from the loaded model when the payload has no validator block', () => {
const v = reviewToValidity({ ok: true }, { partCount: 2, jointCount: 1 });
expect(v?.partCount).toBe(2);
expect(v?.jointCount).toBe(1);
});

it("the server's validator counts win over the local model counts", () => {
const v = reviewToValidity(
{ ok: true, validator: { partCount: 5, jointCount: 4 } },
{ partCount: 2, jointCount: 1 },
);
expect(v?.partCount).toBe(5);
expect(v?.jointCount).toBe(4);
});

it('mechanism: broken overrides ok:true → status=error', () => {
// P1 surface convergence: the loop's mechanism verdict is the
// merge gate, so a broken mechanism flips status to error even
Expand Down
113 changes: 113 additions & 0 deletions src/studio/__tests__/fixtures/assemblyFeatureRecordFixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// The KC-06 model, as `featureRecords`: two parts ("base", "arm") joined by
// one revolute joint, in BOTH declaration vocabularies.
//
// `jointPrimitiveModelRecords()` — what `assembly().part(...)` +
// `asm.revolute(...)` + `asm.solvedModel(...)` captures: one
// `assemblyJoint` record per joint, plus the pose map on the
// `solvedAssembly` record. `metadata.mates` is ABSENT — joint primitives
// live in `Assembly.__joints()`, never in `__mates()`.
//
// `mateModelRecords()` — what `asm.mate(...)` captures: no `assemblyJoint`
// record, the joint shows up as `metadata.mates` on `solvedAssembly`.
//
// Shapes mirror `buildAssemblyJointFeatureSpec` /
// `buildSolvedAssemblyFeatureSpec` in
// `src/modeling/capture/assemblyFeatureRecords.ts`.
import type { FeatureRecord } from '../../../shared/intent/featureRecord';
import type { Param } from '../../../shared/intent/types';

function record(partial: Partial<FeatureRecord> & Pick<FeatureRecord, 'id' | 'kind'>): FeatureRecord {
return {
inputs: {},
params: {},
transforms: [],
suppressed: false,
...partial,
};
}

function part(id: string, partName: string, assemblyName = 'rig'): FeatureRecord {
return record({
id,
kind: 'assemblyPart',
inputs: { shape: { kind: 'feature', id: `${id}-shape` } },
metadata: { assemblyName, partName },
});
}

/** A pose bound to a param table entry, as capture encodes a ParamRef:
* `evaluated` stays 0 and the real value is looked up by `paramRef`. */
function paramRefPose(name: string): Param {
return { expression: name, unit: 'deg', evaluated: 0, paramRef: name };
}

export function jointPrimitiveModelRecords(): FeatureRecord[] {
return [
part('p-base', 'base'),
part('p-arm', 'arm'),
record({
id: 'j-elbow',
kind: 'assemblyJoint',
inputs: {
a: { kind: 'feature', id: 'p-base' },
b: { kind: 'feature', id: 'p-arm' },
},
metadata: {
assemblyName: 'rig',
jointName: 'elbow',
jointKind: 'revolute',
axis: [0, 0, 1],
origin: [0, 0, 10],
limitsDeg: [-90, 90],
},
}),
record({
id: 'solved',
kind: 'solvedAssembly',
inputs: {
part_0: { kind: 'feature', id: 'p-base' },
part_1: { kind: 'feature', id: 'p-arm' },
joint_0: { kind: 'feature', id: 'j-elbow' },
},
metadata: {
assemblyName: 'rig',
partIds: ['p-base', 'p-arm'],
jointIds: ['j-elbow'],
poses: { elbow: { kind: 'scalar', value: paramRefPose('elbowDeg') } },
},
}),
];
}

export function mateModelRecords(): FeatureRecord[] {
return [
part('p-base', 'base'),
part('p-arm', 'arm'),
record({
id: 'solved',
kind: 'solvedAssembly',
inputs: {
part_0: { kind: 'feature', id: 'p-base' },
part_1: { kind: 'feature', id: 'p-arm' },
},
metadata: {
assemblyName: 'rig',
partIds: ['p-base', 'p-arm'],
jointIds: [],
poses: { elbow: { kind: 'scalar', value: paramRefPose('elbowDeg') } },
mates: [
{
name: 'elbow',
a: 'base.top',
b: 'arm.bottom',
type: 'revolute',
pose: { kind: 'scalar', value: paramRefPose('elbowDeg') },
limitsDeg: [-90, 90],
},
],
},
}),
];
}
11 changes: 7 additions & 4 deletions src/studio/__tests__/tabs/ValidityTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StudioRecomputeResult } from '../../types';
import type { StudioRecomputeResult, StudioValidity } from '../../types';
import type { ValidatorDiagnostic, ValidatorResult } from '../../../modeling/mates/validator';

const mockUseRecomputeResult = vi.fn<() => StudioRecomputeResult>();
Expand Down Expand Up @@ -39,17 +39,20 @@ function emptyResult(): StudioRecomputeResult {
};
}

function withValidity(v: ValidatorResult): StudioRecomputeResult {
function withValidity(v: StudioValidity): StudioRecomputeResult {
return { ...emptyResult(), validity: v };
}

/** `validated: true` — these cases stand for a validation that actually ran,
* which is what makes the verdict paintable. The unvalidated case (the
* KC-06 vacuous green) is covered in `ValidityTabLoadedModel.test.tsx`. */
function makeValidity(
status: ValidatorResult['status'],
diagnostics: ValidatorDiagnostic[] = [],
partCount = 0,
jointCount = 0,
): ValidatorResult {
return { status, diagnostics, partCount, jointCount };
): StudioValidity {
return { status, diagnostics, partCount, jointCount, validated: true };
}

afterEach(() => {
Expand Down
Loading
Loading