功能:完善里程边界与氢耗流式统计

This commit is contained in:
lingniu
2026-09-02 14:05:04 +08:00
parent 4201878c35
commit 8759668d39
23 changed files with 1638 additions and 159 deletions
@@ -1054,6 +1054,9 @@ func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1138,6 +1141,9 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1447,7 +1453,7 @@ func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T
}
}
func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
func TestWriterAppendStartsCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1488,18 +1494,18 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
"",
"LMRKH9AC2R1004087",
"",
float64(120672.0),
float64(120788.0),
approxFloat64{want: 116.0, tolerance: 0.000001},
float64(120788.0),
float64(0),
float64(0),
int64(1),
int64(0),
historicalTime,
currentTime,
currentTime,
false,
false,
QualityOK,
QualityReasonHistorical,
QualityReasonCurrentDayFirst,
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
@@ -1608,7 +1614,7 @@ func TestWriterApplyRealtimeBaselineKeepsNegativeHistoricalDeltaInvalid(t *testi
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1651,7 +1657,7 @@ func TestWriterApplyRealtimeBaselineKeepsInvalidWhenCurrentDayBaselineIsStillHig
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1691,7 +1697,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM > -55 || candidate.DailyKM < -56 {
@@ -1705,7 +1711,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
}
}
func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
func TestWriterApplyRealtimeBaselineIgnoresPlausibleMultiDayFallbackDelta(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1734,18 +1740,65 @@ func TestWriterApplyRealtimeBaselineAcceptsPlausibleMultiDayFallbackDelta(t *tes
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical {
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM < 7833.4 || candidate.DailyKM > 7833.6 {
t.Fatalf("daily km = %v, want multi-day gap delta", candidate.DailyKM)
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want current-day first baseline after offline gap", candidate.DailyKM)
}
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
t.Fatalf("current-day boundary not retained: %#v", candidate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(t *testing.T) {
func TestWriterApplyRealtimeBaselineRejectsProtocolOdometerRollbackAfterSourceChange(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
previousTime := time.Date(2026, 8, 5, 22, 41, 0, 0, loc)
currentTime := time.Date(2026, 8, 6, 8, 12, 0, 0, loc)
candidate := SourceMileageSample{
VIN: "LNXNEGRR1SR321395",
StatDate: "2026-08-06",
Protocol: envelope.ProtocolGB32960,
SourceKey: "GB32960:new-device@8.134.95.166",
LatestTotalKM: 0.1,
LatestEventTime: currentTime,
}
// The new device has no source-key baseline, but this VIN already has a
// durable GB32960 odometer. The reset must not become a NORMAL day start.
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LNXNEGRR1SR321395", "2026-08-06", "GB32960", "GB32960:new-device@8.134.95.166").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LNXNEGRR1SR321395", "2026-08-06", "GB32960").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
AddRow(48233.3, previousTime))
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != QualityReasonTotalRollback {
t.Fatalf("quality = %s/%s, want %s/%s", candidate.QualityStatus, candidate.QualityReason, QualityInvalidDelta, QualityReasonTotalRollback)
}
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want 0", candidate.DailyKM)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterApplyRealtimeBaselineIgnoresHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
@@ -1774,14 +1827,14 @@ func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJump(t *testing.T)
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
t.Fatalf("applyRealtimeBaseline() error = %v", err)
}
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
}
if candidate.DailyKM < 29999.9 || candidate.DailyKM > 30000.1 {
t.Fatalf("daily km = %v, want historical jump delta", candidate.DailyKM)
if candidate.DailyKM != 0 {
t.Fatalf("daily km = %v, want current-day first baseline", candidate.DailyKM)
}
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
t.Fatalf("previous-day baseline not preserved: %#v", candidate)
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
t.Fatalf("current-day boundary not retained: %#v", candidate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
@@ -1815,6 +1868,9 @@ func TestWriterAppendMarksCandidateNoPreviousBaselineWhenPreviousBaselineMissing
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(
@@ -1894,7 +1950,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
t.Fatalf("Append() error = %v", err)
}
schemaCalls := 6 + len(DailyMileageAlterSQL)
schemaCalls := 8 + len(DailyMileageAlterSQL)
if len(exec.calls) != schemaCalls+5 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
@@ -1904,7 +1960,9 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_tank_capacity",
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_energy_parameter",
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_stream_state",
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_segment_stream_state",
} {
if !strings.Contains(exec.calls[i].query, want) {
t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want)
@@ -2294,6 +2352,9 @@ func TestWriterRollsBackMileageTransactionWhenProjectionFails(t *testing.T) {
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307765812@115.231.168.135").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}))
mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WithArgs(