feat(core): add Frame.CopyFrom and RowsEqual helpers
CopyFrom copies a frame's compact page (cells, row descriptors, style and payload tables, dictionaries) into the destination's existing storage, so repeated copies allocate nothing once sized. IDs and row rotation are copied verbatim, like Clone, so page-local style/payload IDs stay consistent. RowsEqual compares one logical row of two frames directly on stored cells, resolving page-local style/payload IDs semantically only when needed, so frames with different ID tables or row rotation still compare correctly.
- commit
- d71ada98394f9c2064e79bf992f47c282f7948f3
- author
- brice · · 2026-10-02 09:27 UTC
- parent
- dca8245
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +311 -0
A core/frame_copy.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "maps" | |
| 4 | + | ||
| 5 | + | // CopyFrom makes f an independent structural copy of src, like Replace, but | |
| 6 | + | // reuses f's existing page storage (cells, row descriptors, style and payload | |
| 7 | + | // tables and dictionaries) when f already owns a page. It therefore performs no | |
| 8 | + | // allocation once f's page has grown to src's size. | |
| 9 | + | // | |
| 10 | + | // Page-local style and payload IDs and physical row rotation are copied | |
| 11 | + | // verbatim, exactly as Clone does. Every Frame value sharing f's page observes | |
| 12 | + | // the overwrite. An invalid or empty source leaves f unchanged. Copying a frame | |
| 13 | + | // onto itself only normalizes the dimensions. | |
| 14 | + | func (f *Frame) CopyFrom(src Frame) { | |
| 15 | + | if f == nil { | |
| 16 | + | return | |
| 17 | + | } | |
| 18 | + | if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | if f.page == nil { | |
| 22 | + | *f = src.Clone() | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | f.Width, f.Height = src.Width, src.Height | |
| 26 | + | dst, from := f.page, src.page | |
| 27 | + | if dst == from { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | dst.cells = append(dst.cells[:0], from.cells...) | |
| 31 | + | dst.rows = append(dst.rows[:0], from.rows...) | |
| 32 | + | dst.styles = append(dst.styles[:0], from.styles...) | |
| 33 | + | dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...) | |
| 34 | + | dst.styleCount = from.styleCount | |
| 35 | + | if dst.styleIndex == nil { | |
| 36 | + | dst.styleIndex = make(map[Style]uint32, len(from.styleIndex)) | |
| 37 | + | } else { | |
| 38 | + | clear(dst.styleIndex) | |
| 39 | + | } | |
| 40 | + | maps.Copy(dst.styleIndex, from.styleIndex) | |
| 41 | + | dst.payloads = append(dst.payloads[:0], from.payloads...) | |
| 42 | + | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | |
| 43 | + | dst.payloadBytes = from.payloadBytes | |
| 44 | + | if len(from.payloadIndex) == 0 { | |
| 45 | + | clear(dst.payloadIndex) | |
| 46 | + | } else { | |
| 47 | + | if dst.payloadIndex == nil { | |
| 48 | + | dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex)) | |
| 49 | + | } else { | |
| 50 | + | clear(dst.payloadIndex) | |
| 51 | + | } | |
| 52 | + | maps.Copy(dst.payloadIndex, from.payloadIndex) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // RowsEqual reports whether logical row y holds semantically equal cells in a | |
| 57 | + | // and b (the same comparison as Cell.Equal for every column). It compares | |
| 58 | + | // compact stored cells directly and resolves page-local style and payload IDs | |
| 59 | + | // only when they are not both the default, so it does not allocate and does not | |
| 60 | + | // require the two frames to share ID tables. Frames of different width, an | |
| 61 | + | // out-of-range row, or inconsistent storage compare unequal. | |
| 62 | + | func RowsEqual(a, b Frame, y int) bool { | |
| 63 | + | if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil || | |
| 64 | + | y < 0 || y >= a.Height || y >= b.Height || | |
| 65 | + | y >= len(a.page.rows) || y >= len(b.page.rows) { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | width := a.Width | |
| 69 | + | offA, offB := int(a.page.rows[y]), int(b.page.rows[y]) | |
| 70 | + | if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | rowA := a.page.cells[offA : offA+width] | |
| 74 | + | rowB := b.page.cells[offB : offB+width] | |
| 75 | + | ||
| 76 | + | // Remember the last style ID pair that compared equal so runs of one style | |
| 77 | + | // cost a single table lookup. | |
| 78 | + | var lastStyleA, lastStyleB uint32 | |
| 79 | + | for x := range rowA { | |
| 80 | + | ca, cb := rowA[x], rowB[x] | |
| 81 | + | if ca.rune != cb.rune || ca.flags != cb.flags { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | if ca.styleID != 0 || cb.styleID != 0 { | |
| 85 | + | if ca.styleID != lastStyleA || cb.styleID != lastStyleB { | |
| 86 | + | if !a.storedStylesEqual(b, ca.styleID, cb.styleID) { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | lastStyleA, lastStyleB = ca.styleID, cb.styleID | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if ca.payloadID != 0 || cb.payloadID != 0 { | |
| 93 | + | if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool { | |
| 102 | + | if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) { | |
| 103 | + | return false | |
| 104 | + | } | |
| 105 | + | sa, sb := f.page.styles[id].style, other.page.styles[otherID].style | |
| 106 | + | return sa == sb || sa.Equal(sb) | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "maps" | |||
| 4 | + | ||||
| 5 | + | // CopyFrom makes f an independent structural copy of src, like Replace, but | |||
| 6 | + | // reuses f's existing page storage (cells, row descriptors, style and payload | |||
| 7 | + | // tables and dictionaries) when f already owns a page. It therefore performs no | |||
| 8 | + | // allocation once f's page has grown to src's size. | |||
| 9 | + | // | |||
| 10 | + | // Page-local style and payload IDs and physical row rotation are copied | |||
| 11 | + | // verbatim, exactly as Clone does. Every Frame value sharing f's page observes | |||
| 12 | + | // the overwrite. An invalid or empty source leaves f unchanged. Copying a frame | |||
| 13 | + | // onto itself only normalizes the dimensions. | |||
| 14 | + | func (f *Frame) CopyFrom(src Frame) { | |||
| 15 | + | if f == nil { | |||
| 16 | + | return | |||
| 17 | + | } | |||
| 18 | + | if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | if f.page == nil { | |||
| 22 | + | *f = src.Clone() | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | f.Width, f.Height = src.Width, src.Height | |||
| 26 | + | dst, from := f.page, src.page | |||
| 27 | + | if dst == from { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | dst.cells = append(dst.cells[:0], from.cells...) | |||
| 31 | + | dst.rows = append(dst.rows[:0], from.rows...) | |||
| 32 | + | dst.styles = append(dst.styles[:0], from.styles...) | |||
| 33 | + | dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...) | |||
| 34 | + | dst.styleCount = from.styleCount | |||
| 35 | + | if dst.styleIndex == nil { | |||
| 36 | + | dst.styleIndex = make(map[Style]uint32, len(from.styleIndex)) | |||
| 37 | + | } else { | |||
| 38 | + | clear(dst.styleIndex) | |||
| 39 | + | } | |||
| 40 | + | maps.Copy(dst.styleIndex, from.styleIndex) | |||
| 41 | + | dst.payloads = append(dst.payloads[:0], from.payloads...) | |||
| 42 | + | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | |||
| 43 | + | dst.payloadBytes = from.payloadBytes | |||
| 44 | + | if len(from.payloadIndex) == 0 { | |||
| 45 | + | clear(dst.payloadIndex) | |||
| 46 | + | } else { | |||
| 47 | + | if dst.payloadIndex == nil { | |||
| 48 | + | dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex)) | |||
| 49 | + | } else { | |||
| 50 | + | clear(dst.payloadIndex) | |||
| 51 | + | } | |||
| 52 | + | maps.Copy(dst.payloadIndex, from.payloadIndex) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // RowsEqual reports whether logical row y holds semantically equal cells in a | |||
| 57 | + | // and b (the same comparison as Cell.Equal for every column). It compares | |||
| 58 | + | // compact stored cells directly and resolves page-local style and payload IDs | |||
| 59 | + | // only when they are not both the default, so it does not allocate and does not | |||
| 60 | + | // require the two frames to share ID tables. Frames of different width, an | |||
| 61 | + | // out-of-range row, or inconsistent storage compare unequal. | |||
| 62 | + | func RowsEqual(a, b Frame, y int) bool { | |||
| 63 | + | if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil || | |||
| 64 | + | y < 0 || y >= a.Height || y >= b.Height || | |||
| 65 | + | y >= len(a.page.rows) || y >= len(b.page.rows) { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | width := a.Width | |||
| 69 | + | offA, offB := int(a.page.rows[y]), int(b.page.rows[y]) | |||
| 70 | + | if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | rowA := a.page.cells[offA : offA+width] | |||
| 74 | + | rowB := b.page.cells[offB : offB+width] | |||
| 75 | + | ||||
| 76 | + | // Remember the last style ID pair that compared equal so runs of one style | |||
| 77 | + | // cost a single table lookup. | |||
| 78 | + | var lastStyleA, lastStyleB uint32 | |||
| 79 | + | for x := range rowA { | |||
| 80 | + | ca, cb := rowA[x], rowB[x] | |||
| 81 | + | if ca.rune != cb.rune || ca.flags != cb.flags { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | if ca.styleID != 0 || cb.styleID != 0 { | |||
| 85 | + | if ca.styleID != lastStyleA || cb.styleID != lastStyleB { | |||
| 86 | + | if !a.storedStylesEqual(b, ca.styleID, cb.styleID) { | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | lastStyleA, lastStyleB = ca.styleID, cb.styleID | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | if ca.payloadID != 0 || cb.payloadID != 0 { | |||
| 93 | + | if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) { | |||
| 94 | + | return false | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | return true | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool { | |||
| 102 | + | if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) { | |||
| 103 | + | return false | |||
| 104 | + | } | |||
| 105 | + | sa, sb := f.page.styles[id].style, other.page.styles[otherID].style | |||
| 106 | + | return sa == sb || sa.Equal(sb) | |||
| 107 | + | } | |||
A core/frame_copy_test.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func copyTestPayload(t *testing.T, grapheme, link string) CellPayload { | |
| 9 | + | t.Helper() | |
| 10 | + | p, err := NewCellPayload(grapheme, link) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | return p | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func copyTestFrame(t *testing.T, width, height int, seed rune) Frame { | |
| 18 | + | t.Helper() | |
| 19 | + | f := NewFrame(width, height) | |
| 20 | + | styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}} | |
| 21 | + | for y := range height { | |
| 22 | + | for x := range width { | |
| 23 | + | c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]} | |
| 24 | + | if (x+y)%5 == 0 { | |
| 25 | + | c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3))) | |
| 26 | + | } | |
| 27 | + | f.Set(x, y, c) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | if height > 2 { | |
| 31 | + | f.ScrollUp(0, height-1, 1) | |
| 32 | + | } | |
| 33 | + | return f | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func requireFramesEqual(t *testing.T, got, want Frame) { | |
| 37 | + | t.Helper() | |
| 38 | + | if got.Width != want.Width || got.Height != want.Height { | |
| 39 | + | t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height) | |
| 40 | + | } | |
| 41 | + | for y := range want.Height { | |
| 42 | + | if !reflect.DeepEqual(got.Row(y), want.Row(y)) { | |
| 43 | + | t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y)) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if err := got.CheckInvariants(); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) { | |
| 52 | + | src := copyTestFrame(t, 9, 5, 'a') | |
| 53 | + | var dst Frame | |
| 54 | + | dst.CopyFrom(src) | |
| 55 | + | requireFramesEqual(t, dst, src) | |
| 56 | + | if dst.page == src.page { | |
| 57 | + | t.Fatal("copy aliases source page") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Mutating the source must not affect the copy. | |
| 61 | + | before := dst.Clone() | |
| 62 | + | src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}}) | |
| 63 | + | requireFramesEqual(t, dst, before) | |
| 64 | + | ||
| 65 | + | // Copy over a different, larger-state destination: the page pointer and | |
| 66 | + | // backing arrays are reused and stale styles/payloads do not leak. | |
| 67 | + | other := copyTestFrame(t, 9, 5, 'k') | |
| 68 | + | other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}) | |
| 69 | + | page := dst.page | |
| 70 | + | cells := &dst.page.cells[0] | |
| 71 | + | dst.CopyFrom(other) | |
| 72 | + | if dst.page != page || &dst.page.cells[0] != cells { | |
| 73 | + | t.Fatal("CopyFrom did not reuse destination storage") | |
| 74 | + | } | |
| 75 | + | requireFramesEqual(t, dst, other) | |
| 76 | + | if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() { | |
| 77 | + | t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount()) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // The copy stays a fully functional, independent frame. | |
| 81 | + | dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()}) | |
| 82 | + | dst.ScrollDown(0, 4, 2) | |
| 83 | + | if err := dst.CheckInvariants(); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f } | |
| 90 | + | ||
| 91 | + | func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) { | |
| 92 | + | dst := copyTestFrame(t, 4, 3, 'a') | |
| 93 | + | small := copyTestFrame(t, 2, 2, 'x') | |
| 94 | + | dst.CopyFrom(small) | |
| 95 | + | requireFramesEqual(t, dst, small) | |
| 96 | + | large := copyTestFrame(t, 12, 7, 'm') | |
| 97 | + | dst.CopyFrom(large) | |
| 98 | + | requireFramesEqual(t, dst, large) | |
| 99 | + | ||
| 100 | + | before := dst.Clone() | |
| 101 | + | dst.CopyFrom(Frame{}) | |
| 102 | + | dst.CopyFrom(Frame{Width: 3, Height: 3}) | |
| 103 | + | requireFramesEqual(t, dst, before) | |
| 104 | + | dst.CopyFrom(dst) | |
| 105 | + | requireFramesEqual(t, dst, before) | |
| 106 | + | var nilFrame *Frame | |
| 107 | + | nilFrame.CopyFrom(large) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) { | |
| 111 | + | src := copyTestFrame(t, 40, 10, 'a') | |
| 112 | + | dst := src.Clone() | |
| 113 | + | dst.CopyFrom(src) | |
| 114 | + | if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 { | |
| 115 | + | t.Fatalf("CopyFrom allocated %v times", n) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestRowsEqual(t *testing.T) { | |
| 120 | + | a := copyTestFrame(t, 10, 4, 'a') | |
| 121 | + | b := a.Clone() | |
| 122 | + | for y := range a.Height { | |
| 123 | + | if !RowsEqual(a, b, y) { | |
| 124 | + | t.Fatalf("clone row %d differs", y) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Equal content with different physical rotation and different ID tables. | |
| 129 | + | c := NewFrame(a.Width, a.Height) | |
| 130 | + | // Intern an unrelated style first so IDs differ from a's. | |
| 131 | + | c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}}) | |
| 132 | + | for y := range a.Height { | |
| 133 | + | for x := range a.Width { | |
| 134 | + | c.Set(x, y, a.Cell(x, y)) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | for y := range a.Height { | |
| 138 | + | if !RowsEqual(a, c, y) { | |
| 139 | + | t.Fatalf("semantically equal row %d reported different", y) | |
| 140 | + | } | |
| 141 | + | if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want { | |
| 142 | + | t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Each kind of difference is detected on the right row only. | |
| 147 | + | diffs := []Cell{ | |
| 148 | + | {Rune: 'Q', Style: a.Cell(3, 1).Style}, | |
| 149 | + | {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}}, | |
| 150 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")}, | |
| 151 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true}, | |
| 152 | + | } | |
| 153 | + | for i, d := range diffs { | |
| 154 | + | m := a.Clone() | |
| 155 | + | m.Set(3, 1, d) | |
| 156 | + | for y := range a.Height { | |
| 157 | + | want := y != 1 | |
| 158 | + | if got := RowsEqual(a, m, y); got != want { | |
| 159 | + | t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Payload present on one side only, and differing payloads. | |
| 165 | + | m := a.Clone() | |
| 166 | + | px := -1 | |
| 167 | + | for x := range a.Width { | |
| 168 | + | if a.Cell(x, 0).Payload != (CellPayload{}) { | |
| 169 | + | px = x | |
| 170 | + | break | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | if px < 0 { | |
| 174 | + | t.Fatal("test frame row 0 has no payload") | |
| 175 | + | } | |
| 176 | + | m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style}) | |
| 177 | + | if RowsEqual(a, m, 0) { | |
| 178 | + | t.Fatal("payload removal not detected") | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Inactive RGB fields and other equivalent spellings compare equal. | |
| 182 | + | e1, e2 := NewFrame(2, 1), NewFrame(2, 1) | |
| 183 | + | s1 := Style{Bold: true, Foreground: 2, Background: -1} | |
| 184 | + | s2 := s1 | |
| 185 | + | s2.ForegroundRGB = RGB{R: 9} | |
| 186 | + | e1.Set(0, 0, Cell{Rune: 'a', Style: s1}) | |
| 187 | + | e2.Set(0, 0, Cell{Rune: 'a', Style: s2}) | |
| 188 | + | if !RowsEqual(e1, e2, 0) { | |
| 189 | + | t.Fatal("equivalent styles reported different") | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Mismatched shape or range is unequal and never panics. | |
| 193 | + | if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) { | |
| 194 | + | t.Fatal("invalid comparison reported equal") | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestRowsEqualDoesNotAllocate(t *testing.T) { | |
| 199 | + | a := copyTestFrame(t, 80, 4, 'a') | |
| 200 | + | b := a.Clone() | |
| 201 | + | if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 { | |
| 202 | + | t.Fatalf("RowsEqual allocated %v times", n) | |
| 203 | + | } | |
| 204 | + | } | |
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func copyTestPayload(t *testing.T, grapheme, link string) CellPayload { | |||
| 9 | + | t.Helper() | |||
| 10 | + | p, err := NewCellPayload(grapheme, link) | |||
| 11 | + | if err != nil { | |||
| 12 | + | t.Fatal(err) | |||
| 13 | + | } | |||
| 14 | + | return p | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func copyTestFrame(t *testing.T, width, height int, seed rune) Frame { | |||
| 18 | + | t.Helper() | |||
| 19 | + | f := NewFrame(width, height) | |||
| 20 | + | styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}} | |||
| 21 | + | for y := range height { | |||
| 22 | + | for x := range width { | |||
| 23 | + | c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]} | |||
| 24 | + | if (x+y)%5 == 0 { | |||
| 25 | + | c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3))) | |||
| 26 | + | } | |||
| 27 | + | f.Set(x, y, c) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | if height > 2 { | |||
| 31 | + | f.ScrollUp(0, height-1, 1) | |||
| 32 | + | } | |||
| 33 | + | return f | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func requireFramesEqual(t *testing.T, got, want Frame) { | |||
| 37 | + | t.Helper() | |||
| 38 | + | if got.Width != want.Width || got.Height != want.Height { | |||
| 39 | + | t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height) | |||
| 40 | + | } | |||
| 41 | + | for y := range want.Height { | |||
| 42 | + | if !reflect.DeepEqual(got.Row(y), want.Row(y)) { | |||
| 43 | + | t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y)) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if err := got.CheckInvariants(); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) { | |||
| 52 | + | src := copyTestFrame(t, 9, 5, 'a') | |||
| 53 | + | var dst Frame | |||
| 54 | + | dst.CopyFrom(src) | |||
| 55 | + | requireFramesEqual(t, dst, src) | |||
| 56 | + | if dst.page == src.page { | |||
| 57 | + | t.Fatal("copy aliases source page") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Mutating the source must not affect the copy. | |||
| 61 | + | before := dst.Clone() | |||
| 62 | + | src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}}) | |||
| 63 | + | requireFramesEqual(t, dst, before) | |||
| 64 | + | ||||
| 65 | + | // Copy over a different, larger-state destination: the page pointer and | |||
| 66 | + | // backing arrays are reused and stale styles/payloads do not leak. | |||
| 67 | + | other := copyTestFrame(t, 9, 5, 'k') | |||
| 68 | + | other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}) | |||
| 69 | + | page := dst.page | |||
| 70 | + | cells := &dst.page.cells[0] | |||
| 71 | + | dst.CopyFrom(other) | |||
| 72 | + | if dst.page != page || &dst.page.cells[0] != cells { | |||
| 73 | + | t.Fatal("CopyFrom did not reuse destination storage") | |||
| 74 | + | } | |||
| 75 | + | requireFramesEqual(t, dst, other) | |||
| 76 | + | if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() { | |||
| 77 | + | t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount()) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // The copy stays a fully functional, independent frame. | |||
| 81 | + | dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()}) | |||
| 82 | + | dst.ScrollDown(0, 4, 2) | |||
| 83 | + | if err := dst.CheckInvariants(); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f } | |||
| 90 | + | ||||
| 91 | + | func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) { | |||
| 92 | + | dst := copyTestFrame(t, 4, 3, 'a') | |||
| 93 | + | small := copyTestFrame(t, 2, 2, 'x') | |||
| 94 | + | dst.CopyFrom(small) | |||
| 95 | + | requireFramesEqual(t, dst, small) | |||
| 96 | + | large := copyTestFrame(t, 12, 7, 'm') | |||
| 97 | + | dst.CopyFrom(large) | |||
| 98 | + | requireFramesEqual(t, dst, large) | |||
| 99 | + | ||||
| 100 | + | before := dst.Clone() | |||
| 101 | + | dst.CopyFrom(Frame{}) | |||
| 102 | + | dst.CopyFrom(Frame{Width: 3, Height: 3}) | |||
| 103 | + | requireFramesEqual(t, dst, before) | |||
| 104 | + | dst.CopyFrom(dst) | |||
| 105 | + | requireFramesEqual(t, dst, before) | |||
| 106 | + | var nilFrame *Frame | |||
| 107 | + | nilFrame.CopyFrom(large) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) { | |||
| 111 | + | src := copyTestFrame(t, 40, 10, 'a') | |||
| 112 | + | dst := src.Clone() | |||
| 113 | + | dst.CopyFrom(src) | |||
| 114 | + | if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 { | |||
| 115 | + | t.Fatalf("CopyFrom allocated %v times", n) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestRowsEqual(t *testing.T) { | |||
| 120 | + | a := copyTestFrame(t, 10, 4, 'a') | |||
| 121 | + | b := a.Clone() | |||
| 122 | + | for y := range a.Height { | |||
| 123 | + | if !RowsEqual(a, b, y) { | |||
| 124 | + | t.Fatalf("clone row %d differs", y) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Equal content with different physical rotation and different ID tables. | |||
| 129 | + | c := NewFrame(a.Width, a.Height) | |||
| 130 | + | // Intern an unrelated style first so IDs differ from a's. | |||
| 131 | + | c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}}) | |||
| 132 | + | for y := range a.Height { | |||
| 133 | + | for x := range a.Width { | |||
| 134 | + | c.Set(x, y, a.Cell(x, y)) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | for y := range a.Height { | |||
| 138 | + | if !RowsEqual(a, c, y) { | |||
| 139 | + | t.Fatalf("semantically equal row %d reported different", y) | |||
| 140 | + | } | |||
| 141 | + | if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want { | |||
| 142 | + | t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Each kind of difference is detected on the right row only. | |||
| 147 | + | diffs := []Cell{ | |||
| 148 | + | {Rune: 'Q', Style: a.Cell(3, 1).Style}, | |||
| 149 | + | {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}}, | |||
| 150 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")}, | |||
| 151 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true}, | |||
| 152 | + | } | |||
| 153 | + | for i, d := range diffs { | |||
| 154 | + | m := a.Clone() | |||
| 155 | + | m.Set(3, 1, d) | |||
| 156 | + | for y := range a.Height { | |||
| 157 | + | want := y != 1 | |||
| 158 | + | if got := RowsEqual(a, m, y); got != want { | |||
| 159 | + | t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Payload present on one side only, and differing payloads. | |||
| 165 | + | m := a.Clone() | |||
| 166 | + | px := -1 | |||
| 167 | + | for x := range a.Width { | |||
| 168 | + | if a.Cell(x, 0).Payload != (CellPayload{}) { | |||
| 169 | + | px = x | |||
| 170 | + | break | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | if px < 0 { | |||
| 174 | + | t.Fatal("test frame row 0 has no payload") | |||
| 175 | + | } | |||
| 176 | + | m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style}) | |||
| 177 | + | if RowsEqual(a, m, 0) { | |||
| 178 | + | t.Fatal("payload removal not detected") | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Inactive RGB fields and other equivalent spellings compare equal. | |||
| 182 | + | e1, e2 := NewFrame(2, 1), NewFrame(2, 1) | |||
| 183 | + | s1 := Style{Bold: true, Foreground: 2, Background: -1} | |||
| 184 | + | s2 := s1 | |||
| 185 | + | s2.ForegroundRGB = RGB{R: 9} | |||
| 186 | + | e1.Set(0, 0, Cell{Rune: 'a', Style: s1}) | |||
| 187 | + | e2.Set(0, 0, Cell{Rune: 'a', Style: s2}) | |||
| 188 | + | if !RowsEqual(e1, e2, 0) { | |||
| 189 | + | t.Fatal("equivalent styles reported different") | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // Mismatched shape or range is unequal and never panics. | |||
| 193 | + | if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) { | |||
| 194 | + | t.Fatal("invalid comparison reported equal") | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestRowsEqualDoesNotAllocate(t *testing.T) { | |||
| 199 | + | a := copyTestFrame(t, 80, 4, 'a') | |||
| 200 | + | b := a.Clone() | |||
| 201 | + | if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 { | |||
| 202 | + | t.Fatalf("RowsEqual allocated %v times", n) | |||
| 203 | + | } | |||
| 204 | + | } | |||