Skip to content
Closed
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
131 changes: 131 additions & 0 deletions client/dive-common/typeHierarchy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import fs from 'fs-extra';
import {
acceptPairAsCorrect,
compareTypeNames,
compileHierarchy,
mergePairs,
normalizeTypeHierarchy,
reassignPairs,
removeHierarchyType,
removePair,
resolveConfidenceThreshold,
resolveTypeHierarchy,
Expand All @@ -13,6 +15,7 @@ import {
selectPairIndex,
setPairConfidence,
TypeHierarchyError,
updateHierarchyTypeDefinition,
} from './typeHierarchy';

interface ErrorExpectation {
Expand Down Expand Up @@ -251,6 +254,134 @@ describe('type hierarchy index', () => {
});
});

describe('hierarchy editing transformations', () => {
describe('removeHierarchyType', () => {
it('removes a middle node and promotes all of its children', () => {
expect(removeHierarchyType({
cod: 'fish',
haddock: 'fish',
fish: 'animal',
tern: 'bird',
}, 'fish')).toEqual({
cod: 'animal',
haddock: 'animal',
tern: 'bird',
});
});

it('makes children of a removed top-level node top level', () => {
expect(removeHierarchyType({
cod: 'fish',
haddock: 'fish',
tern: 'bird',
}, 'fish')).toEqual({ tern: 'bird' });
});

it('removes a leaf and returns undefined when the final edge is removed', () => {
expect(removeHierarchyType({ cod: 'fish', tern: 'bird' }, 'cod'))
.toEqual({ tern: 'bird' });
expect(removeHierarchyType({ cod: 'fish' }, 'cod')).toBeUndefined();
});

it('normalizes an absent-type no-op into a fresh map', () => {
const hierarchy = Object.freeze({ cod: 'fish', tern: 'bird' });
const result = removeHierarchyType(hierarchy, 'shark');
expect(result).toEqual(hierarchy);
expect(result).not.toBe(hierarchy);
expect(hierarchy).toEqual({ cod: 'fish', tern: 'bird' });
});
});

describe('updateHierarchyTypeDefinition', () => {
it('sets, reparents, and clears edges without mutating unrelated branches', () => {
expect(updateHierarchyTypeDefinition(undefined, 'cod', 'cod', 'fish'))
.toEqual({ cod: 'fish' });
const hierarchy = { cod: 'fish', tern: 'bird' };
expect(updateHierarchyTypeDefinition(hierarchy, 'cod', 'cod', 'animal')).toEqual({
cod: 'animal',
tern: 'bird',
});
expect(updateHierarchyTypeDefinition(hierarchy, 'cod', 'cod', undefined))
.toEqual({ tern: 'bird' });
expect(updateHierarchyTypeDefinition({ cod: 'fish' }, 'cod', 'cod', undefined))
.toBeUndefined();
});

it('builds a valid rename and reparent result without validating an invalid intermediate map', () => {
const hierarchy = {
cod: 'fish',
haddock: 'animal',
sole: 'cod',
tern: 'bird',
};
expect(() => rewriteHierarchyType(hierarchy, 'cod', 'haddock'))
.toThrow('conflicting parents for "haddock": "animal" and "fish"');

expect(updateHierarchyTypeDefinition(
hierarchy,
'cod',
'haddock',
'fish',
)).toEqual({
haddock: 'fish',
sole: 'haddock',
tern: 'bird',
});
});

it('rewrites child references while applying the final edited parent', () => {
expect(updateHierarchyTypeDefinition(
{ cod: 'fish', fish: 'animal' },
'fish',
'vertebrate',
'life',
)).toEqual({ cod: 'vertebrate', vertebrate: 'life' });
});

it('validates the complete final map and leaves its input unchanged', () => {
const hierarchy = Object.freeze({ cod: 'fish', fish: 'animal' });
expectHierarchyError(
() => updateHierarchyTypeDefinition(hierarchy, 'fish', 'fish', 'cod'),
'cycle cod -> fish -> cod',
'malformed',
);
expect(hierarchy).toEqual({ cod: 'fish', fish: 'animal' });
});

it('rejects blank parents and self edges', () => {
expectHierarchyError(
() => updateHierarchyTypeDefinition(undefined, 'cod', 'cod', '\u001c'),
'empty parent for "cod"',
'malformed',
);
expectHierarchyError(
() => updateHierarchyTypeDefinition(undefined, 'cod', 'cod', 'cod'),
'self edge "cod -> cod"',
'malformed',
);
});

it('returns a fresh map ordered by code point', () => {
const bmp = '\uE000';
const astral = '\u{10000}';
const hierarchy = Object.freeze({ [astral]: 'root' });
const result = updateHierarchyTypeDefinition(hierarchy, bmp, bmp, 'root');
expect(compareTypeNames(bmp, astral)).toBeLessThan(0);
expect(Object.keys(result || {})).toEqual([bmp, astral]);
expect(result).not.toBe(hierarchy);
expect(hierarchy).toEqual({ [astral]: 'root' });
});

it('rejects a blank final name even when it would have no edge', () => {
expectHierarchyError(
() => updateHierarchyTypeDefinition(undefined, 'cod', ' ', undefined),
'empty child',
'malformed',
);
});
});
});

describe('flat pair selection', () => {
const pairs: [string, number][] = [['top', 0.5], ['fallback', 0.8]];

Expand Down
58 changes: 53 additions & 5 deletions client/dive-common/typeHierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function selectFlatPairIndex(

// Python orders strings by code point; JS compares UTF-16 units, which sorts astral
// names before U+E000-U+FFFF. Compare code points so both platforms agree.
function codePointCompare(left: string, right: string): number {
export function compareTypeNames(left: string, right: string): number {
const leftPoints = [...left].map((char) => char.codePointAt(0) as number);
const rightPoints = [...right].map((char) => char.codePointAt(0) as number);
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
Expand All @@ -77,7 +77,7 @@ function codePointCompare(left: string, right: string): number {
}

function sortedNames(names: readonly string[]): string[] {
return [...names].sort(codePointCompare);
return [...names].sort(compareTypeNames);
}

function hasOwn(hierarchy: TypeHierarchy, type: string): boolean {
Expand Down Expand Up @@ -115,7 +115,7 @@ function cycleReason(hierarchy: TypeHierarchy): string | undefined {
const cycle = path.slice(positions.get(current) as number);
let smallestIndex = 0;
cycle.forEach((name, index) => {
if (codePointCompare(name, cycle[smallestIndex]) < 0) {
if (compareTypeNames(name, cycle[smallestIndex]) < 0) {
smallestIndex = index;
}
});
Expand All @@ -128,7 +128,7 @@ function cycleReason(hierarchy: TypeHierarchy): string | undefined {
if (renderedCycles.length === 0) {
return undefined;
}
renderedCycles.sort(codePointCompare);
renderedCycles.sort(compareTypeNames);
return `cycle ${renderedCycles[0]}`;
}

Expand Down Expand Up @@ -317,6 +317,54 @@ export function rewriteHierarchyType(
}
}

export function removeHierarchyType(
hierarchy: TypeHierarchy,
type: string,
): TypeHierarchy | undefined {
const normalized = normalizeTypeHierarchy(hierarchy) || {};
const parent = hasOwn(normalized, type) ? normalized[type] : undefined;
const updated = new Map<string, string>();

sortedNames(Object.keys(normalized)).forEach((child) => {
if (child === type) {
return;
}
const currentParent = normalized[child];
if (currentParent !== type) {
updated.set(child, currentParent);
} else if (parent !== undefined) {
updated.set(child, parent);
}
});
return normalizeTypeHierarchy(Object.fromEntries(updated));
}

/** Build the final hierarchy for an atomic type rename and parent edit. */
export function updateHierarchyTypeDefinition(
hierarchy: TypeHierarchy | undefined,
currentType: string,
newType: string,
parent: string | undefined,
): TypeHierarchy | undefined {
const normalized = normalizeTypeHierarchy(hierarchy || {}) || {};
if (isBlankName(currentType) || isBlankName(newType)) {
throw new TypeHierarchyError('empty child');
}

const updated = new Map<string, string>();
sortedNames(Object.keys(normalized)).forEach((child) => {
if (child === currentType || child === newType) {
return;
}
const currentParent = normalized[child];
updated.set(child, currentParent === currentType ? newType : currentParent);
});
if (parent !== undefined) {
updated.set(newType, parent);
}
return normalizeTypeHierarchy(Object.fromEntries(updated));
}

// Assignment replaces the selected claim's lineage while retaining unrelated claims and the
// stored ancestors still implied by the new type. No missing hierarchy members are synthesized.
export function reassignPairs(
Expand Down Expand Up @@ -381,7 +429,7 @@ export function mergePairs(
});
});
return Array.from(confidenceByType.entries())
.sort((left, right) => (right[1] - left[1]) || codePointCompare(left[0], right[0]));
.sort((left, right) => (right[1] - left[1]) || compareTypeNames(left[0], right[0]));
}

export function selectPairIndex(
Expand Down
Loading
Loading