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}