-
Notifications
You must be signed in to change notification settings - Fork 50
Do not rewrite Locality coordinate fields when a record is merely opened #8369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
foozleface
wants to merge
2
commits into
main
Choose a base branch
from
issue-cas-latlong-write-on-render
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+236
−1
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/LatLongUi.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| /** | ||
| * Opening a Locality record must not modify it. | ||
| * | ||
| * LatLongUi recomputes the decimal coordinate from the verbatim text inside a | ||
| * React effect. That effect is keyed on the rendered value, so it fired on the | ||
| * FIRST render — before the user had touched anything — and wrote both the | ||
| * decimal and the (blackList-trimmed) text back onto the resource. The text | ||
| * write was not silent, so simply opening a record marked it dirty; saving it | ||
| * for any unrelated reason then persisted the rewrite. | ||
| * | ||
| * At the California Academy of Sciences this silently moved 2,534 botany | ||
| * localities to the opposite hemisphere: a Spanish "96° 57' O" (Oeste = West, | ||
| * stored -96.95) became "96° 57' " and +96.95, destroying the evidence that the | ||
| * value had ever been West. | ||
| * | ||
| * These tests pin the invariant: render must be read-only with respect to the | ||
| * resource, and a genuine user edit must still update the derived fields. | ||
| */ | ||
|
|
||
| import { act, fireEvent, render, waitFor } from '@testing-library/react'; | ||
| import React from 'react'; | ||
|
|
||
| import { requireContext } from '../../../tests/helpers'; | ||
| import { tables } from '../../DataModel/tables'; | ||
| import { LatLongUi } from '../LatLongUi'; | ||
|
|
||
| requireContext(); | ||
|
|
||
| function makeLocality() { | ||
| return new tables.Locality.Resource({ | ||
| id: 1, | ||
| localityname: 'Cerro El Veinte', | ||
| lat1text: "17° 33' N", | ||
| latitude1: 17.55, | ||
| long1text: "96° 57' O", // Oeste = West | ||
| longitude1: -96.95, | ||
| srclatlongunit: 2, | ||
| }); | ||
| } | ||
|
|
||
| function makeDecimalOnlyLocality() { | ||
| return new tables.Locality.Resource({ | ||
| id: 2, | ||
| localityname: 'Imported, decimal only', | ||
| latitude1: 17.55, | ||
| longitude1: -96.95, | ||
| }); | ||
| } | ||
|
|
||
| describe('LatLongUi does not mutate the resource on render', () => { | ||
| /* | ||
| * Record-set navigation reuses the component instance — useFieldParser says so | ||
| * outright: "Resource changes when sliding in a record selector, but react | ||
| * reuses the DOM component". A one-way "has the value changed" ref therefore | ||
| * stays latched after any edit, leaving the write-back gate open for every | ||
| * record the curator slides to afterwards. That resurrects the corruption in | ||
| * the batch-review workflow, where it does the most damage. | ||
| */ | ||
| test('sliding to another record does not rewrite it, even after an edit', async () => { | ||
| const first = makeLocality(); | ||
| const { rerender } = render( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={first} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
| await waitFor(() => expect(first.get('long1text')).toBeDefined()); | ||
|
|
||
| // Simulate a real user edit on the first record. | ||
| const input = document.querySelectorAll('input')[1] as HTMLInputElement; | ||
| await act(async () => { | ||
| fireEvent.change(input, { target: { value: "96° 57' W" } }); | ||
| }); | ||
| await waitFor(() => expect(first.get('long1text')).toBe("96° 57' W")); | ||
|
|
||
| // Now slide to a different record, as a record set does. | ||
| const second = makeLocality(); | ||
| await act(async () => { | ||
| rerender( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={second} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
| }); | ||
| await waitFor(() => expect(second.get('long1text')).toBeDefined()); | ||
|
|
||
| expect(second.get('long1text')).toBe("96° 57' O"); | ||
| expect(Number(second.get('longitude1'))).toBeCloseTo(-96.95, 6); | ||
| expect(second.needsSaved).toBe(false); | ||
| }); | ||
|
|
||
| /* | ||
| * KNOWN REMAINING GAP, deliberately not fixed here. | ||
| * | ||
| * A record imported with only a decimal (typical of WorkBench / LocalityUpdate) | ||
| * still becomes dirty on open: the mount-time back-fill calls updateValue, and | ||
| * useFieldParser writes lat1text NON-silently — a path outside this patch's | ||
| * gate. Nothing is corrupted (the text is generated from the decimal, and the | ||
| * decimal is not rewritten), but the record is flagged as needing saving. | ||
| * | ||
| * Asserted as-is so the limitation is visible rather than assumed fixed. The | ||
| * scope of this patch is therefore "opening cannot CORRUPT a record", not the | ||
| * broader "opening cannot touch a record". | ||
| */ | ||
| test('KNOWN GAP: a decimal-only record is still marked dirty on open', async () => { | ||
| const resource = makeDecimalOnlyLocality(); | ||
| expect(resource.needsSaved).toBe(false); | ||
| await act(async () => { | ||
| render( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={resource} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
| }); | ||
| await waitFor(() => expect(resource.get('lat1text')).toBeDefined()); | ||
| expect(resource.needsSaved).toBe(true); | ||
| // The decimal itself is untouched — no corruption, only a dirty flag. | ||
| expect(Number(resource.get('latitude1'))).toBeCloseTo(17.55, 6); | ||
| }); | ||
|
|
||
| test('merely rendering leaves the verbatim text untouched', async () => { | ||
| const resource = makeLocality(); | ||
| render( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={resource} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
|
|
||
| await waitFor(() => expect(resource.get('long1text')).toBeDefined()); | ||
|
|
||
| // The O must survive. Previously this became "96° 57' ". | ||
| expect(resource.get('long1text')).toBe("96° 57' O"); | ||
| expect(resource.get('lat1text')).toBe("17° 33' N"); | ||
| }); | ||
|
|
||
| test('merely rendering leaves the decimal untouched', async () => { | ||
| const resource = makeLocality(); | ||
| render( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={resource} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
|
|
||
| await waitFor(() => expect(resource.get('longitude1')).toBeDefined()); | ||
|
|
||
| // Previously flipped to +96.95. | ||
| expect(Number(resource.get('longitude1'))).toBeCloseTo(-96.95, 6); | ||
| expect(Number(resource.get('latitude1'))).toBeCloseTo(17.55, 6); | ||
| }); | ||
|
|
||
| test('merely rendering does not mark the record as needing saving', async () => { | ||
| const resource = makeLocality(); | ||
| expect(resource.needsSaved).toBe(false); | ||
|
|
||
| render( | ||
| <LatLongUi | ||
| id={undefined} | ||
| latLongType="Point" | ||
| resource={resource} | ||
| step={undefined} | ||
| /> | ||
| ); | ||
|
|
||
| await waitFor(() => expect(resource.get('long1text')).toBeDefined()); | ||
|
|
||
| // A dirty record is what let an unrelated Save persist the corruption. | ||
| expect(resource.needsSaved).toBe(false); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Latch later external text-field changes, not the mount callback.
This handler never sets
hasValueChanged, including for subsequentchange:${coordinateTextField}events. An external update tolat1text/long1textcan therefore leave the guarded parsing effect closed and the derived decimal field stale. Ignore only the initial callback; latch later events and add coverage for this path.🤖 Prompt for AI Agents