fix(renderer): harden prepared draw commits
- commit
- f91f9d42f6cee783526583e8293e7dde8a194d50
- author
- brice · · 2026-08-02 11:45 UTC
- parent
- 4b7cd9e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +296 -214
M pkg/renderer/delta_plan.go +6 -3
| @@ -23,7 +23,7 @@ type DeltaPlan struct { | |||
| 23 | 23 | Spans []Span | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | // DeltaCandidate holds a plan and borrows its source frame until Commit. | |
| 26 | + | // DeltaCandidate holds a plan and owns a source frame snapshot until Commit. | |
| 27 | 27 | type DeltaCandidate struct { | |
| 28 | 28 | Plan DeltaPlan | |
| 29 | 29 | frame Frame | |
| @@ -35,11 +35,14 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 35 | 35 | return DeltaCandidate{}, err | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | candidate := DeltaCandidate{frame: frame} | |
| 38 | + | candidate := DeltaCandidate{frame: frame.Clone()} | |
| 39 | 39 | if reset || frame.Width != committed.Width || frame.Height != committed.Height { | |
| 40 | 40 | candidate.Plan.Snapshot = true | |
| 41 | 41 | return candidate, nil | |
| 42 | 42 | } | |
| 43 | + | if err := committed.Validate(); err != nil { | |
| 44 | + | return DeltaCandidate{}, err | |
| 45 | + | } | |
| 43 | 46 | ||
| 44 | 47 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 45 | 48 | candidate.Plan = planSingleDamage(frame, damage[0]) | |
| @@ -186,7 +189,7 @@ func styleRunCount(cells []Cell) int { | |||
| 186 | 189 | return runs | |
| 187 | 190 | } | |
| 188 | 191 | ||
| 189 | - | // Commit advances dst to the frame borrowed by the candidate. | |
| 192 | + | // Commit advances dst to the frame snapshot owned by the candidate. | |
| 190 | 193 | func (c DeltaCandidate) Commit(dst *Frame) { | |
| 191 | 194 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | |
| 192 | 195 | replaceFrame(dst, c.frame) | |
| @@ -23,7 +23,7 @@ type DeltaPlan struct { | |||||
| 23 | Spans []Span | 23 | Spans []Span | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | // DeltaCandidate holds a plan and borrows its source frame until Commit. | 26 | + | // DeltaCandidate holds a plan and owns a source frame snapshot until Commit. |
| 27 | type DeltaCandidate struct { | 27 | type DeltaCandidate struct { | ||
| 28 | Plan DeltaPlan | 28 | Plan DeltaPlan | ||
| 29 | frame Frame | 29 | frame Frame | ||
| @@ -35,11 +35,14 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||||
| 35 | return DeltaCandidate{}, err | 35 | return DeltaCandidate{}, err | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | - | candidate := DeltaCandidate{frame: frame} | 38 | + | candidate := DeltaCandidate{frame: frame.Clone()} |
| 39 | if reset || frame.Width != committed.Width || frame.Height != committed.Height { | 39 | if reset || frame.Width != committed.Width || frame.Height != committed.Height { | ||
| 40 | candidate.Plan.Snapshot = true | 40 | candidate.Plan.Snapshot = true | ||
| 41 | return candidate, nil | 41 | return candidate, nil | ||
| 42 | } | 42 | } | ||
| 43 | + | if err := committed.Validate(); err != nil { | |||
| 44 | + | return DeltaCandidate{}, err | |||
| 45 | + | } | |||
| 43 | 46 | ||||
| 44 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 47 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 45 | candidate.Plan = planSingleDamage(frame, damage[0]) | 48 | candidate.Plan = planSingleDamage(frame, damage[0]) | ||
| @@ -186,7 +189,7 @@ func styleRunCount(cells []Cell) int { | |||||
| 186 | return runs | 189 | return runs | ||
| 187 | } | 190 | } | ||
| 188 | 191 | ||||
| 189 | - | // Commit advances dst to the frame borrowed by the candidate. | 192 | + | // Commit advances dst to the frame snapshot owned by the candidate. |
| 190 | func (c DeltaCandidate) Commit(dst *Frame) { | 193 | func (c DeltaCandidate) Commit(dst *Frame) { | ||
| 191 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | 194 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | ||
| 192 | replaceFrame(dst, c.frame) | 195 | replaceFrame(dst, c.frame) | ||
M pkg/renderer/renderer.go +12 -11
| @@ -27,10 +27,10 @@ type Renderer struct { | |||
| 27 | 27 | // PreparedDraw owns encoded output and its transactional delta until Commit. | |
| 28 | 28 | // It is returned by value so ordinary prepared draws do not allocate. | |
| 29 | 29 | type PreparedDraw struct { | |
| 30 | - | renderer *Renderer | |
| 31 | - | candidate DeltaCandidate | |
| 32 | - | data []byte | |
| 33 | - | committed bool | |
| 30 | + | renderer *Renderer | |
| 31 | + | candidate DeltaCandidate | |
| 32 | + | data []byte | |
| 33 | + | commitOnce *sync.Once | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | |
| @@ -49,13 +49,14 @@ func (p PreparedDraw) Bytes() []byte { return p.data } | |||
| 49 | 49 | // Commit applies the prepared delta exactly once. Discarding it leaves the | |
| 50 | 50 | // renderer's committed state unchanged. | |
| 51 | 51 | func (p *PreparedDraw) Commit() { | |
| 52 | - | if p == nil || p.renderer == nil || p.committed { | |
| 52 | + | if p == nil || p.renderer == nil || p.commitOnce == nil { | |
| 53 | 53 | return | |
| 54 | 54 | } | |
| 55 | - | committed := p.renderer.committedFrame() | |
| 56 | - | p.candidate.Commit(&committed) | |
| 57 | - | p.renderer.setCommittedFrame(committed) | |
| 58 | - | p.committed = true | |
| 55 | + | p.commitOnce.Do(func() { | |
| 56 | + | committed := p.renderer.committedFrame() | |
| 57 | + | p.candidate.Commit(&committed) | |
| 58 | + | p.renderer.setCommittedFrame(committed) | |
| 59 | + | }) | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | // Prepare plans and encodes a transactional draw. The renderer advances only | |
| @@ -66,7 +67,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 66 | 67 | var err error | |
| 67 | 68 | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 68 | 69 | if err = frame.Validate(); err == nil { | |
| 69 | - | candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame} | |
| 70 | + | candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame.Clone()} | |
| 70 | 71 | } | |
| 71 | 72 | } else { | |
| 72 | 73 | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | |
| @@ -74,7 +75,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 74 | 75 | if err != nil { | |
| 75 | 76 | return PreparedDraw{}, err | |
| 76 | 77 | } | |
| 77 | - | prepared := PreparedDraw{renderer: r, candidate: candidate} | |
| 78 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | |
| 78 | 79 | plan := candidate.Plan | |
| 79 | 80 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |
| 80 | 81 | return prepared, nil | |
| @@ -27,10 +27,10 @@ type Renderer struct { | |||||
| 27 | // PreparedDraw owns encoded output and its transactional delta until Commit. | 27 | // PreparedDraw owns encoded output and its transactional delta until Commit. | ||
| 28 | // It is returned by value so ordinary prepared draws do not allocate. | 28 | // It is returned by value so ordinary prepared draws do not allocate. | ||
| 29 | type PreparedDraw struct { | 29 | type PreparedDraw struct { | ||
| 30 | - | renderer *Renderer | 30 | + | renderer *Renderer |
| 31 | - | candidate DeltaCandidate | 31 | + | candidate DeltaCandidate |
| 32 | - | data []byte | 32 | + | data []byte |
| 33 | - | committed bool | 33 | + | commitOnce *sync.Once |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | 36 | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | ||
| @@ -49,13 +49,14 @@ func (p PreparedDraw) Bytes() []byte { return p.data } | |||||
| 49 | // Commit applies the prepared delta exactly once. Discarding it leaves the | 49 | // Commit applies the prepared delta exactly once. Discarding it leaves the | ||
| 50 | // renderer's committed state unchanged. | 50 | // renderer's committed state unchanged. | ||
| 51 | func (p *PreparedDraw) Commit() { | 51 | func (p *PreparedDraw) Commit() { | ||
| 52 | - | if p == nil || p.renderer == nil || p.committed { | 52 | + | if p == nil || p.renderer == nil || p.commitOnce == nil { |
| 53 | return | 53 | return | ||
| 54 | } | 54 | } | ||
| 55 | - | committed := p.renderer.committedFrame() | 55 | + | p.commitOnce.Do(func() { |
| 56 | - | p.candidate.Commit(&committed) | 56 | + | committed := p.renderer.committedFrame() |
| 57 | - | p.renderer.setCommittedFrame(committed) | 57 | + | p.candidate.Commit(&committed) |
| 58 | - | p.committed = true | 58 | + | p.renderer.setCommittedFrame(committed) |
| 59 | + | }) | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | // Prepare plans and encodes a transactional draw. The renderer advances only | 62 | // Prepare plans and encodes a transactional draw. The renderer advances only | ||
| @@ -66,7 +67,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||||
| 66 | var err error | 67 | var err error | ||
| 67 | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 68 | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 68 | if err = frame.Validate(); err == nil { | 69 | if err = frame.Validate(); err == nil { | ||
| 69 | - | candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame} | 70 | + | candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame.Clone()} |
| 70 | } | 71 | } | ||
| 71 | } else { | 72 | } else { | ||
| 72 | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | 73 | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | ||
| @@ -74,7 +75,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||||
| 74 | if err != nil { | 75 | if err != nil { | ||
| 75 | return PreparedDraw{}, err | 76 | return PreparedDraw{}, err | ||
| 76 | } | 77 | } | ||
| 77 | - | prepared := PreparedDraw{renderer: r, candidate: candidate} | 78 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} |
| 78 | plan := candidate.Plan | 79 | plan := candidate.Plan | ||
| 79 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | 80 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | ||
| 80 | return prepared, nil | 81 | return prepared, nil | ||
M pkg/renderer/renderer_test.go +278 -200
| @@ -1082,223 +1082,301 @@ func TestRendererPrepare(t *testing.T) { | |||
| 1082 | 1082 | fullFrame := testFrame("ABC", "DEF") | |
| 1083 | 1083 | fullDamage := []Damage{FullRedraw()} | |
| 1084 | 1084 | ||
| 1085 | - | t.Run("zero commit is safe", func(t *testing.T) { | |
| 1086 | - | var prepared PreparedDraw | |
| 1087 | - | prepared.Commit() | |
| 1088 | - | }) | |
| 1089 | - | ||
| 1090 | - | t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) { | |
| 1091 | - | r := New(Capabilities{}) | |
| 1092 | - | first, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1093 | - | if err != nil { | |
| 1094 | - | t.Fatal(err) | |
| 1095 | - | } | |
| 1096 | - | want := append([]byte(nil), first.Bytes()...) | |
| 1085 | + | tests := []struct { | |
| 1086 | + | name string | |
| 1087 | + | run func(*testing.T) | |
| 1088 | + | }{ | |
| 1089 | + | {name: "zero commit is safe", run: func(t *testing.T) { | |
| 1090 | + | var prepared PreparedDraw | |
| 1091 | + | prepared.Commit() | |
| 1092 | + | }}, | |
| 1097 | 1093 | ||
| 1098 | - | second, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1099 | - | if err != nil { | |
| 1100 | - | t.Fatal(err) | |
| 1101 | - | } | |
| 1102 | - | if !bytes.Equal(second.Bytes(), want) { | |
| 1103 | - | t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want) | |
| 1104 | - | } | |
| 1094 | + | {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) { | |
| 1095 | + | r := New(Capabilities{}) | |
| 1096 | + | first, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1097 | + | if err != nil { | |
| 1098 | + | t.Fatal(err) | |
| 1099 | + | } | |
| 1100 | + | want := append([]byte(nil), first.Bytes()...) | |
| 1105 | 1101 | ||
| 1106 | - | other := testFrame("12345678", "abcdefgh", "ABCDEFGH") | |
| 1107 | - | if _, err := r.Prepare(other, fullDamage, false); err != nil { | |
| 1108 | - | t.Fatal(err) | |
| 1109 | - | } | |
| 1110 | - | if !bytes.Equal(first.Bytes(), want) { | |
| 1111 | - | t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want) | |
| 1112 | - | } | |
| 1113 | - | }) | |
| 1102 | + | second, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1103 | + | if err != nil { | |
| 1104 | + | t.Fatal(err) | |
| 1105 | + | } | |
| 1106 | + | if !bytes.Equal(second.Bytes(), want) { | |
| 1107 | + | t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want) | |
| 1108 | + | } | |
| 1114 | 1109 | ||
| 1115 | - | t.Run("commit then no-op", func(t *testing.T) { | |
| 1116 | - | r := New(Capabilities{}) | |
| 1117 | - | prepared, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1118 | - | if err != nil { | |
| 1119 | - | t.Fatal(err) | |
| 1120 | - | } | |
| 1121 | - | prepared.Commit() | |
| 1110 | + | other := testFrame("12345678", "abcdefgh", "ABCDEFGH") | |
| 1111 | + | if _, err := r.Prepare(other, fullDamage, false); err != nil { | |
| 1112 | + | t.Fatal(err) | |
| 1113 | + | } | |
| 1114 | + | if !bytes.Equal(first.Bytes(), want) { | |
| 1115 | + | t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want) | |
| 1116 | + | } | |
| 1117 | + | }}, | |
| 1122 | 1118 | ||
| 1123 | - | next, err := r.Prepare(fullFrame, nil, false) | |
| 1124 | - | if err != nil { | |
| 1125 | - | t.Fatal(err) | |
| 1126 | - | } | |
| 1127 | - | if len(next.Bytes()) != 0 { | |
| 1128 | - | t.Fatalf("unchanged frame emitted %q", next.Bytes()) | |
| 1129 | - | } | |
| 1130 | - | }) | |
| 1119 | + | {name: "commit then no-op", run: func(t *testing.T) { | |
| 1120 | + | r := New(Capabilities{}) | |
| 1121 | + | prepared, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1122 | + | if err != nil { | |
| 1123 | + | t.Fatal(err) | |
| 1124 | + | } | |
| 1125 | + | prepared.Commit() | |
| 1131 | 1126 | ||
| 1132 | - | t.Run("reset preparation is transactional", func(t *testing.T) { | |
| 1133 | - | r := New(Capabilities{}) | |
| 1134 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1135 | - | if err != nil { | |
| 1136 | - | t.Fatal(err) | |
| 1137 | - | } | |
| 1138 | - | initial.Commit() | |
| 1127 | + | next, err := r.Prepare(fullFrame, nil, false) | |
| 1128 | + | if err != nil { | |
| 1129 | + | t.Fatal(err) | |
| 1130 | + | } | |
| 1131 | + | if len(next.Bytes()) != 0 { | |
| 1132 | + | t.Fatalf("unchanged frame emitted %q", next.Bytes()) | |
| 1133 | + | } | |
| 1134 | + | }}, | |
| 1139 | 1135 | ||
| 1140 | - | resetFrame := testFrame("ABC", "XYZ") | |
| 1141 | - | resetPrepared, err := r.Prepare(resetFrame, nil, true) | |
| 1142 | - | if err != nil { | |
| 1143 | - | t.Fatal(err) | |
| 1144 | - | } | |
| 1145 | - | want, err := New(Capabilities{}).Draw(resetFrame, nil) | |
| 1146 | - | if err != nil { | |
| 1147 | - | t.Fatal(err) | |
| 1148 | - | } | |
| 1149 | - | if !bytes.Equal(resetPrepared.Bytes(), want) { | |
| 1150 | - | t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want) | |
| 1151 | - | } | |
| 1136 | + | {name: "reset preparation is transactional", run: func(t *testing.T) { | |
| 1137 | + | r := New(Capabilities{}) | |
| 1138 | + | initial, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1139 | + | if err != nil { | |
| 1140 | + | t.Fatal(err) | |
| 1141 | + | } | |
| 1142 | + | initial.Commit() | |
| 1152 | 1143 | ||
| 1153 | - | unchanged, err := r.Prepare(fullFrame, nil, false) | |
| 1154 | - | if err != nil { | |
| 1155 | - | t.Fatal(err) | |
| 1156 | - | } | |
| 1157 | - | if len(unchanged.Bytes()) != 0 { | |
| 1158 | - | t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes()) | |
| 1159 | - | } | |
| 1144 | + | resetFrame := testFrame("ABC", "XYZ") | |
| 1145 | + | resetPrepared, err := r.Prepare(resetFrame, nil, true) | |
| 1146 | + | if err != nil { | |
| 1147 | + | t.Fatal(err) | |
| 1148 | + | } | |
| 1149 | + | want, err := New(Capabilities{}).Draw(resetFrame, nil) | |
| 1150 | + | if err != nil { | |
| 1151 | + | t.Fatal(err) | |
| 1152 | + | } | |
| 1153 | + | if !bytes.Equal(resetPrepared.Bytes(), want) { | |
| 1154 | + | t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want) | |
| 1155 | + | } | |
| 1160 | 1156 | ||
| 1161 | - | resetPrepared, err = r.Prepare(resetFrame, nil, true) | |
| 1162 | - | if err != nil { | |
| 1163 | - | t.Fatal(err) | |
| 1164 | - | } | |
| 1165 | - | resetPrepared.Commit() | |
| 1166 | - | afterCommit, err := r.Prepare(resetFrame, nil, false) | |
| 1167 | - | if err != nil { | |
| 1168 | - | t.Fatal(err) | |
| 1169 | - | } | |
| 1170 | - | if len(afterCommit.Bytes()) != 0 { | |
| 1171 | - | t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes()) | |
| 1172 | - | } | |
| 1173 | - | }) | |
| 1157 | + | unchanged, err := r.Prepare(fullFrame, nil, false) | |
| 1158 | + | if err != nil { | |
| 1159 | + | t.Fatal(err) | |
| 1160 | + | } | |
| 1161 | + | if len(unchanged.Bytes()) != 0 { | |
| 1162 | + | t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes()) | |
| 1163 | + | } | |
| 1174 | 1164 | ||
| 1175 | - | t.Run("successful no-byte commit", func(t *testing.T) { | |
| 1176 | - | r := New(Capabilities{}) | |
| 1177 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1178 | - | if err != nil { | |
| 1179 | - | t.Fatal(err) | |
| 1180 | - | } | |
| 1181 | - | initial.Commit() | |
| 1165 | + | resetPrepared, err = r.Prepare(resetFrame, nil, true) | |
| 1166 | + | if err != nil { | |
| 1167 | + | t.Fatal(err) | |
| 1168 | + | } | |
| 1169 | + | resetPrepared.Commit() | |
| 1170 | + | afterCommit, err := r.Prepare(resetFrame, nil, false) | |
| 1171 | + | if err != nil { | |
| 1172 | + | t.Fatal(err) | |
| 1173 | + | } | |
| 1174 | + | if len(afterCommit.Bytes()) != 0 { | |
| 1175 | + | t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes()) | |
| 1176 | + | } | |
| 1177 | + | }}, | |
| 1182 | 1178 | ||
| 1183 | - | noOp, err := r.Prepare(fullFrame, nil, false) | |
| 1184 | - | if err != nil { | |
| 1185 | - | t.Fatal(err) | |
| 1186 | - | } | |
| 1187 | - | if len(noOp.Bytes()) != 0 { | |
| 1188 | - | t.Fatalf("no-op emitted %q", noOp.Bytes()) | |
| 1189 | - | } | |
| 1190 | - | noOp.Commit() | |
| 1179 | + | {name: "successful no-byte commit", run: func(t *testing.T) { | |
| 1180 | + | r := New(Capabilities{}) | |
| 1181 | + | initial, err := r.Prepare(fullFrame, fullDamage, false) | |
| 1182 | + | if err != nil { | |
| 1183 | + | t.Fatal(err) | |
| 1184 | + | } | |
| 1185 | + | initial.Commit() | |
| 1191 | 1186 | ||
| 1192 | - | again, err := r.Prepare(fullFrame, nil, false) | |
| 1193 | - | if err != nil { | |
| 1194 | - | t.Fatal(err) | |
| 1195 | - | } | |
| 1196 | - | if len(again.Bytes()) != 0 { | |
| 1197 | - | t.Fatalf("committed no-op changed renderer state: %q", again.Bytes()) | |
| 1198 | - | } | |
| 1199 | - | }) | |
| 1200 | - | ||
| 1201 | - | t.Run("double commit is idempotent", func(t *testing.T) { | |
| 1202 | - | base := testFrame("0000", "1111", "2222", "3333") | |
| 1203 | - | scrolled := testFrame("1111", "2222", "3333", "new!") | |
| 1204 | - | damage := []Damage{ | |
| 1205 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |
| 1206 | - | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |
| 1207 | - | } | |
| 1208 | - | r := New(Capabilities{}) | |
| 1209 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1210 | - | t.Fatal(err) | |
| 1211 | - | } | |
| 1187 | + | noOp, err := r.Prepare(fullFrame, nil, false) | |
| 1188 | + | if err != nil { | |
| 1189 | + | t.Fatal(err) | |
| 1190 | + | } | |
| 1191 | + | if len(noOp.Bytes()) != 0 { | |
| 1192 | + | t.Fatalf("no-op emitted %q", noOp.Bytes()) | |
| 1193 | + | } | |
| 1194 | + | noOp.Commit() | |
| 1212 | 1195 | ||
| 1213 | - | prepared, err := r.Prepare(scrolled, damage, false) | |
| 1214 | - | if err != nil { | |
| 1215 | - | t.Fatal(err) | |
| 1216 | - | } | |
| 1217 | - | prepared.Commit() | |
| 1218 | - | prepared.Commit() | |
| 1196 | + | again, err := r.Prepare(fullFrame, nil, false) | |
| 1197 | + | if err != nil { | |
| 1198 | + | t.Fatal(err) | |
| 1199 | + | } | |
| 1200 | + | if len(again.Bytes()) != 0 { | |
| 1201 | + | t.Fatalf("committed no-op changed renderer state: %q", again.Bytes()) | |
| 1202 | + | } | |
| 1203 | + | }}, | |
| 1219 | 1204 | ||
| 1220 | - | next, err := r.Prepare(scrolled, nil, false) | |
| 1221 | - | if err != nil { | |
| 1222 | - | t.Fatal(err) | |
| 1223 | - | } | |
| 1224 | - | if len(next.Bytes()) != 0 { | |
| 1225 | - | t.Fatalf("double commit advanced state twice: %q", next.Bytes()) | |
| 1226 | - | } | |
| 1227 | - | }) | |
| 1228 | - | ||
| 1229 | - | t.Run("matches concrete ANSI fixtures", func(t *testing.T) { | |
| 1230 | - | tests := []struct { | |
| 1231 | - | name string | |
| 1232 | - | prepare func(t *testing.T) PreparedDraw | |
| 1233 | - | want string | |
| 1234 | - | }{ | |
| 1235 | - | { | |
| 1236 | - | name: "full draw", | |
| 1237 | - | prepare: func(t *testing.T) PreparedDraw { | |
| 1238 | - | prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false) | |
| 1239 | - | if err != nil { | |
| 1240 | - | t.Fatal(err) | |
| 1241 | - | } | |
| 1242 | - | return prepared | |
| 1205 | + | {name: "double commit is idempotent", run: func(t *testing.T) { | |
| 1206 | + | base := testFrame("0000", "1111", "2222", "3333") | |
| 1207 | + | scrolled := testFrame("1111", "2222", "3333", "new!") | |
| 1208 | + | damage := []Damage{ | |
| 1209 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |
| 1210 | + | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |
| 1211 | + | } | |
| 1212 | + | r := New(Capabilities{}) | |
| 1213 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1214 | + | t.Fatal(err) | |
| 1215 | + | } | |
| 1216 | + | ||
| 1217 | + | prepared, err := r.Prepare(scrolled, damage, false) | |
| 1218 | + | if err != nil { | |
| 1219 | + | t.Fatal(err) | |
| 1220 | + | } | |
| 1221 | + | prepared.Commit() | |
| 1222 | + | prepared.Commit() | |
| 1223 | + | ||
| 1224 | + | next, err := r.Prepare(scrolled, nil, false) | |
| 1225 | + | if err != nil { | |
| 1226 | + | t.Fatal(err) | |
| 1227 | + | } | |
| 1228 | + | if len(next.Bytes()) != 0 { | |
| 1229 | + | t.Fatalf("double commit advanced state twice: %q", next.Bytes()) | |
| 1230 | + | } | |
| 1231 | + | }}, | |
| 1232 | + | ||
| 1233 | + | {name: "copied prepared draw commits scroll once", run: func(t *testing.T) { | |
| 1234 | + | base := testFrame("0000", "1111", "2222", "3333") | |
| 1235 | + | scrolled := testFrame("1111", "2222", "3333", "new!") | |
| 1236 | + | damage := []Damage{ | |
| 1237 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |
| 1238 | + | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |
| 1239 | + | } | |
| 1240 | + | r := New(Capabilities{}) | |
| 1241 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1242 | + | t.Fatal(err) | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | prepared, err := r.Prepare(scrolled, damage, false) | |
| 1246 | + | if err != nil { | |
| 1247 | + | t.Fatal(err) | |
| 1248 | + | } | |
| 1249 | + | copied := prepared | |
| 1250 | + | prepared.Commit() | |
| 1251 | + | copied.Commit() | |
| 1252 | + | ||
| 1253 | + | next, err := r.Prepare(scrolled, nil, false) | |
| 1254 | + | if err != nil { | |
| 1255 | + | t.Fatal(err) | |
| 1256 | + | } | |
| 1257 | + | if len(next.Bytes()) != 0 { | |
| 1258 | + | t.Fatalf("copied commit advanced state twice: %q", next.Bytes()) | |
| 1259 | + | } | |
| 1260 | + | }}, | |
| 1261 | + | ||
| 1262 | + | {name: "commit uses immutable frame snapshot", run: func(t *testing.T) { | |
| 1263 | + | r := New(Capabilities{}) | |
| 1264 | + | base := testFrame("ABC", "DEF") | |
| 1265 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1266 | + | t.Fatal(err) | |
| 1267 | + | } | |
| 1268 | + | ||
| 1269 | + | changed := base.Clone() | |
| 1270 | + | changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 1271 | + | want := changed.Clone() | |
| 1272 | + | prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false) | |
| 1273 | + | if err != nil { | |
| 1274 | + | t.Fatal(err) | |
| 1275 | + | } | |
| 1276 | + | if !strings.Contains(string(prepared.Bytes()), "X") { | |
| 1277 | + | t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes()) | |
| 1278 | + | } | |
| 1279 | + | ||
| 1280 | + | changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()}) | |
| 1281 | + | prepared.Commit() | |
| 1282 | + | ||
| 1283 | + | next, err := r.Prepare(want, nil, false) | |
| 1284 | + | if err != nil { | |
| 1285 | + | t.Fatal(err) | |
| 1286 | + | } | |
| 1287 | + | if len(next.Bytes()) != 0 { | |
| 1288 | + | t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes()) | |
| 1289 | + | } | |
| 1290 | + | }}, | |
| 1291 | + | ||
| 1292 | + | {name: "matches concrete ANSI fixtures", run: func(t *testing.T) { | |
| 1293 | + | tests := []struct { | |
| 1294 | + | name string | |
| 1295 | + | prepare func(t *testing.T) PreparedDraw | |
| 1296 | + | want string | |
| 1297 | + | }{ | |
| 1298 | + | { | |
| 1299 | + | name: "full draw", | |
| 1300 | + | prepare: func(t *testing.T) PreparedDraw { | |
| 1301 | + | prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false) | |
| 1302 | + | if err != nil { | |
| 1303 | + | t.Fatal(err) | |
| 1304 | + | } | |
| 1305 | + | return prepared | |
| 1306 | + | }, | |
| 1307 | + | want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m", | |
| 1243 | 1308 | }, | |
| 1244 | - | want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m", | |
| 1245 | - | }, | |
| 1246 | - | { | |
| 1247 | - | name: "fragmented damage", | |
| 1248 | - | prepare: func(t *testing.T) PreparedDraw { | |
| 1249 | - | base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx") | |
| 1250 | - | frame := base.Clone() | |
| 1251 | - | frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 1252 | - | frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()}) | |
| 1253 | - | frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 1254 | - | damage := []Damage{ | |
| 1255 | - | {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1}, | |
| 1256 | - | {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1}, | |
| 1257 | - | {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1}, | |
| 1258 | - | } | |
| 1259 | - | r := New(Capabilities{}) | |
| 1260 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1261 | - | t.Fatal(err) | |
| 1262 | - | } | |
| 1263 | - | prepared, err := r.Prepare(frame, damage, false) | |
| 1264 | - | if err != nil { | |
| 1265 | - | t.Fatal(err) | |
| 1266 | - | } | |
| 1267 | - | return prepared | |
| 1309 | + | { | |
| 1310 | + | name: "fragmented damage", | |
| 1311 | + | prepare: func(t *testing.T) PreparedDraw { | |
| 1312 | + | base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx") | |
| 1313 | + | frame := base.Clone() | |
| 1314 | + | frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 1315 | + | frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()}) | |
| 1316 | + | frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 1317 | + | damage := []Damage{ | |
| 1318 | + | {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1}, | |
| 1319 | + | {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1}, | |
| 1320 | + | {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1}, | |
| 1321 | + | } | |
| 1322 | + | r := New(Capabilities{}) | |
| 1323 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1324 | + | t.Fatal(err) | |
| 1325 | + | } | |
| 1326 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 1327 | + | if err != nil { | |
| 1328 | + | t.Fatal(err) | |
| 1329 | + | } | |
| 1330 | + | return prepared | |
| 1331 | + | }, | |
| 1332 | + | want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m", | |
| 1268 | 1333 | }, | |
| 1269 | - | want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m", | |
| 1270 | - | }, | |
| 1271 | - | { | |
| 1272 | - | name: "scroll", | |
| 1273 | - | prepare: func(t *testing.T) PreparedDraw { | |
| 1274 | - | base := testFrame("00000", "11111", "22222", "33333") | |
| 1275 | - | frame := testFrame("11111", "22222", "33333", "new!!") | |
| 1276 | - | damage := []Damage{ | |
| 1277 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |
| 1278 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |
| 1279 | - | } | |
| 1280 | - | r := New(Capabilities{}) | |
| 1281 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1282 | - | t.Fatal(err) | |
| 1283 | - | } | |
| 1284 | - | prepared, err := r.Prepare(frame, damage, false) | |
| 1285 | - | if err != nil { | |
| 1286 | - | t.Fatal(err) | |
| 1287 | - | } | |
| 1288 | - | return prepared | |
| 1334 | + | { | |
| 1335 | + | name: "scroll", | |
| 1336 | + | prepare: func(t *testing.T) PreparedDraw { | |
| 1337 | + | base := testFrame("00000", "11111", "22222", "33333") | |
| 1338 | + | frame := testFrame("11111", "22222", "33333", "new!!") | |
| 1339 | + | damage := []Damage{ | |
| 1340 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |
| 1341 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |
| 1342 | + | } | |
| 1343 | + | r := New(Capabilities{}) | |
| 1344 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1345 | + | t.Fatal(err) | |
| 1346 | + | } | |
| 1347 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 1348 | + | if err != nil { | |
| 1349 | + | t.Fatal(err) | |
| 1350 | + | } | |
| 1351 | + | return prepared | |
| 1352 | + | }, | |
| 1353 | + | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", | |
| 1289 | 1354 | }, | |
| 1290 | - | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", | |
| 1291 | - | }, | |
| 1292 | - | } | |
| 1355 | + | } | |
| 1293 | 1356 | ||
| 1294 | - | for _, tt := range tests { | |
| 1295 | - | t.Run(tt.name, func(t *testing.T) { | |
| 1296 | - | if got := string(tt.prepare(t).Bytes()); got != tt.want { | |
| 1297 | - | t.Fatalf("prepared ANSI = %q, want %q", got, tt.want) | |
| 1298 | - | } | |
| 1299 | - | }) | |
| 1300 | - | } | |
| 1301 | - | }) | |
| 1357 | + | for _, tt := range tests { | |
| 1358 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1359 | + | if got := string(tt.prepare(t).Bytes()); got != tt.want { | |
| 1360 | + | t.Fatalf("prepared ANSI = %q, want %q", got, tt.want) | |
| 1361 | + | } | |
| 1362 | + | }) | |
| 1363 | + | } | |
| 1364 | + | }}, | |
| 1365 | + | } | |
| 1366 | + | ||
| 1367 | + | for _, tt := range tests { | |
| 1368 | + | t.Run(tt.name, tt.run) | |
| 1369 | + | } | |
| 1370 | + | } | |
| 1371 | + | ||
| 1372 | + | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |
| 1373 | + | frame := NewFrame(2, 1) | |
| 1374 | + | committed := NewFrame(2, 1) | |
| 1375 | + | committed.lineOffset = nil | |
| 1376 | + | ||
| 1377 | + | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |
| 1378 | + | t.Fatal("PlanDelta accepted an invalid committed frame") | |
| 1379 | + | } | |
| 1302 | 1380 | } | |
| 1303 | 1381 | ||
| 1304 | 1382 | func TestWideCharDamageEmission(t *testing.T) { | |
| @@ -1082,223 +1082,301 @@ func TestRendererPrepare(t *testing.T) { | |||||
| 1082 | fullFrame := testFrame("ABC", "DEF") | 1082 | fullFrame := testFrame("ABC", "DEF") | ||
| 1083 | fullDamage := []Damage{FullRedraw()} | 1083 | fullDamage := []Damage{FullRedraw()} | ||
| 1084 | 1084 | ||||
| 1085 | - | t.Run("zero commit is safe", func(t *testing.T) { | 1085 | + | tests := []struct { |
| 1086 | - | var prepared PreparedDraw | 1086 | + | name string |
| 1087 | - | prepared.Commit() | 1087 | + | run func(*testing.T) |
| 1088 | - | }) | 1088 | + | }{ |
| 1089 | - | 1089 | + | {name: "zero commit is safe", run: func(t *testing.T) { | |
| 1090 | - | t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) { | 1090 | + | var prepared PreparedDraw |
| 1091 | - | r := New(Capabilities{}) | 1091 | + | prepared.Commit() |
| 1092 | - | first, err := r.Prepare(fullFrame, fullDamage, false) | 1092 | + | }}, |
| 1093 | - | if err != nil { | |||
| 1094 | - | t.Fatal(err) | |||
| 1095 | - | } | |||
| 1096 | - | want := append([]byte(nil), first.Bytes()...) | |||
| 1097 | 1093 | ||||
| 1098 | - | second, err := r.Prepare(fullFrame, fullDamage, false) | 1094 | + | {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) { |
| 1099 | - | if err != nil { | 1095 | + | r := New(Capabilities{}) |
| 1100 | - | t.Fatal(err) | 1096 | + | first, err := r.Prepare(fullFrame, fullDamage, false) |
| 1101 | - | } | 1097 | + | if err != nil { |
| 1102 | - | if !bytes.Equal(second.Bytes(), want) { | 1098 | + | t.Fatal(err) |
| 1103 | - | t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want) | 1099 | + | } |
| 1104 | - | } | 1100 | + | want := append([]byte(nil), first.Bytes()...) |
| 1105 | 1101 | ||||
| 1106 | - | other := testFrame("12345678", "abcdefgh", "ABCDEFGH") | 1102 | + | second, err := r.Prepare(fullFrame, fullDamage, false) |
| 1107 | - | if _, err := r.Prepare(other, fullDamage, false); err != nil { | 1103 | + | if err != nil { |
| 1108 | - | t.Fatal(err) | 1104 | + | t.Fatal(err) |
| 1109 | - | } | 1105 | + | } |
| 1110 | - | if !bytes.Equal(first.Bytes(), want) { | 1106 | + | if !bytes.Equal(second.Bytes(), want) { |
| 1111 | - | t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want) | 1107 | + | t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want) |
| 1112 | - | } | 1108 | + | } |
| 1113 | - | }) | |||
| 1114 | 1109 | ||||
| 1115 | - | t.Run("commit then no-op", func(t *testing.T) { | 1110 | + | other := testFrame("12345678", "abcdefgh", "ABCDEFGH") |
| 1116 | - | r := New(Capabilities{}) | 1111 | + | if _, err := r.Prepare(other, fullDamage, false); err != nil { |
| 1117 | - | prepared, err := r.Prepare(fullFrame, fullDamage, false) | 1112 | + | t.Fatal(err) |
| 1118 | - | if err != nil { | 1113 | + | } |
| 1119 | - | t.Fatal(err) | 1114 | + | if !bytes.Equal(first.Bytes(), want) { |
| 1120 | - | } | 1115 | + | t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want) |
| 1121 | - | prepared.Commit() | 1116 | + | } |
| 1117 | + | }}, | |||
| 1122 | 1118 | ||||
| 1123 | - | next, err := r.Prepare(fullFrame, nil, false) | 1119 | + | {name: "commit then no-op", run: func(t *testing.T) { |
| 1124 | - | if err != nil { | 1120 | + | r := New(Capabilities{}) |
| 1125 | - | t.Fatal(err) | 1121 | + | prepared, err := r.Prepare(fullFrame, fullDamage, false) |
| 1126 | - | } | 1122 | + | if err != nil { |
| 1127 | - | if len(next.Bytes()) != 0 { | 1123 | + | t.Fatal(err) |
| 1128 | - | t.Fatalf("unchanged frame emitted %q", next.Bytes()) | 1124 | + | } |
| 1129 | - | } | 1125 | + | prepared.Commit() |
| 1130 | - | }) | |||
| 1131 | 1126 | ||||
| 1132 | - | t.Run("reset preparation is transactional", func(t *testing.T) { | 1127 | + | next, err := r.Prepare(fullFrame, nil, false) |
| 1133 | - | r := New(Capabilities{}) | 1128 | + | if err != nil { |
| 1134 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | 1129 | + | t.Fatal(err) |
| 1135 | - | if err != nil { | 1130 | + | } |
| 1136 | - | t.Fatal(err) | 1131 | + | if len(next.Bytes()) != 0 { |
| 1137 | - | } | 1132 | + | t.Fatalf("unchanged frame emitted %q", next.Bytes()) |
| 1138 | - | initial.Commit() | 1133 | + | } |
| 1134 | + | }}, | |||
| 1139 | 1135 | ||||
| 1140 | - | resetFrame := testFrame("ABC", "XYZ") | 1136 | + | {name: "reset preparation is transactional", run: func(t *testing.T) { |
| 1141 | - | resetPrepared, err := r.Prepare(resetFrame, nil, true) | 1137 | + | r := New(Capabilities{}) |
| 1142 | - | if err != nil { | 1138 | + | initial, err := r.Prepare(fullFrame, fullDamage, false) |
| 1143 | - | t.Fatal(err) | 1139 | + | if err != nil { |
| 1144 | - | } | 1140 | + | t.Fatal(err) |
| 1145 | - | want, err := New(Capabilities{}).Draw(resetFrame, nil) | 1141 | + | } |
| 1146 | - | if err != nil { | 1142 | + | initial.Commit() |
| 1147 | - | t.Fatal(err) | |||
| 1148 | - | } | |||
| 1149 | - | if !bytes.Equal(resetPrepared.Bytes(), want) { | |||
| 1150 | - | t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want) | |||
| 1151 | - | } | |||
| 1152 | 1143 | ||||
| 1153 | - | unchanged, err := r.Prepare(fullFrame, nil, false) | 1144 | + | resetFrame := testFrame("ABC", "XYZ") |
| 1154 | - | if err != nil { | 1145 | + | resetPrepared, err := r.Prepare(resetFrame, nil, true) |
| 1155 | - | t.Fatal(err) | 1146 | + | if err != nil { |
| 1156 | - | } | 1147 | + | t.Fatal(err) |
| 1157 | - | if len(unchanged.Bytes()) != 0 { | 1148 | + | } |
| 1158 | - | t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes()) | 1149 | + | want, err := New(Capabilities{}).Draw(resetFrame, nil) |
| 1159 | - | } | 1150 | + | if err != nil { |
| 1151 | + | t.Fatal(err) | |||
| 1152 | + | } | |||
| 1153 | + | if !bytes.Equal(resetPrepared.Bytes(), want) { | |||
| 1154 | + | t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want) | |||
| 1155 | + | } | |||
| 1160 | 1156 | ||||
| 1161 | - | resetPrepared, err = r.Prepare(resetFrame, nil, true) | 1157 | + | unchanged, err := r.Prepare(fullFrame, nil, false) |
| 1162 | - | if err != nil { | 1158 | + | if err != nil { |
| 1163 | - | t.Fatal(err) | 1159 | + | t.Fatal(err) |
| 1164 | - | } | 1160 | + | } |
| 1165 | - | resetPrepared.Commit() | 1161 | + | if len(unchanged.Bytes()) != 0 { |
| 1166 | - | afterCommit, err := r.Prepare(resetFrame, nil, false) | 1162 | + | t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes()) |
| 1167 | - | if err != nil { | 1163 | + | } |
| 1168 | - | t.Fatal(err) | |||
| 1169 | - | } | |||
| 1170 | - | if len(afterCommit.Bytes()) != 0 { | |||
| 1171 | - | t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes()) | |||
| 1172 | - | } | |||
| 1173 | - | }) | |||
| 1174 | 1164 | ||||
| 1175 | - | t.Run("successful no-byte commit", func(t *testing.T) { | 1165 | + | resetPrepared, err = r.Prepare(resetFrame, nil, true) |
| 1176 | - | r := New(Capabilities{}) | 1166 | + | if err != nil { |
| 1177 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | 1167 | + | t.Fatal(err) |
| 1178 | - | if err != nil { | 1168 | + | } |
| 1179 | - | t.Fatal(err) | 1169 | + | resetPrepared.Commit() |
| 1180 | - | } | 1170 | + | afterCommit, err := r.Prepare(resetFrame, nil, false) |
| 1181 | - | initial.Commit() | 1171 | + | if err != nil { |
| 1172 | + | t.Fatal(err) | |||
| 1173 | + | } | |||
| 1174 | + | if len(afterCommit.Bytes()) != 0 { | |||
| 1175 | + | t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes()) | |||
| 1176 | + | } | |||
| 1177 | + | }}, | |||
| 1182 | 1178 | ||||
| 1183 | - | noOp, err := r.Prepare(fullFrame, nil, false) | 1179 | + | {name: "successful no-byte commit", run: func(t *testing.T) { |
| 1184 | - | if err != nil { | 1180 | + | r := New(Capabilities{}) |
| 1185 | - | t.Fatal(err) | 1181 | + | initial, err := r.Prepare(fullFrame, fullDamage, false) |
| 1186 | - | } | 1182 | + | if err != nil { |
| 1187 | - | if len(noOp.Bytes()) != 0 { | 1183 | + | t.Fatal(err) |
| 1188 | - | t.Fatalf("no-op emitted %q", noOp.Bytes()) | 1184 | + | } |
| 1189 | - | } | 1185 | + | initial.Commit() |
| 1190 | - | noOp.Commit() | |||
| 1191 | 1186 | ||||
| 1192 | - | again, err := r.Prepare(fullFrame, nil, false) | 1187 | + | noOp, err := r.Prepare(fullFrame, nil, false) |
| 1193 | - | if err != nil { | 1188 | + | if err != nil { |
| 1194 | - | t.Fatal(err) | 1189 | + | t.Fatal(err) |
| 1195 | - | } | 1190 | + | } |
| 1196 | - | if len(again.Bytes()) != 0 { | 1191 | + | if len(noOp.Bytes()) != 0 { |
| 1197 | - | t.Fatalf("committed no-op changed renderer state: %q", again.Bytes()) | 1192 | + | t.Fatalf("no-op emitted %q", noOp.Bytes()) |
| 1198 | - | } | 1193 | + | } |
| 1199 | - | }) | 1194 | + | noOp.Commit() |
| 1200 | - | ||||
| 1201 | - | t.Run("double commit is idempotent", func(t *testing.T) { | |||
| 1202 | - | base := testFrame("0000", "1111", "2222", "3333") | |||
| 1203 | - | scrolled := testFrame("1111", "2222", "3333", "new!") | |||
| 1204 | - | damage := []Damage{ | |||
| 1205 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |||
| 1206 | - | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |||
| 1207 | - | } | |||
| 1208 | - | r := New(Capabilities{}) | |||
| 1209 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1210 | - | t.Fatal(err) | |||
| 1211 | - | } | |||
| 1212 | 1195 | ||||
| 1213 | - | prepared, err := r.Prepare(scrolled, damage, false) | 1196 | + | again, err := r.Prepare(fullFrame, nil, false) |
| 1214 | - | if err != nil { | 1197 | + | if err != nil { |
| 1215 | - | t.Fatal(err) | 1198 | + | t.Fatal(err) |
| 1216 | - | } | 1199 | + | } |
| 1217 | - | prepared.Commit() | 1200 | + | if len(again.Bytes()) != 0 { |
| 1218 | - | prepared.Commit() | 1201 | + | t.Fatalf("committed no-op changed renderer state: %q", again.Bytes()) |
| 1202 | + | } | |||
| 1203 | + | }}, | |||
| 1219 | 1204 | ||||
| 1220 | - | next, err := r.Prepare(scrolled, nil, false) | 1205 | + | {name: "double commit is idempotent", run: func(t *testing.T) { |
| 1221 | - | if err != nil { | 1206 | + | base := testFrame("0000", "1111", "2222", "3333") |
| 1222 | - | t.Fatal(err) | 1207 | + | scrolled := testFrame("1111", "2222", "3333", "new!") |
| 1223 | - | } | 1208 | + | damage := []Damage{ |
| 1224 | - | if len(next.Bytes()) != 0 { | 1209 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, |
| 1225 | - | t.Fatalf("double commit advanced state twice: %q", next.Bytes()) | 1210 | + | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, |
| 1226 | - | } | 1211 | + | } |
| 1227 | - | }) | 1212 | + | r := New(Capabilities{}) |
| 1228 | - | 1213 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |
| 1229 | - | t.Run("matches concrete ANSI fixtures", func(t *testing.T) { | 1214 | + | t.Fatal(err) |
| 1230 | - | tests := []struct { | 1215 | + | } |
| 1231 | - | name string | 1216 | + | |
| 1232 | - | prepare func(t *testing.T) PreparedDraw | 1217 | + | prepared, err := r.Prepare(scrolled, damage, false) |
| 1233 | - | want string | 1218 | + | if err != nil { |
| 1234 | - | }{ | 1219 | + | t.Fatal(err) |
| 1235 | - | { | 1220 | + | } |
| 1236 | - | name: "full draw", | 1221 | + | prepared.Commit() |
| 1237 | - | prepare: func(t *testing.T) PreparedDraw { | 1222 | + | prepared.Commit() |
| 1238 | - | prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false) | 1223 | + | |
| 1239 | - | if err != nil { | 1224 | + | next, err := r.Prepare(scrolled, nil, false) |
| 1240 | - | t.Fatal(err) | 1225 | + | if err != nil { |
| 1241 | - | } | 1226 | + | t.Fatal(err) |
| 1242 | - | return prepared | 1227 | + | } |
| 1228 | + | if len(next.Bytes()) != 0 { | |||
| 1229 | + | t.Fatalf("double commit advanced state twice: %q", next.Bytes()) | |||
| 1230 | + | } | |||
| 1231 | + | }}, | |||
| 1232 | + | ||||
| 1233 | + | {name: "copied prepared draw commits scroll once", run: func(t *testing.T) { | |||
| 1234 | + | base := testFrame("0000", "1111", "2222", "3333") | |||
| 1235 | + | scrolled := testFrame("1111", "2222", "3333", "new!") | |||
| 1236 | + | damage := []Damage{ | |||
| 1237 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |||
| 1238 | + | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |||
| 1239 | + | } | |||
| 1240 | + | r := New(Capabilities{}) | |||
| 1241 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1242 | + | t.Fatal(err) | |||
| 1243 | + | } | |||
| 1244 | + | ||||
| 1245 | + | prepared, err := r.Prepare(scrolled, damage, false) | |||
| 1246 | + | if err != nil { | |||
| 1247 | + | t.Fatal(err) | |||
| 1248 | + | } | |||
| 1249 | + | copied := prepared | |||
| 1250 | + | prepared.Commit() | |||
| 1251 | + | copied.Commit() | |||
| 1252 | + | ||||
| 1253 | + | next, err := r.Prepare(scrolled, nil, false) | |||
| 1254 | + | if err != nil { | |||
| 1255 | + | t.Fatal(err) | |||
| 1256 | + | } | |||
| 1257 | + | if len(next.Bytes()) != 0 { | |||
| 1258 | + | t.Fatalf("copied commit advanced state twice: %q", next.Bytes()) | |||
| 1259 | + | } | |||
| 1260 | + | }}, | |||
| 1261 | + | ||||
| 1262 | + | {name: "commit uses immutable frame snapshot", run: func(t *testing.T) { | |||
| 1263 | + | r := New(Capabilities{}) | |||
| 1264 | + | base := testFrame("ABC", "DEF") | |||
| 1265 | + | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1266 | + | t.Fatal(err) | |||
| 1267 | + | } | |||
| 1268 | + | ||||
| 1269 | + | changed := base.Clone() | |||
| 1270 | + | changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 1271 | + | want := changed.Clone() | |||
| 1272 | + | prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false) | |||
| 1273 | + | if err != nil { | |||
| 1274 | + | t.Fatal(err) | |||
| 1275 | + | } | |||
| 1276 | + | if !strings.Contains(string(prepared.Bytes()), "X") { | |||
| 1277 | + | t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes()) | |||
| 1278 | + | } | |||
| 1279 | + | ||||
| 1280 | + | changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()}) | |||
| 1281 | + | prepared.Commit() | |||
| 1282 | + | ||||
| 1283 | + | next, err := r.Prepare(want, nil, false) | |||
| 1284 | + | if err != nil { | |||
| 1285 | + | t.Fatal(err) | |||
| 1286 | + | } | |||
| 1287 | + | if len(next.Bytes()) != 0 { | |||
| 1288 | + | t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes()) | |||
| 1289 | + | } | |||
| 1290 | + | }}, | |||
| 1291 | + | ||||
| 1292 | + | {name: "matches concrete ANSI fixtures", run: func(t *testing.T) { | |||
| 1293 | + | tests := []struct { | |||
| 1294 | + | name string | |||
| 1295 | + | prepare func(t *testing.T) PreparedDraw | |||
| 1296 | + | want string | |||
| 1297 | + | }{ | |||
| 1298 | + | { | |||
| 1299 | + | name: "full draw", | |||
| 1300 | + | prepare: func(t *testing.T) PreparedDraw { | |||
| 1301 | + | prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false) | |||
| 1302 | + | if err != nil { | |||
| 1303 | + | t.Fatal(err) | |||
| 1304 | + | } | |||
| 1305 | + | return prepared | |||
| 1306 | + | }, | |||
| 1307 | + | want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m", | |||
| 1243 | }, | 1308 | }, | ||
| 1244 | - | want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m", | 1309 | + | { |
| 1245 | - | }, | 1310 | + | name: "fragmented damage", |
| 1246 | - | { | 1311 | + | prepare: func(t *testing.T) PreparedDraw { |
| 1247 | - | name: "fragmented damage", | 1312 | + | base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx") |
| 1248 | - | prepare: func(t *testing.T) PreparedDraw { | 1313 | + | frame := base.Clone() |
| 1249 | - | base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx") | 1314 | + | frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()}) |
| 1250 | - | frame := base.Clone() | 1315 | + | frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()}) |
| 1251 | - | frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()}) | 1316 | + | frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()}) |
| 1252 | - | frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()}) | 1317 | + | damage := []Damage{ |
| 1253 | - | frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()}) | 1318 | + | {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1}, |
| 1254 | - | damage := []Damage{ | 1319 | + | {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1}, |
| 1255 | - | {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1}, | 1320 | + | {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1}, |
| 1256 | - | {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1}, | 1321 | + | } |
| 1257 | - | {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1}, | 1322 | + | r := New(Capabilities{}) |
| 1258 | - | } | 1323 | + | if _, err := r.Draw(base, fullDamage); err != nil { |
| 1259 | - | r := New(Capabilities{}) | 1324 | + | t.Fatal(err) |
| 1260 | - | if _, err := r.Draw(base, fullDamage); err != nil { | 1325 | + | } |
| 1261 | - | t.Fatal(err) | 1326 | + | prepared, err := r.Prepare(frame, damage, false) |
| 1262 | - | } | 1327 | + | if err != nil { |
| 1263 | - | prepared, err := r.Prepare(frame, damage, false) | 1328 | + | t.Fatal(err) |
| 1264 | - | if err != nil { | 1329 | + | } |
| 1265 | - | t.Fatal(err) | 1330 | + | return prepared |
| 1266 | - | } | 1331 | + | }, |
| 1267 | - | return prepared | 1332 | + | want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m", |
| 1268 | }, | 1333 | }, | ||
| 1269 | - | want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m", | 1334 | + | { |
| 1270 | - | }, | 1335 | + | name: "scroll", |
| 1271 | - | { | 1336 | + | prepare: func(t *testing.T) PreparedDraw { |
| 1272 | - | name: "scroll", | 1337 | + | base := testFrame("00000", "11111", "22222", "33333") |
| 1273 | - | prepare: func(t *testing.T) PreparedDraw { | 1338 | + | frame := testFrame("11111", "22222", "33333", "new!!") |
| 1274 | - | base := testFrame("00000", "11111", "22222", "33333") | 1339 | + | damage := []Damage{ |
| 1275 | - | frame := testFrame("11111", "22222", "33333", "new!!") | 1340 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, |
| 1276 | - | damage := []Damage{ | 1341 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, |
| 1277 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | 1342 | + | } |
| 1278 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | 1343 | + | r := New(Capabilities{}) |
| 1279 | - | } | 1344 | + | if _, err := r.Draw(base, fullDamage); err != nil { |
| 1280 | - | r := New(Capabilities{}) | 1345 | + | t.Fatal(err) |
| 1281 | - | if _, err := r.Draw(base, fullDamage); err != nil { | 1346 | + | } |
| 1282 | - | t.Fatal(err) | 1347 | + | prepared, err := r.Prepare(frame, damage, false) |
| 1283 | - | } | 1348 | + | if err != nil { |
| 1284 | - | prepared, err := r.Prepare(frame, damage, false) | 1349 | + | t.Fatal(err) |
| 1285 | - | if err != nil { | 1350 | + | } |
| 1286 | - | t.Fatal(err) | 1351 | + | return prepared |
| 1287 | - | } | 1352 | + | }, |
| 1288 | - | return prepared | 1353 | + | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", |
| 1289 | }, | 1354 | }, | ||
| 1290 | - | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", | 1355 | + | } |
| 1291 | - | }, | |||
| 1292 | - | } | |||
| 1293 | 1356 | ||||
| 1294 | - | for _, tt := range tests { | 1357 | + | for _, tt := range tests { |
| 1295 | - | t.Run(tt.name, func(t *testing.T) { | 1358 | + | t.Run(tt.name, func(t *testing.T) { |
| 1296 | - | if got := string(tt.prepare(t).Bytes()); got != tt.want { | 1359 | + | if got := string(tt.prepare(t).Bytes()); got != tt.want { |
| 1297 | - | t.Fatalf("prepared ANSI = %q, want %q", got, tt.want) | 1360 | + | t.Fatalf("prepared ANSI = %q, want %q", got, tt.want) |
| 1298 | - | } | 1361 | + | } |
| 1299 | - | }) | 1362 | + | }) |
| 1300 | - | } | 1363 | + | } |
| 1301 | - | }) | 1364 | + | }}, |
| 1365 | + | } | |||
| 1366 | + | ||||
| 1367 | + | for _, tt := range tests { | |||
| 1368 | + | t.Run(tt.name, tt.run) | |||
| 1369 | + | } | |||
| 1370 | + | } | |||
| 1371 | + | ||||
| 1372 | + | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |||
| 1373 | + | frame := NewFrame(2, 1) | |||
| 1374 | + | committed := NewFrame(2, 1) | |||
| 1375 | + | committed.lineOffset = nil | |||
| 1376 | + | ||||
| 1377 | + | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |||
| 1378 | + | t.Fatal("PlanDelta accepted an invalid committed frame") | |||
| 1379 | + | } | |||
| 1302 | } | 1380 | } | ||
| 1303 | 1381 | ||||
| 1304 | func TestWideCharDamageEmission(t *testing.T) { | 1382 | func TestWideCharDamageEmission(t *testing.T) { | ||