@@ -289,15 +289,17 @@ public function testBuildReusesForecastAsSyntheticDataForLaterNoDataDaysAndRecal
289289 self ::assertSame ([[null , null , null , null , 47.9996 , 58.3997 , 74.7198 , 59.9994 ]], $ forecastData );
290290 }
291291
292- public function testBuildMonotonicReturnsFullPriorWhenIncompleteTickHasNoData (): void
292+ public function testBuildMonotonicReturnsTrendAwarePriorWhenIncompleteTickHasNoData (): void
293293 {
294294 $ site = $ this ->createSiteMock ();
295295
296296 // The "today" tick exists in the date range but has no archived data yet — currentValue
297297 // is 0 and there is no earlier incomplete tick to carry a forecast forward from. The
298298 // blend would otherwise dilute the historical prior with a meaningless zero; the builder
299- // must return the full prior mean instead. Apr 3/10/17/24 are all Fridays so the daily
300- // weekday filter keeps every prior tick in the sample.
299+ // falls back to computeHistoricalPrior. With three same-weekday priors [80, 100, 60] the
300+ // least-squares fit gives slope = -10, intercept = 100. The damped projection at
301+ // x = 3 + TREND_DAMPING (0.5) is 100 + (-10) * 3.5 = 65. Apr 3/10/17/24 are all Fridays
302+ // so the daily weekday filter keeps every prior tick in the sample.
301303 $ dataTables = [
302304 $ this ->createDataTableForDay ('2026-04-03 ' , $ site ),
303305 $ this ->createDataTableForDay ('2026-04-10 ' , $ site ),
@@ -317,7 +319,103 @@ public function testBuildMonotonicReturnsFullPriorWhenIncompleteTickHasNoData():
317319 ['Visits ' => false ]
318320 );
319321
320- self ::assertSame ([[null , null , null , 80.0 ]], $ forecastData );
322+ self ::assertSame ([[null , null , null , 65.0 ]], $ forecastData );
323+ }
324+
325+ public function testBuildAppliesDampedLinearTrendOnMultiSamplePriors (): void
326+ {
327+ $ site = $ this ->createSiteMock ();
328+
329+ // Five consecutive Fridays so the daily weekday filter keeps all four priors. Priors
330+ // [100, 120, 140, 160] form a clean linear trend: slope = 20, intercept = 80, regressed
331+ // value at x=4 = 160. The damped projection adds TREND_DAMPING * slope to that anchor:
332+ // 160 + 0.5 * 20 = 170. A flat-mean predictor would have returned 130, so the test pins
333+ // the trend-aware behaviour on a realistic four-week history.
334+ $ dataTables = [
335+ $ this ->createDataTableForDay ('2026-04-03 ' , $ site ),
336+ $ this ->createDataTableForDay ('2026-04-10 ' , $ site ),
337+ $ this ->createDataTableForDay ('2026-04-17 ' , $ site ),
338+ $ this ->createDataTableForDay ('2026-04-24 ' , $ site ),
339+ $ this ->createDataTableForDay ('2026-05-01 ' , $ site , '2026-05-01 00:00:00 ' ),
340+ ];
341+
342+ $ forecastData = (new ForecastBuilder ())->build (
343+ ['Visits ' => [100.0 , 120.0 , 140.0 , 160.0 , 0.0 ]],
344+ $ dataTables ,
345+ [
346+ ArchiveState::COMPLETE ,
347+ ArchiveState::COMPLETE ,
348+ ArchiveState::COMPLETE ,
349+ ArchiveState::COMPLETE ,
350+ ArchiveState::INCOMPLETE ,
351+ ],
352+ ['Visits ' => false ]
353+ );
354+
355+ self ::assertSame ([[null , null , null , null , 170.0 ]], $ forecastData );
356+ }
357+
358+ public function testBuildPriorReducesToMeanWhenTrendIsFlat (): void
359+ {
360+ $ site = $ this ->createSiteMock ();
361+
362+ // With identical priors the least-squares slope is zero, so the damped projection
363+ // reduces exactly to the historical mean. This guards against accidental drift the
364+ // trend formulation might introduce on stable series.
365+ $ dataTables = [
366+ $ this ->createDataTableForDay ('2026-04-03 ' , $ site ),
367+ $ this ->createDataTableForDay ('2026-04-10 ' , $ site ),
368+ $ this ->createDataTableForDay ('2026-04-17 ' , $ site ),
369+ $ this ->createDataTableForDay ('2026-04-24 ' , $ site ),
370+ $ this ->createDataTableForDay ('2026-05-01 ' , $ site , '2026-05-01 00:00:00 ' ),
371+ ];
372+
373+ $ forecastData = (new ForecastBuilder ())->build (
374+ ['Visits ' => [50.0 , 50.0 , 50.0 , 50.0 , 0.0 ]],
375+ $ dataTables ,
376+ [
377+ ArchiveState::COMPLETE ,
378+ ArchiveState::COMPLETE ,
379+ ArchiveState::COMPLETE ,
380+ ArchiveState::COMPLETE ,
381+ ArchiveState::INCOMPLETE ,
382+ ],
383+ ['Visits ' => false ]
384+ );
385+
386+ self ::assertSame ([[null , null , null , null , 50.0 ]], $ forecastData );
387+ }
388+
389+ public function testBuildClampsNegativeTrendExtrapolationToZero (): void
390+ {
391+ $ site = $ this ->createSiteMock ();
392+
393+ // A steeply collapsing prior series produces a damped extrapolation below zero
394+ // (slope = -32.5, intercept = 122.5, damped projection at x=4.5 = -23.75). Counts and
395+ // percentages are non-negative by construction, so the forecast clamps to 0 rather
396+ // than rendering a meaningless negative value on the chart.
397+ $ dataTables = [
398+ $ this ->createDataTableForDay ('2026-04-03 ' , $ site ),
399+ $ this ->createDataTableForDay ('2026-04-10 ' , $ site ),
400+ $ this ->createDataTableForDay ('2026-04-17 ' , $ site ),
401+ $ this ->createDataTableForDay ('2026-04-24 ' , $ site ),
402+ $ this ->createDataTableForDay ('2026-05-01 ' , $ site , '2026-05-01 00:00:00 ' ),
403+ ];
404+
405+ $ forecastData = (new ForecastBuilder ())->build (
406+ ['Visits ' => [100.0 , 50.0 , 10.0 , 5.0 , 0.0 ]],
407+ $ dataTables ,
408+ [
409+ ArchiveState::COMPLETE ,
410+ ArchiveState::COMPLETE ,
411+ ArchiveState::COMPLETE ,
412+ ArchiveState::COMPLETE ,
413+ ArchiveState::INCOMPLETE ,
414+ ],
415+ ['Visits ' => false ]
416+ );
417+
418+ self ::assertSame ([[null , null , null , null , 0.0 ]], $ forecastData );
321419 }
322420
323421 public function testBuildDoesNotCarryForwardAcrossSuppressedForecastGap (): void
0 commit comments