fix(voice): validate a code's region against its target properly
Second correctness pass one271858found three ways an arch-only appliance could be written as a per-tooth job, plus a gap in the previous repair. 1. The deferred region check was never completed. A mixed-region category resolved with no region recorded, and nothing re-validated the leaf the clinician then picked — "پروتز متحرک برای دندون ۱۲" wrote a complete denture onto tooth 12, which the manual chart cannot produce and which task generation would expand as denture steps. Targets now resolve BEFORE types, so the target kind narrows a category's candidates and an impossible leaf is never offered. The deferral disappears with it. 2. `some` over the stack's regions let one legal code admit every other. `types: ['pfm_crown','night_guard_soft']` on tooth 12 passed, because `crown` suited the tooth, and wrote a night guard onto that tooth. Now every named code must suit the target. 3. `regions.size === 1` also deferred `implant`, whose leaves span root and crown — both tooth regions, so not a tooth/arch category at all. An implant aimed at a jaw resolved as a UA target. Narrowing by target kind rejects it instead. 4. Thee271858type-row lock ticked the row and the payload but not the count, so the sheet read "Apply 1 field" while two landed. One `effectiveSelection` now drives the count and the payload. Also narrows a `filter(Boolean)` in the disjointness test that left two implicit-any errors under the full tsconfig (nest build excludes specs, so the repo gate never saw them). Pre-existing at15ddb9a. Adds four resolver tests: candidate narrowing per target kind, a category with no usable leaf, a stack where only one code suits, and a legal stack. All three defects passed the previous 209 tests. Gates: backend 212 tests, nest build, ESLint clean on the voice module; frontend 37 Vitest tests, tsc --noEmit, ESLint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -281,7 +281,9 @@ describe('resolveProsthesisAssignment', () => {
|
||||
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
|
||||
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
|
||||
const subcategoryCodes = new Set(
|
||||
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(Boolean),
|
||||
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(
|
||||
(code): code is string => Boolean(code),
|
||||
),
|
||||
);
|
||||
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
|
||||
for (const code of subcategoryCodes) {
|
||||
@@ -334,10 +336,11 @@ describe('resolveProsthesisAssignment', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('defers the region check for a mixed-region category (removable)', () => {
|
||||
// complete_denture is 'arch', partial_denture is 'crown' — no region to check until a
|
||||
// leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still
|
||||
// the bare category.
|
||||
it('narrows a mixed-region category to the leaves the target can carry', () => {
|
||||
// complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a
|
||||
// leaf was picked left nothing to complete it, and wrote a complete denture onto one
|
||||
// tooth. The target kind narrows the candidates instead, so an impossible leaf is never
|
||||
// offered.
|
||||
const toothTarget = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['removable'],
|
||||
@@ -345,7 +348,10 @@ describe('resolveProsthesisAssignment', () => {
|
||||
});
|
||||
expect(toothTarget.resolved.targets).toEqual(['12']);
|
||||
expect(toothTarget.unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
|
||||
expect.objectContaining({
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['partial_denture'],
|
||||
}),
|
||||
);
|
||||
expect(toothTarget.unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
@@ -357,11 +363,66 @@ describe('resolveProsthesisAssignment', () => {
|
||||
spoken: 'دنچر فک بالا',
|
||||
});
|
||||
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||
expect(jawTarget.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['complete_denture'],
|
||||
}),
|
||||
);
|
||||
expect(jawTarget.unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a category with no leaf the target can carry', () => {
|
||||
// `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch
|
||||
// category and must not defer. Aimed at a jaw it is a contradiction.
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||
types: ['implant'],
|
||||
spoken: 'ایمپلنت بالا',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(resolved.types).toEqual([]);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
expect(unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a stack where only one code suits the target', () => {
|
||||
// One legal crown must not admit an arch-only appliance onto the same tooth. Validating
|
||||
// with `some` over the stack's regions did exactly that.
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown', 'night_guard_soft'],
|
||||
spoken: 'دندون ۱۲ روکش و نایت گارد',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
spoken: '12',
|
||||
reason: 'code_not_valid_for_target',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a stack whose every code suits the target', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['zirconia_abutment', 'monolithic_zirconia'],
|
||||
spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا',
|
||||
});
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(resolved.types).toEqual([
|
||||
'zirconia_abutment',
|
||||
'monolithic_zirconia',
|
||||
]);
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('13'), tooth('14')],
|
||||
|
||||
Reference in New Issue
Block a user