@@ -58,6 +58,120 @@ describe('findClosestMatches', () => {
5858 } ) ;
5959} ) ;
6060
61+ describe ( 'camelCase parity in the distance fallback (#4990)' , ( ) => {
62+ // The budget `strictUnknownKeyError` actually spends. Reproduced rather than
63+ // imported because the point of these tests is the INTERACTION between the
64+ // budget and the scoring — a test that shared the constant could not show it.
65+ const budget = ( key : string ) => Math . max ( 2 , Math . floor ( key . length / 3 ) ) ;
66+ const suggest = ( input : string , cands : readonly string [ ] ) : string | undefined =>
67+ findClosestMatches ( input , cands , budget ( input ) , 1 ) [ 0 ] ;
68+
69+ it ( 're-measures the four rows the issue tabled, at their real budgets' , ( ) => {
70+ // Before the fix, row 1 was `[]`: `hideOn` scored 3 against a budget of 2,
71+ // because `hiddenOn`'s capital O was charged to the author as an edit.
72+ expect ( suggest ( 'hideOn' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ;
73+ expect ( suggest ( 'hiddenon' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ;
74+ expect ( suggest ( 'hiddenOnn' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ;
75+ expect ( suggest ( 'maxLenght' , [ 'maxLength' ] ) ) . toBe ( 'maxLength' ) ;
76+ } ) ;
77+
78+ // THE INVARIANT, and the substance of #4990.
79+ //
80+ // Stating it took one correction worth recording. The issue phrases it as
81+ // "the all-lowercase form must not get a better suggestion than the correctly
82+ // cased one", and the obvious encoding — compare `suggest(T)` with
83+ // `suggest(T.toLowerCase())` — is VACUOUS against the buggy code, which
84+ // lowercased the input as its first act: both calls collapsed to the same
85+ // one and agreed trivially, on 462 probes, while the bug was fully present.
86+ //
87+ // The asymmetry is not between two spellings of the INPUT. It is between two
88+ // spellings of the DECLARED KEY: the identical typo was judged one way
89+ // against `hiddenOn` and another against `hiddenon`, because only the
90+ // candidate kept its capitals and each one cost the author an edit. That is
91+ // the title's "systematically weak on camelCase keys", and it is what this
92+ // pins: a key's capitalisation must not change the verdict on a typo.
93+ //
94+ // Measured against the pre-fix implementation, this corpus breaks parity 55
95+ // times in 462 probes — in BOTH directions (`bordeRradius` resolved against
96+ // camelCase `borderRadius` but not against flat `borderradius`, the capital
97+ // paying off by luck). Case must decide nothing either way.
98+ it ( 'judges a typo the same whether the declared key is camelCase or flat' , ( ) => {
99+ const corpus = [
100+ 'hiddenOn' , 'maxLength' , 'iteratorVariable' , 'primaryField' , 'defaultValue' ,
101+ 'borderRadius' , 'customVars' , 'referenceTo' , 'maxIterations' , 'displayName' ,
102+ 'onDelete' , 'sortOrder' , 'isRequired' , 'allowedPaths' , 'triggerPhrases' ,
103+ 'xAxis' , 'viewName' , 'pluginId' ,
104+ ] ;
105+ const failures : string [ ] = [ ] ;
106+ for ( const key of corpus ) {
107+ const flat = key . toLowerCase ( ) ;
108+ // How an author actually mistypes: dropped character, swapped pair,
109+ // dropped pair — spelled with the camelCase they were aiming for.
110+ const variants = new Set < string > ( ) ;
111+ for ( let i = 1 ; i < key . length ; i ++ ) variants . add ( key . slice ( 0 , i ) + key . slice ( i + 1 ) ) ;
112+ for ( let i = 1 ; i < key . length - 1 ; i ++ ) {
113+ variants . add ( key . slice ( 0 , i ) + key [ i + 1 ] + key [ i ] + key . slice ( i + 2 ) ) ;
114+ variants . add ( key . slice ( 0 , i ) + key . slice ( i + 2 ) ) ;
115+ }
116+ variants . delete ( key ) ;
117+ for ( const typo of variants ) {
118+ // Skip the degenerate comparison: when the typo lowercases to the flat
119+ // key itself (`bordeRradius` → `borderradius`), the flat side is the
120+ // author echoing their own string and is correctly refused, while the
121+ // camelCase side is a real — and maximally confident — suggestion.
122+ // That asymmetry is the self-match rule, not a case-parity break.
123+ if ( typo . toLowerCase ( ) === flat ) continue ;
124+ const againstCamel = suggest ( typo , [ key ] ) === key ;
125+ const againstFlat = suggest ( typo . toLowerCase ( ) , [ flat ] ) === flat ;
126+ if ( againstCamel !== againstFlat ) {
127+ failures . push (
128+ `${ key } : '${ typo } ' resolves=${ againstCamel } but flat '${ flat } ' resolves=${ againstFlat } ` ,
129+ ) ;
130+ }
131+ }
132+ }
133+ expect (
134+ failures ,
135+ `a declared key's capitalisation changed the verdict:\n${ failures . join ( '\n' ) } ` ,
136+ ) . toEqual ( [ ] ) ;
137+ } ) ;
138+
139+ // The issue's own headline comparison, kept as a named case because it is the
140+ // sentence the bug was reported in: the author who wrote the key ALL LOWERCASE
141+ // was served better than the author who cased it right and slipped two letters.
142+ it ( 'serves the correctly-cased author no worse than the all-lowercase one' , ( ) => {
143+ expect ( suggest ( 'hiddenon' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ; // was already fine
144+ expect ( suggest ( 'hideOn' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ; // was `undefined`
145+ } ) ;
146+
147+ it ( 'suggests a candidate that differs from the input ONLY in case' , ( ) => {
148+ // Folding both sides makes such a candidate distance 0, and the old
149+ // `distance > 0` filter discarded it along with the true self-match. It is
150+ // the single most confident suggestion the fallback can make.
151+ expect ( suggest ( 'hiddenon' , [ 'hiddenOn' ] ) ) . toBe ( 'hiddenOn' ) ;
152+ expect ( suggest ( 'MAXLENGTH' , [ 'maxLength' ] ) ) . toBe ( 'maxLength' ) ;
153+ expect ( suggest ( 'reference_to' , [ 'referenceTo' ] ) ) . toBe ( 'referenceTo' ) ;
154+ } ) ;
155+
156+ it ( 'still refuses to echo back the exact string the author typed' , ( ) => {
157+ expect ( findClosestMatches ( 'maxLength' , [ 'maxLength' , 'minLength' ] , 3 , 3 ) )
158+ . not . toContain ( 'maxLength' ) ;
159+ } ) ;
160+
161+ it ( 'breaks a folded tie on the author\'s own capitalisation' , ( ) => {
162+ // `allowedaPths` is equidistant from `allowedPaths` and `allowedAPIs` once
163+ // case is folded away. The capitals the author did type are the only
164+ // evidence left, and they point at `allowedPaths`.
165+ expect ( suggest ( 'allowedaPths' , [ 'allowedAPIs' , 'allowedPaths' ] ) ) . toBe ( 'allowedPaths' ) ;
166+ } ) ;
167+
168+ it ( 'leaves genuinely unrelated keys unsuggested — the fold is not a widening' , ( ) => {
169+ const keys = [ 'hiddenOn' , 'columns' , 'order' , 'breakpoint' ] ;
170+ expect ( suggest ( 'workflows' , keys ) ) . toBeUndefined ( ) ;
171+ expect ( suggest ( 'responsiveStyles' , keys ) ) . toBeUndefined ( ) ;
172+ } ) ;
173+ } ) ;
174+
61175describe ( 'suggestFieldType' , ( ) => {
62176 it ( 'should suggest via alias map for common alternatives' , ( ) => {
63177 expect ( suggestFieldType ( 'string' ) ) . toEqual ( [ 'text' ] ) ;
0 commit comments