|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui#8993 — `bucketCardsIntoColumns` decides lane membership TWICE, and |
| 11 | + * before this pin the two decisions used different key types. |
| 12 | + * |
| 13 | + * ## The mechanism |
| 14 | + * |
| 15 | + * - **Injection** reads `groups[col.id]`. A property read coerces its key, so |
| 16 | + * a lane `{ id: 1 }` correctly picks up the group stored under `'1'`. |
| 17 | + * - **The leftover sweep** (#2792) built its known-id Set from the RAW value |
| 18 | + * and filtered `Object.keys(groups)` — which are ALWAYS strings. Since |
| 19 | + * `new Set([1]).has('1')` is `false`, every record the injection had just |
| 20 | + * placed was swept a second time into the trailing "Uncategorized" lane. |
| 21 | + * |
| 22 | + * ⇒ A numeric-id board rendered every card twice. Silent: the board draws, the |
| 23 | + * totals just do not reconcile, which is why the counting row below exists. |
| 24 | + * |
| 25 | + * ## What each row is for |
| 26 | + * |
| 27 | + * 1. NUMERIC — the reported reading. Was `'1:r1, 2:r2, __uncolumned__:r1+r2'`. |
| 28 | + * 2. STRING CONTROL — a live control, green on BOTH sides of the repair. It is |
| 29 | + * what makes row 1 a statement about the id TYPE rather than about bucketing |
| 30 | + * in general; if it ever reddens, the repair went the wrong way. |
| 31 | + * 3. THE CLASS — the defect is "the id is not a string", not "the id is a |
| 32 | + * number", so every non-string id type the function can be handed is pinned, |
| 33 | + * including the falsy ones (`0`, `false`) that also skip the label map. |
| 34 | + * 4. THE LABEL PATH — a record can reach `groups` through the label→id map, |
| 35 | + * which stores the RAW id as its VALUE. That path double-bucketed too, so |
| 36 | + * one row keeps it honest; keying only the direct path would leave it open. |
| 37 | + * 5. THE SWEEP STILL SWEEPS — the control that separates a repair from a |
| 38 | + * deletion. Rows 1-4 would all pass if the sweep were simply removed, and |
| 39 | + * that would resurrect #2792 (records silently dropped). An unmatched record |
| 40 | + * must still surface in the trailing lane. |
| 41 | + * 6. NO NEW COLLISION — coercing a key can MINT a collision where there was |
| 42 | + * none, so the boundary is asserted rather than argued: `String(null)` is |
| 43 | + * `'null'` and `String(undefined)` is `'undefined'`, and neither may swallow |
| 44 | + * the empty key that a null/absent VALUE produces; a lane declared with the |
| 45 | + * `KANBAN_UNCOLUMNED_ID` spelling keeps its existing reading; and a symbol |
| 46 | + * id — the one key a property read does NOT stringify — must keep behaving |
| 47 | + * as it does today instead of throwing inside `String()`. |
| 48 | + * |
| 49 | + * ⛔ The repair belongs on the SWEEP side. Making the injection strict would |
| 50 | + * pass rows 1-2 while breaking the coercion lanes depend on, and the string |
| 51 | + * control could not catch it. |
| 52 | + */ |
| 53 | + |
| 54 | +import { describe, it, expect } from 'vitest'; |
| 55 | +import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index'; |
| 56 | + |
| 57 | +/** `id:card+card` per lane, in lane order — the whole board in one string. */ |
| 58 | +const summarise = (lanes: Array<any>): string => |
| 59 | + lanes |
| 60 | + .map((l: any) => `${String(l.id)}:${(l.cards || []).map((c: any) => c.id).join('+')}`) |
| 61 | + .join(', '); |
| 62 | + |
| 63 | +const bucket = (columns: Array<any>, data: Array<any>): string => |
| 64 | + summarise(bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized')); |
| 65 | + |
| 66 | +/** Every card id the board renders, across every lane, duplicates included. */ |
| 67 | +const renderedCardIds = (columns: Array<any>, data: Array<any>): string[] => |
| 68 | + bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized').flatMap( |
| 69 | + (l: any) => (l.cards || []).map((c: any) => c.id), |
| 70 | + ); |
| 71 | + |
| 72 | +describe('bucketCardsIntoColumns — lane id key coercion (objectui#8993)', () => { |
| 73 | + it('row 1 — NUMERIC lane ids bucket each record exactly once', () => { |
| 74 | + expect( |
| 75 | + bucket( |
| 76 | + [ |
| 77 | + { id: 1, title: 'One' }, |
| 78 | + { id: 2, title: 'Two' }, |
| 79 | + ], |
| 80 | + [ |
| 81 | + { id: 'r1', status: 1 }, |
| 82 | + { id: 'r2', status: '2' }, |
| 83 | + ], |
| 84 | + ), |
| 85 | + ).toBe('1:r1, 2:r2'); |
| 86 | + }); |
| 87 | + |
| 88 | + it('row 2 — STRING control: unchanged, green on both sides of the repair', () => { |
| 89 | + expect(bucket([{ id: 'one', title: 'One' }], [{ id: 'r1', status: 'one' }])).toBe('one:r1'); |
| 90 | + }); |
| 91 | + |
| 92 | + describe('row 3 — the class is "id is not a string", not "id is a number"', () => { |
| 93 | + const cases: Array<{ name: string; id: unknown; value: unknown; lane: string }> = [ |
| 94 | + { name: 'number', id: 1, value: 1, lane: '1' }, |
| 95 | + { name: 'zero (falsy, so the label map skips it)', id: 0, value: 0, lane: '0' }, |
| 96 | + { name: 'boolean true', id: true, value: true, lane: 'true' }, |
| 97 | + { name: 'boolean false (falsy)', id: false, value: false, lane: 'false' }, |
| 98 | + { name: 'null', id: null, value: 'null', lane: 'null' }, |
| 99 | + { name: 'undefined (a lane with no id at all)', id: undefined, value: 'undefined', lane: 'undefined' }, |
| 100 | + { name: 'NaN', id: NaN, value: NaN, lane: 'NaN' }, |
| 101 | + { name: 'array', id: [1, 2], value: '1,2', lane: '1,2' }, |
| 102 | + { name: 'plain object', id: { a: 1 }, value: '[object Object]', lane: '[object Object]' }, |
| 103 | + ]; |
| 104 | + |
| 105 | + for (const { name, id, value, lane } of cases) { |
| 106 | + it(`${name} — one lane, one card, no trailing "Uncategorized"`, () => { |
| 107 | + const columns = [{ id, title: 'Lane' }]; |
| 108 | + const data = [{ id: 'r1', status: value }]; |
| 109 | + expect(bucket(columns, data)).toBe(`${lane}:r1`); |
| 110 | + // The counting row: the visible card total must reconcile with the |
| 111 | + // record count. This is the user-visible symptom, stated directly. |
| 112 | + expect(renderedCardIds(columns, data)).toEqual(['r1']); |
| 113 | + }); |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + it('row 4 — the label→id path is keyed the same way (it stores the RAW id)', () => { |
| 118 | + // 'One' matches the lane's TITLE, so the group key comes out of |
| 119 | + // `labelToColumnId`, whose stored VALUE is the raw numeric id. |
| 120 | + expect(bucket([{ id: 1, title: 'One' }], [{ id: 'r1', status: 'One' }])).toBe('1:r1'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('row 5 — the sweep still sweeps: an unmatched record still surfaces (#2792)', () => { |
| 124 | + // A repair that simply deleted the sweep would pass every row above and |
| 125 | + // silently drop this record — the defect #2792 closed. |
| 126 | + expect( |
| 127 | + bucket( |
| 128 | + [ |
| 129 | + { id: 1, title: 'One' }, |
| 130 | + { id: 2, title: 'Two' }, |
| 131 | + ], |
| 132 | + [ |
| 133 | + { id: 'r1', status: 1 }, |
| 134 | + { id: 'r9', status: 'retired_option' }, |
| 135 | + ], |
| 136 | + ), |
| 137 | + ).toBe(`1:r1, 2:, ${KANBAN_UNCOLUMNED_ID}:r9`); |
| 138 | + }); |
| 139 | + |
| 140 | + describe('row 6 — the coercion mints no new collision', () => { |
| 141 | + it("a null lane id does not swallow the empty key a null VALUE produces", () => { |
| 142 | + // `String(null)` is 'null', but a record whose group VALUE is null is |
| 143 | + // keyed '' (`String(item[groupBy] ?? '')`). The two must stay apart. |
| 144 | + expect(bucket([{ id: null, title: 'N' }], [{ id: 'r1', status: null }])).toBe( |
| 145 | + `null:, ${KANBAN_UNCOLUMNED_ID}:r1`, |
| 146 | + ); |
| 147 | + }); |
| 148 | + |
| 149 | + it("an undefined lane id does not swallow the empty key an ABSENT value produces", () => { |
| 150 | + expect(bucket([{ title: 'U' }], [{ id: 'r1' }])).toBe( |
| 151 | + `undefined:, ${KANBAN_UNCOLUMNED_ID}:r1`, |
| 152 | + ); |
| 153 | + }); |
| 154 | + |
| 155 | + it('a lane declared with the sentinel spelling keeps its own records', () => { |
| 156 | + const lanes = bucketCardsIntoColumns( |
| 157 | + [{ id: KANBAN_UNCOLUMNED_ID, title: 'Declared' }], |
| 158 | + [{ id: 'r1', status: KANBAN_UNCOLUMNED_ID }], |
| 159 | + 'status', |
| 160 | + undefined, |
| 161 | + 'Uncategorized', |
| 162 | + ); |
| 163 | + expect(summarise(lanes)).toBe(`${KANBAN_UNCOLUMNED_ID}:r1`); |
| 164 | + expect(lanes).toHaveLength(1); // no second lane under the same id |
| 165 | + }); |
| 166 | + |
| 167 | + it("a symbol lane id keeps today's reading instead of throwing", () => { |
| 168 | + // A symbol is the one key a property read does NOT stringify, so it is |
| 169 | + // never pushed through `String()` (which throws on symbols). `Object.keys` |
| 170 | + // never yields a symbol, so such a lane matches nothing — exactly as |
| 171 | + // before this repair. |
| 172 | + const columns = [{ id: Symbol('lane'), title: 'S' }]; |
| 173 | + const data = [{ id: 'r1', status: 'x' }]; |
| 174 | + expect(() => bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized')) |
| 175 | + .not.toThrow(); |
| 176 | + expect(renderedCardIds(columns, data)).toEqual(['r1']); |
| 177 | + expect(bucket(columns, data)).toBe(`Symbol(lane):, ${KANBAN_UNCOLUMNED_ID}:r1`); |
| 178 | + }); |
| 179 | + }); |
| 180 | +}); |
0 commit comments