Skip to main content

lattice_diff/
pane_group.rs

1//! D.4.b (2026-05-29) / D.6.b (2026-05-31): `HunkRowMapper`.
2//!
3//! [`RowMapper`] impl that translates rows between two or
4//! three sides of a diff session. Composed with the D.4.a
5//! [`PaneGroup`] substrate; consumed by `:diffsplit` /
6//! `:diffthis` in D.4.d for two-way and D.6.c for three-way.
7//!
8//! See `docs/dev/architecture/pane-groups.md` and
9//! `docs/dev/architecture/diff-system.md` §5.2.
10//!
11//! ## Algorithm
12//!
13//! The walk is pane-index parametric: given a (from_pane,
14//! to_pane) pair (positions into `Hunk::ranges`), for each
15//! hunk the mapper tracks a `cumulative_shift` = sum over
16//! preceding hunks of `to_len - from_len`.
17//!
18//! - **Row before this hunk** (`row < from_r.start`):
19//!   apply `cumulative_shift` and return — the row sits in
20//!   a "gap" of pure-identity territory shifted by every
21//!   prior hunk's length delta.
22//! - **Row inside this hunk** (`from_r.start <= row <
23//!   from_r.end`):
24//!   - Either side empty (Add ⇒ from empty; Remove ⇒
25//!     to empty) ⇒ collapse to `to_r.start`.
26//!     There's no meaningful proportional mapping when one
27//!     side has zero lines to map into.
28//!   - Otherwise proportional: `offset * to_len /
29//!     from_len`, capped at `to_len - 1`.
30//! - **Row past this hunk**: accumulate the length delta
31//!   into `cumulative_shift` and continue.
32//! - **Past all hunks**: apply final `cumulative_shift`.
33//!
34//! All directions (2 in two-way, 6 in three-way) collapse
35//! to one body via [`map_between`]; the public
36//! [`map_baseline_to_current`] / [`map_current_to_baseline`]
37//! are thin aliases preserved for back-compat with D.4.b
38//! callers.
39//!
40//! ## Why the trait works with raw indices
41//!
42//! [`RowMapper::map_row`] receives `(from_idx, to_idx, row)`
43//! over `PaneGroup::members`. `HunkRowMapper` is
44//! constructed with the member indices for each role
45//! ([`HunkRowMapper::new`] for two-way; [`HunkRowMapper::three_pane`]
46//! for three-way), and resolves each side of the
47//! `(from_idx, to_idx)` pair to its pane-index slot in
48//! `Hunk::ranges` (0/1 in two-way; 0/1/2 in three-way).
49//! Any (from, to) pair that doesn't match a known role
50//! falls back to identity, so unfamiliar member
51//! configurations never produce silently wrong scrolling.
52
53use std::sync::Arc;
54
55use crate::HunkIndex;
56
57use crate::subsystem::DiffSession;
58use lattice_core::ui::pane::RowMapper;
59
60/// D.4.b / D.6.b: maps rows between two or three sides of a
61/// diff session.
62///
63/// Constructed via [`Self::new`] (two-way) or
64/// [`Self::three_pane`] (three-way). Internally stores the
65/// member-index → pane-index assignment as
66/// [`MapperShape`]; `RowMapper::map_row` looks up each side
67/// of the `(from, to)` pair and dispatches into the
68/// pane-index-parametric [`map_between`].
69pub struct HunkRowMapper {
70    session: Arc<DiffSession>,
71    shape: MapperShape,
72}
73
74/// D.6.b: which member indices in `PaneGroup::members` play
75/// which role. Roles correspond to slot positions in
76/// `Hunk::ranges` (`ranges[pane_index]`):
77/// - **TwoWay**: baseline = 0, current = 1.
78/// - **ThreeWay**: base = 0, local = 1, remote = 2.
79#[derive(Debug, Clone, Copy)]
80enum MapperShape {
81    TwoWay {
82        baseline_member_idx: usize,
83        current_member_idx: usize,
84    },
85    ThreeWay {
86        base_member_idx: usize,
87        local_member_idx: usize,
88        remote_member_idx: usize,
89    },
90}
91
92impl MapperShape {
93    /// Resolve a `PaneGroup::members` index to its
94    /// `Hunk::ranges` slot, or `None` if the member isn't
95    /// part of this mapper's shape.
96    fn pane_index_of(&self, member_idx: usize) -> Option<usize> {
97        match *self {
98            Self::TwoWay {
99                baseline_member_idx,
100                current_member_idx,
101            } => {
102                if member_idx == baseline_member_idx {
103                    Some(0)
104                } else if member_idx == current_member_idx {
105                    Some(1)
106                } else {
107                    None
108                }
109            }
110            Self::ThreeWay {
111                base_member_idx,
112                local_member_idx,
113                remote_member_idx,
114            } => {
115                if member_idx == base_member_idx {
116                    Some(0)
117                } else if member_idx == local_member_idx {
118                    Some(1)
119                } else if member_idx == remote_member_idx {
120                    Some(2)
121                } else {
122                    None
123                }
124            }
125        }
126    }
127}
128
129impl std::fmt::Debug for HunkRowMapper {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("HunkRowMapper")
132            .field("shape", &self.shape)
133            .finish()
134    }
135}
136
137impl HunkRowMapper {
138    /// Construct a mapper for a two-way side-by-side diff
139    /// session.
140    ///
141    /// `baseline_member_idx` and `current_member_idx` are
142    /// the indices in `PaneGroup::members` that hold the
143    /// baseline pane and the current pane respectively.
144    /// They must be distinct.
145    pub fn new(
146        session: Arc<DiffSession>,
147        baseline_member_idx: usize,
148        current_member_idx: usize,
149    ) -> Self {
150        debug_assert_ne!(
151            baseline_member_idx, current_member_idx,
152            "baseline and current member indices must be distinct"
153        );
154        Self {
155            session,
156            shape: MapperShape::TwoWay {
157                baseline_member_idx,
158                current_member_idx,
159            },
160        }
161    }
162
163    /// D.6.b: construct a mapper for a three-way merge
164    /// session. Member indices correspond to the three
165    /// `Hunk::ranges` slots — `base` = `ranges[0]` (common
166    /// ancestor), `local` = `ranges[1]` (the side the
167    /// session is keyed under), `remote` = `ranges[2]` (the
168    /// third party). All three indices must be distinct.
169    pub fn three_pane(
170        session: Arc<DiffSession>,
171        base_member_idx: usize,
172        local_member_idx: usize,
173        remote_member_idx: usize,
174    ) -> Self {
175        debug_assert_ne!(base_member_idx, local_member_idx);
176        debug_assert_ne!(base_member_idx, remote_member_idx);
177        debug_assert_ne!(local_member_idx, remote_member_idx);
178        Self {
179            session,
180            shape: MapperShape::ThreeWay {
181                base_member_idx,
182                local_member_idx,
183                remote_member_idx,
184            },
185        }
186    }
187}
188
189impl RowMapper for HunkRowMapper {
190    fn map_row(&self, from: usize, to: usize, row: u32) -> u32 {
191        let (Some(from_pane), Some(to_pane)) =
192            (self.shape.pane_index_of(from), self.shape.pane_index_of(to))
193        else {
194            // Member pair the mapper wasn't constructed for —
195            // fall back to identity rather than guess.
196            return row;
197        };
198        if from_pane == to_pane {
199            return row;
200        }
201        let hunks = self.session.current_hunks();
202        map_between(&hunks, from_pane, to_pane, row)
203    }
204}
205
206/// D.6.b: translate a row from `from_pane` to `to_pane`
207/// where each pane is a slot index into `Hunk::ranges`
208/// (0/1 in two-way; 0/1/2 in three-way). Pure function of
209/// the published `HunkIndex` + indices; exposed for direct
210/// unit testing without round-tripping through `RowMapper`
211/// and as the body all directional aliases call into.
212pub fn map_between(index: &HunkIndex, from_pane: usize, to_pane: usize, row: u32) -> u32 {
213    if from_pane == to_pane {
214        return row;
215    }
216    let mut shift: i32 = 0;
217    for hunk in &index.hunks {
218        let Some(from_r) = hunk.ranges.get(from_pane) else {
219            continue;
220        };
221        let Some(to_r) = hunk.ranges.get(to_pane) else {
222            continue;
223        };
224        if row < from_r.start {
225            return apply_shift(row, shift);
226        }
227        if row < from_r.end {
228            return map_inside(row, from_r.start, from_r.end, to_r.start, to_r.end);
229        }
230        // Past this hunk's `from_pane` range — accumulate
231        // delta for the next iteration / fall-through.
232        shift += (to_r.end as i32 - to_r.start as i32) - (from_r.end as i32 - from_r.start as i32);
233    }
234    apply_shift(row, shift)
235}
236
237/// Translate a baseline-side row to its current-side
238/// counterpart. Pure function of the published `HunkIndex`
239/// and the input row; exposed for direct unit testing
240/// without round-tripping through `RowMapper`. D.4.b-shape
241/// alias for `map_between(index, 0, 1, row)`.
242pub fn map_baseline_to_current(index: &HunkIndex, row: u32) -> u32 {
243    map_between(index, 0, 1, row)
244}
245
246/// Translate a current-side row to its baseline-side
247/// counterpart. Symmetric to [`map_baseline_to_current`].
248/// Alias for `map_between(index, 1, 0, row)`.
249pub fn map_current_to_baseline(index: &HunkIndex, row: u32) -> u32 {
250    map_between(index, 1, 0, row)
251}
252
253/// Map a row inside a hunk's `from`-side range to the
254/// corresponding `to`-side row.
255///
256/// - Either side empty (Add or Remove) ⇒ collapse to
257///   `to_start`. Without a non-empty target there's no
258///   proportional mapping — the row "lands at" the
259///   insertion point on the other side.
260/// - Otherwise: `to_start + (offset * to_len / from_len)`,
261///   capped at `to_len - 1` so we never spill past the
262///   hunk's range on the target side.
263fn map_inside(row: u32, from_start: u32, from_end: u32, to_start: u32, to_end: u32) -> u32 {
264    let from_len = from_end - from_start;
265    let to_len = to_end - to_start;
266    if from_len == 0 || to_len == 0 {
267        return to_start;
268    }
269    let offset = row - from_start;
270    // Proportional via 64-bit to dodge `offset * to_len`
271    // overflow when both are u32-max-ish (won't happen in
272    // practice — file sizes are bounded — but the cast is
273    // free and removes the only sharp edge).
274    let mapped_offset = (offset as u64 * to_len as u64 / from_len as u64) as u32;
275    to_start + mapped_offset.min(to_len - 1)
276}
277
278/// Apply a (possibly-negative) shift to a row, saturating
279/// at zero. Same shape as the identity mapper's no-op when
280/// `shift == 0`.
281fn apply_shift(row: u32, shift: i32) -> u32 {
282    if shift >= 0 {
283        row.saturating_add(shift as u32)
284    } else {
285        row.saturating_sub(shift.unsigned_abs())
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    #![allow(clippy::unwrap_used)]
292    use super::*;
293    use crate::{DiffAlgorithm, Hunk, HunkKind, LineRange};
294    use smallvec::smallvec;
295
296    fn hunk(kind: HunkKind, baseline: LineRange, current: LineRange) -> Hunk {
297        Hunk {
298            kind,
299            ranges: smallvec![baseline, current],
300            refine: Default::default(),
301        }
302    }
303
304    fn idx(hunks: Vec<Hunk>) -> HunkIndex {
305        HunkIndex {
306            hunks,
307            algorithm: DiffAlgorithm::Histogram,
308            revision: 1,
309        }
310    }
311
312    // ── Empty index ────────────────────────────────────────
313
314    #[test]
315    fn empty_index_is_identity_in_both_directions() {
316        let i = HunkIndex::empty(DiffAlgorithm::Histogram);
317        for row in [0, 1, 42, 10_000] {
318            assert_eq!(map_baseline_to_current(&i, row), row);
319            assert_eq!(map_current_to_baseline(&i, row), row);
320        }
321    }
322
323    // ── Single Add hunk ────────────────────────────────────
324
325    #[test]
326    fn add_hunk_shifts_baseline_rows_past_it() {
327        // Add 3 lines on current at position 5; baseline
328        // has nothing there.
329        let i = idx(vec![hunk(
330            HunkKind::Add,
331            LineRange::new(5, 5),
332            LineRange::new(5, 8),
333        )]);
334        // Before the hunk: identity.
335        assert_eq!(map_baseline_to_current(&i, 0), 0);
336        assert_eq!(map_baseline_to_current(&i, 4), 4);
337        // At the hunk insertion point: cumulative shift
338        // applies (row >= br.start, row >= br.end since
339        // br is zero-length). Shift = current_len - 0 = +3.
340        assert_eq!(map_baseline_to_current(&i, 5), 8);
341        // Well past the hunk: shifted by +3.
342        assert_eq!(map_baseline_to_current(&i, 100), 103);
343    }
344
345    #[test]
346    fn add_hunk_current_to_baseline_collapses_inserted_rows() {
347        let i = idx(vec![hunk(
348            HunkKind::Add,
349            LineRange::new(5, 5),
350            LineRange::new(5, 8),
351        )]);
352        // Current rows before the inserted block: identity.
353        assert_eq!(map_current_to_baseline(&i, 0), 0);
354        assert_eq!(map_current_to_baseline(&i, 4), 4);
355        // Current rows INSIDE the inserted block: collapse
356        // to the baseline insertion point (the lines don't
357        // exist on baseline at all).
358        assert_eq!(map_current_to_baseline(&i, 5), 5);
359        assert_eq!(map_current_to_baseline(&i, 6), 5);
360        assert_eq!(map_current_to_baseline(&i, 7), 5);
361        // After the inserted block: shift back by -3.
362        assert_eq!(map_current_to_baseline(&i, 8), 5);
363        assert_eq!(map_current_to_baseline(&i, 100), 97);
364    }
365
366    // ── Single Remove hunk ─────────────────────────────────
367
368    #[test]
369    fn remove_hunk_shifts_current_rows_past_it() {
370        // Remove 3 lines from baseline at position 5;
371        // current has nothing there.
372        let i = idx(vec![hunk(
373            HunkKind::Remove,
374            LineRange::new(5, 8),
375            LineRange::new(5, 5),
376        )]);
377        // Before the hunk: identity.
378        assert_eq!(map_current_to_baseline(&i, 0), 0);
379        assert_eq!(map_current_to_baseline(&i, 4), 4);
380        // At/past the deletion point: shift by +3
381        // (current → baseline reverses Add semantics).
382        assert_eq!(map_current_to_baseline(&i, 5), 8);
383        assert_eq!(map_current_to_baseline(&i, 100), 103);
384    }
385
386    #[test]
387    fn remove_hunk_baseline_inside_collapses_to_current_start() {
388        let i = idx(vec![hunk(
389            HunkKind::Remove,
390            LineRange::new(5, 8),
391            LineRange::new(5, 5),
392        )]);
393        assert_eq!(map_baseline_to_current(&i, 5), 5);
394        assert_eq!(map_baseline_to_current(&i, 6), 5);
395        assert_eq!(map_baseline_to_current(&i, 7), 5);
396        // After the removed block: shift -3.
397        assert_eq!(map_baseline_to_current(&i, 8), 5);
398        assert_eq!(map_baseline_to_current(&i, 100), 97);
399    }
400
401    // ── Change hunk: proportional inside ───────────────────
402
403    #[test]
404    fn change_hunk_maps_inside_proportionally() {
405        // baseline [10, 13) -> current [10, 15)
406        // 3 lines compressed against 5 lines.
407        let i = idx(vec![hunk(
408            HunkKind::Change,
409            LineRange::new(10, 13),
410            LineRange::new(10, 15),
411        )]);
412        // baseline → current: 3 lines into 5.
413        assert_eq!(map_baseline_to_current(&i, 10), 10); // 0/3 → 0
414        assert_eq!(map_baseline_to_current(&i, 11), 11); // 1*5/3=1
415        assert_eq!(map_baseline_to_current(&i, 12), 13); // 2*5/3=3
416        // After the hunk: shift = 5 - 3 = +2.
417        assert_eq!(map_baseline_to_current(&i, 20), 22);
418    }
419
420    #[test]
421    fn change_hunk_compresses_when_target_is_shorter() {
422        // baseline [10, 15) -> current [10, 12)
423        let i = idx(vec![hunk(
424            HunkKind::Change,
425            LineRange::new(10, 15),
426            LineRange::new(10, 12),
427        )]);
428        // 5 lines into 2: most map to start.
429        assert_eq!(map_baseline_to_current(&i, 10), 10); // 0*2/5=0
430        assert_eq!(map_baseline_to_current(&i, 11), 10); // 1*2/5=0
431        assert_eq!(map_baseline_to_current(&i, 12), 10); // 2*2/5=0
432        assert_eq!(map_baseline_to_current(&i, 13), 11); // 3*2/5=1
433        assert_eq!(map_baseline_to_current(&i, 14), 11); // 4*2/5=1
434        // After: shift = 2 - 5 = -3.
435        assert_eq!(map_baseline_to_current(&i, 20), 17);
436    }
437
438    // ── Multiple hunks: cumulative shift ───────────────────
439
440    #[test]
441    fn cumulative_shift_across_multiple_hunks() {
442        // Two Adds: +3 then +2.
443        let i = idx(vec![
444            hunk(HunkKind::Add, LineRange::new(5, 5), LineRange::new(5, 8)),
445            hunk(
446                HunkKind::Add,
447                LineRange::new(20, 20),
448                LineRange::new(23, 25), // 23 = 20 + 3 (already shifted)
449            ),
450        ]);
451        // baseline 0 → current 0 (before first hunk)
452        assert_eq!(map_baseline_to_current(&i, 0), 0);
453        // baseline 10 → current 13 (shifted by first hunk's +3)
454        assert_eq!(map_baseline_to_current(&i, 10), 13);
455        // baseline 30 → current 35 (shifted by +3 + +2)
456        assert_eq!(map_baseline_to_current(&i, 30), 35);
457    }
458
459    #[test]
460    fn mixed_add_and_remove_cumulative_shifts_cancel() {
461        // Add 3, then Remove 3 — cumulative shift returns to 0.
462        let i = idx(vec![
463            hunk(HunkKind::Add, LineRange::new(5, 5), LineRange::new(5, 8)),
464            hunk(
465                HunkKind::Remove,
466                LineRange::new(15, 18),
467                LineRange::new(18, 18), // baseline 15 == current 18 after +3 shift
468            ),
469        ]);
470        // Before either hunk.
471        assert_eq!(map_baseline_to_current(&i, 0), 0);
472        // Between hunks: shift +3.
473        assert_eq!(map_baseline_to_current(&i, 10), 13);
474        // After both: net shift 0.
475        assert_eq!(map_baseline_to_current(&i, 100), 100);
476    }
477
478    // ── Defensive ──────────────────────────────────────────
479
480    #[test]
481    fn malformed_hunk_with_fewer_than_two_ranges_is_skipped() {
482        let i = idx(vec![Hunk {
483            kind: HunkKind::Add,
484            ranges: smallvec![LineRange::new(0, 0)], // only one range
485            refine: Default::default(),
486        }]);
487        assert_eq!(map_baseline_to_current(&i, 42), 42);
488        assert_eq!(map_current_to_baseline(&i, 42), 42);
489    }
490
491    #[test]
492    fn unfamiliar_member_pair_falls_back_to_identity() {
493        use lattice_core::ui::pane::RowMapper;
494        // We don't have a real DiffSession in the test;
495        // but the (from, to) != (baseline, current) branch
496        // never touches the session — it returns `row`
497        // directly. Construct minimally and exercise that.
498        let session = Arc::new(DiffSession::new(
499            lattice_core::BufferId(1),
500            DiffAlgorithm::Histogram,
501        ));
502        let mapper = HunkRowMapper::new(session, 0, 1);
503        // (from, to) = (2, 3) — neither baseline nor current
504        // direction. Must be identity, regardless of the
505        // (empty) HunkIndex on the session.
506        assert_eq!(mapper.map_row(2, 3, 42), 42);
507        assert_eq!(mapper.map_row(0, 2, 42), 42);
508        assert_eq!(mapper.map_row(2, 1, 42), 42);
509    }
510
511    #[test]
512    fn round_trip_through_session_uses_published_hunks() {
513        use lattice_core::ui::pane::RowMapper;
514        let session = Arc::new(DiffSession::new(
515            lattice_core::BufferId(1),
516            DiffAlgorithm::Histogram,
517        ));
518        // Publish an Add hunk; mapper should consult it.
519        session.publish(Arc::new(idx(vec![hunk(
520            HunkKind::Add,
521            LineRange::new(5, 5),
522            LineRange::new(5, 8),
523        )])));
524        let mapper = HunkRowMapper::new(session.clone(), 0, 1);
525        assert_eq!(
526            mapper.map_row(0, 1, 100),
527            103,
528            "baseline-to-current shift +3 from the Add hunk"
529        );
530        assert_eq!(
531            mapper.map_row(1, 0, 100),
532            97,
533            "current-to-baseline shift -3"
534        );
535    }
536
537    // ──────────────────────────────────────────────────────
538    // D.6.b (2026-05-31): three-pane mapping
539    // ──────────────────────────────────────────────────────
540
541    /// Construct a three-way hunk with `[base, local, remote]`
542    /// ranges. Mirrors the engine's `compute_three_way`
543    /// emission shape.
544    fn hunk3(kind: HunkKind, base: LineRange, local: LineRange, remote: LineRange) -> Hunk {
545        Hunk {
546            kind,
547            ranges: smallvec![base, local, remote],
548            refine: Default::default(),
549        }
550    }
551
552    #[test]
553    fn map_between_is_pane_index_parametric_for_two_way() {
554        // Same Add hunk; baseline=pane 0, current=pane 1.
555        // Verifies the new generic shape produces identical
556        // results to the D.4.b-shape aliases.
557        let i = idx(vec![hunk(
558            HunkKind::Add,
559            LineRange::new(5, 5),
560            LineRange::new(5, 8),
561        )]);
562        for row in [0, 4, 5, 6, 7, 8, 100] {
563            assert_eq!(map_between(&i, 0, 1, row), map_baseline_to_current(&i, row));
564            assert_eq!(map_between(&i, 1, 0, row), map_current_to_baseline(&i, row));
565            // Identity for same-pane.
566            assert_eq!(map_between(&i, 0, 0, row), row);
567            assert_eq!(map_between(&i, 1, 1, row), row);
568        }
569    }
570
571    #[test]
572    fn three_way_change_hunk_maps_all_six_directions() {
573        // base [10, 13) = 3 lines; local [10, 14) = 4 lines;
574        // remote [10, 16) = 6 lines. All three differ from
575        // one another past the hunk: every pair has its own
576        // cumulative shift.
577        let i = HunkIndex {
578            hunks: vec![hunk3(
579                HunkKind::Change,
580                LineRange::new(10, 13),
581                LineRange::new(10, 14),
582                LineRange::new(10, 16),
583            )],
584            algorithm: DiffAlgorithm::Histogram,
585            revision: 1,
586        };
587
588        // Before the hunk: identity in all directions.
589        for (from, to) in [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)] {
590            assert_eq!(map_between(&i, from, to, 5), 5);
591        }
592
593        // After the hunk: shift = to_len - from_len.
594        // base→local: +1; base→remote: +3; local→base: -1;
595        // local→remote: +2; remote→base: -3; remote→local: -2.
596        assert_eq!(map_between(&i, 0, 1, 30), 31);
597        assert_eq!(map_between(&i, 0, 2, 30), 33);
598        assert_eq!(map_between(&i, 1, 0, 30), 29);
599        assert_eq!(map_between(&i, 1, 2, 30), 32);
600        assert_eq!(map_between(&i, 2, 0, 30), 27);
601        assert_eq!(map_between(&i, 2, 1, 30), 28);
602    }
603
604    #[test]
605    fn three_way_conflict_hunk_contributes_to_shift_like_change() {
606        // Conflict hunks have the same row-geometry semantics
607        // as Change hunks — the conflict is about content,
608        // not layout. Both should drive cumulative shifts.
609        let conflict_idx = HunkIndex {
610            hunks: vec![hunk3(
611                HunkKind::Conflict,
612                LineRange::new(10, 13),
613                LineRange::new(10, 14),
614                LineRange::new(10, 16),
615            )],
616            algorithm: DiffAlgorithm::Histogram,
617            revision: 1,
618        };
619        let change_idx = HunkIndex {
620            hunks: vec![hunk3(
621                HunkKind::Change,
622                LineRange::new(10, 13),
623                LineRange::new(10, 14),
624                LineRange::new(10, 16),
625            )],
626            algorithm: DiffAlgorithm::Histogram,
627            revision: 1,
628        };
629        for (from, to) in [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)] {
630            assert_eq!(
631                map_between(&conflict_idx, from, to, 30),
632                map_between(&change_idx, from, to, 30),
633                "Conflict and Change should map identically for row geometry ({from}→{to})"
634            );
635        }
636    }
637
638    #[test]
639    fn three_way_add_on_one_side_collapses_inside_to_anchor() {
640        // local adds 3 lines at row 10; base + remote both
641        // empty there.
642        let i = HunkIndex {
643            hunks: vec![hunk3(
644                HunkKind::Add,
645                LineRange::new(10, 10),
646                LineRange::new(10, 13),
647                LineRange::new(10, 10),
648            )],
649            algorithm: DiffAlgorithm::Histogram,
650            revision: 1,
651        };
652        // local→base for rows INSIDE the added block: collapse
653        // to base.start (the insertion point, since base is
654        // empty here).
655        assert_eq!(map_between(&i, 1, 0, 10), 10);
656        assert_eq!(map_between(&i, 1, 0, 11), 10);
657        assert_eq!(map_between(&i, 1, 0, 12), 10);
658        // local→remote: collapses to remote.start = 10.
659        assert_eq!(map_between(&i, 1, 2, 10), 10);
660        assert_eq!(map_between(&i, 1, 2, 12), 10);
661        // base→remote when neither has lines for this hunk:
662        // identity (both empty; nothing to shift inside).
663        assert_eq!(map_between(&i, 0, 2, 10), 10);
664    }
665
666    #[test]
667    fn three_pane_mapper_dispatches_all_six_member_pair_directions() {
668        use lattice_core::ui::pane::RowMapper;
669        let session = Arc::new(DiffSession::new(
670            lattice_core::BufferId(1),
671            DiffAlgorithm::Histogram,
672        ));
673        session.publish(Arc::new(HunkIndex {
674            hunks: vec![hunk3(
675                HunkKind::Change,
676                LineRange::new(10, 13),
677                LineRange::new(10, 14),
678                LineRange::new(10, 16),
679            )],
680            algorithm: DiffAlgorithm::Histogram,
681            revision: 1,
682        }));
683        // Non-zero base/local/remote member indices — make
684        // sure the lookup matches by member-idx identity,
685        // not by accidentally hard-coded 0/1/2.
686        let mapper = HunkRowMapper::three_pane(session, 7, 4, 9);
687        // Member 7 = base (pane 0); 4 = local (pane 1); 9 = remote (pane 2).
688        assert_eq!(mapper.map_row(7, 4, 30), 31, "base→local +1");
689        assert_eq!(mapper.map_row(7, 9, 30), 33, "base→remote +3");
690        assert_eq!(mapper.map_row(4, 7, 30), 29, "local→base -1");
691        assert_eq!(mapper.map_row(4, 9, 30), 32, "local→remote +2");
692        assert_eq!(mapper.map_row(9, 7, 30), 27, "remote→base -3");
693        assert_eq!(mapper.map_row(9, 4, 30), 28, "remote→local -2");
694        // Same-member identity.
695        assert_eq!(mapper.map_row(4, 4, 42), 42);
696        // Member not in the shape ⇒ identity.
697        assert_eq!(mapper.map_row(100, 4, 42), 42);
698        assert_eq!(mapper.map_row(4, 100, 42), 42);
699    }
700
701    #[test]
702    fn three_pane_mapper_with_two_way_hunks_is_still_safe() {
703        // Two-way hunks (ranges.len() == 2) routed through a
704        // three-pane mapper: any direction involving pane 2
705        // (remote) skips the hunk (missing range) and returns
706        // identity (no shifts accumulate).
707        use lattice_core::ui::pane::RowMapper;
708        let session = Arc::new(DiffSession::new(
709            lattice_core::BufferId(1),
710            DiffAlgorithm::Histogram,
711        ));
712        // Publish a two-way Add hunk.
713        session.publish(Arc::new(idx(vec![hunk(
714            HunkKind::Add,
715            LineRange::new(5, 5),
716            LineRange::new(5, 8),
717        )])));
718        let mapper = HunkRowMapper::three_pane(session, 0, 1, 2);
719        // base↔local mapping still works (the existing 2-way
720        // pair).
721        assert_eq!(mapper.map_row(0, 1, 100), 103);
722        assert_eq!(mapper.map_row(1, 0, 100), 97);
723        // Any direction involving the absent remote slot:
724        // hunks are skipped (no ranges[2]) → identity.
725        assert_eq!(mapper.map_row(0, 2, 100), 100);
726        assert_eq!(mapper.map_row(2, 0, 100), 100);
727        assert_eq!(mapper.map_row(1, 2, 100), 100);
728        assert_eq!(mapper.map_row(2, 1, 100), 100);
729    }
730}