Repository: LiveSplit/livesplit-core
Pull request #160 (2019-02-03 21:01:52)

Introduce a notion of predecessors for Sum of Best
Annotating each individual segment with the predecessor, that its sum of best segments value is derived from, allows for traversing the actual shortest path that leads to the total sum of best. Due to combined segments there is the potential that some segments end up as leaves that don't contribute to that shortest path. These are incredibly confusing for the Best Segments comparison, as the segment time between two segments in that comparison may not mean anything and could even be negative. This caused a variety of issues, which is why now we only show the actual shortest path in the Best Segments comparison.


Patch:
diff --git a/capi/src/lib.rs b/capi/src/lib.rs
index bc4fc47..d1a26e3 100644
--- a/capi/src/lib.rs
+++ b/capi/src/lib.rs
@@ -3,8 +3,6 @@
 
 //! mod
 
-use serde_json;
-
 use std::cell::{Cell, RefCell};
 use std::ffi::CStr;
 use std::os::raw::c_char;
diff --git a/src/analysis/sum_of_segments/best.rs b/src/analysis/sum_of_segments/best.rs
index 7b9e027..2f6c7e3 100644
--- a/src/analysis/sum_of_segments/best.rs
+++ b/src/analysis/sum_of_segments/best.rs
@@ -7,28 +7,39 @@
 //! the actual sum of their best segment times. The name is therefore a bit
 //! misleading, but sticks around for historical reasons.
 
-use super::{track_branch, track_current_run, track_personal_best_run};
+use super::{track_branch, track_current_run, track_personal_best_run, Prediction};
 use crate::{Segment, TimeSpan, TimingMethod};
 
-fn populate_prediction(prediction: &mut Option<TimeSpan>, predicted_time: Option<TimeSpan>) {
+fn populate_prediction(
+    predecessor: usize,
+    target_prediction: &mut Option<Prediction>,
+    predicted_time: Option<TimeSpan>,
+) {
     if let Some(predicted_time) = predicted_time {
-        if prediction.map_or(true, |t| predicted_time < t) {
-            *prediction = Some(predicted_time);
+        if target_prediction.map_or(true, |p| predicted_time < p.time) {
+            *target_prediction = Some(Prediction {
+                time: predicted_time,
+                predecessor,
+            });
         }
     }
 }
 
 fn populate_predictions(
     segments: &[Segment],
-    current_time: Option<TimeSpan>,
+    current_prediction: Option<Prediction>,
     segment_index: usize,
-    predictions: &mut [Option<TimeSpan>],
+    predictions: &mut [Option<Prediction>],
     simple_calculation: bool,
     use_current_run: bool,
     method: TimingMethod,
 ) {
-    if let Some(current_time) = current_time {
+    if let Some(Prediction {
+        time: current_time, ..
+    }) = current_prediction
+    {
         populate_prediction(
+            segment_index,
             &mut predictions[segment_index + 1],
             segments[segment_index].best_segment_time()[method].map(|t| t + current_time),
         );
@@ -55,18 +66,18 @@ fn populate_predictions(
                         null_segment_index,
                         method,
                     );
-                    populate_prediction(&mut predictions[index], time[method]);
+                    populate_prediction(segment_index, &mut predictions[index], time[method]);
                 }
             }
         }
         if use_current_run {
             let (index, time) =
                 track_current_run(segments, Some(current_time), segment_index, method);
-            populate_prediction(&mut predictions[index], time[method]);
+            populate_prediction(segment_index, &mut predictions[index], time[method]);
         }
         let (index, time) =
             track_personal_best_run(segments, Some(current_time), segment_index, method);
-        populate_prediction(&mut predictions[index], time[method]);
+        populate_prediction(segment_index, &mut predictions[index], time[method]);
     }
 }
 
@@ -92,18 +103,17 @@ fn populate_predictions(
 #[allow(clippy::needless_range_loop)]
 pub fn calculate(
     segments: &[Segment],
-    predictions: &mut [Option<TimeSpan>],
+    predictions: &mut [Option<Prediction>],
     simple_calculation: bool,
     use_current_run: bool,
     method: TimingMethod,
 ) -> Option<TimeSpan> {
-    predictions[0] = Some(TimeSpan::zero());
+    predictions[0] = Some(Prediction::default());
     let end_index = segments.len();
     for segment_index in 0..end_index {
-        let current_time = predictions[segment_index];
         populate_predictions(
             segments,
-            current_time,
+            predictions[segment_index],
             segment_index,
             predictions,
             simple_calculation,
@@ -111,5 +121,5 @@ pub fn calculate(
             method,
         );
     }
-    predictions[end_index]
+    Some(predictions[end_index]?.time)
 }
diff --git a/src/analysis/sum_of_segments/mod.rs b/src/analysis/sum_of_segments/mod.rs
index 22c164e..261f0ea 100644
--- a/src/analysis/sum_of_segments/mod.rs
+++ b/src/analysis/sum_of_segments/mod.rs
@@ -10,8 +10,24 @@
 pub mod best;
 pub mod worst;
 
+#[cfg(test)]
+mod tests;
+
 use crate::{Segment, Time, TimeSpan, TimingMethod};
 
+/// Describes the shortest amount of time it takes to reach a certain segment.
+/// Since there is the possibility that the shortest path is actually skipping
+/// segments, there's an additional predecessor index that describes the segment
+/// this prediction is based on. By following all the predecessors backwards,
+/// you can get access to the single fastest route.
+#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
+pub struct Prediction {
+    /// The shortest amount of time it takes to reach the segment.
+    pub time: TimeSpan,
+    /// The index of the predecessor that directly leads to this segment.
+    pub predecessor: usize,
+}
+
 /// Calculates the Sum of Best Segments for the timing method provided. This is
 /// the fastest time possible to complete a run of a category, based on
 /// information collected from all the previous attempts. This often matches up
@@ -28,8 +44,7 @@ pub fn calculate_best(
     use_current_run: bool,
     method: TimingMethod,
 ) -> Option<TimeSpan> {
-    let mut predictions = Vec::with_capacity(segments.len() + 1);
-    predictions.resize(segments.len() + 1, None);
+    let mut predictions = vec![None; segments.len() + 1];
     best::calculate(
         segments,
         &mut predictions,
@@ -50,8 +65,7 @@ pub fn calculate_worst(
     use_current_run: bool,
     method: TimingMethod,
 ) -> Option<TimeSpan> {
-    let mut predictions = Vec::with_capacity(segments.len() + 1);
-    predictions.resize(segments.len() + 1, None);
+    let mut predictions = vec![None; segments.len() + 1];
     worst::calculate(segments, &mut predictions, use_current_run, method)
 }
 
diff --git a/src/analysis/sum_of_segments/worst.rs b/src/analysis/sum_of_segments/worst.rs
index 3195d06..3367d02 100644
--- a/src/analysis/sum_of_segments/worst.rs
+++ b/src/analysis/sum_of_segments/worst.rs
@@ -4,27 +4,38 @@
 //! from all the previous attempts. This obviously isn't really the worst
 //! possible time, but may be useful information regardless.
 
-use super::{track_branch, track_current_run, track_personal_best_run};
+use super::{track_branch, track_current_run, track_personal_best_run, Prediction};
 use crate::{Segment, TimeSpan, TimingMethod};
 
-fn populate_prediction(prediction: &mut Option<TimeSpan>, predicted_time: Option<TimeSpan>) {
+fn populate_prediction(
+    predecessor: usize,
+    target_prediction: &mut Option<Prediction>,
+    predicted_time: Option<TimeSpan>,
+) {
     if let Some(predicted_time) = predicted_time {
-        if prediction.map_or(true, |t| predicted_time > t) {
-            *prediction = Some(predicted_time);
+        if target_prediction.map_or(true, |p| predicted_time > p.time) {
+            *target_prediction = Some(Prediction {
+                time: predicted_time,
+                predecessor,
+            });
         }
     }
 }
 
 fn populate_predictions(
     segments: &[Segment],
-    current_time: Option<TimeSpan>,
+    current_prediction: Option<Prediction>,
     segment_index: usize,
-    predictions: &mut [Option<TimeSpan>],
+    predictions: &mut [Option<Prediction>],
     use_current_run: bool,
     method: TimingMethod,
 ) {
-    if let Some(current_time) = current_time {
+    if let Some(Prediction {
+        time: current_time, ..
+    }) = current_prediction
+    {
         populate_prediction(
+            segment_index,
             &mut predictions[segment_index + 1],
             segments[segment_index].best_segment_time()[method].map(|t| t + current_time),
         );
@@ -46,17 +57,17 @@ fn populate_predictions(
                     segment_history_index,
                     method,
                 );
-                populate_prediction(&mut predictions[index], time[method]);
+                populate_prediction(segment_index, &mut predictions[index], time[method]);
             }
         }
         if use_current_run {
             let (index, time) =
                 track_current_run(segments, Some(current_time), segment_index, method);
-            populate_prediction(&mut predictions[index], time[method]);
+            populate_prediction(segment_index, &mut predictions[index], time[method]);
         }
         let (index, time) =
             track_personal_best_run(segments, Some(current_time), segment_index, method);
-        populate_prediction(&mut predictions[index], time[method]);
+        populate_prediction(segment_index, &mut predictions[index], time[method]);
     }
 }
 
@@ -77,22 +88,21 @@ fn populate_predictions(
 #[allow(clippy::needless_range_loop)]
 pub fn calculate(
     segments: &[Segment],
-    predictions: &mut [Option<TimeSpan>],
+    predictions: &mut [Option<Prediction>],
     use_current_run: bool,
     method: TimingMethod,
 ) -> Option<TimeSpan> {
-    predictions[0] = Some(TimeSpan::zero());
+    predictions[0] = Some(Prediction::default());
     let end_index = segments.len();
     for segment_index in 0..end_index {
-        let current_time = predictions[segment_index];
         populate_predictions(
             segments,
-            current_time,
+            predictions[segment_index],
             segment_index,
             predictions,
             use_current_run,
             method,
         );
     }
-    predictions[end_index]
+    Some(predictions[end_index]?.time)
 }
diff --git a/src/comparison/best_segments.rs b/src/comparison/best_segments.rs
index d17980a..b6f1d70 100644
--- a/src/comparison/best_segments.rs
+++ b/src/comparison/best_segments.rs
@@ -20,32 +20,28 @@ impl ComparisonGenerator for BestSegments {
     }
 
     fn generate(&mut self, segments: &mut [Segment], _: &[Attempt]) {
-        let mut real_time_predictions = vec![None; segments.len() + 1];
-        let mut game_time_predictions = vec![None; segments.len() + 1];
-
-        calculate(
-            segments,
-            &mut real_time_predictions,
-            false,
-            false,
-            TimingMethod::RealTime,
-        );
-        calculate(
-            segments,
-            &mut game_time_predictions,
-            false,
-            false,
-            TimingMethod::GameTime,
-        );
-
-        for ((segment, &real_time), &game_time) in segments
+        let mut predictions = Vec::with_capacity(segments.len() + 1);
+
+        segments
             .iter_mut()
-            .zip(real_time_predictions[1..].iter())
-            .zip(game_time_predictions[1..].iter())
-        {
-            *segment.comparison_mut(NAME) = Time::new()
-                .with_real_time(real_time)
-                .with_game_time(game_time);
+            .for_each(|s| *s.comparison_mut(NAME) = Time::new());
+
+        for &method in &TimingMethod::all() {
+            predictions.clear();
+            predictions.resize(segments.len() + 1, None);
+
+            calculate(segments, &mut predictions, false, false, method);
+
+            let mut index = predictions
+                .iter()
+                .rposition(|p| p.is_some())
+                .expect("There must always be a first sentinel prediction that is not None");
+            while let Some(segment_index) = index.checked_sub(1) {
+                let prediction =
+                    predictions[index].expect("A predecessor prediction always needs to exist");
+                segments[segment_index].comparison_mut(NAME)[method] = Some(prediction.time);
+                index = prediction.predecessor;
+            }
         }
     }
 }
diff --git a/src/comparison/worst_segments.rs b/src/comparison/worst_segments.rs
index 011e1d1..c005737 100644
--- a/src/comparison/worst_segments.rs
+++ b/src/comparison/worst_segments.rs
@@ -20,30 +20,28 @@ impl ComparisonGenerator for WorstSegments {
     }
 
     fn generate(&mut self, segments: &mut [Segment], _: &[Attempt]) {
-        let mut real_time_predictions = vec![None; segments.len() + 1];
-        let mut game_time_predictions = vec![None; segments.len() + 1];
-
-        calculate(
-            segments,
-            &mut real_time_predictions,
-            false,
-            TimingMethod::RealTime,
-        );
-        calculate(
-            segments,
-            &mut game_time_predictions,
-            false,
-            TimingMethod::GameTime,
-        );
-
-        for ((segment, &real_time), &game_time) in segments
+        let mut predictions = Vec::with_capacity(segments.len() + 1);
+
+        segments
             .iter_mut()
-            .zip(real_time_predictions[1..].iter())
-            .zip(game_time_predictions[1..].iter())
-        {
-            *segment.comparison_mut(NAME) = Time::new()
-                .with_real_time(real_time)
-                .with_game_time(game_time);
+            .for_each(|s| *s.comparison_mut(NAME) = Time::new());
+
+        for &method in &TimingMethod::all() {
+            predictions.clear();
+            predictions.resize(segments.len() + 1, None);
+
+            calculate(segments, &mut predictions, false, method);
+
+            let mut index = predictions
+                .iter()
+                .rposition(|p| p.is_some())
+                .expect("There must always be a first sentinel prediction that is not None");
+            while let Some(segment_index) = index.checked_sub(1) {
+                let prediction =
+                    predictions[index].expect("A predecessor prediction always needs to exist");
+                segments[segment_index].comparison_mut(NAME)[method] = Some(prediction.time);
+                index = prediction.predecessor;
+            }
         }
     }
 }
diff --git a/src/run/editor/cleaning.rs b/src/run/editor/cleaning.rs
index f6ecd34..e1eaa7a 100644
--- a/src/run/editor/cleaning.rs
+++ b/src/run/editor/cleaning.rs
@@ -6,7 +6,7 @@
 //! occurrences of this and allows you to delete them individually if any of
 //! them seem wrong.
 
-use crate::analysis::sum_of_segments::{best, track_branch};
+use crate::analysis::sum_of_segments::{best, track_branch, Prediction};
 use crate::timing::formatter::{Short, TimeFormatter};
 use crate::{Attempt, Run, Segment, TimeSpan, TimingMethod};
 use chrono::Local;
@@ -21,7 +21,7 @@ use std::mem::replace;
 /// individually if any of them seem wrong.
 pub struct SumOfBestCleaner<'r> {
     run: &'r mut Run,
-    predictions: Vec<Option<TimeSpan>>,
+    predictions: Vec<Option<Prediction>>,
     state: State,
 }
 
@@ -155,10 +155,10 @@ impl<'r> SumOfBestCleaner<'r> {
                 }
                 State::IteratingRun(state) => {
                     self.state = if state.segment_index < self.run.len() {
-                        let current_time = self.predictions[state.segment_index];
+                        let current_prediction = self.predictions[state.segment_index];
                         State::IteratingHistory(IteratingHistoryState {
                             parent: state,
-                            current_time,
+                            current_time: current_prediction.map(|p| p.time),
                             skip_count: 0,
                         })
                     } else if state.method == TimingMethod::RealTime {
@@ -216,7 +216,7 @@ impl<'r> SumOfBestCleaner<'r> {
 
 fn check_prediction<'a>(
     run: &'a Run,
-    predictions: &[Option<TimeSpan>],
+    predictions: &[Option<Prediction>],
     predicted_time: Option<TimeSpan>,
     starting_index: isize,
     ending_index: usize,
@@ -224,7 +224,7 @@ fn check_prediction<'a>(
     method: TimingMethod,
 ) -> Option<PotentialCleanUp<'a>> {
     if let Some(predicted_time) = predicted_time {
-        if predictions[ending_index + 1].map_or(true, |t| predicted_time < t) {
+        if predictions[ending_index + 1].map_or(true, |p| predicted_time < p.time) {
             if let Some(segment_history_element) =
                 run.segment(ending_index).segment_history().get(run_index)
             {
@@ -237,9 +237,11 @@ fn check_prediction<'a>(
                     ending_segment: run.segment(ending_index),
                     time_between: segment_history_element[method]
                         .expect("Cleanup path is shorter but doesn't have a time"),
-                    combined_sum_of_best: predictions[ending_index + 1].map(|t| {
-                        t - predictions[(starting_index + 1) as usize]
-                            .expect("Start time must not be empty")
+                    combined_sum_of_best: predictions[ending_index + 1].map(|p| {
+                        p.time
+                            - predictions[(starting_index + 1) as usize]
+                                .expect("Start time must not be empty")
+                                .time
                     }),
                     attempt: run
                         .attempt_history()
@@ -258,7 +260,7 @@ fn check_prediction<'a>(
     None
 }
 
-fn next_timing_method(run: &Run, predictions: &mut Vec<Option<TimeSpan>>, method: TimingMethod) {
+fn next_timing_method(run: &Run, predictions: &mut Vec<Option<Prediction>>, method: TimingMethod) {
     let segments = run.segments();
 
     predictions.clear();


Test patch:
diff --git a/src/analysis/sum_of_segments/tests.rs b/src/analysis/sum_of_segments/tests.rs
new file mode 100644
index 0000000..7b119c3
--- /dev/null
+++ b/src/analysis/sum_of_segments/tests.rs
@@ -0,0 +1,129 @@
+use super::{best, Prediction};
+use crate::{
+    comparison::best_segments,
+    tests_helper::{create_timer, run_with_splits_opt, span},
+    Timer, TimingMethod,
+};
+
+fn assert(timer: &Timer, got: [Option<Prediction>; 4], [a, b, c]: [(f64, usize, bool); 3]) {
+    assert_eq!(
+        got,
+        [
+            Prediction {
+                time: span(0.0),
+                predecessor: 0,
+            }
+            .into(),
+            Prediction {
+                time: span(a.0),
+                predecessor: a.1,
+            }
+            .into(),
+            Prediction {
+                time: span(b.0),
+                predecessor: b.1,
+            }
+            .into(),
+            Prediction {
+                time: span(c.0),
+                predecessor: c.1,
+            }
+            .into(),
+        ]
+    );
+    for (index, (segment, &(expected, _, should_be_some))) in
+        timer.run().segments().iter().zip(&[a, b, c]).enumerate()
+    {
+        let segment_val = segment.comparison(best_segments::NAME).game_time;
+        assert_eq!(
+            segment_val,
+            if should_be_some {
+                Some(span(expected))
+            } else {
+                None
+            },
+            "Segment {} was incorrect",
+            index,
+        );
+    }
+}
+
+#[test]
+pub fn sum_of_best() {
+    let mut timer = create_timer(&["A", "B", "C"]);
+
+    run_with_splits_opt(&mut timer, &[Some(5.0), Some(20.0), Some(60.0)]);
+    let mut predictions = [None; 4];
+    best::calculate(
+        timer.run().segments(),
+        &mut predictions,
+        false,
+        false,
+        TimingMethod::GameTime,
+    );
+    assert(
+        &timer,
+        predictions,
+        [(5.0, 0, true), (20.0, 1, true), (60.0, 2, true)],
+    );
+
+    run_with_splits_opt(&mut timer, &[None, Some(10.0), None]);
+    predictions = [None; 4];
+    best::calculate(
+        timer.run().segments(),
+        &mut predictions,
+        false,
+        false,
+        TimingMethod::GameTime,
+    );
+    assert(
+        &timer,
+        predictions,
+        [(5.0, 0, false), (10.0, 0, true), (50.0, 2, true)],
+    );
+
+    run_with_splits_opt(&mut timer, &[Some(10.0), None, Some(30.0)]);
+    predictions = [None; 4];
+    best::calculate(
+        timer.run().segments(),
+        &mut predictions,
+        false,
+        false,
+        TimingMethod::GameTime,
+    );
+    assert(
+        &timer,
+        predictions,
+        [(5.0, 0, true), (10.0, 0, false), (25.0, 1, true)],
+    );
+
+    run_with_splits_opt(&mut timer, &[Some(7.0), Some(10.0), None]);
+    predictions = [None; 4];
+    best::calculate(
+        timer.run().segments(),
+        &mut predictions,
+        false,
+        false,
+        TimingMethod::GameTime,
+    );
+    assert(
+        &timer,
+        predictions,
+        [(5.0, 0, true), (8.0, 1, false), (25.0, 1, true)],
+    );
+
+    run_with_splits_opt(&mut timer, &[None, Some(15.0), Some(20.0)]);
+    predictions = [None; 4];
+    best::calculate(
+        timer.run().segments(),
+        &mut predictions,
+        false,
+        false,
+        TimingMethod::GameTime,
+    );
+    assert(
+        &timer,
+        predictions,
+        [(5.0, 0, true), (8.0, 1, true), (13.0, 2, true)],
+    );
+}
diff --git a/src/component/splits/tests/column.rs b/src/component/splits/tests/column.rs
index 5b36ca2..0b3ec94 100644
--- a/src/component/splits/tests/column.rs
+++ b/src/component/splits/tests/column.rs
@@ -3,8 +3,8 @@ use super::{
     State,
 };
 use crate::settings::SemanticColor::{
-    self, AheadGainingTime as AheadGaining, BehindGainingTime as BehindGaining,
-    BehindLosingTime as BehindLosing, BestSegment as Best, Default as Text,
+    self, AheadGainingTime as AheadGaining, BehindLosingTime as BehindLosing, BestSegment as Best,
+    Default as Text,
 };
 use crate::tests_helper::{make_progress_run_with_splits_opt, run_with_splits_opt, start_run};
 use crate::{Run, Segment, TimeSpan, Timer};
@@ -68,16 +68,22 @@ fn column_empty_delta() {
                 [BehindLosing, Text, Best, Text, Text, Text],
             ),
             (
+                // The fourth segment is a best segment because the current
+                // run's combined segment time from the first to the fourth
+                // segment is 9 seconds while the best segments comparison has a
+                // combined segment time of 10 seconds. The third segment is
+                // empty in the best segments comparison because it is not a
+                // part of the shortest path in the Sum of Best calculation.
                 ["+3.5", "—", "—", "+2.5", "", ""],
-                [BehindLosing, Text, Best, BehindGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "+2.5", "—", ""],
-                [BehindLosing, Text, Best, BehindGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "+2.5", "—", "−1:00"],
-                [BehindLosing, Text, Best, BehindGaining, Text, AheadGaining],
+                [BehindLosing, Text, Best, Best, Text, AheadGaining],
             ),
         ],
     );
@@ -106,16 +112,22 @@ fn column_empty_segment_delta() {
                 [BehindLosing, Text, Best, Text, Text, Text],
             ),
             (
+                // The fourth segment is a best segment because the current
+                // run's combined segment time from the first to the fourth
+                // segment is 9 seconds while the best segments comparison has a
+                // combined segment time of 10 seconds. The third segment is
+                // empty in the best segments comparison because it is not a
+                // part of the shortest path in the Sum of Best calculation.
                 ["+3.5", "—", "—", "−1.0", "", ""],
-                [BehindLosing, Text, Best, AheadGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "−1.0", "—", ""],
-                [BehindLosing, Text, Best, AheadGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "−1.0", "—", "−1:02"],
-                [BehindLosing, Text, Best, AheadGaining, Text, AheadGaining],
+                [BehindLosing, Text, Best, Best, Text, AheadGaining],
             ),
         ],
     )
@@ -293,23 +305,29 @@ fn column_comparison_time_delta() {
             ),
             (
                 // In the original LiveSplit, we showed the split time for this
-                // column type if the comparison time was missing Instead, we
+                // column type if the comparison time was missing. Instead, we
                 // show a dash for the third segment rather than showing the
-                // split time
+                // split time.
                 ["+3.5", "—", "—", "0:15", "0:20", "1:25"],
                 [BehindLosing, Text, Best, Text, Text, Text],
             ),
             (
+                // The fourth segment is a best segment because the current
+                // run's combined segment time from the first to the fourth
+                // segment is 9 seconds while the best segments comparison has a
+                // combined segment time of 10 seconds. The third segment is
+                // empty in the best segments comparison because it is not a
+                // part of the shortest path in the Sum of Best calculation.
                 ["+3.5", "—", "—", "+2.5", "0:20", "1:25"],
-                [BehindLosing, Text, Best, BehindGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "+2.5", "—", "1:25"],
-                [BehindLosing, Text, Best, BehindGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "+2.5", "—", "−1:00"],
-                [BehindLosing, Text, Best, BehindGaining, Text, AheadGaining],
+                [BehindLosing, Text, Best, Best, Text, AheadGaining],
             ),
         ],
     )
@@ -335,23 +353,29 @@ fn column_comparison_segment_time_segment_delta() {
             ),
             (
                 // In the original LiveSplit, we showed the segment time for
-                // this column type if the comparison segment time was missing
+                // this column type if the comparison segment time was missing.
                 // Instead, we show a dash for the third segment rather than
-                // showing the segment time
+                // showing the segment time.
                 ["+3.5", "—", "—", "0:10", "0:05", "1:05"],
                 [BehindLosing, Text, Best, Text, Text, Text],
             ),
             (
+                // The fourth segment is a best segment because the current
+                // run's combined segment time from the first to the fourth
+                // segment is 9 seconds while the best segments comparison has a
+                // combined segment time of 10 seconds. The third segment is
+                // empty in the best segments comparison because it is not a
+                // part of the shortest path in the Sum of Best calculation.
                 ["+3.5", "—", "—", "−1.0", "0:05", "1:05"],
-                [BehindLosing, Text, Best, AheadGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "−1.0", "—", "1:05"],
-                [BehindLosing, Text, Best, AheadGaining, Text, Text],
+                [BehindLosing, Text, Best, Best, Text, Text],
             ),
             (
                 ["+3.5", "—", "—", "−1.0", "—", "−1:02"],
-                [BehindLosing, Text, Best, AheadGaining, Text, AheadGaining],
+                [BehindLosing, Text, Best, Best, Text, AheadGaining],
             ),
         ],
     )
diff --git a/src/tests_helper.rs b/src/tests_helper.rs
index 360ed6d..3502ff7 100644
--- a/src/tests_helper.rs
+++ b/src/tests_helper.rs
@@ -1,4 +1,16 @@
-use crate::{TimeSpan, Timer, TimingMethod};
+use crate::{Run, Segment, TimeSpan, Timer, TimingMethod};
+
+pub(crate) fn create_run(names: &[&str]) -> Run {
+    let mut run = Run::new();
+    for &name in names {
+        run.push_segment(Segment::new(name));
+    }
+    run
+}
+
+pub(crate) fn create_timer(names: &[&str]) -> Timer {
+    Timer::new(create_run(names)).unwrap()
+}
 
 pub(crate) fn start_run(timer: &mut Timer) {
     timer.set_current_timing_method(TimingMethod::GameTime);
@@ -37,3 +49,7 @@ pub(crate) fn run_with_splits_opt(timer: &mut Timer, splits: &[Option<f64>]) {
     make_progress_run_with_splits_opt(timer, splits);
     timer.reset(true);
 }
+
+pub(crate) fn span(seconds: f64) -> TimeSpan {
+    TimeSpan::from_seconds(seconds)
+}