|
| 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#8055 — `GeolocationField`'s DISPLAY path reads a valid `0` |
| 11 | + * coordinate as "no location", and leaks the literal `0` into the DOM. |
| 12 | + * |
| 13 | + * ## TWO defects, and this file keeps them apart |
| 14 | + * |
| 15 | + * The card measured one reading and correctly separated two independent |
| 16 | + * defects inside it. They are pinned by DIFFERENT assertions here, because |
| 17 | + * fixing either one alone leaves the other live: |
| 18 | + * |
| 19 | + * 1. **Falsy used as a presence test.** `if (!loc.latitude || !loc.longitude)` |
| 20 | + * asks the parsed NUMBER whether it is falsy, and `0` is falsy — so the |
| 21 | + * equator (`latitude: 0`), the prime meridian (`longitude: 0`) and a |
| 22 | + * perfect `accuracy: 0` all took the "no value" branch. Measured on `main`: |
| 23 | + * `{ latitude: 0, longitude: 120.1551 }` rendered `"—0"` where the control |
| 24 | + * `{ latitude: 30.2741, longitude: 120.1551 }` rendered |
| 25 | + * `"30.274100, 120.155100View on map"`. |
| 26 | + * ⇒ pinned by the "renders the coordinates" assertions. |
| 27 | + * |
| 28 | + * 2. **A numeric operand in a JSX conditional.** The same guard was also |
| 29 | + * written as `{location.latitude && location.longitude && (…)}`. With |
| 30 | + * `latitude === 0` that expression evaluates to `0`, and React renders the |
| 31 | + * NUMBER as a text node — the trailing `0` of `"—0"`. |
| 32 | + * ⇒ pinned by {@link directTextNodes}, which reads exactly the place such |
| 33 | + * an operand lands: a text node that is a DIRECT child of the container |
| 34 | + * holding the guard. |
| 35 | + * |
| 36 | + * ⚠️ The two are pinned separately on purpose. `directTextNodes` stays EMPTY |
| 37 | + * when only defect 1 is live (a boolean guard renders nothing either way), and |
| 38 | + * the coordinate string is still rendered when only defect 2 is live — so |
| 39 | + * neither assertion can stand in for the other. The "View on map" assertions |
| 40 | + * answer to BOTH, because that one site carried both defects; they are the |
| 41 | + * user-facing consequence, not the separator. |
| 42 | + * |
| 43 | + * ## The two live CONTROLS this card came with |
| 44 | + * |
| 45 | + * `value={location.latitude ?? ''}` was ALREADY correct on `main` — it uses |
| 46 | + * `??`, and the box showed `"0"`. It is pinned below as a control: it proves |
| 47 | + * the probe can see this widget and read a `0` out of it, and it must not have |
| 48 | + * been "fixed" into consistency with the broken guards. The non-zero render |
| 49 | + * string is the second control and must be byte-identical to the card's |
| 50 | + * reading. |
| 51 | + */ |
| 52 | + |
| 53 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 54 | +import React from 'react'; |
| 55 | +import { render, cleanup, fireEvent } from '@testing-library/react'; |
| 56 | +import type { FieldMetadata } from '@object-ui/types'; |
| 57 | +import { GeolocationField, type GeolocationValue } from '../widgets/GeolocationField'; |
| 58 | + |
| 59 | +/** U+2014, the glyph `EmptyValue` draws — asserting on it is asserting "no value". */ |
| 60 | +const EM_DASH = '—'; |
| 61 | + |
| 62 | +const field = (name: string, type: string): FieldMetadata => |
| 63 | + ({ name, label: name, type } as unknown as FieldMetadata); |
| 64 | + |
| 65 | +const noop = () => undefined; |
| 66 | + |
| 67 | +/** The readonly composite collapses to one row; that row is the container. */ |
| 68 | +function renderReadonly(value: GeolocationValue): HTMLElement { |
| 69 | + const { container } = render( |
| 70 | + <GeolocationField value={value} onChange={noop} field={field('geo', 'location')} readonly />, |
| 71 | + ); |
| 72 | + return container.firstElementChild as HTMLElement; |
| 73 | +} |
| 74 | + |
| 75 | +/** The editable composite's outer container (`space-y-3`). */ |
| 76 | +function renderEditable(value: GeolocationValue): HTMLElement { |
| 77 | + const { container } = render( |
| 78 | + <GeolocationField value={value} onChange={noop} field={field('geo', 'location')} />, |
| 79 | + ); |
| 80 | + return container.firstElementChild as HTMLElement; |
| 81 | +} |
| 82 | + |
| 83 | +/** |
| 84 | + * The non-blank text nodes that are DIRECT children of `el`. |
| 85 | + * |
| 86 | + * ⭐ This is defect 2's oracle, and it is written as a DOM shape rather than as |
| 87 | + * a string diff on purpose: `{0 && <Button/>}` puts the number where React puts |
| 88 | + * any other child — a text node parented by the element holding the guard. A |
| 89 | + * text node nested deeper is somebody's legitimate content (the accuracy row |
| 90 | + * really does render `"Accuracy: ±"`, `"0"`, `"m"` as three nodes), so the |
| 91 | + * scan is deliberately NOT recursive. |
| 92 | + */ |
| 93 | +function directTextNodes(el: Element): string[] { |
| 94 | + return Array.from(el.childNodes) |
| 95 | + .filter((n) => n.nodeType === 3 /* Node.TEXT_NODE */) |
| 96 | + .map((n) => n.textContent ?? '') |
| 97 | + .filter((t) => t.trim() !== ''); |
| 98 | +} |
| 99 | + |
| 100 | +afterEach(cleanup); |
| 101 | + |
| 102 | +describe('objectui#8055 defect 1 — a `0` coordinate is a PLACE, not an absence', () => { |
| 103 | + it('⭐ readonly: a zero LATITUDE renders the coordinates, not the empty placeholder', () => { |
| 104 | + const row = renderReadonly({ latitude: 0, longitude: 120.1551 }); |
| 105 | + |
| 106 | + // ⛔ Not `not.toContain('—')` alone: that assertion is satisfied by the |
| 107 | + // stray `0` of defect 2 and would call a still-broken widget fixed. The |
| 108 | + // positive half is `toFixed(6)`'s own product, which nothing but a real |
| 109 | + // formatted coordinate can produce. |
| 110 | + expect(row.textContent).toContain('0.000000, 120.155100'); |
| 111 | + expect(row.textContent).not.toContain(EM_DASH); |
| 112 | + }); |
| 113 | + |
| 114 | + it('readonly: a zero LONGITUDE is the same rule, not a second one', () => { |
| 115 | + const row = renderReadonly({ latitude: 30.2741, longitude: 0 }); |
| 116 | + expect(row.textContent).toContain('30.274100, 0.000000'); |
| 117 | + expect(row.textContent).not.toContain(EM_DASH); |
| 118 | + }); |
| 119 | + |
| 120 | + it('readonly: `{ latitude: 0, longitude: 0 }` renders both zeros', () => { |
| 121 | + const row = renderReadonly({ latitude: 0, longitude: 0 }); |
| 122 | + expect(row.textContent).toContain('0.000000, 0.000000'); |
| 123 | + expect(row.textContent).not.toContain(EM_DASH); |
| 124 | + }); |
| 125 | + |
| 126 | + it('editable: an `accuracy` of 0 renders its row instead of hiding it', () => { |
| 127 | + const outer = renderEditable({ latitude: 30.2741, longitude: 120.1551, accuracy: 0 }); |
| 128 | + expect(outer.textContent).toContain('Accuracy: ±0m'); |
| 129 | + }); |
| 130 | +}); |
| 131 | + |
| 132 | +describe('objectui#8055 defect 2 — no numeric operand reaches the DOM as a text node', () => { |
| 133 | + it('⭐ readonly: the coordinates row has no bare text-node children', () => { |
| 134 | + const row = renderReadonly({ latitude: 0, longitude: 120.1551 }); |
| 135 | + |
| 136 | + // On `main` this is `['0']` — the trailing `0` of the card's `"—0"`. |
| 137 | + expect(directTextNodes(row)).toEqual([]); |
| 138 | + }); |
| 139 | + |
| 140 | + it('⭐ editable: the button row has no bare text-node children', () => { |
| 141 | + const outer = renderEditable({ latitude: 0, longitude: 120.1551 }); |
| 142 | + const buttonRow = outer.firstElementChild as HTMLElement; |
| 143 | + expect(directTextNodes(buttonRow)).toEqual([]); |
| 144 | + }); |
| 145 | + |
| 146 | + it('⭐ editable: the accuracy guard leaks nothing into the outer container', () => { |
| 147 | + const outer = renderEditable({ latitude: 30.2741, longitude: 120.1551, accuracy: 0 }); |
| 148 | + expect(directTextNodes(outer)).toEqual([]); |
| 149 | + }); |
| 150 | +}); |
| 151 | + |
| 152 | +describe('objectui#8055 — the affordance the two defects withheld', () => { |
| 153 | + it('readonly: "View on map" is offered for a zero coordinate', () => { |
| 154 | + const row = renderReadonly({ latitude: 0, longitude: 120.1551 }); |
| 155 | + expect(row.querySelector('button')?.textContent).toBe('View on map'); |
| 156 | + }); |
| 157 | + |
| 158 | + it('editable: both buttons are offered, matching the non-zero control', () => { |
| 159 | + const outer = renderEditable({ latitude: 0, longitude: 120.1551 }); |
| 160 | + const labels = Array.from(outer.querySelectorAll('button')).map((b) => b.textContent); |
| 161 | + expect(labels).toEqual(['Use Current Location', 'View on map']); |
| 162 | + }); |
| 163 | + |
| 164 | + it('the map opens on the real zero coordinate — `openInMaps` refused it too', () => { |
| 165 | + const opened = vi.spyOn(window, 'open').mockImplementation(() => null); |
| 166 | + const row = renderReadonly({ latitude: 0, longitude: 120.1551 }); |
| 167 | + |
| 168 | + fireEvent.click(row.querySelector('button') as HTMLButtonElement); |
| 169 | + |
| 170 | + expect(opened).toHaveBeenCalledWith('https://www.google.com/maps?q=0,120.1551', '_blank'); |
| 171 | + opened.mockRestore(); |
| 172 | + }); |
| 173 | +}); |
| 174 | + |
| 175 | +describe('objectui#8055 CONTROLS — what must NOT have moved', () => { |
| 176 | + it('⭐ CONTROL: the coordinate boxes already showed `0` and still do (`?? \'\'`)', () => { |
| 177 | + // The card measured this site as ALREADY CORRECT on `main`. A control that |
| 178 | + // goes red with the subject is not a control: this one proves the probe |
| 179 | + // reads a `0` out of this widget, and that the one guard that was right was |
| 180 | + // not "fixed" into consistency with the broken ones. |
| 181 | + const outer = renderEditable({ latitude: 0, longitude: 0 }); |
| 182 | + const boxes = Array.from(outer.querySelectorAll('input[type="number"]')); |
| 183 | + expect(boxes.map((b) => (b as HTMLInputElement).value)).toEqual(['0', '0']); |
| 184 | + }); |
| 185 | + |
| 186 | + it('⭐ CONTROL: the non-zero render string is byte-identical to the card`s reading', () => { |
| 187 | + const row = renderReadonly({ latitude: 30.2741, longitude: 120.1551 }); |
| 188 | + expect(row.textContent).toBe('30.274100, 120.155100View on map'); |
| 189 | + }); |
| 190 | + |
| 191 | + it('CONTROL: a genuinely ABSENT coordinate still renders the empty placeholder', () => { |
| 192 | + const row = renderReadonly({}); |
| 193 | + expect(row.textContent).toContain(EM_DASH); |
| 194 | + expect(row.querySelector('button')).toBeNull(); |
| 195 | + }); |
| 196 | + |
| 197 | + it('CONTROL: one absent half is still no location — a half pair invents nothing', () => { |
| 198 | + const row = renderReadonly({ latitude: 0 }); |
| 199 | + expect(row.textContent).toContain(EM_DASH); |
| 200 | + expect(row.querySelector('button')).toBeNull(); |
| 201 | + }); |
| 202 | + |
| 203 | + it('CONTROL: `NaN` stays empty — the delta of this fix is exactly `0`', () => { |
| 204 | + // The presence predicate is nullish AND NaN-excluding, deliberately: a bare |
| 205 | + // `!= null` would have started rendering `"NaN, NaN"` at a surface that has |
| 206 | + // always shown the placeholder for an unreadable coordinate. Every input |
| 207 | + // other than `0` / `-0` answers exactly as it did before this card. |
| 208 | + const row = renderReadonly({ latitude: NaN, longitude: 120.1551 }); |
| 209 | + expect(row.textContent).toContain(EM_DASH); |
| 210 | + expect(row.querySelector('button')).toBeNull(); |
| 211 | + }); |
| 212 | +}); |
0 commit comments