|
| 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#6770 — a `null` gantt date is refused, not silently drawn at 1970. |
| 11 | + * |
| 12 | + * ## What was wrong |
| 13 | + * |
| 14 | + * `new Date(null).getTime()` is `0`, not `NaN`. That is the Unix epoch, not an |
| 15 | + * invalid date, so a `null` date passed objectui#6759's parse guard untouched |
| 16 | + * and reached the arithmetic as `1970-01-01`. Measured on this card's base |
| 17 | + * c6732825d — i.e. WITH #6759's guards already in the tree — with a throwaway |
| 18 | + * probe, since no pin covered this input: |
| 19 | + * |
| 20 | + * CASE-NULL-END endDate: null -> alert: null, axis 649 columns |
| 21 | + * (Jan 1970 … Jan 2024), bars ["left: 100%; width: -100%;"] |
| 22 | + * CASE-NULL-START startDate: null -> alert: null, axis 651 columns, |
| 23 | + * bars ["left: 0%; width: 100%;"] |
| 24 | + * CASE-NULL-BOTH both null -> alert: null, axis ["Jan 1970"], |
| 25 | + * bars ["left: 0%; width: 100%;"] |
| 26 | + * CASE-PIN-NULL minDate/maxDate null -> the rows' own range, correct |
| 27 | + * |
| 28 | + * The filed case is the first: a fifty-four-year axis and a bar with NEGATIVE |
| 29 | + * width, hanging off the right-hand edge, with no diagnostic. The other two are |
| 30 | + * the reason the answer is a refusal rather than a repair — they do not even |
| 31 | + * look broken. A full-width bar under a one-bucket `Jan 1970` axis is a chart a |
| 32 | + * reader would believe. |
| 33 | + * |
| 34 | + * ## The arm taken, and the pin that decided it |
| 35 | + * |
| 36 | + * Three readings were defensible on paper — refuse, treat as open-ended, treat |
| 37 | + * as absent — and the card refused to let a fixer pick one silently. It did not |
| 38 | + * need a fresh ruling: #6759 ALREADY refuses this input class one spelling |
| 39 | + * over. `new Date(undefined)` is `NaN`, so a row item that merely omits |
| 40 | + * `endDate` is refused and named, pinned next door as "an ABSENT date is the |
| 41 | + * same input class and is named as `undefined`" |
| 42 | + * (`timeline-gantt-unusable-date-range.test.tsx`, pin 1). `null` is that same |
| 43 | + * absence with a different spelling: a record mapping that omits the key and |
| 44 | + * one that emits `null` describe the same unset field upstream. Rendering them |
| 45 | + * differently would make the chart depend on how a mapping layer spells "no |
| 46 | + * value", which no author writes and none can predict — so the refusal is |
| 47 | + * consistency with an existing pin, not a new policy. |
| 48 | + * |
| 49 | + * The other two arms fail the test both neighbouring cards already applied: |
| 50 | + * "open-ended" is a new rendering behaviour that would need a spec key of its |
| 51 | + * own rather than a meaning smuggled into `null`, and "fall back to |
| 52 | + * `startDate`" substitutes a plausible value for one the document does not |
| 53 | + * carry — the consumer-side tolerance #6750 and #6759 both rejected. |
| 54 | + * |
| 55 | + * ## The wording is reused, deliberately |
| 56 | + * |
| 57 | + * The diagnostic is #6759's `timeline.gantt.unusableRange.malformedDate`, whose |
| 58 | + * `{{value}}` hole `spellGanttDateValue` already fills as `null` — a branch |
| 59 | + * #6759 wrote for "one they wrote as empty" and that nothing could reach until |
| 60 | + * this card. A `null`-specific sentence would be a new key in all ten locale |
| 61 | + * packs (`packages/i18n`), which is another card's file surface. |
| 62 | + * |
| 63 | + * ## Assertions count BAR ELEMENTS, never styles |
| 64 | + * |
| 65 | + * #6759's rule, inherited: a bar whose geometry is `NaN` carries NO `style` |
| 66 | + * attribute at all (the CSSOM rejects `left: NaN%`), so an assertion phrased |
| 67 | + * over styles reads identically for "the bar is gone" and "the bar is there and |
| 68 | + * broken". Every refusal assertion below is positive about the diagnostic and |
| 69 | + * counts elements. Styles are read only on the paths that must stay UNCHANGED, |
| 70 | + * where their exact float spelling is the point. |
| 71 | + */ |
| 72 | + |
| 73 | +import React from 'react'; |
| 74 | +import { render, screen } from '@testing-library/react'; |
| 75 | +import { describe, it, expect, vi } from 'vitest'; |
| 76 | +import { ObjectTimeline } from '../ObjectTimeline'; |
| 77 | +import { TimelineRenderer } from '../renderer'; |
| 78 | + |
| 79 | +vi.mock('@object-ui/react', async (importOriginal) => { |
| 80 | + const actual = await (importOriginal() as Promise<Record<string, unknown>>); |
| 81 | + return { |
| 82 | + ...actual, |
| 83 | + useDataScope: () => undefined, |
| 84 | + useNavigationOverlay: () => ({ |
| 85 | + isOverlay: false, |
| 86 | + handleClick: vi.fn(), |
| 87 | + selectedRecord: null, |
| 88 | + isOpen: false, |
| 89 | + close: vi.fn(), |
| 90 | + setIsOpen: vi.fn(), |
| 91 | + mode: 'overlay', |
| 92 | + view: undefined, |
| 93 | + }), |
| 94 | + useObjectLabel: () => ({ |
| 95 | + fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb, |
| 96 | + translateOptions: (_o: string, _f: string, opts: unknown[]) => opts, |
| 97 | + fieldLabel: (_o: string, _f: string, fb: string) => fb, |
| 98 | + }), |
| 99 | + }; |
| 100 | +}); |
| 101 | + |
| 102 | +const TESTID = 'timeline-unusable-date-range'; |
| 103 | + |
| 104 | +/** The axis header cells the gantt branch emits, in order. */ |
| 105 | +const axisOf = (container: HTMLElement): string[] => |
| 106 | + Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map( |
| 107 | + (n) => n.textContent ?? '', |
| 108 | + ); |
| 109 | + |
| 110 | +/** How many bar ELEMENTS exist — not their geometry. See the header. */ |
| 111 | +const barCountOf = (container: HTMLElement): number => |
| 112 | + container.querySelectorAll('.absolute.h-8.rounded-md').length; |
| 113 | + |
| 114 | +/** Every bar's inline geometry, in order — for the unchanged-path pins only. */ |
| 115 | +const barStylesOf = (container: HTMLElement): (string | null)[] => |
| 116 | + Array.from(container.querySelectorAll('.absolute.h-8.rounded-md')).map((n) => |
| 117 | + (n as HTMLElement).getAttribute('style'), |
| 118 | + ); |
| 119 | + |
| 120 | +/** The diagnostic's text, or `null` when the gantt rendered instead. */ |
| 121 | +const diagnosticOf = (container: HTMLElement): string | null => { |
| 122 | + const el = container.querySelector(`[data-testid="${TESTID}"]`); |
| 123 | + return el ? el.textContent ?? '' : null; |
| 124 | +}; |
| 125 | + |
| 126 | +const gantt = (schema: Record<string, unknown>) => |
| 127 | + render(<TimelineRenderer schema={{ type: 'timeline', variant: 'gantt', ...schema } as any} />); |
| 128 | + |
| 129 | +/** One row carrying one item, so a case only has to say what is wrong with it. */ |
| 130 | +const rowWith = (item: Record<string, unknown>) => [{ label: 'R', items: [{ title: 'T', ...item }] }]; |
| 131 | + |
| 132 | +/** One legitimate row — the thing that is NOT at fault in the pinned-range cases. */ |
| 133 | +const GOOD_ROW = { |
| 134 | + label: 'Backend', |
| 135 | + items: [{ title: 'API Design', startDate: '2024-01-01', endDate: '2024-03-01' }], |
| 136 | +}; |
| 137 | + |
| 138 | +describe('pin 1 — a `null` row date refuses and names itself (objectui#6770)', () => { |
| 139 | + it('a null `endDate` is refused, spelled as `null`, at the path the author wrote it', () => { |
| 140 | + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: null }) }); |
| 141 | + |
| 142 | + const el = screen.getByTestId(TESTID); |
| 143 | + // #6655's shape by way of #6759: an alert, not a silent panel. |
| 144 | + expect(el.getAttribute('role')).toBe('alert'); |
| 145 | + |
| 146 | + const text = el.textContent ?? ''; |
| 147 | + // Spelled as itself. `undefined` and `null` read differently to an author — |
| 148 | + // a key they forgot versus one they wrote as empty — and #6759's |
| 149 | + // `spellGanttDateValue` already keeps them apart. |
| 150 | + expect(text, 'the diagnostic did not name the offending value').toContain('null'); |
| 151 | + expect(text, 'the diagnostic did not name the authored path').toContain( |
| 152 | + 'items[0].items[0].endDate', |
| 153 | + ); |
| 154 | + }); |
| 155 | + |
| 156 | + it('the 649-column 1970 axis and the negative-width bar are BOTH gone', () => { |
| 157 | + // Base reading: axis 649 columns Jan 1970 … Jan 2024, bars |
| 158 | + // ["left: 100%; width: -100%;"]. Counted, not read off a style attribute. |
| 159 | + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: null }) }); |
| 160 | + |
| 161 | + expect(axisOf(container), 'the 1970 axis survived the refusal').toEqual([]); |
| 162 | + expect(barCountOf(container), 'a bar was drawn from a null date').toBe(0); |
| 163 | + expect(screen.queryByText('T')).toBeNull(); |
| 164 | + }); |
| 165 | + |
| 166 | + it('a null `startDate` is refused too — the case that LOOKED fine', () => { |
| 167 | + // Base reading: 651 columns and bars ["left: 0%; width: 100%;"] — a bar |
| 168 | + // with entirely plausible geometry on an axis starting in 1970. Nothing |
| 169 | + // about that render says it is wrong, which is why it is here. |
| 170 | + const { container } = gantt({ items: rowWith({ startDate: null, endDate: '2024-03-01' }) }); |
| 171 | + |
| 172 | + const text = diagnosticOf(container) ?? ''; |
| 173 | + expect(text).toContain('items[0].items[0].startDate'); |
| 174 | + expect(text).toContain('null'); |
| 175 | + expect(barCountOf(container)).toBe(0); |
| 176 | + }); |
| 177 | + |
| 178 | + it('both dates null names the first one and draws nothing', () => { |
| 179 | + // Base reading: a single `Jan 1970` bucket with a full-width bar — the most |
| 180 | + // believable of the three, and the least true. |
| 181 | + const { container } = gantt({ items: rowWith({ startDate: null, endDate: null }) }); |
| 182 | + |
| 183 | + const text = diagnosticOf(container) ?? ''; |
| 184 | + expect(text).toContain('items[0].items[0].startDate'); |
| 185 | + expect(axisOf(container)).toEqual([]); |
| 186 | + expect(barCountOf(container)).toBe(0); |
| 187 | + }); |
| 188 | + |
| 189 | + it('the path is the one the null actually sits at, not a hardcoded first row', () => { |
| 190 | + // The scan reports the first fault it meets. A null on the second item of |
| 191 | + // the second row must be reported THERE — otherwise "names where you wrote |
| 192 | + // it" is only true for a one-row document. |
| 193 | + const { container } = gantt({ |
| 194 | + items: [ |
| 195 | + { label: 'A', items: [{ title: 'T', startDate: '2024-01-01', endDate: '2024-02-01' }] }, |
| 196 | + { |
| 197 | + label: 'B', |
| 198 | + items: [ |
| 199 | + { title: 'U', startDate: '2024-01-01', endDate: '2024-02-01' }, |
| 200 | + { title: 'V', startDate: '2024-01-01', endDate: null }, |
| 201 | + ], |
| 202 | + }, |
| 203 | + ], |
| 204 | + }); |
| 205 | + |
| 206 | + expect(diagnosticOf(container) ?? '').toContain('items[1].items[1].endDate'); |
| 207 | + }); |
| 208 | + |
| 209 | + it('`ObjectTimeline` reaches the same refusal on an authored gantt', () => { |
| 210 | + // `items` authored means `ObjectTimeline` is a pass-through, so #6655's |
| 211 | + // composed-path refusal correctly stays away and the schema reaches |
| 212 | + // `TimelineRenderer`. Both entry points drew the 1970 chart on the base; |
| 213 | + // both must refuse now. |
| 214 | + const { container } = render( |
| 215 | + <ObjectTimeline |
| 216 | + schema={ |
| 217 | + { |
| 218 | + type: 'timeline', |
| 219 | + variant: 'gantt', |
| 220 | + items: rowWith({ startDate: '2024-01-01', endDate: null }), |
| 221 | + } as any |
| 222 | + } |
| 223 | + />, |
| 224 | + ); |
| 225 | + |
| 226 | + expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].endDate'); |
| 227 | + expect( |
| 228 | + screen.queryByTestId('timeline-unsupported-variant'), |
| 229 | + "#6655's refusal fired on an authored gantt", |
| 230 | + ).toBeNull(); |
| 231 | + }); |
| 232 | +}); |
| 233 | + |
| 234 | +describe('pin 2 — a PINNED null is not judged (objectui#6770 boundary)', () => { |
| 235 | + it('`minDate: null` / `maxDate: null` draw the rows’ own range, with no diagnostic', () => { |
| 236 | + // The caller resolves `schema.minDate || dateRange.minDate`, so a null pin |
| 237 | + // is discarded before anything reads it — exactly like #6759's empty-string |
| 238 | + // pin. Judging a value the render never reads would refuse a chart that |
| 239 | + // draws correctly. Measured on the base and unchanged by this card. |
| 240 | + const { container } = gantt({ items: [GOOD_ROW], minDate: null, maxDate: null }); |
| 241 | + |
| 242 | + expect(diagnosticOf(container), 'a discarded null pin was refused').toBeNull(); |
| 243 | + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); |
| 244 | + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); |
| 245 | + }); |
| 246 | +}); |
| 247 | + |
| 248 | +describe('pin 3 — the line is `null`, not "coerces to the epoch" (objectui#6770)', () => { |
| 249 | + it('a numeric millisecond timestamp still renders', () => { |
| 250 | + // The reason the test is `value === null` and nothing wider: a number IS a |
| 251 | + // date in this encoding. Refusing numbers to catch `0` would break input |
| 252 | + // that draws correctly today. |
| 253 | + const { container } = gantt({ |
| 254 | + items: rowWith({ startDate: '2024-01-01', endDate: Date.UTC(2024, 2, 1) }), |
| 255 | + }); |
| 256 | + |
| 257 | + expect(diagnosticOf(container), 'a valid numeric timestamp was refused').toBeNull(); |
| 258 | + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); |
| 259 | + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); |
| 260 | + }); |
| 261 | + |
| 262 | + it('a `Date` instance still renders', () => { |
| 263 | + const { container } = gantt({ |
| 264 | + items: rowWith({ startDate: '2024-01-01', endDate: new Date('2024-03-01') }), |
| 265 | + }); |
| 266 | + |
| 267 | + expect(diagnosticOf(container), 'a Date instance was refused').toBeNull(); |
| 268 | + expect(barCountOf(container)).toBe(1); |
| 269 | + }); |
| 270 | +}); |
| 271 | + |
| 272 | +describe('pin 4 — the neighbouring cards are untouched (objectui#6759, objectui#6750)', () => { |
| 273 | + it('an ABSENT date is still spelled `undefined`, not `null`', () => { |
| 274 | + // The two spellings are refused for the same reason and must keep reading |
| 275 | + // differently: this card widened WHICH values are refused, not how any of |
| 276 | + // them is named. |
| 277 | + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01' }) }); |
| 278 | + |
| 279 | + const text = diagnosticOf(container) ?? ''; |
| 280 | + expect(text).toContain('undefined'); |
| 281 | + expect(text).toContain('items[0].items[0].endDate'); |
| 282 | + }); |
| 283 | + |
| 284 | + it('an unparseable date is still named by its quoted value', () => { |
| 285 | + const { container } = gantt({ items: rowWith({ startDate: 'not-a-date', endDate: 'also-bad' }) }); |
| 286 | + |
| 287 | + expect(diagnosticOf(container) ?? '').toContain('"not-a-date"'); |
| 288 | + }); |
| 289 | + |
| 290 | + it("#6750's EMPTY gantt still gets its sentinel rather than this card's refusal", () => { |
| 291 | + // An empty list has no dates to be null, so it must pass straight through |
| 292 | + // to the one-bucket sentinel. The hazard in both directions: this guard |
| 293 | + // eating the empty state, or the sentinel being widened to swallow a null. |
| 294 | + const { container } = gantt({ items: [] }); |
| 295 | + |
| 296 | + expect(diagnosticOf(container), "this card's guard ate #6750's empty state").toBeNull(); |
| 297 | + expect(axisOf(container).length, 'the empty gantt lost its one-bucket axis').toBe(1); |
| 298 | + expect(barCountOf(container)).toBe(0); |
| 299 | + }); |
| 300 | + |
| 301 | + it('a valid gantt draws the same axis and the same bar geometry as before this card', () => { |
| 302 | + // #6759 pin 6's fixture and its baseline, re-asserted here because a guard |
| 303 | + // that rounded, clamped or short-circuited the arithmetic would pass a |
| 304 | + // tolerance assertion and fail this one. |
| 305 | + const { container } = gantt({ |
| 306 | + scale: 'month', |
| 307 | + items: [ |
| 308 | + { |
| 309 | + label: 'Backend Development', |
| 310 | + items: [ |
| 311 | + { title: 'API Design', startDate: '2024-01-01', endDate: '2024-01-31', variant: 'success' }, |
| 312 | + { title: 'Implementation', startDate: '2024-02-01', endDate: '2024-03-31', variant: 'info' }, |
| 313 | + ], |
| 314 | + }, |
| 315 | + { |
| 316 | + label: 'Frontend Development', |
| 317 | + items: [ |
| 318 | + { title: 'UI Design', startDate: '2024-01-15', endDate: '2024-02-15', variant: 'warning' }, |
| 319 | + ], |
| 320 | + }, |
| 321 | + ], |
| 322 | + }); |
| 323 | + |
| 324 | + expect(diagnosticOf(container), 'the guard fired on a perfectly good gantt').toBeNull(); |
| 325 | + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); |
| 326 | + expect(barStylesOf(container)).toEqual([ |
| 327 | + 'left: 0%; width: 33.33333333333333%;', |
| 328 | + 'left: 34.44444444444444%; width: 65.55555555555556%;', |
| 329 | + 'left: 15.555555555555555%; width: 34.44444444444444%;', |
| 330 | + ]); |
| 331 | + }); |
| 332 | + |
| 333 | + it('a `null` date on the VERTICAL variant is untouched', () => { |
| 334 | + // The gantt guard stays gantt-only, as #6759's pin 4 has it: the other |
| 335 | + // variants never build a date range. |
| 336 | + const { container } = render( |
| 337 | + <TimelineRenderer |
| 338 | + schema={{ type: 'timeline', variant: 'vertical', items: [{ title: 'T', time: null }] } as any} |
| 339 | + />, |
| 340 | + ); |
| 341 | + |
| 342 | + expect(diagnosticOf(container), 'the gantt guard fired on a vertical timeline').toBeNull(); |
| 343 | + expect(screen.getByText('T')).toBeDefined(); |
| 344 | + }); |
| 345 | +}); |
0 commit comments