Skip to content

Commit 0b00630

Browse files
scenario/xpath test revisions, further consistency with JR
I’m not sure I even know how to get a coherent commit history from this branch. Let’s squash & merge!
1 parent c1851fa commit 0b00630

File tree

7 files changed

+129
-129
lines changed

7 files changed

+129
-129
lines changed

packages/scenario/test/data-types/geo.test.ts

Lines changed: 54 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -274,25 +274,52 @@ describe('Geopoint', () => {
274274
describe('Geoshape', () => {
275275
describe('GeoAreaTest.java', () => {
276276
describe('area', () => {
277-
describe.each<TrailingSemicolonOptions>([
278-
{ stripTrailingSemicolon: false },
279-
{ stripTrailingSemicolon: true },
280-
])('string trailing semicolon: $stripTrailingSemicolon', ({ stripTrailingSemicolon }) => {
281-
let testFn: typeof it | typeof it.fails;
277+
/**
278+
* **PORTING NOTES**
279+
*
280+
* Previously failed due to trailing semicolon. Addressed while extracting
281+
* XPath logic for decoding serialized geopoint lists.
282+
*/
283+
it('is computed for geoshape', async () => {
284+
const scenario = await Scenario.init(
285+
'geoshape area',
286+
html(
287+
head(
288+
title('Geoshape area'),
289+
model(
290+
mainInstance(
291+
t(
292+
'data id="geoshape-area"',
293+
t(
294+
'polygon',
295+
'38.253094215699576 21.756382658677467 0 0; 38.25021274773806 21.756382658677467 0 0; 38.25007793942195 21.763892843919166 0 0; 38.25290886154963 21.763935759263404 0 0; 38.25146813817506 21.758421137528785 0 0;'
296+
),
297+
t('area')
298+
)
299+
),
300+
bind('/data/polygon').type('geoshape'),
301+
bind('/data/area').type('decimal').calculate('area(/data/polygon)')
302+
)
303+
),
304+
body(input('/data/polygon'))
305+
)
306+
);
282307

283-
if (stripTrailingSemicolon) {
284-
testFn = it;
285-
} else {
286-
testFn = it.fails;
287-
}
308+
// JR:
309+
//
310+
// http://www.mapdevelopers.com/area_finder.php?&points=%5B%5B38.253094215699576%2C21.756382658677467%5D%2C%5B38.25021274773806%2C21.756382658677467%5D%2C%5B38.25007793942195%2C21.763892843919166%5D%2C%5B38.25290886154963%2C21.763935759263404%5D%2C%5B38.25146813817506%2C21.758421137528785%5D%5D
311+
// assertThat(Double.parseDouble(scenario.answerOf("/data/area").getDisplayText()),
312+
// closeTo(151452, 0.5));
313+
expect(scenario.answerOf('/data/area')).toHaveAnswerCloseTo(expectedArea(151452, 0.5));
314+
});
288315

316+
describe('when shape has fewer than three points', () => {
289317
/**
290318
* **PORTING NOTES**
291319
*
292-
* - Direct port fails due to trailing semicolon in `/data/polygon` value,
293-
* as demonstrated by parameterized alternate test.
320+
* Previously failed with trailing semicolon, and lack of logic to enforce a minimum number of points. Both addressed while extracting XPath logic for decoding serializeed geopoint lists.
294321
*/
295-
testFn('is computed for geoshape', async () => {
322+
it('is zero', async () => {
296323
const scenario = await Scenario.init(
297324
'geoshape area',
298325
html(
@@ -302,87 +329,29 @@ describe('Geoshape', () => {
302329
mainInstance(
303330
t(
304331
'data id="geoshape-area"',
332+
t('polygon1', '38.253094215699576 21.756382658677467 0 0;'),
305333
t(
306-
'polygon',
307-
geopointListValue(
308-
'38.253094215699576 21.756382658677467 0 0; 38.25021274773806 21.756382658677467 0 0; 38.25007793942195 21.763892843919166 0 0; 38.25290886154963 21.763935759263404 0 0; 38.25146813817506 21.758421137528785 0 0;',
309-
{ stripTrailingSemicolon }
310-
)
334+
'polygon2',
335+
'38.253094215699576 21.756382658677467 0 0; 38.25021274773806 21.756382658677467 0 0;'
311336
),
312-
t('area')
337+
t('area1'),
338+
t('area2')
313339
)
314340
),
315-
bind('/data/polygon').type('geoshape'),
316-
bind('/data/area').type('decimal').calculate('area(/data/polygon)')
341+
bind('/data/polygon1').type('geoshape'),
342+
bind('/data/polygon2').type('geoshape'),
343+
bind('/data/area1').type('decimal').calculate('area(/data/polygon1)'),
344+
bind('/data/area2').type('decimal').calculate('area(/data/polygon2)')
317345
)
318346
),
319-
body(input('/data/polygon'))
347+
body(input('/data/polygon1'), input('/data/polygon2'))
320348
)
321349
);
322350

323-
// JR:
324-
//
325-
// http://www.mapdevelopers.com/area_finder.php?&points=%5B%5B38.253094215699576%2C21.756382658677467%5D%2C%5B38.25021274773806%2C21.756382658677467%5D%2C%5B38.25007793942195%2C21.763892843919166%5D%2C%5B38.25290886154963%2C21.763935759263404%5D%2C%5B38.25146813817506%2C21.758421137528785%5D%5D
326-
// assertThat(Double.parseDouble(scenario.answerOf("/data/area").getDisplayText()),
327-
// closeTo(151452, 0.5));
328-
expect(scenario.answerOf('/data/area')).toHaveAnswerCloseTo(expectedArea(151452, 0.5));
329-
});
330-
331-
describe('when shape has fewer than three points', () => {
332-
/**
333-
* **PORTING NOTES**
334-
*
335-
* - Direct port is currently expected to fail due to trailing
336-
* semicolons in `/data/polygon1` and `/data/polygon2` values.
337-
*
338-
* - Parameterized alternate test stripping those trailing semicolons
339-
* also fails, producing {@link NaN} where the value is expected to be
340-
* zero. This is presumably a bug in the XPath `area` function's
341-
* handling of this specific case (and would likely affect Enketo as
342-
* well, since the extant tests were ported from ORXE).
343-
*/
344-
it.fails('is zero', async () => {
345-
const scenario = await Scenario.init(
346-
'geoshape area',
347-
html(
348-
head(
349-
title('Geoshape area'),
350-
model(
351-
mainInstance(
352-
t(
353-
'data id="geoshape-area"',
354-
t(
355-
'polygon1',
356-
geopointListValue('38.253094215699576 21.756382658677467 0 0;', {
357-
stripTrailingSemicolon,
358-
})
359-
),
360-
t(
361-
'polygon2',
362-
geopointListValue(
363-
'38.253094215699576 21.756382658677467 0 0; 38.25021274773806 21.756382658677467 0 0;',
364-
{ stripTrailingSemicolon }
365-
)
366-
),
367-
t('area1'),
368-
t('area2')
369-
)
370-
),
371-
bind('/data/polygon1').type('geoshape'),
372-
bind('/data/polygon2').type('geoshape'),
373-
bind('/data/area1').type('decimal').calculate('area(/data/polygon1)'),
374-
bind('/data/area2').type('decimal').calculate('area(/data/polygon2)')
375-
)
376-
),
377-
body(input('/data/polygon1'), input('/data/polygon2'))
378-
)
379-
);
380-
381-
// assertThat(Double.parseDouble(scenario.answerOf("/data/area1").getDisplayText()), is(0.0));
382-
expect(scenario.answerOf('/data/area1')).toEqualAnswer(floatAnswer(0.0));
383-
// assertThat(Double.parseDouble(scenario.answerOf("/data/area2").getDisplayText()), is(0.0));
384-
expect(scenario.answerOf('/data/area2')).toEqualAnswer(floatAnswer(0.0));
385-
});
351+
// assertThat(Double.parseDouble(scenario.answerOf("/data/area1").getDisplayText()), is(0.0));
352+
expect(scenario.answerOf('/data/area1')).toEqualAnswer(floatAnswer(0.0));
353+
// assertThat(Double.parseDouble(scenario.answerOf("/data/area2").getDisplayText()), is(0.0));
354+
expect(scenario.answerOf('/data/area2')).toEqualAnswer(floatAnswer(0.0));
386355
});
387356
});
388357
});
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { JRCompatibleError } from './JRCompatibleError.ts';
22

3+
// prettier-ignore
4+
type JRCompatibleFallibleGeoFunction =
5+
| 'distance'
6+
| 'geofence' // TODO!
7+
;
8+
39
export class JRCompatibleGeoValueError extends JRCompatibleError {
4-
constructor() {
5-
super("The function 'distance' received a value that does not represent GPS coordinates");
10+
constructor(geoFunction: JRCompatibleFallibleGeoFunction) {
11+
super(`The function '${geoFunction}' received a value that does not represent GPS coordinates`);
612
}
713
}

packages/xpath/src/functions/xforms/geo.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { XPathNode } from '../../adapter/interface/XPathNode.ts';
22
import { EvaluationContext } from '../../context/EvaluationContext.ts';
3+
import { JRCompatibleGeoValueError } from '../../error/JRCompatibleGeoValueError.ts';
34
import type { EvaluableArgument } from '../../evaluator/functions/FunctionImplementation.ts';
45
import { NumberFunction } from '../../evaluator/functions/NumberFunction.ts';
56
import { Geotrace } from '../../lib/geo/Geotrace.ts';
@@ -68,8 +69,8 @@ const evaluateArgumentValues = <T extends XPathNode>(
6869

6970
export const area = new NumberFunction('area', [{ arityType: 'required' }], (context, args) => {
7071
const values = evaluateArgumentValues(context, args);
71-
const { lines } = Geotrace.fromEncodedValues(values);
72-
const areaResult = geodesicArea(lines);
72+
const geotrace = Geotrace.fromEncodedValues(values);
73+
const areaResult = geodesicArea(geotrace?.lines ?? []);
7374

7475
return toAbsolutePrecision(areaResult, PRECISION);
7576
});
@@ -102,7 +103,12 @@ export const distance = new NumberFunction(
102103
[{ arityType: 'required' }, { arityType: 'variadic' }],
103104
(context, args) => {
104105
const values = evaluateArgumentValues(context, args);
105-
const { lines } = Geotrace.fromEncodedValues(values);
106+
const lines = Geotrace.fromEncodedValues(values)?.lines;
107+
108+
if (lines == null) {
109+
throw new JRCompatibleGeoValueError('distance');
110+
}
111+
106112
const distances = lines.map(geodesicDistance);
107113

108114
return toAbsolutePrecision(sum(distances), PRECISION);

packages/xpath/src/lib/geo/Geopoint.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import { JRCompatibleGeoValueError } from '../../error/JRCompatibleGeoValueError.ts';
2-
31
// prettier-ignore
4-
type GeopointNodeSubstringValues = readonly [
2+
type GeopointEncodedSubstringValues = readonly [
53
latitude: string,
64
longitude: string,
75
altitude?: string,
86
accuracy?: string,
97
];
108

11-
type AssertGeopointNodeSubstringValues = (
9+
const isGeopointEncodedSubstringValues = (
1210
values: ReadonlyArray<string | undefined>
13-
) => asserts values is GeopointNodeSubstringValues;
14-
15-
const assertGeopointNodeSubstringValues: AssertGeopointNodeSubstringValues = (values) => {
16-
if (values[0] == null || values[1] == null || values.length > 4) {
17-
throw new JRCompatibleGeoValueError();
18-
}
11+
): values is GeopointEncodedSubstringValues => {
12+
const { length } = values;
13+
14+
return (
15+
length >= 2 &&
16+
length <= 4 &&
17+
values.every((value) => {
18+
return value != null;
19+
})
20+
);
1921
};
2022

2123
const DEGREES_MAX = {
@@ -25,13 +27,13 @@ const DEGREES_MAX = {
2527

2628
type GeographicAngleCoordinate = keyof typeof DEGREES_MAX;
2729

28-
const decodeDegrees = (coordinate: GeographicAngleCoordinate, value: string): number => {
30+
const decodeDegrees = (coordinate: GeographicAngleCoordinate, value: string): number | null => {
2931
const degrees = Number(value);
3032
const absolute = Math.abs(degrees);
3133
const max = DEGREES_MAX[coordinate];
3234

3335
if (absolute > max) {
34-
throw new JRCompatibleGeoValueError();
36+
return null;
3537
}
3638

3739
return degrees;
@@ -42,16 +44,22 @@ export interface GeopointCoordinates {
4244
readonly longitude: number;
4345
}
4446

45-
const decodeGeopointCoordinates = (nodeValue: string): GeopointCoordinates => {
47+
const decodeGeopointCoordinates = (nodeValue: string): GeopointCoordinates | null => {
4648
const substringValues = nodeValue.split(/\s+/);
4749

48-
assertGeopointNodeSubstringValues(substringValues);
50+
if (!isGeopointEncodedSubstringValues(substringValues)) {
51+
return null;
52+
}
4953

5054
const [latitudeValue, longitudeValue] = substringValues;
5155

5256
const latitude = decodeDegrees('latitude', latitudeValue);
5357
const longitude = decodeDegrees('longitude', longitudeValue);
5458

59+
if (latitude == null || longitude == null) {
60+
return null;
61+
}
62+
5563
return {
5664
latitude,
5765
longitude,
@@ -77,9 +85,13 @@ const decodeGeopointCoordinates = (nodeValue: string): GeopointCoordinates => {
7785
* with a {@link https://geojson.org/ | GeoJSON format}.
7886
*/
7987
export class Geopoint implements GeopointCoordinates {
80-
static fromNodeValue(nodeValue: string): Geopoint {
88+
static fromNodeValue(nodeValue: string): Geopoint | null {
8189
const coordinates = decodeGeopointCoordinates(nodeValue);
8290

91+
if (coordinates == null) {
92+
return null;
93+
}
94+
8395
return new this(coordinates);
8496
}
8597

packages/xpath/src/lib/geo/Geotrace.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
import { JRCompatibleGeoValueError } from '../../error/JRCompatibleGeoValueError.ts';
2-
import { Geopoint } from './Geopoint.ts';
1+
import type { Geopoint } from './Geopoint.ts';
2+
import { geopointCodec, type GeopointRuntimeValue } from './geopointCodec.ts';
33
import { GeotraceLine } from './GeotraceLine.ts';
44

55
export type GeotracePoints = readonly [Geopoint, Geopoint, ...Geopoint[]];
66

7-
const isGeotracePoints = (geopoints: readonly Geopoint[]): geopoints is GeotracePoints => {
8-
return geopoints.length >= 2;
9-
};
10-
11-
type AssertGeotracePoints = (geopoints: readonly Geopoint[]) => asserts geopoints is GeotracePoints;
12-
13-
const assertGeotracePoints: AssertGeotracePoints = (geopoints) => {
14-
if (!isGeotracePoints(geopoints)) {
15-
throw new JRCompatibleGeoValueError();
16-
}
7+
const isGeotracePoints = (
8+
geopoints: readonly GeopointRuntimeValue[]
9+
): geopoints is GeotracePoints => {
10+
return (
11+
geopoints.length >= 2 &&
12+
geopoints.every((geopoint) => {
13+
return geopoint != null;
14+
})
15+
);
1716
};
1817

1918
const collectLines = (geopoints: GeotracePoints): readonly GeotraceLine[] => {
@@ -38,37 +37,39 @@ const collectLines = (geopoints: GeotracePoints): readonly GeotraceLine[] => {
3837
};
3938

4039
export class Geotrace {
41-
static fromEncodedGeotrace(encoded: string): Geotrace {
40+
static fromEncodedGeotrace(encoded: string): Geotrace | null {
4241
const geopoints = encoded
4342
.replace(/\s*;\s*$/, '')
4443
.split(/\s*;\s*/)
4544
.map((value) => {
46-
return Geopoint.fromNodeValue(value);
45+
return geopointCodec.decodeValue(value);
4746
});
4847

4948
return this.fromGeopoints(geopoints);
5049
}
5150

52-
static fromEncodedValues(values: readonly string[]): Geotrace {
51+
static fromEncodedValues(values: readonly string[]): Geotrace | null {
5352
const [head, ...tail] = values;
5453

5554
if (head == null) {
56-
throw new JRCompatibleGeoValueError();
55+
return null;
5756
}
5857

5958
if (tail.length === 0) {
6059
return this.fromEncodedGeotrace(head);
6160
}
6261

6362
const geopoints = values.map((value) => {
64-
return Geopoint.fromNodeValue(value);
63+
return geopointCodec.decodeValue(value);
6564
});
6665

6766
return this.fromGeopoints(geopoints);
6867
}
6968

70-
static fromGeopoints(geopoints: readonly Geopoint[]): Geotrace {
71-
assertGeotracePoints(geopoints);
69+
static fromGeopoints(geopoints: readonly GeopointRuntimeValue[]): Geotrace | null {
70+
if (!isGeotracePoints(geopoints)) {
71+
return null;
72+
}
7273

7374
return new this(geopoints);
7475
}

packages/xpath/src/lib/geo/geopointCodec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { encodeValueStubFactory } from './encodeGeoValueStubFactory.ts';
22
import { Geopoint } from './Geopoint.ts';
33
import type { GeoValueCodec } from './GeoValueCodec.ts';
44

5-
export const geopointCodec: GeoValueCodec<'geopoint', Geopoint> = {
5+
export type GeopointRuntimeValue = Geopoint | null;
6+
7+
export const geopointCodec: GeoValueCodec<'geopoint', GeopointRuntimeValue> = {
68
valueType: 'geopoint',
79
encodeValue: encodeValueStubFactory('geopoint'),
810
decodeValue: (value) => {

0 commit comments

Comments
 (0)