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
126 changes: 126 additions & 0 deletions apps/viewer/src/components/viewer/IDSPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ import type {
IDSEntityResult,
IDSRequirementResult,
} from '@ifc-lite/ids';
import {
groupRequirementResults,
computeCheckStats,
type RequirementGroup,
} from '@/hooks/ids/idsRequirementGrouping';
import { cn } from '@/lib/utils';
import { tourAnchor, TOUR_ANCHORS } from '@/lib/tours/anchors';
import { useViewerStore } from '@/store';
Expand Down Expand Up @@ -171,6 +176,27 @@ function SpecificationCard({
);
}, [result.entityResults, filterMode]);

// Regroup this specification's entity results by requirement ("check")
// rather than by entity. A specification can carry several requirements
// (fire rating, certificate ref, width, ...) — grouping first (before any
// status filtering) keeps the per-requirement counts aligned across
// entities; see idsRequirementGrouping.ts for why that ordering matters.
const requirementGroups = useMemo(
() => groupRequirementResults(result.entityResults),
[result.entityResults]
);
const checkStats = useMemo(
() => computeCheckStats(result.entityResults),
[result.entityResults]
);
const filteredRequirementGroups = useMemo(() => {
if (filterMode === 'all') return requirementGroups;
return requirementGroups.filter((g) =>
filterMode === 'failed' ? g.failedCount > 0 : g.passedCount > 0
);
}, [requirementGroups, filterMode]);
const applicableChecks = checkStats.passedChecks + checkStats.failedChecks;

return (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div
Expand Down Expand Up @@ -214,14 +240,43 @@ function SpecificationCard({
<div className="mt-2">
<PassRateBar passRate={result.passRate} />
</div>
{/* Check-level rate: an entity is failed by its FIRST failing
requirement while its other requirements still count as
passes here, so this normally reads HIGHER than the
entity-level rate above — both matter and are shown
separately rather than picking one. See computeCheckStats
for the denominator caveat. */}
{applicableChecks > 0 && (
<div className="mt-1 text-xs text-muted-foreground">
{checkStats.passedChecks}/{applicableChecks} checks passed ({checkStats.checkPassRate}%)
{requirementGroups.length > 1 && ` across ${requirementGroups.length} requirements`}
</div>
)}
</div>
</div>
</button>
</CollapsibleTrigger>

{/* Requirement Breakdown */}
<CollapsibleContent>
<Separator />
<div className="p-2 space-y-1">
{filteredRequirementGroups.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
No {filterMode === 'failed' ? 'failed' : filterMode === 'passed' ? 'passed' : ''} requirements
</div>
) : (
filteredRequirementGroups.map((group) => (
<RequirementGroupRow key={group.key} group={group} onEntityClick={onEntityClick} />
))
)}
</div>
</CollapsibleContent>

{/* Entity Results */}
<CollapsibleContent>
<Separator />
<div className="p-2 pt-1 text-xs font-medium text-muted-foreground">By entity</div>
<div className="max-h-64 overflow-auto">
{filteredEntities.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
Expand Down Expand Up @@ -342,6 +397,77 @@ function RequirementResultRow({ result }: RequirementResultRowProps) {
);
}

// ============================================================================
// Requirement Group Row Component
// ============================================================================

interface RequirementGroupRowProps {
group: RequirementGroup;
onEntityClick: (modelId: string, expressId: number) => void;
}

function RequirementGroupRow({ group, onEntityClick }: RequirementGroupRowProps) {
const [showFailures, setShowFailures] = useState(false);
const hasFailures = group.failingEntities.length > 0;
const status: 'pass' | 'fail' | 'not_applicable' =
group.failedCount > 0 ? 'fail' : group.passedCount > 0 ? 'pass' : 'not_applicable';

return (
<div className="rounded-md border border-border/60">
<button
type="button"
className="w-full p-2 text-left flex items-start gap-2 hover:bg-muted/50 rounded-md disabled:hover:bg-transparent"
onClick={() => hasFailures && setShowFailures((v) => !v)}
disabled={!hasFailures}
aria-expanded={hasFailures ? showFailures : undefined}
>
<StatusIcon status={status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-[10px] uppercase">{group.facetType}</Badge>
<span className="text-xs truncate">{group.checkedDescription}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<span className="text-green-600">{group.passedCount} passed</span>
{' · '}
<span className="text-red-600">{group.failedCount} failed</span>
{group.notApplicableCount > 0 && (
<>
{' · '}
<span>{group.notApplicableCount} n/a</span>
</>
)}
</div>
</div>
{hasFailures && (
showFailures ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />
)}
</button>
{showFailures && hasFailures && (
<div className="pl-6 pr-2 pb-2 space-y-1">
{group.failingEntities.map((entity) => (
<button
key={`${entity.modelId}:${entity.expressId}`}
type="button"
className="w-full text-left text-xs p-1.5 rounded hover:bg-muted/50 flex flex-col gap-0.5"
onClick={() => onEntityClick(entity.modelId, entity.expressId)}
>
<span className="truncate">
{entity.entityType}
{entity.entityName ? ` · ${entity.entityName}` : ''}
{entity.globalId ? ` · ${entity.globalId}` : ''}
</span>
{entity.failureReason && (
<span className="text-red-600">{entity.failureReason}</span>
)}
</button>
))}
</div>
)}
</div>
);
}

// ============================================================================
// Report Export Split Button
// ============================================================================
Expand Down
220 changes: 220 additions & 0 deletions apps/viewer/src/hooks/ids/idsRequirementGrouping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import { describe, it } from 'node:test';
import assert from 'node:assert';
import type { IDSEntityResult, IDSRequirementResult, IDSRequirement } from '@ifc-lite/ids';
import { groupRequirementResults, computeCheckStats } from './idsRequirementGrouping.js';

/**
* Contract tests for the IDS panel's requirement-grouping helpers.
*
* The IDSPanel used to only expose `requirementResults` inside a
* per-entity expander — the same defect the HTML export had. These
* helpers re-slice a specification's entity results by requirement so a
* user can see, per requirement, how many checks passed/failed and which
* elements failed, without drilling into every entity individually.
*/

function makeRequirement(id: string): IDSRequirement {
return {
id,
facet: { type: 'attribute', name: { simpleValue: 'Name' } } as unknown as IDSRequirement['facet'],
optionality: 'required',
};
}

const fireRating = makeRequirement('req-0');
const certificateRef = makeRequirement('req-1');
const width = makeRequirement('req-2');

function reqResult(
requirement: IDSRequirement,
status: IDSRequirementResult['status'],
overrides: Partial<IDSRequirementResult> = {}
): IDSRequirementResult {
return {
requirement,
status,
facetType: 'attribute',
checkedDescription: `Checks ${requirement.id}`,
...overrides,
};
}

function entity(
expressId: number,
requirementResults: IDSRequirementResult[],
overrides: Partial<IDSEntityResult> = {}
): IDSEntityResult {
return {
expressId,
modelId: 'model-1',
entityType: 'IfcDoor',
entityName: `Door ${expressId}`,
globalId: `GID-${expressId}`,
passed: requirementResults.every((r) => r.status !== 'fail'),
requirementResults,
...overrides,
};
}

describe('groupRequirementResults', () => {
it('groups per-entity requirement results by requirement, not by entity', () => {
// 3 doors, each checked against 2 requirements (fire rating, width).
const entities: IDSEntityResult[] = [
entity(1, [reqResult(fireRating, 'pass'), reqResult(width, 'pass')]),
entity(2, [reqResult(fireRating, 'fail', { failureReason: 'Missing FireRating' }), reqResult(width, 'pass')]),
entity(3, [reqResult(fireRating, 'pass'), reqResult(width, 'fail', { failureReason: 'Width too small' })]),
];

const groups = groupRequirementResults(entities);

assert.strictEqual(groups.length, 2, 'one group per requirement, not per entity');
const byKey = new Map(groups.map((g) => [g.key, g]));

const fireGroup = byKey.get('req-0')!;
assert.strictEqual(fireGroup.passedCount, 2);
assert.strictEqual(fireGroup.failedCount, 1);
assert.strictEqual(fireGroup.failingEntities.length, 1);
assert.strictEqual(fireGroup.failingEntities[0].expressId, 2);
assert.strictEqual(fireGroup.failingEntities[0].failureReason, 'Missing FireRating');

const widthGroup = byKey.get('req-2')!;
assert.strictEqual(widthGroup.passedCount, 2);
assert.strictEqual(widthGroup.failedCount, 1);
assert.strictEqual(widthGroup.failingEntities[0].expressId, 3);
});

it('does not count not_applicable as a pass, in either the group counts or its rate', () => {
// certificateRef is only applicable to 2 of 3 doors.
const entities: IDSEntityResult[] = [
entity(1, [reqResult(certificateRef, 'not_applicable')]),
entity(2, [reqResult(certificateRef, 'pass')]),
entity(3, [reqResult(certificateRef, 'fail', { failureReason: 'No certificate' })]),
];

const [group] = groupRequirementResults(entities);

assert.strictEqual(group.notApplicableCount, 1);
assert.strictEqual(group.passedCount, 1);
assert.strictEqual(group.failedCount, 1);
// Rate is passed / (passed + failed) = 1/2 = 50, NOT 1/3 (which would
// silently treat not_applicable as a passing check) and NOT 2/3
// (which would silently treat it as a failing one).
assert.strictEqual(group.passRate, 50);
});

it('grouping is order-independent of pre-filtering: not_applicable entities still register in the right group', () => {
// Regression guard for the "grouping trap": filtering not_applicable
// OUT before grouping would misalign per-requirement counts. Here we
// deliberately interleave entities whose FIRST requirement result is
// not_applicable to prove the grouping keys off requirement id, not
// array position filtered by status.
const entities: IDSEntityResult[] = [
entity(1, [reqResult(certificateRef, 'not_applicable'), reqResult(width, 'pass')]),
entity(2, [reqResult(certificateRef, 'pass'), reqResult(width, 'fail', { failureReason: 'too narrow' })]),
];

const groups = groupRequirementResults(entities);
assert.strictEqual(groups.length, 2);

const widthGroup = groups.find((g) => g.key === 'req-2')!;
assert.strictEqual(widthGroup.passedCount, 1);
assert.strictEqual(widthGroup.failedCount, 1);
assert.strictEqual(widthGroup.failingEntities[0].expressId, 2);
});
});

describe('computeCheckStats', () => {
it('check-level pass rate legitimately differs from an entity-level rate', () => {
// 3 doors x 3 requirements = 9 checks. Only 1 check fails, but it's
// spread across a single entity, so entity-level (2/3 = 67%) and
// check-level (8/9 = 88%) intentionally disagree.
const entities: IDSEntityResult[] = [
entity(1, [reqResult(fireRating, 'pass'), reqResult(certificateRef, 'pass'), reqResult(width, 'pass')]),
entity(2, [reqResult(fireRating, 'pass'), reqResult(certificateRef, 'pass'), reqResult(width, 'pass')]),
entity(3, [
reqResult(fireRating, 'fail', { failureReason: 'Missing FireRating' }),
reqResult(certificateRef, 'pass'),
reqResult(width, 'pass'),
]),
];

const stats = computeCheckStats(entities);
assert.strictEqual(stats.passedChecks, 8);
assert.strictEqual(stats.failedChecks, 1);
assert.strictEqual(stats.checkPassRate, 88);

// Entity-level rate computed the way the validator computes
// specification.passRate (passed entities / total entities, floored).
const passedEntities = entities.filter((e) => e.passed).length;
const entityPassRate = Math.floor((passedEntities / entities.length) * 100);
assert.strictEqual(entityPassRate, 66);
assert.notStrictEqual(stats.checkPassRate, entityPassRate);
});

it('excludes not_applicable checks from both the numerator and denominator', () => {
const entities: IDSEntityResult[] = [
entity(1, [reqResult(certificateRef, 'not_applicable')]),
entity(2, [reqResult(certificateRef, 'not_applicable')]),
entity(3, [reqResult(certificateRef, 'pass')]),
];

const stats = computeCheckStats(entities);
assert.strictEqual(stats.notApplicableChecks, 2);
assert.strictEqual(stats.passedChecks, 1);
assert.strictEqual(stats.failedChecks, 0);
// 1/1 applicable check passed = 100%, not 1/3 (33%).
assert.strictEqual(stats.checkPassRate, 100);
});

it('defaults to a 100% rate when there are no applicable checks at all', () => {
const entities: IDSEntityResult[] = [entity(1, [reqResult(certificateRef, 'not_applicable')])];
const stats = computeCheckStats(entities);
assert.strictEqual(stats.checkPassRate, 100);
});

// The direction of the check-level vs entity-level relation was documented
// backwards ("always <=") until this pinned it. It is prose nobody can run,
// so both directions live here as arithmetic instead.
it('reads HIGHER than the entity-level rate when a failing entity still passes some checks', () => {
const entities: IDSEntityResult[] = [
// A: fails one of two checks -> a FAILED entity that still passes a check.
entity(1, [reqResult(fireRating, 'pass'), reqResult(width, 'fail')]),
// B: passes both.
entity(2, [reqResult(fireRating, 'pass'), reqResult(width, 'pass')]),
];

const stats = computeCheckStats(entities);
const passedEntities = entities.filter((e) => e.passed).length;
const entityPassRate = Math.floor((passedEntities / entities.length) * 100);

assert.strictEqual(entityPassRate, 50, 'one of two entities passes');
assert.strictEqual(stats.checkPassRate, 75, 'three of four applicable checks pass');
assert.ok(
stats.checkPassRate > entityPassRate,
`check-level ${stats.checkPassRate}% must exceed entity-level ${entityPassRate}%, not fall below it`
);
});

it('can read LOWER than the entity-level rate: an all-not_applicable entity passes but contributes no check', () => {
const entities: IDSEntityResult[] = [
// A: passes as an entity (nothing failed) but adds zero applicable checks.
entity(1, [reqResult(certificateRef, 'not_applicable')]),
// B: fails its only check.
entity(2, [reqResult(certificateRef, 'fail')]),
];

const stats = computeCheckStats(entities);
const passedEntities = entities.filter((e) => e.passed).length;
const entityPassRate = Math.floor((passedEntities / entities.length) * 100);

assert.strictEqual(entityPassRate, 50);
assert.strictEqual(stats.checkPassRate, 0, 'zero of one applicable check passes');
// Hence the docblock says "normally above", never "always": the two rates
// have different denominators, so neither direction holds unconditionally.
assert.ok(stats.checkPassRate < entityPassRate);
});
});
Loading
Loading