Skip to content

Commit 9ea7225

Browse files
Add pipeline behavior tests for predictions, arrivals/departures, matching, and block assignment
Extends the transitclockPipelineTests suite with four new behavior test classes covering previously-unobserved pipeline outputs. The existing AvlProcessorBehaviorTest only verified that a matched vehicle became predictable — regressions in the downstream generators would leave that check green while silently breaking user-visible output. - PredictionGeneratorBehaviorTest (6 tests): predictions appear in PredictionDataCache and on VehicleState, cover the in-horizon stops of the current trip, carry correct block/route/vehicle metadata, and emit both arrival and departure predictions for mid-trip stops. - ArrivalDepartureBehaviorTest (4 tests): driving a vehicle through two stops generates departure records at the origin and arrival records at the destination in StopArrivalDepartureCache with correct metadata. - BlockAssignerBehaviorTest (8 tests): exercises BLOCK_ID / TRIP_ID / TRIP_SHORT_NAME / ROUTE_ID / UNSET assignments plus negative cases (invalid block id, invalid trip id) against the real DbConfig. - MatchingBehaviorTest (6 tests): inspects TemporalMatch content — at-stop indicator, stop path index, trip/block/route references, distance to segment under the configured tolerance, populated schedule adherence, and projected-location proximity to the reported AVL point. Pipeline tests now total 35 (up from 11). Also adds a pipeline-test-only ehcache.xml that shadows the main-classpath copy to redirect the ehcache persistence directory from /usr/local/transitclock/cache (not writable in CI) to target/ehcache-pipeline-store.
1 parent afb8792 commit 9ea7225

5 files changed

Lines changed: 841 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package org.transitclock.pipelinetests;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.Date;
6+
import java.util.List;
7+
import java.util.concurrent.atomic.AtomicLong;
8+
9+
import org.junit.ClassRule;
10+
import org.junit.Test;
11+
import org.transitclock.core.AvlProcessor;
12+
import org.transitclock.core.VehicleState;
13+
import org.transitclock.core.dataCache.StopArrivalDepartureCacheFactory;
14+
import org.transitclock.core.dataCache.StopArrivalDepartureCacheKey;
15+
import org.transitclock.core.dataCache.VehicleStateManager;
16+
import org.transitclock.db.structs.AvlReport;
17+
import org.transitclock.db.structs.AvlReport.AssignmentType;
18+
import org.transitclock.ipc.data.IpcArrivalDeparture;
19+
20+
/**
21+
* Behavior tests for {@link org.transitclock.core.ArrivalDepartureGeneratorDefaultImpl}
22+
* reached via {@code MatchProcessor.processArrivalDepartures}.
23+
*
24+
* <p>Drives a vehicle through two stops on block SE-08's first trip
25+
* (868588900) and asserts that arrival/departure records show up in
26+
* {@link org.transitclock.core.dataCache.StopArrivalDepartureCacheInterface}.
27+
* The current pipeline suite only checks {@code isPredictable()} — a
28+
* regression that breaks stop-traversal detection would leave the vehicle
29+
* predictable but silently stop logging arrivals/departures, making this a
30+
* distinct layer of coverage from {@link AvlProcessorBehaviorTest} and
31+
* {@link PredictionGeneratorBehaviorTest}.
32+
*
33+
* <p>Trip 868588900 stop_times (verified via the 5A fixture):
34+
* <pre>
35+
* stop_sequence 1: 14253 Dulles Airport 11:55:00
36+
* stop_sequence 4: 13056 Herndon-Monroe Park&amp;Ride 12:03:00
37+
* stop_sequence 6: 14078 N Moore + 19th 12:29:57
38+
* </pre>
39+
* The happy-path report anchors the vehicle at stop 14253 at 11:50; the
40+
* advancement report places it at 13056 a few minutes after its scheduled
41+
* arrival there.
42+
*/
43+
public class ArrivalDepartureBehaviorTest {
44+
45+
@ClassRule
46+
public static final CoreHarness CORE = CoreHarness.withWmata5A();
47+
48+
private static final String BLOCK_ID = "SE-08";
49+
50+
// First stop of trip 868588900.
51+
private static final String STOP_FIRST_ID = "14253";
52+
private static final double STOP_FIRST_LAT = 38.953562;
53+
private static final double STOP_FIRST_LON = -77.447485;
54+
55+
// A later stop on the same trip (stop_sequence 4).
56+
private static final String STOP_ADVANCED_ID = "13056";
57+
private static final double STOP_ADVANCED_LAT = 38.951704;
58+
private static final double STOP_ADVANCED_LON = -77.383009;
59+
60+
/** 2016-06-20 11:50:00 America/New_York (EDT = UTC-4) → 15:50:00 UTC.
61+
* Anchors the vehicle at the first stop 5 minutes before its scheduled
62+
* departure, matching the known-good configuration of the happy-path
63+
* test in {@link AvlProcessorBehaviorTest}. */
64+
private static final long AVL_AT_FIRST_STOP_EPOCH_MS = 1466437800000L;
65+
66+
/** Keeps AVL timestamps strictly increasing across tests in this class. */
67+
private static final AtomicLong nextTime = new AtomicLong(AVL_AT_FIRST_STOP_EPOCH_MS);
68+
69+
private static AvlReport avlReport(String vehicleId, double lat, double lon, long timeMs) {
70+
return new AvlReport(vehicleId, timeMs, lat, lon,
71+
Float.NaN, Float.NaN, "test");
72+
}
73+
74+
/**
75+
* Pushes two AVL reports for {@code vehicleId}: one at the first stop of
76+
* trip 868588900, then one at the next timepoint ~8 minutes later. The
77+
* second report creates a previous-match / current-match pair with stops
78+
* traversed in between, which is the precondition for
79+
* ArrivalDepartureGenerator to actually produce records (without a
80+
* previous match and stopPathIndex=0 it short-circuits).
81+
*
82+
* @return the VehicleState after the second report was processed.
83+
*/
84+
private static VehicleState advanceVehicleFromFirstStopToNext(String vehicleId) {
85+
// Step 1: report at the first stop — establishes the match but
86+
// generates no arrival/departure records because there is no
87+
// previous match and stopPathIndex is 0.
88+
long t1 = nextTime.getAndAdd(1);
89+
CORE.setNow(t1);
90+
AvlReport first = avlReport(vehicleId, STOP_FIRST_LAT, STOP_FIRST_LON, t1);
91+
first.setAssignment(BLOCK_ID, AssignmentType.BLOCK_ID);
92+
AvlProcessor.getInstance().processAvlReport(first);
93+
94+
// Step 2: report at the advanced stop. Core's clock jumps forward so
95+
// that travel-time-based extrapolation is plausible; the
96+
// previous-match is the at-stop match from step 1.
97+
long t2 = t1 + (8 * 60_000L); // 8 minutes later
98+
nextTime.updateAndGet(cur -> Math.max(cur, t2 + 1));
99+
CORE.setNow(t2);
100+
AvlReport second = avlReport(vehicleId, STOP_ADVANCED_LAT, STOP_ADVANCED_LON, t2);
101+
second.setAssignment(BLOCK_ID, AssignmentType.BLOCK_ID);
102+
AvlProcessor.getInstance().processAvlReport(second);
103+
104+
return VehicleStateManager.getInstance().getVehicleState(vehicleId);
105+
}
106+
107+
private static List<IpcArrivalDeparture> stopHistory(String stopId, long atEpochMs) {
108+
StopArrivalDepartureCacheKey key = new StopArrivalDepartureCacheKey(stopId, new Date(atEpochMs));
109+
List<IpcArrivalDeparture> events =
110+
StopArrivalDepartureCacheFactory.getInstance().getStopHistory(key);
111+
// Cache returns null for "no entries" (see ehcache StopArrivalDepartureCache).
112+
// Normalize to an empty list so callers can use a uniform filter/assert pattern.
113+
return events == null ? List.of() : events;
114+
}
115+
116+
// ---------- Tests ----------
117+
118+
@Test
119+
public void advancingVehicleRemainsPredictableWithNewerMatch() {
120+
// Precondition for all other assertions in this class — if the second
121+
// report doesn't match, no arrival/departure can be generated and the
122+
// rest of the suite fails for a pre-check reason rather than a real
123+
// regression. Run this first.
124+
VehicleState state = advanceVehicleFromFirstStopToNext("v-ad-pre");
125+
assertThat(state.isPredictable())
126+
.as("vehicle should remain predictable after advancing to next stop")
127+
.isTrue();
128+
assertThat(state.getPreviousMatch())
129+
.as("advancing produces a previous-match snapshot (required for AD generation)")
130+
.isNotNull();
131+
}
132+
133+
@Test
134+
public void advancingVehicleLogsDepartureAtFirstStop() {
135+
// When the vehicle was at stop 14253 and then moved away, the
136+
// generator should emit a Departure at 14253 with isArrival=false.
137+
VehicleState state = advanceVehicleFromFirstStopToNext("v-ad-depart");
138+
long avlTime = state.getAvlReport().getTime();
139+
140+
List<IpcArrivalDeparture> history = stopHistory(STOP_FIRST_ID, avlTime);
141+
assertThat(history)
142+
.as("StopArrivalDepartureCache should have at least one record for stop %s", STOP_FIRST_ID)
143+
.isNotEmpty();
144+
145+
boolean hasDeparture = history.stream()
146+
.filter(e -> "v-ad-depart".equals(e.getVehicleId()))
147+
.anyMatch(e -> !e.isArrival());
148+
assertThat(hasDeparture)
149+
.as("a departure record for vehicle v-ad-depart at stop %s should exist", STOP_FIRST_ID)
150+
.isTrue();
151+
}
152+
153+
@Test
154+
public void advancingVehicleLogsArrivalAtNextStop() {
155+
// Counterpart to the departure test: when the vehicle arrives at
156+
// 13056, the generator should emit an Arrival there.
157+
VehicleState state = advanceVehicleFromFirstStopToNext("v-ad-arrive");
158+
long avlTime = state.getAvlReport().getTime();
159+
160+
List<IpcArrivalDeparture> history = stopHistory(STOP_ADVANCED_ID, avlTime);
161+
assertThat(history)
162+
.as("StopArrivalDepartureCache should have at least one record for stop %s", STOP_ADVANCED_ID)
163+
.isNotEmpty();
164+
165+
boolean hasArrival = history.stream()
166+
.filter(e -> "v-ad-arrive".equals(e.getVehicleId()))
167+
.anyMatch(IpcArrivalDeparture::isArrival);
168+
assertThat(hasArrival)
169+
.as("an arrival record for vehicle v-ad-arrive at stop %s should exist", STOP_ADVANCED_ID)
170+
.isTrue();
171+
}
172+
173+
@Test
174+
public void generatedRecordsCarryCorrectBlockAndTripMetadata() {
175+
// Pull any record generated for our vehicle out of the first stop's
176+
// cache and assert its block/trip/stop fields line up with what we
177+
// assigned. Defends against a silent regression that swaps
178+
// identifying fields during IpcArrivalDeparture marshalling.
179+
//
180+
// NOTE: IpcArrivalDeparture marks routeId, routeShortName, and
181+
// serviceId as `transient`, so they are null after the Kyro
182+
// serializer round-trips through ehcache. Assertions therefore only
183+
// cover fields that survive serialization.
184+
VehicleState state = advanceVehicleFromFirstStopToNext("v-ad-meta");
185+
long avlTime = state.getAvlReport().getTime();
186+
187+
List<IpcArrivalDeparture> history = stopHistory(STOP_FIRST_ID, avlTime);
188+
IpcArrivalDeparture mine = history.stream()
189+
.filter(e -> "v-ad-meta".equals(e.getVehicleId()))
190+
.findFirst()
191+
.orElseThrow(() -> new AssertionError(
192+
"no arrival/departure recorded for v-ad-meta at stop " + STOP_FIRST_ID));
193+
194+
assertThat(mine.getBlockId()).isEqualTo(BLOCK_ID);
195+
assertThat(mine.getStopId()).isEqualTo(STOP_FIRST_ID);
196+
assertThat(mine.getTripId()).isEqualTo("868588900");
197+
assertThat(mine.getDirectionId()).isEqualTo("0");
198+
}
199+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package org.transitclock.pipelinetests;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.ClassRule;
6+
import org.junit.Test;
7+
import org.transitclock.core.BlockAssigner;
8+
import org.transitclock.db.structs.AvlReport;
9+
import org.transitclock.db.structs.AvlReport.AssignmentType;
10+
import org.transitclock.db.structs.Block;
11+
12+
/**
13+
* Behavior tests for {@link BlockAssigner} against a real
14+
* {@link org.transitclock.gtfs.DbConfig} loaded from the WMATA 5A fixture.
15+
*
16+
* <p>BlockAssigner is 180 lines but touches every AVL report flowing through
17+
* AvlProcessor. A regression here would cause silent mass-unassignment of
18+
* vehicles — each vehicle becomes {@code !isPredictable} because its block
19+
* can't be resolved, so AvlProcessorBehaviorTest would still pass the
20+
* "match at first stop" case while everything in production reports
21+
* unpredictable. These tests exercise the three non-trivial lookup paths
22+
* (BLOCK_ID, TRIP_ID, TRIP_SHORT_NAME) plus the negative cases.
23+
*
24+
* <p>The 5A fixture has no {@code trip_short_name} column in {@code trips.txt},
25+
* so {@link org.transitclock.gtfs.gtfsStructs.GtfsTrip} falls back to using
26+
* {@code trip_id} as the short name. Hence a TRIP_SHORT_NAME assignment with
27+
* "868588900" resolves via the same underlying trip as a TRIP_ID assignment
28+
* with "868588900".
29+
*/
30+
public class BlockAssignerBehaviorTest {
31+
32+
@ClassRule
33+
public static final CoreHarness CORE = CoreHarness.withWmata5A();
34+
35+
private static final String KNOWN_BLOCK_ID = "SE-08";
36+
private static final String KNOWN_TRIP_ID = "868588900";
37+
private static final String KNOWN_ROUTE_ID = "5A";
38+
39+
/** 2016-06-20 11:50:00 America/New_York — same anchor the other behavior
40+
* tests use to ensure the relevant service IDs are active. */
41+
private static final long ASSIGNMENT_TEST_EPOCH_MS = 1466437800000L;
42+
43+
private static AvlReport avlWithAssignment(
44+
String vehicleId, String assignmentId, AssignmentType type) {
45+
AvlReport report = new AvlReport(vehicleId,
46+
ASSIGNMENT_TEST_EPOCH_MS,
47+
38.953562, -77.447485,
48+
Float.NaN, Float.NaN, "test");
49+
if (assignmentId != null) {
50+
report.setAssignment(assignmentId, type);
51+
}
52+
return report;
53+
}
54+
55+
// ---------- Tests ----------
56+
57+
@Test
58+
public void blockIdAssignmentResolvesToKnownBlock() {
59+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
60+
AvlReport report = avlWithAssignment("v-bid", KNOWN_BLOCK_ID, AssignmentType.BLOCK_ID);
61+
62+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
63+
64+
assertThat(block)
65+
.as("BLOCK_ID assignment %s should resolve to a non-null Block on an active service day",
66+
KNOWN_BLOCK_ID)
67+
.isNotNull();
68+
assertThat(block.getId()).isEqualTo(KNOWN_BLOCK_ID);
69+
}
70+
71+
@Test
72+
public void tripIdAssignmentResolvesToContainingBlock() {
73+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
74+
AvlReport report = avlWithAssignment("v-tid", KNOWN_TRIP_ID, AssignmentType.TRIP_ID);
75+
76+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
77+
78+
assertThat(block)
79+
.as("TRIP_ID assignment should resolve to the trip's containing block")
80+
.isNotNull();
81+
// Trip 868588900 belongs to block SE-08 per trips.txt.
82+
assertThat(block.getId()).isEqualTo(KNOWN_BLOCK_ID);
83+
}
84+
85+
@Test
86+
public void tripShortNameAssignmentResolvesToContainingBlock() {
87+
// In the 5A fixture trip_short_name falls back to trip_id because
88+
// the trips.txt has no trip_short_name column. So "868588900" is
89+
// a valid trip short name here.
90+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
91+
AvlReport report = avlWithAssignment("v-tsn", KNOWN_TRIP_ID, AssignmentType.TRIP_SHORT_NAME);
92+
93+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
94+
95+
assertThat(block)
96+
.as("TRIP_SHORT_NAME assignment should resolve to the trip's block via short-name lookup")
97+
.isNotNull();
98+
assertThat(block.getId()).isEqualTo(KNOWN_BLOCK_ID);
99+
}
100+
101+
@Test
102+
public void routeIdAssignmentDoesNotResolveToBlock() {
103+
// ROUTE_ID is not a block assignment — BlockAssigner.getBlockAssignment
104+
// must return null for it. If this ever changes, vehicles assigned to
105+
// a route would unexpectedly be matched to a block, changing the
106+
// prediction semantics.
107+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
108+
AvlReport report = avlWithAssignment("v-rid", KNOWN_ROUTE_ID, AssignmentType.ROUTE_ID);
109+
110+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
111+
112+
assertThat(block)
113+
.as("ROUTE_ID assignments should never produce a block")
114+
.isNull();
115+
}
116+
117+
@Test
118+
public void unassignedReportProducesNoBlock() {
119+
AvlReport report = avlWithAssignment("v-unset", null, AssignmentType.UNSET);
120+
121+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
122+
123+
assertThat(block)
124+
.as("AVL reports with no assignment should not produce a block")
125+
.isNull();
126+
}
127+
128+
@Test
129+
public void invalidBlockIdProducesNoBlock() {
130+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
131+
AvlReport report = avlWithAssignment("v-bad-bid", "DOES-NOT-EXIST", AssignmentType.BLOCK_ID);
132+
133+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
134+
135+
assertThat(block)
136+
.as("nonsense BLOCK_ID should return null rather than throwing")
137+
.isNull();
138+
}
139+
140+
@Test
141+
public void invalidTripIdProducesNoBlock() {
142+
CORE.setNow(ASSIGNMENT_TEST_EPOCH_MS);
143+
AvlReport report = avlWithAssignment("v-bad-tid", "DOES-NOT-EXIST", AssignmentType.TRIP_ID);
144+
145+
Block block = BlockAssigner.getInstance().getBlockAssignment(report);
146+
147+
assertThat(block)
148+
.as("nonsense TRIP_ID should return null rather than throwing")
149+
.isNull();
150+
}
151+
152+
@Test
153+
public void getRouteIdAssignmentReturnsIdOnlyForRouteIdType() {
154+
// Exercises the second public entry point. The BLOCK_ID variant
155+
// should not accidentally return the block id as if it were a
156+
// route id.
157+
AvlReport asRoute = avlWithAssignment("v-ret-route", KNOWN_ROUTE_ID, AssignmentType.ROUTE_ID);
158+
AvlReport asBlock = avlWithAssignment("v-ret-block", KNOWN_BLOCK_ID, AssignmentType.BLOCK_ID);
159+
160+
assertThat(BlockAssigner.getInstance().getRouteIdAssignment(asRoute))
161+
.as("ROUTE_ID assignment should surface the id")
162+
.isEqualTo(KNOWN_ROUTE_ID);
163+
assertThat(BlockAssigner.getInstance().getRouteIdAssignment(asBlock))
164+
.as("BLOCK_ID assignment should not be misreported as a route id")
165+
.isNull();
166+
}
167+
}

0 commit comments

Comments
 (0)