mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
/**
|
|
* One wall-record preservation invariant for product transactions and the
|
|
* offline model audit (#264).
|
|
*
|
|
* The broad audit answers whether a physical thickness class disappeared
|
|
* completely; an exact fixed-topology transaction additionally proves the
|
|
* whole `cm` multiset, including meaningful zero-thickness walls (#306).
|
|
*/
|
|
|
|
export interface WallRecordLike {
|
|
cm?: unknown;
|
|
}
|
|
|
|
export interface WallRecordPreservationOptions {
|
|
/** Explicit destructive thickness tools may opt out. Resize never does. */
|
|
allowClear?: boolean;
|
|
/** Fixed-topology operations preserve every record and its multiplicity. */
|
|
exactMultiplicity?: boolean;
|
|
}
|
|
|
|
export interface WallRecordViolation {
|
|
invariant: 'wall_records';
|
|
kind: 'lost' | 'count';
|
|
owner: string;
|
|
reference: string;
|
|
detail: string;
|
|
}
|
|
|
|
const finiteCm = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isFinite(value);
|
|
|
|
const counts = (
|
|
walls: readonly WallRecordLike[] | null | undefined,
|
|
includeEveryFiniteValue: boolean,
|
|
): Map<number, number> => {
|
|
const out = new Map<number, number>();
|
|
for (const wall of walls || []) {
|
|
if (!finiteCm(wall?.cm) || (!includeEveryFiniteValue && wall.cm <= 0)) continue;
|
|
out.set(wall.cm, (out.get(wall.cm) || 0) + 1);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const label = (cm: number): string => [String(cm), 'см'].join(' ');
|
|
|
|
/**
|
|
* Return an empty list only when the requested preservation contract holds.
|
|
* Structured violations are deliberately bounded: no coordinates, ids or
|
|
* exception strings may reach UI or CI logs through this helper.
|
|
*/
|
|
export function checkWallRecordsPreserved(
|
|
before: readonly WallRecordLike[] | null | undefined,
|
|
after: readonly WallRecordLike[] | null | undefined,
|
|
options: WallRecordPreservationOptions = {},
|
|
): WallRecordViolation[] {
|
|
if (options.allowClear) return [];
|
|
const exact = options.exactMultiplicity === true;
|
|
const from = counts(before, exact);
|
|
const to = counts(after, exact);
|
|
const violations: WallRecordViolation[] = [];
|
|
|
|
for (const [cm, was] of from) {
|
|
const now = to.get(cm) || 0;
|
|
if (now === 0) {
|
|
violations.push({
|
|
invariant: 'wall_records', kind: 'lost', owner: label(cm),
|
|
reference: `было ${was}`, detail: 'записи этой толщины исчезли целиком',
|
|
});
|
|
} else if (exact && now !== was) {
|
|
violations.push({
|
|
invariant: 'wall_records', kind: 'count', owner: label(cm),
|
|
reference: `было ${was}, стало ${now}`,
|
|
detail: 'изменилось число записей этой толщины',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (exact) {
|
|
for (const [cm, now] of to) {
|
|
if (from.has(cm)) continue;
|
|
violations.push({
|
|
invariant: 'wall_records', kind: 'count', owner: label(cm),
|
|
reference: `было 0, стало ${now}`,
|
|
detail: 'появились записи новой толщины',
|
|
});
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|