merge(perf): ansi renderer reusable shadow frames and row compare
10 files · +1006 -46
M ansi/delta_plan.go +35 -20
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | + | ||
| 3 | 5 | // Span identifies a changed horizontal range in a logical frame row. | |
| 4 | 6 | type Span struct { | |
| 5 | 7 | Y int | |
| @@ -31,35 +33,45 @@ type DeltaCandidate struct { | |||
| 31 | 33 | frame Frame | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | - | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { | |
| 36 | + | // newDeltaCandidate builds a candidate whose snapshot, when the plan has work to | |
| 37 | + | // commit, is copied into scratch (reusing its storage) or into a fresh frame | |
| 38 | + | // when scratch is nil. | |
| 39 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate { | |
| 35 | 40 | candidate := DeltaCandidate{Plan: plan} | |
| 36 | 41 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 37 | - | candidate.frame = cloneCellSource(frame) | |
| 42 | + | candidate.frame = snapshotCellSource(scratch, frame) | |
| 38 | 43 | } | |
| 39 | 44 | return candidate | |
| 40 | 45 | } | |
| 41 | 46 | ||
| 42 | - | // PlanDelta prepares a non-mutating update from committed to frame. | |
| 47 | + | // PlanDelta prepares a non-mutating update from committed to frame. The | |
| 48 | + | // returned candidate owns an independent snapshot of frame. | |
| 43 | 49 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | |
| 50 | + | return planDelta(frame, damage, committed, reset, nil) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // planDelta implements PlanDelta. A non-nil scratch receives the candidate's | |
| 54 | + | // snapshot in place of a fresh allocation; the caller then owns its lifetime. | |
| 55 | + | func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) { | |
| 44 | 56 | if err := validateCellSource(frame); err != nil { | |
| 45 | 57 | return DeltaCandidate{}, err | |
| 46 | 58 | } | |
| 47 | 59 | ||
| 48 | 60 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | |
| 49 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 61 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 50 | 62 | } | |
| 51 | 63 | if err := committed.Validate(); err != nil { | |
| 52 | 64 | return DeltaCandidate{}, err | |
| 53 | 65 | } | |
| 54 | 66 | ||
| 55 | 67 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 56 | - | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil | |
| 68 | + | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil | |
| 57 | 69 | } | |
| 58 | 70 | ||
| 59 | 71 | if needsFull(damage) { | |
| 60 | 72 | dirty, full := buildDirtyLinePlan(frame, committed) | |
| 61 | 73 | if len(dirty) > 0 || full { | |
| 62 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil | |
| 74 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil | |
| 63 | 75 | } | |
| 64 | 76 | return DeltaCandidate{}, nil | |
| 65 | 77 | } | |
| @@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||
| 70 | 82 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | |
| 71 | 83 | skip = &scroll | |
| 72 | 84 | } else if hasScrollDamage(damage) { | |
| 73 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 85 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 74 | 86 | } | |
| 75 | 87 | ||
| 76 | 88 | var spans []Span | |
| @@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||
| 81 | 93 | spans, full = buildDamagePlan(frame, damage, skip) | |
| 82 | 94 | } | |
| 83 | 95 | if full { | |
| 84 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 96 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 85 | 97 | } | |
| 86 | 98 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | |
| 87 | 99 | if plan.Scroll.Height != 0 { | |
| 88 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 100 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil | |
| 89 | 101 | } | |
| 90 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | |
| 102 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil | |
| 91 | 103 | } | |
| 92 | 104 | plan.Spans = spans | |
| 93 | - | return newDeltaCandidate(frame, plan), nil | |
| 105 | + | return newDeltaCandidate(frame, plan, scratch), nil | |
| 94 | 106 | } | |
| 95 | 107 | ||
| 96 | 108 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |
| @@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |||
| 107 | 119 | ||
| 108 | 120 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | |
| 109 | 121 | var spans []Span | |
| 122 | + | fast, fastOK := compactFrames(frame, committed) | |
| 110 | 123 | for y := range frame.Rows() { | |
| 111 | 124 | dirty := false | |
| 112 | - | for x := range frame.Columns() { | |
| 113 | - | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 114 | - | dirty = true | |
| 115 | - | break | |
| 125 | + | if fastOK { | |
| 126 | + | dirty = !core.RowsEqual(fast.a, fast.b, y) | |
| 127 | + | } else { | |
| 128 | + | for x := range frame.Columns() { | |
| 129 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 130 | + | dirty = true | |
| 131 | + | break | |
| 132 | + | } | |
| 116 | 133 | } | |
| 117 | 134 | } | |
| 118 | 135 | if dirty { | |
| @@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 198 | 215 | return | |
| 199 | 216 | } | |
| 200 | 217 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | |
| 201 | - | replaceFrame(dst, c.frame) | |
| 218 | + | // dst may be shared with the caller, so give it a fresh page rather | |
| 219 | + | // than overwriting storage other Frame values might still reference. | |
| 220 | + | dst.Replace(c.frame) | |
| 202 | 221 | return | |
| 203 | 222 | } | |
| 204 | 223 | if c.Plan.Scroll.Height != 0 { | |
| @@ -215,7 +234,3 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 215 | 234 | } | |
| 216 | 235 | } | |
| 217 | 236 | } | |
| 218 | - | ||
| 219 | - | func replaceFrame(dst *Frame, src Frame) { | |
| 220 | - | dst.Replace(src) | |
| 221 | - | } | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package ansi | 1 | package ansi | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/vev-vt/core" | |||
| 4 | + | ||||
| 3 | // Span identifies a changed horizontal range in a logical frame row. | 5 | // Span identifies a changed horizontal range in a logical frame row. | ||
| 4 | type Span struct { | 6 | type Span struct { | ||
| 5 | Y int | 7 | Y int | ||
| @@ -31,35 +33,45 @@ type DeltaCandidate struct { | |||||
| 31 | frame Frame | 33 | frame Frame | ||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | - | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { | 36 | + | // newDeltaCandidate builds a candidate whose snapshot, when the plan has work to |
| 37 | + | // commit, is copied into scratch (reusing its storage) or into a fresh frame | |||
| 38 | + | // when scratch is nil. | |||
| 39 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate { | |||
| 35 | candidate := DeltaCandidate{Plan: plan} | 40 | candidate := DeltaCandidate{Plan: plan} | ||
| 36 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | 41 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | ||
| 37 | - | candidate.frame = cloneCellSource(frame) | 42 | + | candidate.frame = snapshotCellSource(scratch, frame) |
| 38 | } | 43 | } | ||
| 39 | return candidate | 44 | return candidate | ||
| 40 | } | 45 | } | ||
| 41 | 46 | ||||
| 42 | - | // PlanDelta prepares a non-mutating update from committed to frame. | 47 | + | // PlanDelta prepares a non-mutating update from committed to frame. The |
| 48 | + | // returned candidate owns an independent snapshot of frame. | |||
| 43 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | 49 | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | ||
| 50 | + | return planDelta(frame, damage, committed, reset, nil) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // planDelta implements PlanDelta. A non-nil scratch receives the candidate's | |||
| 54 | + | // snapshot in place of a fresh allocation; the caller then owns its lifetime. | |||
| 55 | + | func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) { | |||
| 44 | if err := validateCellSource(frame); err != nil { | 56 | if err := validateCellSource(frame); err != nil { | ||
| 45 | return DeltaCandidate{}, err | 57 | return DeltaCandidate{}, err | ||
| 46 | } | 58 | } | ||
| 47 | 59 | ||||
| 48 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | 60 | if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() { | ||
| 49 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 61 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 50 | } | 62 | } | ||
| 51 | if err := committed.Validate(); err != nil { | 63 | if err := committed.Validate(); err != nil { | ||
| 52 | return DeltaCandidate{}, err | 64 | return DeltaCandidate{}, err | ||
| 53 | } | 65 | } | ||
| 54 | 66 | ||||
| 55 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 67 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 56 | - | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil | 68 | + | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil |
| 57 | } | 69 | } | ||
| 58 | 70 | ||||
| 59 | if needsFull(damage) { | 71 | if needsFull(damage) { | ||
| 60 | dirty, full := buildDirtyLinePlan(frame, committed) | 72 | dirty, full := buildDirtyLinePlan(frame, committed) | ||
| 61 | if len(dirty) > 0 || full { | 73 | if len(dirty) > 0 || full { | ||
| 62 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil | 74 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil |
| 63 | } | 75 | } | ||
| 64 | return DeltaCandidate{}, nil | 76 | return DeltaCandidate{}, nil | ||
| 65 | } | 77 | } | ||
| @@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||||
| 70 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | 82 | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown} | ||
| 71 | skip = &scroll | 83 | skip = &scroll | ||
| 72 | } else if hasScrollDamage(damage) { | 84 | } else if hasScrollDamage(damage) { | ||
| 73 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 85 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 74 | } | 86 | } | ||
| 75 | 87 | ||||
| 76 | var spans []Span | 88 | var spans []Span | ||
| @@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) ( | |||||
| 81 | spans, full = buildDamagePlan(frame, damage, skip) | 93 | spans, full = buildDamagePlan(frame, damage, skip) | ||
| 82 | } | 94 | } | ||
| 83 | if full { | 95 | if full { | ||
| 84 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 96 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 85 | } | 97 | } | ||
| 86 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | 98 | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | ||
| 87 | if plan.Scroll.Height != 0 { | 99 | if plan.Scroll.Height != 0 { | ||
| 88 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 100 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 89 | } | 101 | } | ||
| 90 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | 102 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil |
| 91 | } | 103 | } | ||
| 92 | plan.Spans = spans | 104 | plan.Spans = spans | ||
| 93 | - | return newDeltaCandidate(frame, plan), nil | 105 | + | return newDeltaCandidate(frame, plan, scratch), nil |
| 94 | } | 106 | } | ||
| 95 | 107 | ||||
| 96 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | 108 | func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | ||
| @@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan { | |||||
| 107 | 119 | ||||
| 108 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | 120 | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | ||
| 109 | var spans []Span | 121 | var spans []Span | ||
| 122 | + | fast, fastOK := compactFrames(frame, committed) | |||
| 110 | for y := range frame.Rows() { | 123 | for y := range frame.Rows() { | ||
| 111 | dirty := false | 124 | dirty := false | ||
| 112 | - | for x := range frame.Columns() { | 125 | + | if fastOK { |
| 113 | - | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | 126 | + | dirty = !core.RowsEqual(fast.a, fast.b, y) |
| 114 | - | dirty = true | 127 | + | } else { |
| 115 | - | break | 128 | + | for x := range frame.Columns() { |
| 129 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |||
| 130 | + | dirty = true | |||
| 131 | + | break | |||
| 132 | + | } | |||
| 116 | } | 133 | } | ||
| 117 | } | 134 | } | ||
| 118 | if dirty { | 135 | if dirty { | ||
| @@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 198 | return | 215 | return | ||
| 199 | } | 216 | } | ||
| 200 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | 217 | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | ||
| 201 | - | replaceFrame(dst, c.frame) | 218 | + | // dst may be shared with the caller, so give it a fresh page rather |
| 219 | + | // than overwriting storage other Frame values might still reference. | |||
| 220 | + | dst.Replace(c.frame) | |||
| 202 | return | 221 | return | ||
| 203 | } | 222 | } | ||
| 204 | if c.Plan.Scroll.Height != 0 { | 223 | if c.Plan.Scroll.Height != 0 { | ||
| @@ -215,7 +234,3 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 215 | } | 234 | } | ||
| 216 | } | 235 | } | ||
| 217 | } | 236 | } | ||
| 218 | - | ||||
| 219 | - | func replaceFrame(dst *Frame, src Frame) { | |||
| 220 | - | dst.Replace(src) | |||
| 221 | - | } | |||
M ansi/frame.go +58 -0
| @@ -39,6 +39,64 @@ func validateCellSource(source CellSource) error { | |||
| 39 | 39 | return nil | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // snapshotCellSource returns an independent Frame holding source's cells. When | |
| 43 | + | // scratch is non-nil the snapshot is written into scratch's existing storage | |
| 44 | + | // (allocating only to grow or on first use) and the returned Frame shares that | |
| 45 | + | // storage; the caller owns scratch and must not hand it to anyone else while | |
| 46 | + | // the snapshot is live. A nil scratch allocates a fresh frame. | |
| 47 | + | func snapshotCellSource(scratch *Frame, source CellSource) Frame { | |
| 48 | + | if scratch == nil { | |
| 49 | + | return cloneCellSource(source) | |
| 50 | + | } | |
| 51 | + | switch frame := source.(type) { | |
| 52 | + | case Frame: | |
| 53 | + | scratch.CopyFrom(frame) | |
| 54 | + | return *scratch | |
| 55 | + | case *Frame: | |
| 56 | + | scratch.CopyFrom(*frame) | |
| 57 | + | return *scratch | |
| 58 | + | } | |
| 59 | + | columns, rows := source.Columns(), source.Rows() | |
| 60 | + | if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil { | |
| 61 | + | *scratch = NewFrame(columns, rows) | |
| 62 | + | } | |
| 63 | + | for y := range rows { | |
| 64 | + | for x := range columns { | |
| 65 | + | scratch.Set(x, y, source.Cell(x, y)) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return *scratch | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // framePair holds two compact frames compared through core.RowsEqual. | |
| 72 | + | type framePair struct{ a, b Frame } | |
| 73 | + | ||
| 74 | + | // compactFrames reports whether both sources are core.Frame values (directly or | |
| 75 | + | // by pointer), enabling stored-cell comparison without the CellSource interface. | |
| 76 | + | func compactFrames(a, b CellSource) (framePair, bool) { | |
| 77 | + | fa, ok := asFrame(a) | |
| 78 | + | if !ok { | |
| 79 | + | return framePair{}, false | |
| 80 | + | } | |
| 81 | + | fb, ok := asFrame(b) | |
| 82 | + | if !ok { | |
| 83 | + | return framePair{}, false | |
| 84 | + | } | |
| 85 | + | return framePair{fa, fb}, true | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func asFrame(source CellSource) (Frame, bool) { | |
| 89 | + | switch frame := source.(type) { | |
| 90 | + | case Frame: | |
| 91 | + | return frame, true | |
| 92 | + | case *Frame: | |
| 93 | + | if frame != nil { | |
| 94 | + | return *frame, true | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return Frame{}, false | |
| 98 | + | } | |
| 99 | + | ||
| 42 | 100 | func cloneCellSource(source CellSource) Frame { | |
| 43 | 101 | switch frame := source.(type) { | |
| 44 | 102 | case Frame: | |
| @@ -39,6 +39,64 @@ func validateCellSource(source CellSource) error { | |||||
| 39 | return nil | 39 | return nil | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // snapshotCellSource returns an independent Frame holding source's cells. When | |||
| 43 | + | // scratch is non-nil the snapshot is written into scratch's existing storage | |||
| 44 | + | // (allocating only to grow or on first use) and the returned Frame shares that | |||
| 45 | + | // storage; the caller owns scratch and must not hand it to anyone else while | |||
| 46 | + | // the snapshot is live. A nil scratch allocates a fresh frame. | |||
| 47 | + | func snapshotCellSource(scratch *Frame, source CellSource) Frame { | |||
| 48 | + | if scratch == nil { | |||
| 49 | + | return cloneCellSource(source) | |||
| 50 | + | } | |||
| 51 | + | switch frame := source.(type) { | |||
| 52 | + | case Frame: | |||
| 53 | + | scratch.CopyFrom(frame) | |||
| 54 | + | return *scratch | |||
| 55 | + | case *Frame: | |||
| 56 | + | scratch.CopyFrom(*frame) | |||
| 57 | + | return *scratch | |||
| 58 | + | } | |||
| 59 | + | columns, rows := source.Columns(), source.Rows() | |||
| 60 | + | if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil { | |||
| 61 | + | *scratch = NewFrame(columns, rows) | |||
| 62 | + | } | |||
| 63 | + | for y := range rows { | |||
| 64 | + | for x := range columns { | |||
| 65 | + | scratch.Set(x, y, source.Cell(x, y)) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | return *scratch | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // framePair holds two compact frames compared through core.RowsEqual. | |||
| 72 | + | type framePair struct{ a, b Frame } | |||
| 73 | + | ||||
| 74 | + | // compactFrames reports whether both sources are core.Frame values (directly or | |||
| 75 | + | // by pointer), enabling stored-cell comparison without the CellSource interface. | |||
| 76 | + | func compactFrames(a, b CellSource) (framePair, bool) { | |||
| 77 | + | fa, ok := asFrame(a) | |||
| 78 | + | if !ok { | |||
| 79 | + | return framePair{}, false | |||
| 80 | + | } | |||
| 81 | + | fb, ok := asFrame(b) | |||
| 82 | + | if !ok { | |||
| 83 | + | return framePair{}, false | |||
| 84 | + | } | |||
| 85 | + | return framePair{fa, fb}, true | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func asFrame(source CellSource) (Frame, bool) { | |||
| 89 | + | switch frame := source.(type) { | |||
| 90 | + | case Frame: | |||
| 91 | + | return frame, true | |||
| 92 | + | case *Frame: | |||
| 93 | + | if frame != nil { | |||
| 94 | + | return *frame, true | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return Frame{}, false | |||
| 98 | + | } | |||
| 99 | + | ||||
| 42 | func cloneCellSource(source CellSource) Frame { | 100 | func cloneCellSource(source CellSource) Frame { | ||
| 43 | switch frame := source.(type) { | 101 | switch frame := source.(type) { | ||
| 44 | case Frame: | 102 | case Frame: | ||
M ansi/prepared_ownership_test.go +176 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "runtime" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| @@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) { | |||
| 23 | 24 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | |
| 24 | 25 | require.Equal(t, encoded, alias.Bytes()) | |
| 25 | 26 | } | |
| 27 | + | ||
| 28 | + | func TestRendererNeverAliasesCallerFrame(t *testing.T) { | |
| 29 | + | r := New(Capabilities{}) | |
| 30 | + | frame := NewFrame(6, 3) | |
| 31 | + | markFrame(&frame) | |
| 32 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | ||
| 35 | + | // Mutate the caller's frame in place after commit without drawing, then | |
| 36 | + | // restore it: the renderer's committed state must not have followed it. | |
| 37 | + | original := frame.Cell(2, 1) | |
| 38 | + | frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}}) | |
| 39 | + | require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune) | |
| 40 | + | frame.Set(2, 1, original) | |
| 41 | + | prepared, err := r.Prepare(frame, nil, false) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.Empty(t, prepared.Bytes(), "committed state followed caller mutation") | |
| 44 | + | prepared.Commit() | |
| 45 | + | ||
| 46 | + | // Mutate after Prepare and before Commit: the commit keeps the prepared | |
| 47 | + | // content, so the next draw re-emits the difference. | |
| 48 | + | frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()}) | |
| 49 | + | prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()}) | |
| 52 | + | prepared.Commit() | |
| 53 | + | require.Equal(t, 'P', r.committed.Cell(0, 0).Rune) | |
| 54 | + | out, err := r.Draw(frame, nil) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Contains(t, string(out), "R") | |
| 57 | + | require.Equal(t, 'R', r.committed.Cell(0, 0).Rune) | |
| 58 | + | require.NoError(t, r.committed.CheckInvariants()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) { | |
| 62 | + | r := New(Capabilities{}) | |
| 63 | + | frame := NewFrame(5, 3) | |
| 64 | + | markFrame(&frame) | |
| 65 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | committed := r.committed.Clone() | |
| 68 | + | ||
| 69 | + | for _, damage := range [][]Damage{ | |
| 70 | + | {FullRedraw()}, | |
| 71 | + | {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}}, | |
| 72 | + | nil, | |
| 73 | + | } { | |
| 74 | + | frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}}) | |
| 75 | + | discarded, err := r.Prepare(frame, damage, false) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.NotEmpty(t, discarded.Bytes()) | |
| 78 | + | for y := range committed.Height { | |
| 79 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |
| 80 | + | } | |
| 81 | + | // Preparing again after a discard produces the same bytes. | |
| 82 | + | again, err := r.Prepare(frame, damage, false) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | require.Equal(t, discarded.Bytes(), again.Bytes()) | |
| 85 | + | discarded.Commit() // superseded by a later Prepare: must not apply | |
| 86 | + | for y := range committed.Height { | |
| 87 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |
| 88 | + | } | |
| 89 | + | again.Commit() | |
| 90 | + | require.Equal(t, '#', r.committed.Cell(1, 1).Rune) | |
| 91 | + | require.NoError(t, r.committed.CheckInvariants()) | |
| 92 | + | ||
| 93 | + | // Restore for the next scenario. | |
| 94 | + | frame.Set(1, 1, committed.Cell(1, 1)) | |
| 95 | + | _, err = r.Draw(frame, nil) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestPreparedBytesSurviveLaterDraws(t *testing.T) { | |
| 102 | + | r := New(Capabilities{}) | |
| 103 | + | frame := NewFrame(8, 2) | |
| 104 | + | markFrame(&frame) | |
| 105 | + | first, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | want := append([]byte(nil), first.Bytes()...) | |
| 108 | + | first.Commit() | |
| 109 | + | for i := range 5 { | |
| 110 | + | frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()}) | |
| 111 | + | _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}}) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | } | |
| 114 | + | require.Equal(t, want, first.Bytes()) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) { | |
| 118 | + | // A long mixed sequence on one renderer (buffer reuse, resizes, reset, | |
| 119 | + | // scroll) must keep emitting exactly what a renderer with an exact | |
| 120 | + | // reference committed frame would: full redraw after each step equals | |
| 121 | + | // the fresh-renderer snapshot, and no-damage diffs are empty. | |
| 122 | + | r := New(Capabilities{}) | |
| 123 | + | frame := NewFrame(7, 4) | |
| 124 | + | markFrame(&frame) | |
| 125 | + | steps := []func(){ | |
| 126 | + | func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) }, | |
| 127 | + | func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) }, | |
| 128 | + | func() { frame = NewFrame(9, 5); markFrame(&frame) }, | |
| 129 | + | func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) }, | |
| 130 | + | func() { | |
| 131 | + | frame = NewFrame(7, 4) | |
| 132 | + | markFrame(&frame) | |
| 133 | + | frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()}) | |
| 134 | + | }, | |
| 135 | + | } | |
| 136 | + | for i, step := range steps { | |
| 137 | + | step() | |
| 138 | + | if i == 3 { | |
| 139 | + | r.Reset() | |
| 140 | + | } | |
| 141 | + | _, err := r.Draw(frame, nil) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | for y := range frame.Height { | |
| 144 | + | require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y) | |
| 145 | + | } | |
| 146 | + | out, err := r.Draw(frame, nil) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.Empty(t, out, "step %d", i) | |
| 149 | + | // Reset keeps equivalence with a brand new renderer. | |
| 150 | + | r2 := New(Capabilities{}) | |
| 151 | + | want, err := r2.Draw(frame, nil) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | r.Reset() | |
| 154 | + | got, err := r.Draw(frame, nil) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | require.Equal(t, want, got, "step %d", i) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) { | |
| 161 | + | r := New(Capabilities{}) | |
| 162 | + | frame := NewFrame(120, 40) | |
| 163 | + | markFrame(&frame) | |
| 164 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | _, err = r.Draw(frame, []Damage{FullRedraw()}) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | ||
| 169 | + | // A whole frame is ~76 KB; steady-state draws must stay far below it. | |
| 170 | + | toggled := false | |
| 171 | + | damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}} | |
| 172 | + | allocs := testing.AllocsPerRun(20, func() { | |
| 173 | + | toggled = !toggled | |
| 174 | + | ch := 'X' | |
| 175 | + | if toggled { | |
| 176 | + | ch = 'Y' | |
| 177 | + | } | |
| 178 | + | frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()}) | |
| 179 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 180 | + | if err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | prepared.Commit() | |
| 184 | + | }) | |
| 185 | + | require.LessOrEqual(t, allocs, 8.0) | |
| 186 | + | var m1, m2 runtime.MemStats | |
| 187 | + | runtime.ReadMemStats(&m1) | |
| 188 | + | for range 50 { | |
| 189 | + | toggled = !toggled | |
| 190 | + | frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()}) | |
| 191 | + | prepared, err := r.Prepare(frame, damage, false) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | prepared.Commit() | |
| 194 | + | frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()}) | |
| 195 | + | prepared, err = r.Prepare(frame, damage, false) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | prepared.Commit() | |
| 198 | + | } | |
| 199 | + | runtime.ReadMemStats(&m2) | |
| 200 | + | require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated") | |
| 201 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ansi | 1 | package ansi | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "runtime" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| @@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) { | |||||
| 23 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | 24 | require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells") | ||
| 24 | require.Equal(t, encoded, alias.Bytes()) | 25 | require.Equal(t, encoded, alias.Bytes()) | ||
| 25 | } | 26 | } | ||
| 27 | + | ||||
| 28 | + | func TestRendererNeverAliasesCallerFrame(t *testing.T) { | |||
| 29 | + | r := New(Capabilities{}) | |||
| 30 | + | frame := NewFrame(6, 3) | |||
| 31 | + | markFrame(&frame) | |||
| 32 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | ||||
| 35 | + | // Mutate the caller's frame in place after commit without drawing, then | |||
| 36 | + | // restore it: the renderer's committed state must not have followed it. | |||
| 37 | + | original := frame.Cell(2, 1) | |||
| 38 | + | frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}}) | |||
| 39 | + | require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune) | |||
| 40 | + | frame.Set(2, 1, original) | |||
| 41 | + | prepared, err := r.Prepare(frame, nil, false) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.Empty(t, prepared.Bytes(), "committed state followed caller mutation") | |||
| 44 | + | prepared.Commit() | |||
| 45 | + | ||||
| 46 | + | // Mutate after Prepare and before Commit: the commit keeps the prepared | |||
| 47 | + | // content, so the next draw re-emits the difference. | |||
| 48 | + | frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()}) | |||
| 49 | + | prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()}) | |||
| 52 | + | prepared.Commit() | |||
| 53 | + | require.Equal(t, 'P', r.committed.Cell(0, 0).Rune) | |||
| 54 | + | out, err := r.Draw(frame, nil) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Contains(t, string(out), "R") | |||
| 57 | + | require.Equal(t, 'R', r.committed.Cell(0, 0).Rune) | |||
| 58 | + | require.NoError(t, r.committed.CheckInvariants()) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) { | |||
| 62 | + | r := New(Capabilities{}) | |||
| 63 | + | frame := NewFrame(5, 3) | |||
| 64 | + | markFrame(&frame) | |||
| 65 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | committed := r.committed.Clone() | |||
| 68 | + | ||||
| 69 | + | for _, damage := range [][]Damage{ | |||
| 70 | + | {FullRedraw()}, | |||
| 71 | + | {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}}, | |||
| 72 | + | nil, | |||
| 73 | + | } { | |||
| 74 | + | frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}}) | |||
| 75 | + | discarded, err := r.Prepare(frame, damage, false) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NotEmpty(t, discarded.Bytes()) | |||
| 78 | + | for y := range committed.Height { | |||
| 79 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |||
| 80 | + | } | |||
| 81 | + | // Preparing again after a discard produces the same bytes. | |||
| 82 | + | again, err := r.Prepare(frame, damage, false) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | require.Equal(t, discarded.Bytes(), again.Bytes()) | |||
| 85 | + | discarded.Commit() // superseded by a later Prepare: must not apply | |||
| 86 | + | for y := range committed.Height { | |||
| 87 | + | require.Equal(t, committed.Row(y), r.committed.Row(y)) | |||
| 88 | + | } | |||
| 89 | + | again.Commit() | |||
| 90 | + | require.Equal(t, '#', r.committed.Cell(1, 1).Rune) | |||
| 91 | + | require.NoError(t, r.committed.CheckInvariants()) | |||
| 92 | + | ||||
| 93 | + | // Restore for the next scenario. | |||
| 94 | + | frame.Set(1, 1, committed.Cell(1, 1)) | |||
| 95 | + | _, err = r.Draw(frame, nil) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestPreparedBytesSurviveLaterDraws(t *testing.T) { | |||
| 102 | + | r := New(Capabilities{}) | |||
| 103 | + | frame := NewFrame(8, 2) | |||
| 104 | + | markFrame(&frame) | |||
| 105 | + | first, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | want := append([]byte(nil), first.Bytes()...) | |||
| 108 | + | first.Commit() | |||
| 109 | + | for i := range 5 { | |||
| 110 | + | frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()}) | |||
| 111 | + | _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}}) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | } | |||
| 114 | + | require.Equal(t, want, first.Bytes()) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) { | |||
| 118 | + | // A long mixed sequence on one renderer (buffer reuse, resizes, reset, | |||
| 119 | + | // scroll) must keep emitting exactly what a renderer with an exact | |||
| 120 | + | // reference committed frame would: full redraw after each step equals | |||
| 121 | + | // the fresh-renderer snapshot, and no-damage diffs are empty. | |||
| 122 | + | r := New(Capabilities{}) | |||
| 123 | + | frame := NewFrame(7, 4) | |||
| 124 | + | markFrame(&frame) | |||
| 125 | + | steps := []func(){ | |||
| 126 | + | func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) }, | |||
| 127 | + | func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) }, | |||
| 128 | + | func() { frame = NewFrame(9, 5); markFrame(&frame) }, | |||
| 129 | + | func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) }, | |||
| 130 | + | func() { | |||
| 131 | + | frame = NewFrame(7, 4) | |||
| 132 | + | markFrame(&frame) | |||
| 133 | + | frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()}) | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | for i, step := range steps { | |||
| 137 | + | step() | |||
| 138 | + | if i == 3 { | |||
| 139 | + | r.Reset() | |||
| 140 | + | } | |||
| 141 | + | _, err := r.Draw(frame, nil) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | for y := range frame.Height { | |||
| 144 | + | require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y) | |||
| 145 | + | } | |||
| 146 | + | out, err := r.Draw(frame, nil) | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | require.Empty(t, out, "step %d", i) | |||
| 149 | + | // Reset keeps equivalence with a brand new renderer. | |||
| 150 | + | r2 := New(Capabilities{}) | |||
| 151 | + | want, err := r2.Draw(frame, nil) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | r.Reset() | |||
| 154 | + | got, err := r.Draw(frame, nil) | |||
| 155 | + | require.NoError(t, err) | |||
| 156 | + | require.Equal(t, want, got, "step %d", i) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) { | |||
| 161 | + | r := New(Capabilities{}) | |||
| 162 | + | frame := NewFrame(120, 40) | |||
| 163 | + | markFrame(&frame) | |||
| 164 | + | _, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | _, err = r.Draw(frame, []Damage{FullRedraw()}) | |||
| 167 | + | require.NoError(t, err) | |||
| 168 | + | ||||
| 169 | + | // A whole frame is ~76 KB; steady-state draws must stay far below it. | |||
| 170 | + | toggled := false | |||
| 171 | + | damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}} | |||
| 172 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 173 | + | toggled = !toggled | |||
| 174 | + | ch := 'X' | |||
| 175 | + | if toggled { | |||
| 176 | + | ch = 'Y' | |||
| 177 | + | } | |||
| 178 | + | frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()}) | |||
| 179 | + | prepared, err := r.Prepare(frame, damage, false) | |||
| 180 | + | if err != nil { | |||
| 181 | + | t.Fatal(err) | |||
| 182 | + | } | |||
| 183 | + | prepared.Commit() | |||
| 184 | + | }) | |||
| 185 | + | require.LessOrEqual(t, allocs, 8.0) | |||
| 186 | + | var m1, m2 runtime.MemStats | |||
| 187 | + | runtime.ReadMemStats(&m1) | |||
| 188 | + | for range 50 { | |||
| 189 | + | toggled = !toggled | |||
| 190 | + | frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()}) | |||
| 191 | + | prepared, err := r.Prepare(frame, damage, false) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | prepared.Commit() | |||
| 194 | + | frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()}) | |||
| 195 | + | prepared, err = r.Prepare(frame, damage, false) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | prepared.Commit() | |||
| 198 | + | } | |||
| 199 | + | runtime.ReadMemStats(&m2) | |||
| 200 | + | require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated") | |||
| 201 | + | } | |||
M ansi/renderer.go +46 -26
| @@ -24,6 +24,12 @@ type Renderer struct { | |||
| 24 | 24 | height int | |
| 25 | 25 | hasCommitted bool | |
| 26 | 26 | committed Frame | |
| 27 | + | // scratch receives the snapshot of the frame being prepared. It never | |
| 28 | + | // aliases committed or caller storage: a snapshot commit swaps the two so | |
| 29 | + | // both buffers are reused across draws without allocating. | |
| 30 | + | scratch Frame | |
| 31 | + | // generation identifies the latest prepared draw that owns scratch. | |
| 32 | + | generation uint64 | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // PreparedDraw owns encoded output and its transactional delta until Commit. | |
| @@ -32,6 +38,7 @@ type PreparedDraw struct { | |||
| 32 | 38 | renderer *Renderer | |
| 33 | 39 | candidate DeltaCandidate | |
| 34 | 40 | data []byte | |
| 41 | + | generation uint64 | |
| 35 | 42 | commitOnce *sync.Once | |
| 36 | 43 | } | |
| 37 | 44 | ||
| @@ -48,7 +55,7 @@ func (r *Renderer) Reset() { | |||
| 48 | 55 | r.width = 0 | |
| 49 | 56 | r.height = 0 | |
| 50 | 57 | r.hasCommitted = false | |
| 51 | - | r.committed = Frame{} | |
| 58 | + | // The frame buffers are kept for reuse; hasCommitted gates their content. | |
| 52 | 59 | } | |
| 53 | 60 | ||
| 54 | 61 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | |
| @@ -57,46 +64,70 @@ func (p PreparedDraw) Bytes() []byte { return p.data } | |||
| 57 | 64 | ||
| 58 | 65 | // Commit applies the prepared delta exactly once. Discarding it leaves the | |
| 59 | 66 | // renderer's committed state unchanged. | |
| 67 | + | // | |
| 68 | + | // Committing a draw that a later Prepare has superseded does not apply it: | |
| 69 | + | // the renderer instead drops its committed state, so the next Prepare | |
| 70 | + | // re-emits a full snapshot instead of diffing against a shadow that may no | |
| 71 | + | // longer match the terminal. Draws without output are unaffected. | |
| 60 | 72 | func (p *PreparedDraw) Commit() { | |
| 61 | 73 | if p == nil || p.renderer == nil || p.commitOnce == nil { | |
| 62 | 74 | return | |
| 63 | 75 | } | |
| 64 | 76 | p.commitOnce.Do(func() { | |
| 65 | - | committed := p.renderer.committedFrame() | |
| 66 | - | if p.candidate.Plan.Snapshot { | |
| 67 | - | // PreparedDraw privately owns this snapshot. Its shared Once makes | |
| 68 | - | // transfer safe even when the caller retains copies of the draw. | |
| 69 | - | committed = p.candidate.frame | |
| 77 | + | r := p.renderer | |
| 78 | + | plan := p.candidate.Plan | |
| 79 | + | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | if p.generation != r.generation { | |
| 83 | + | // A later Prepare reused the scratch frame this draw's snapshot | |
| 84 | + | // lives in, so it cannot be applied. Its bytes may nevertheless | |
| 85 | + | // have reached the terminal, which would then differ from the | |
| 86 | + | // committed shadow in unknown ways: forget the shadow so the next | |
| 87 | + | // Prepare emits a full snapshot. | |
| 88 | + | r.hasCommitted = false | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | if plan.Snapshot || !r.hasCommitted { | |
| 92 | + | // The snapshot lives in the renderer's scratch buffer; promote it | |
| 93 | + | // and recycle the previous committed buffer as the next scratch. | |
| 94 | + | r.committed, r.scratch = p.candidate.frame, r.committed | |
| 70 | 95 | } else { | |
| 71 | - | p.candidate.Commit(&committed) | |
| 96 | + | p.candidate.Commit(&r.committed) | |
| 72 | 97 | } | |
| 73 | - | p.renderer.setCommittedFrame(committed) | |
| 98 | + | r.width = r.committed.Width | |
| 99 | + | r.height = r.committed.Height | |
| 100 | + | r.hasCommitted = true | |
| 74 | 101 | }) | |
| 75 | 102 | } | |
| 76 | 103 | ||
| 77 | 104 | // Prepare plans and encodes a transactional draw. The renderer advances only | |
| 78 | 105 | // when the returned draw is committed. Keep at most one prepared draw | |
| 79 | - | // outstanding; commit or discard it before calling Prepare again. | |
| 106 | + | // outstanding; commit or discard it before calling Prepare again. A draw | |
| 107 | + | // superseded by a later Prepare is discarded and its Commit does nothing. | |
| 108 | + | // | |
| 109 | + | // The renderer copies frame into reusable private storage, so the caller may | |
| 110 | + | // keep mutating frame between draws. | |
| 80 | 111 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | |
| 81 | 112 | var candidate DeltaCandidate | |
| 82 | 113 | var err error | |
| 83 | 114 | if err = validateCellSource(frame); err != nil { | |
| 84 | 115 | return PreparedDraw{}, err | |
| 85 | 116 | } | |
| 117 | + | // Any earlier prepared draw loses its claim on the scratch snapshot, even | |
| 118 | + | // if this Prepare fails after partially rewriting it. | |
| 119 | + | r.generation++ | |
| 86 | 120 | columns, rows := frame.Columns(), frame.Rows() | |
| 87 | 121 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 88 | 122 | plan := planSingleDamage(frame, damage[0]) | |
| 89 | - | candidate = DeltaCandidate{Plan: plan} | |
| 90 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 91 | - | candidate.frame = cloneCellSource(frame) | |
| 92 | - | } | |
| 123 | + | candidate = newDeltaCandidate(frame, plan, &r.scratch) | |
| 93 | 124 | } else { | |
| 94 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted) | |
| 125 | + | candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch) | |
| 95 | 126 | } | |
| 96 | 127 | if err != nil { | |
| 97 | 128 | return PreparedDraw{}, err | |
| 98 | 129 | } | |
| 99 | - | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | |
| 130 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)} | |
| 100 | 131 | plan := candidate.Plan | |
| 101 | 132 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |
| 102 | 133 | return prepared, nil | |
| @@ -149,17 +180,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) { | |||
| 149 | 180 | return prepared.Bytes(), nil | |
| 150 | 181 | } | |
| 151 | 182 | ||
| 152 | - | func (r *Renderer) committedFrame() Frame { | |
| 153 | - | return r.committed | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | func (r *Renderer) setCommittedFrame(frame Frame) { | |
| 157 | - | r.width = frame.Width | |
| 158 | - | r.height = frame.Height | |
| 159 | - | r.hasCommitted = true | |
| 160 | - | r.committed = frame | |
| 161 | - | } | |
| 162 | - | ||
| 163 | 183 | // copyBytes copies the buffer contents into a fresh byte slice and is used | |
| 164 | 184 | // to return output that is independent of the pooled scratch buffer. | |
| 165 | 185 | func copyBytes(buf *bytes.Buffer) []byte { | |
| @@ -24,6 +24,12 @@ type Renderer struct { | |||||
| 24 | height int | 24 | height int | ||
| 25 | hasCommitted bool | 25 | hasCommitted bool | ||
| 26 | committed Frame | 26 | committed Frame | ||
| 27 | + | // scratch receives the snapshot of the frame being prepared. It never | |||
| 28 | + | // aliases committed or caller storage: a snapshot commit swaps the two so | |||
| 29 | + | // both buffers are reused across draws without allocating. | |||
| 30 | + | scratch Frame | |||
| 31 | + | // generation identifies the latest prepared draw that owns scratch. | |||
| 32 | + | generation uint64 | |||
| 27 | } | 33 | } | ||
| 28 | 34 | ||||
| 29 | // PreparedDraw owns encoded output and its transactional delta until Commit. | 35 | // PreparedDraw owns encoded output and its transactional delta until Commit. | ||
| @@ -32,6 +38,7 @@ type PreparedDraw struct { | |||||
| 32 | renderer *Renderer | 38 | renderer *Renderer | ||
| 33 | candidate DeltaCandidate | 39 | candidate DeltaCandidate | ||
| 34 | data []byte | 40 | data []byte | ||
| 41 | + | generation uint64 | |||
| 35 | commitOnce *sync.Once | 42 | commitOnce *sync.Once | ||
| 36 | } | 43 | } | ||
| 37 | 44 | ||||
| @@ -48,7 +55,7 @@ func (r *Renderer) Reset() { | |||||
| 48 | r.width = 0 | 55 | r.width = 0 | ||
| 49 | r.height = 0 | 56 | r.height = 0 | ||
| 50 | r.hasCommitted = false | 57 | r.hasCommitted = false | ||
| 51 | - | r.committed = Frame{} | 58 | + | // The frame buffers are kept for reuse; hasCommitted gates their content. |
| 52 | } | 59 | } | ||
| 53 | 60 | ||||
| 54 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | 61 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | ||
| @@ -57,46 +64,70 @@ func (p PreparedDraw) Bytes() []byte { return p.data } | |||||
| 57 | 64 | ||||
| 58 | // Commit applies the prepared delta exactly once. Discarding it leaves the | 65 | // Commit applies the prepared delta exactly once. Discarding it leaves the | ||
| 59 | // renderer's committed state unchanged. | 66 | // renderer's committed state unchanged. | ||
| 67 | + | // | |||
| 68 | + | // Committing a draw that a later Prepare has superseded does not apply it: | |||
| 69 | + | // the renderer instead drops its committed state, so the next Prepare | |||
| 70 | + | // re-emits a full snapshot instead of diffing against a shadow that may no | |||
| 71 | + | // longer match the terminal. Draws without output are unaffected. | |||
| 60 | func (p *PreparedDraw) Commit() { | 72 | func (p *PreparedDraw) Commit() { | ||
| 61 | if p == nil || p.renderer == nil || p.commitOnce == nil { | 73 | if p == nil || p.renderer == nil || p.commitOnce == nil { | ||
| 62 | return | 74 | return | ||
| 63 | } | 75 | } | ||
| 64 | p.commitOnce.Do(func() { | 76 | p.commitOnce.Do(func() { | ||
| 65 | - | committed := p.renderer.committedFrame() | 77 | + | r := p.renderer |
| 66 | - | if p.candidate.Plan.Snapshot { | 78 | + | plan := p.candidate.Plan |
| 67 | - | // PreparedDraw privately owns this snapshot. Its shared Once makes | 79 | + | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { |
| 68 | - | // transfer safe even when the caller retains copies of the draw. | 80 | + | return |
| 69 | - | committed = p.candidate.frame | 81 | + | } |
| 82 | + | if p.generation != r.generation { | |||
| 83 | + | // A later Prepare reused the scratch frame this draw's snapshot | |||
| 84 | + | // lives in, so it cannot be applied. Its bytes may nevertheless | |||
| 85 | + | // have reached the terminal, which would then differ from the | |||
| 86 | + | // committed shadow in unknown ways: forget the shadow so the next | |||
| 87 | + | // Prepare emits a full snapshot. | |||
| 88 | + | r.hasCommitted = false | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | if plan.Snapshot || !r.hasCommitted { | |||
| 92 | + | // The snapshot lives in the renderer's scratch buffer; promote it | |||
| 93 | + | // and recycle the previous committed buffer as the next scratch. | |||
| 94 | + | r.committed, r.scratch = p.candidate.frame, r.committed | |||
| 70 | } else { | 95 | } else { | ||
| 71 | - | p.candidate.Commit(&committed) | 96 | + | p.candidate.Commit(&r.committed) |
| 72 | } | 97 | } | ||
| 73 | - | p.renderer.setCommittedFrame(committed) | 98 | + | r.width = r.committed.Width |
| 99 | + | r.height = r.committed.Height | |||
| 100 | + | r.hasCommitted = true | |||
| 74 | }) | 101 | }) | ||
| 75 | } | 102 | } | ||
| 76 | 103 | ||||
| 77 | // Prepare plans and encodes a transactional draw. The renderer advances only | 104 | // Prepare plans and encodes a transactional draw. The renderer advances only | ||
| 78 | // when the returned draw is committed. Keep at most one prepared draw | 105 | // when the returned draw is committed. Keep at most one prepared draw | ||
| 79 | - | // outstanding; commit or discard it before calling Prepare again. | 106 | + | // outstanding; commit or discard it before calling Prepare again. A draw |
| 107 | + | // superseded by a later Prepare is discarded and its Commit does nothing. | |||
| 108 | + | // | |||
| 109 | + | // The renderer copies frame into reusable private storage, so the caller may | |||
| 110 | + | // keep mutating frame between draws. | |||
| 80 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | 111 | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { | ||
| 81 | var candidate DeltaCandidate | 112 | var candidate DeltaCandidate | ||
| 82 | var err error | 113 | var err error | ||
| 83 | if err = validateCellSource(frame); err != nil { | 114 | if err = validateCellSource(frame); err != nil { | ||
| 84 | return PreparedDraw{}, err | 115 | return PreparedDraw{}, err | ||
| 85 | } | 116 | } | ||
| 117 | + | // Any earlier prepared draw loses its claim on the scratch snapshot, even | |||
| 118 | + | // if this Prepare fails after partially rewriting it. | |||
| 119 | + | r.generation++ | |||
| 86 | columns, rows := frame.Columns(), frame.Rows() | 120 | columns, rows := frame.Columns(), frame.Rows() | ||
| 87 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | 121 | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | ||
| 88 | plan := planSingleDamage(frame, damage[0]) | 122 | plan := planSingleDamage(frame, damage[0]) | ||
| 89 | - | candidate = DeltaCandidate{Plan: plan} | 123 | + | candidate = newDeltaCandidate(frame, plan, &r.scratch) |
| 90 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |||
| 91 | - | candidate.frame = cloneCellSource(frame) | |||
| 92 | - | } | |||
| 93 | } else { | 124 | } else { | ||
| 94 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted) | 125 | + | candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch) |
| 95 | } | 126 | } | ||
| 96 | if err != nil { | 127 | if err != nil { | ||
| 97 | return PreparedDraw{}, err | 128 | return PreparedDraw{}, err | ||
| 98 | } | 129 | } | ||
| 99 | - | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | 130 | + | prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)} |
| 100 | plan := candidate.Plan | 131 | plan := candidate.Plan | ||
| 101 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | 132 | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | ||
| 102 | return prepared, nil | 133 | return prepared, nil | ||
| @@ -149,17 +180,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) { | |||||
| 149 | return prepared.Bytes(), nil | 180 | return prepared.Bytes(), nil | ||
| 150 | } | 181 | } | ||
| 151 | 182 | ||||
| 152 | - | func (r *Renderer) committedFrame() Frame { | |||
| 153 | - | return r.committed | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | func (r *Renderer) setCommittedFrame(frame Frame) { | |||
| 157 | - | r.width = frame.Width | |||
| 158 | - | r.height = frame.Height | |||
| 159 | - | r.hasCommitted = true | |||
| 160 | - | r.committed = frame | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | // copyBytes copies the buffer contents into a fresh byte slice and is used | 183 | // copyBytes copies the buffer contents into a fresh byte slice and is used | ||
| 164 | // to return output that is independent of the pooled scratch buffer. | 184 | // to return output that is independent of the pooled scratch buffer. | ||
| 165 | func copyBytes(buf *bytes.Buffer) []byte { | 185 | func copyBytes(buf *bytes.Buffer) []byte { | ||
M ansi/scroll.go +7 -0
| @@ -3,6 +3,8 @@ package ansi | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "strconv" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev-vt/core" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | |
| @@ -92,6 +94,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com | |||
| 92 | 94 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | |
| 93 | 95 | start, end, offset := scrollRetainedRows(scroll) | |
| 94 | 96 | for y := start; y < end; y++ { | |
| 97 | + | // Scrolls are full width, so a retained row that matches whole needs no | |
| 98 | + | // per-cell damage lookups. | |
| 99 | + | if scroll.X == 0 && scroll.Width == frame.Width && core.RowsEqualAt(committed, y+offset, frame, y) { | |
| 100 | + | continue | |
| 101 | + | } | |
| 95 | 102 | for x := range scroll.Width { | |
| 96 | 103 | column := scroll.X + x | |
| 97 | 104 | committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y) | |
| @@ -3,6 +3,8 @@ package ansi | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | 10 | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { | ||
| @@ -92,6 +94,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com | |||||
| 92 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | 94 | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | ||
| 93 | start, end, offset := scrollRetainedRows(scroll) | 95 | start, end, offset := scrollRetainedRows(scroll) | ||
| 94 | for y := start; y < end; y++ { | 96 | for y := start; y < end; y++ { | ||
| 97 | + | // Scrolls are full width, so a retained row that matches whole needs no | |||
| 98 | + | // per-cell damage lookups. | |||
| 99 | + | if scroll.X == 0 && scroll.Width == frame.Width && core.RowsEqualAt(committed, y+offset, frame, y) { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 95 | for x := range scroll.Width { | 102 | for x := range scroll.Width { | ||
| 96 | column := scroll.X + x | 103 | column := scroll.X + x | ||
| 97 | committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y) | 104 | committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y) | ||
M ansi/scroll_down_test.go +53 -0
| @@ -77,3 +77,56 @@ func TestDownwardScrollMismatchFallsBack(t *testing.T) { | |||
| 77 | 77 | } | |
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | + | ||
| 81 | + | func TestStaleCommitForcesSnapshotAndReplaysCorrectly(t *testing.T) { | |
| 82 | + | for _, deliverB := range []bool{false, true} { | |
| 83 | + | frame := vt.NewFrame(10, 4) | |
| 84 | + | for y := range frame.Height { | |
| 85 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()}) | |
| 86 | + | } | |
| 87 | + | r := ansi.New(ansi.Capabilities{}) | |
| 88 | + | terminal := vt.NewScreen(frame.Width, frame.Height) | |
| 89 | + | initial, err := r.Draw(frame, nil) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | terminal.Write(initial) | |
| 94 | + | ||
| 95 | + | frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()}) | |
| 96 | + | a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | terminal.Write(a.Bytes()) | |
| 101 | + | frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.Style{Bold: true, Foreground: 2, Background: -1}}) | |
| 102 | + | b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if deliverB { | |
| 107 | + | terminal.Write(b.Bytes()) | |
| 108 | + | } | |
| 109 | + | a.Commit() // superseded by b: must drop the committed shadow | |
| 110 | + | ||
| 111 | + | next, err := r.Prepare(frame, nil, false) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | if len(next.Bytes()) == 0 || !bytes.Contains(next.Bytes(), []byte("a")) || !bytes.Contains(next.Bytes(), []byte("d")) { | |
| 116 | + | t.Fatalf("deliverB=%v: next prepare is not a full snapshot: %q", deliverB, next.Bytes()) | |
| 117 | + | } | |
| 118 | + | terminal.Write(next.Bytes()) | |
| 119 | + | next.Commit() | |
| 120 | + | for y := range frame.Height { | |
| 121 | + | for x := range frame.Width { | |
| 122 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |
| 123 | + | t.Fatalf("deliverB=%v: cell %d,%d = %+v, want %+v", deliverB, x, y, terminal.Cell(x, y), frame.Cell(x, y)) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | unchanged, err := r.Draw(frame, nil) | |
| 128 | + | if err != nil || len(unchanged) != 0 { | |
| 129 | + | t.Fatalf("committed shadow differs after snapshot: %q, %v", unchanged, err) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -77,3 +77,56 @@ func TestDownwardScrollMismatchFallsBack(t *testing.T) { | |||||
| 77 | } | 77 | } | ||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | + | ||||
| 81 | + | func TestStaleCommitForcesSnapshotAndReplaysCorrectly(t *testing.T) { | |||
| 82 | + | for _, deliverB := range []bool{false, true} { | |||
| 83 | + | frame := vt.NewFrame(10, 4) | |||
| 84 | + | for y := range frame.Height { | |||
| 85 | + | frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()}) | |||
| 86 | + | } | |||
| 87 | + | r := ansi.New(ansi.Capabilities{}) | |||
| 88 | + | terminal := vt.NewScreen(frame.Width, frame.Height) | |||
| 89 | + | initial, err := r.Draw(frame, nil) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | terminal.Write(initial) | |||
| 94 | + | ||||
| 95 | + | frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()}) | |||
| 96 | + | a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | terminal.Write(a.Bytes()) | |||
| 101 | + | frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.Style{Bold: true, Foreground: 2, Background: -1}}) | |||
| 102 | + | b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if deliverB { | |||
| 107 | + | terminal.Write(b.Bytes()) | |||
| 108 | + | } | |||
| 109 | + | a.Commit() // superseded by b: must drop the committed shadow | |||
| 110 | + | ||||
| 111 | + | next, err := r.Prepare(frame, nil, false) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | if len(next.Bytes()) == 0 || !bytes.Contains(next.Bytes(), []byte("a")) || !bytes.Contains(next.Bytes(), []byte("d")) { | |||
| 116 | + | t.Fatalf("deliverB=%v: next prepare is not a full snapshot: %q", deliverB, next.Bytes()) | |||
| 117 | + | } | |||
| 118 | + | terminal.Write(next.Bytes()) | |||
| 119 | + | next.Commit() | |||
| 120 | + | for y := range frame.Height { | |||
| 121 | + | for x := range frame.Width { | |||
| 122 | + | if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) { | |||
| 123 | + | t.Fatalf("deliverB=%v: cell %d,%d = %+v, want %+v", deliverB, x, y, terminal.Cell(x, y), frame.Cell(x, y)) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | unchanged, err := r.Draw(frame, nil) | |||
| 128 | + | if err != nil || len(unchanged) != 0 { | |||
| 129 | + | t.Fatalf("committed shadow differs after snapshot: %q, %v", unchanged, err) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
A ansi/shadow_helpers_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package ansi | |
| 2 | + | ||
| 3 | + | // committedFrame returns the renderer's committed frame (sharing its storage). | |
| 4 | + | func (r *Renderer) committedFrame() Frame { return r.committed } | |
| 5 | + | ||
| 6 | + | // setCommittedFrame installs frame as the committed shadow. | |
| 7 | + | func (r *Renderer) setCommittedFrame(frame Frame) { | |
| 8 | + | r.width = frame.Width | |
| 9 | + | r.height = frame.Height | |
| 10 | + | r.hasCommitted = true | |
| 11 | + | r.committed = frame | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // replaceFrame overwrites dst's own storage with a copy of src, reusing | |
| 15 | + | // capacity. | |
| 16 | + | func replaceFrame(dst *Frame, src Frame) { dst.CopyFrom(src) } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package ansi | |||
| 2 | + | ||||
| 3 | + | // committedFrame returns the renderer's committed frame (sharing its storage). | |||
| 4 | + | func (r *Renderer) committedFrame() Frame { return r.committed } | |||
| 5 | + | ||||
| 6 | + | // setCommittedFrame installs frame as the committed shadow. | |||
| 7 | + | func (r *Renderer) setCommittedFrame(frame Frame) { | |||
| 8 | + | r.width = frame.Width | |||
| 9 | + | r.height = frame.Height | |||
| 10 | + | r.hasCommitted = true | |||
| 11 | + | r.committed = frame | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // replaceFrame overwrites dst's own storage with a copy of src, reusing | |||
| 15 | + | // capacity. | |||
| 16 | + | func replaceFrame(dst *Frame, src Frame) { dst.CopyFrom(src) } | |||
A ansi/shadow_property_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package ansi | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "math/rand/v2" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev-vt/core" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // genericSource hides the concrete core.Frame type so the renderer must use | |
| 13 | + | // the generic CellSource paths. | |
| 14 | + | type genericSource struct{ f Frame } | |
| 15 | + | ||
| 16 | + | func (g genericSource) Columns() int { return g.f.Columns() } | |
| 17 | + | func (g genericSource) Rows() int { return g.f.Rows() } | |
| 18 | + | func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) } | |
| 19 | + | ||
| 20 | + | var propertyStyles = []Style{ | |
| 21 | + | DefaultStyle(), | |
| 22 | + | {Bold: true, Foreground: 1, Background: -1}, | |
| 23 | + | {Italic: true, Foreground: -1, Background: 4}, | |
| 24 | + | {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}}, | |
| 25 | + | {Inverse: true, Foreground: 2, Background: 3}, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func propertyCell(r *rand.Rand) Cell { | |
| 29 | + | c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]} | |
| 30 | + | if r.IntN(12) == 0 { | |
| 31 | + | if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil { | |
| 32 | + | c.Payload = p | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | return c | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage { | |
| 39 | + | d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)} | |
| 40 | + | d.Width = 1 + r.IntN(w-d.X) | |
| 41 | + | d.Height = 1 + r.IntN(h-d.Y) | |
| 42 | + | if kind == DamageScrollUp || kind == DamageScrollDown { | |
| 43 | + | d.X, d.Width = 0, w | |
| 44 | + | d.Count = 1 + r.IntN(max(1, d.Height)) | |
| 45 | + | } | |
| 46 | + | return d | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func propertyDamage(r *rand.Rand, w, h int) []Damage { | |
| 50 | + | switch r.IntN(6) { | |
| 51 | + | case 0: | |
| 52 | + | return nil | |
| 53 | + | case 1: | |
| 54 | + | return []Damage{FullRedraw()} | |
| 55 | + | case 2: | |
| 56 | + | return []Damage{propertyRect(r, w, h, DamageText)} | |
| 57 | + | case 3: | |
| 58 | + | return []Damage{propertyRect(r, w, h, DamageClear)} | |
| 59 | + | case 4: | |
| 60 | + | return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)} | |
| 61 | + | default: | |
| 62 | + | out := []Damage{propertyRect(r, w, h, DamageScrollDown)} | |
| 63 | + | for range r.IntN(4) { | |
| 64 | + | out = append(out, propertyRect(r, w, h, DamageText)) | |
| 65 | + | } | |
| 66 | + | return out | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // TestShadowMatchesCloneReference drives one renderer (reused buffers) and a | |
| 71 | + | // reference built from the public clone-based PlanDelta/Commit pair through | |
| 72 | + | // random edits, under-reported damage, scrolls, resets, resizes, discarded | |
| 73 | + | // draws and stale commits. The committed shadows must always agree. | |
| 74 | + | func TestShadowMatchesCloneReference(t *testing.T) { | |
| 75 | + | for seed := range uint64(40) { | |
| 76 | + | t.Run(fmt.Sprint(seed), func(t *testing.T) { | |
| 77 | + | r := rand.New(rand.NewPCG(seed, 0xa11ce)) | |
| 78 | + | rend := New(Capabilities{}) | |
| 79 | + | var ref Frame | |
| 80 | + | refHas := false | |
| 81 | + | w, h := 3+r.IntN(10), 2+r.IntN(8) | |
| 82 | + | frame := NewFrame(w, h) | |
| 83 | + | var outstanding *PreparedDraw // last prepared draw, for stale commits | |
| 84 | + | ||
| 85 | + | for step := range 150 { | |
| 86 | + | // Mutate the caller's single frame in place. | |
| 87 | + | switch r.IntN(8) { | |
| 88 | + | case 0: | |
| 89 | + | w, h = 3+r.IntN(10), 2+r.IntN(8) | |
| 90 | + | frame = NewFrame(w, h) | |
| 91 | + | case 1: | |
| 92 | + | top := r.IntN(h) | |
| 93 | + | bottom := top + r.IntN(h-top) | |
| 94 | + | n := 1 + r.IntN(bottom-top+1) | |
| 95 | + | if r.IntN(2) == 0 { | |
| 96 | + | frame.ScrollUp(top, bottom, n) | |
| 97 | + | } else { | |
| 98 | + | frame.ScrollDown(top, bottom, n) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | for range r.IntN(12) { | |
| 102 | + | frame.Set(r.IntN(w), r.IntN(h), propertyCell(r)) | |
| 103 | + | } | |
| 104 | + | damage := propertyDamage(r, w, h) | |
| 105 | + | reset := r.IntN(15) == 0 | |
| 106 | + | ||
| 107 | + | var source CellSource = frame | |
| 108 | + | switch r.IntN(4) { | |
| 109 | + | case 0: | |
| 110 | + | source = &frame | |
| 111 | + | case 1: | |
| 112 | + | source = genericSource{frame} | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Reference plan against a clone-based committed shadow. | |
| 116 | + | refReset := reset || !refHas | |
| 117 | + | want, err := PlanDelta(source, damage, ref, refReset) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | prepared, err := rend.Prepare(source, damage, reset) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) { | |
| 126 | + | t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan) | |
| 127 | + | } | |
| 128 | + | if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) { | |
| 129 | + | t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Caller keeps mutating after Prepare, before Commit. | |
| 133 | + | snapshotMutation := r.IntN(3) == 0 | |
| 134 | + | if snapshotMutation { | |
| 135 | + | frame.Set(r.IntN(w), r.IntN(h), propertyCell(r)) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | switch k := r.IntN(10); { | |
| 139 | + | case k < 6: // commit | |
| 140 | + | prepared.Commit() | |
| 141 | + | want.Commit(&ref) | |
| 142 | + | refHas = refHas || want.frame.Width != 0 | |
| 143 | + | if want.frame.Width == 0 && !refHas { | |
| 144 | + | // A no-op candidate with no shadow cannot occur: reset snapshots. | |
| 145 | + | t.Fatalf("step %d: empty plan without committed state", step) | |
| 146 | + | } | |
| 147 | + | outstanding = nil | |
| 148 | + | case k < 8: // discard | |
| 149 | + | outstanding = &prepared | |
| 150 | + | default: // stale commit of the previous discarded draw | |
| 151 | + | if outstanding != nil { | |
| 152 | + | if hasWork(outstanding.candidate.Plan) { | |
| 153 | + | outstanding.Commit() | |
| 154 | + | refHas = false | |
| 155 | + | } else { | |
| 156 | + | outstanding.Commit() | |
| 157 | + | } | |
| 158 | + | outstanding = nil | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | if r.IntN(25) == 0 { | |
| 162 | + | rend.Reset() | |
| 163 | + | refHas = false | |
| 164 | + | ref = Frame{} | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | if rend.hasCommitted != refHas { | |
| 168 | + | t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas) | |
| 169 | + | } | |
| 170 | + | if refHas { | |
| 171 | + | if !framesEqual(rend.committed, ref) { | |
| 172 | + | t.Fatalf("step %d: committed shadow differs from reference", step) | |
| 173 | + | } | |
| 174 | + | if err := rend.committed.CheckInvariants(); err != nil { | |
| 175 | + | t.Fatalf("step %d: %v", step, err) | |
| 176 | + | } | |
| 177 | + | if rend.width != ref.Width || rend.height != ref.Height { | |
| 178 | + | t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | }) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 } | |
| 187 | + | ||
| 188 | + | func framesEqual(a, b Frame) bool { | |
| 189 | + | if a.Width != b.Width || a.Height != b.Height { | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | for y := range a.Height { | |
| 193 | + | for x := range a.Width { | |
| 194 | + | if !a.Cell(x, y).Equal(b.Cell(x, y)) { | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return true | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package ansi | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "math/rand/v2" | |||
| 6 | + | "reflect" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev-vt/core" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // genericSource hides the concrete core.Frame type so the renderer must use | |||
| 13 | + | // the generic CellSource paths. | |||
| 14 | + | type genericSource struct{ f Frame } | |||
| 15 | + | ||||
| 16 | + | func (g genericSource) Columns() int { return g.f.Columns() } | |||
| 17 | + | func (g genericSource) Rows() int { return g.f.Rows() } | |||
| 18 | + | func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) } | |||
| 19 | + | ||||
| 20 | + | var propertyStyles = []Style{ | |||
| 21 | + | DefaultStyle(), | |||
| 22 | + | {Bold: true, Foreground: 1, Background: -1}, | |||
| 23 | + | {Italic: true, Foreground: -1, Background: 4}, | |||
| 24 | + | {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}}, | |||
| 25 | + | {Inverse: true, Foreground: 2, Background: 3}, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func propertyCell(r *rand.Rand) Cell { | |||
| 29 | + | c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]} | |||
| 30 | + | if r.IntN(12) == 0 { | |||
| 31 | + | if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil { | |||
| 32 | + | c.Payload = p | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | return c | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage { | |||
| 39 | + | d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)} | |||
| 40 | + | d.Width = 1 + r.IntN(w-d.X) | |||
| 41 | + | d.Height = 1 + r.IntN(h-d.Y) | |||
| 42 | + | if kind == DamageScrollUp || kind == DamageScrollDown { | |||
| 43 | + | d.X, d.Width = 0, w | |||
| 44 | + | d.Count = 1 + r.IntN(max(1, d.Height)) | |||
| 45 | + | } | |||
| 46 | + | return d | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func propertyDamage(r *rand.Rand, w, h int) []Damage { | |||
| 50 | + | switch r.IntN(6) { | |||
| 51 | + | case 0: | |||
| 52 | + | return nil | |||
| 53 | + | case 1: | |||
| 54 | + | return []Damage{FullRedraw()} | |||
| 55 | + | case 2: | |||
| 56 | + | return []Damage{propertyRect(r, w, h, DamageText)} | |||
| 57 | + | case 3: | |||
| 58 | + | return []Damage{propertyRect(r, w, h, DamageClear)} | |||
| 59 | + | case 4: | |||
| 60 | + | return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)} | |||
| 61 | + | default: | |||
| 62 | + | out := []Damage{propertyRect(r, w, h, DamageScrollDown)} | |||
| 63 | + | for range r.IntN(4) { | |||
| 64 | + | out = append(out, propertyRect(r, w, h, DamageText)) | |||
| 65 | + | } | |||
| 66 | + | return out | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // TestShadowMatchesCloneReference drives one renderer (reused buffers) and a | |||
| 71 | + | // reference built from the public clone-based PlanDelta/Commit pair through | |||
| 72 | + | // random edits, under-reported damage, scrolls, resets, resizes, discarded | |||
| 73 | + | // draws and stale commits. The committed shadows must always agree. | |||
| 74 | + | func TestShadowMatchesCloneReference(t *testing.T) { | |||
| 75 | + | for seed := range uint64(40) { | |||
| 76 | + | t.Run(fmt.Sprint(seed), func(t *testing.T) { | |||
| 77 | + | r := rand.New(rand.NewPCG(seed, 0xa11ce)) | |||
| 78 | + | rend := New(Capabilities{}) | |||
| 79 | + | var ref Frame | |||
| 80 | + | refHas := false | |||
| 81 | + | w, h := 3+r.IntN(10), 2+r.IntN(8) | |||
| 82 | + | frame := NewFrame(w, h) | |||
| 83 | + | var outstanding *PreparedDraw // last prepared draw, for stale commits | |||
| 84 | + | ||||
| 85 | + | for step := range 150 { | |||
| 86 | + | // Mutate the caller's single frame in place. | |||
| 87 | + | switch r.IntN(8) { | |||
| 88 | + | case 0: | |||
| 89 | + | w, h = 3+r.IntN(10), 2+r.IntN(8) | |||
| 90 | + | frame = NewFrame(w, h) | |||
| 91 | + | case 1: | |||
| 92 | + | top := r.IntN(h) | |||
| 93 | + | bottom := top + r.IntN(h-top) | |||
| 94 | + | n := 1 + r.IntN(bottom-top+1) | |||
| 95 | + | if r.IntN(2) == 0 { | |||
| 96 | + | frame.ScrollUp(top, bottom, n) | |||
| 97 | + | } else { | |||
| 98 | + | frame.ScrollDown(top, bottom, n) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | for range r.IntN(12) { | |||
| 102 | + | frame.Set(r.IntN(w), r.IntN(h), propertyCell(r)) | |||
| 103 | + | } | |||
| 104 | + | damage := propertyDamage(r, w, h) | |||
| 105 | + | reset := r.IntN(15) == 0 | |||
| 106 | + | ||||
| 107 | + | var source CellSource = frame | |||
| 108 | + | switch r.IntN(4) { | |||
| 109 | + | case 0: | |||
| 110 | + | source = &frame | |||
| 111 | + | case 1: | |||
| 112 | + | source = genericSource{frame} | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Reference plan against a clone-based committed shadow. | |||
| 116 | + | refReset := reset || !refHas | |||
| 117 | + | want, err := PlanDelta(source, damage, ref, refReset) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | prepared, err := rend.Prepare(source, damage, reset) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) { | |||
| 126 | + | t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan) | |||
| 127 | + | } | |||
| 128 | + | if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) { | |||
| 129 | + | t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Caller keeps mutating after Prepare, before Commit. | |||
| 133 | + | snapshotMutation := r.IntN(3) == 0 | |||
| 134 | + | if snapshotMutation { | |||
| 135 | + | frame.Set(r.IntN(w), r.IntN(h), propertyCell(r)) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | switch k := r.IntN(10); { | |||
| 139 | + | case k < 6: // commit | |||
| 140 | + | prepared.Commit() | |||
| 141 | + | want.Commit(&ref) | |||
| 142 | + | refHas = refHas || want.frame.Width != 0 | |||
| 143 | + | if want.frame.Width == 0 && !refHas { | |||
| 144 | + | // A no-op candidate with no shadow cannot occur: reset snapshots. | |||
| 145 | + | t.Fatalf("step %d: empty plan without committed state", step) | |||
| 146 | + | } | |||
| 147 | + | outstanding = nil | |||
| 148 | + | case k < 8: // discard | |||
| 149 | + | outstanding = &prepared | |||
| 150 | + | default: // stale commit of the previous discarded draw | |||
| 151 | + | if outstanding != nil { | |||
| 152 | + | if hasWork(outstanding.candidate.Plan) { | |||
| 153 | + | outstanding.Commit() | |||
| 154 | + | refHas = false | |||
| 155 | + | } else { | |||
| 156 | + | outstanding.Commit() | |||
| 157 | + | } | |||
| 158 | + | outstanding = nil | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | if r.IntN(25) == 0 { | |||
| 162 | + | rend.Reset() | |||
| 163 | + | refHas = false | |||
| 164 | + | ref = Frame{} | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | if rend.hasCommitted != refHas { | |||
| 168 | + | t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas) | |||
| 169 | + | } | |||
| 170 | + | if refHas { | |||
| 171 | + | if !framesEqual(rend.committed, ref) { | |||
| 172 | + | t.Fatalf("step %d: committed shadow differs from reference", step) | |||
| 173 | + | } | |||
| 174 | + | if err := rend.committed.CheckInvariants(); err != nil { | |||
| 175 | + | t.Fatalf("step %d: %v", step, err) | |||
| 176 | + | } | |||
| 177 | + | if rend.width != ref.Width || rend.height != ref.Height { | |||
| 178 | + | t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | }) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 } | |||
| 187 | + | ||||
| 188 | + | func framesEqual(a, b Frame) bool { | |||
| 189 | + | if a.Width != b.Width || a.Height != b.Height { | |||
| 190 | + | return false | |||
| 191 | + | } | |||
| 192 | + | for y := range a.Height { | |||
| 193 | + | for x := range a.Width { | |||
| 194 | + | if !a.Cell(x, y).Equal(b.Cell(x, y)) { | |||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | return true | |||
| 200 | + | } | |||
A core/frame_copy.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 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 | + | // The cache refers to a slot in the style table copied above, so copying it | |
| 36 | + | // keeps it valid. Leaving the destination's old cache would point at a slot | |
| 37 | + | // that may now hold a different style. | |
| 38 | + | dst.styleCache, dst.styleCacheID, dst.styleCacheOK = from.styleCache, from.styleCacheID, from.styleCacheOK | |
| 39 | + | if dst.styleIndex == nil { | |
| 40 | + | dst.styleIndex = make(map[Style]uint32, len(from.styleIndex)) | |
| 41 | + | } else { | |
| 42 | + | clear(dst.styleIndex) | |
| 43 | + | } | |
| 44 | + | maps.Copy(dst.styleIndex, from.styleIndex) | |
| 45 | + | dst.payloads = append(dst.payloads[:0], from.payloads...) | |
| 46 | + | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | |
| 47 | + | dst.payloadBytes = from.payloadBytes | |
| 48 | + | if len(from.payloadIndex) == 0 { | |
| 49 | + | clear(dst.payloadIndex) | |
| 50 | + | } else { | |
| 51 | + | if dst.payloadIndex == nil { | |
| 52 | + | dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex)) | |
| 53 | + | } else { | |
| 54 | + | clear(dst.payloadIndex) | |
| 55 | + | } | |
| 56 | + | maps.Copy(dst.payloadIndex, from.payloadIndex) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // RowsEqual reports whether logical row y holds semantically equal cells in a | |
| 61 | + | // and b (the same comparison as Cell.Equal for every column). It compares | |
| 62 | + | // compact stored cells directly and resolves page-local style and payload IDs | |
| 63 | + | // only when they are not both the default, so it does not allocate and does not | |
| 64 | + | // require the two frames to share ID tables. Frames of different width, an | |
| 65 | + | // out-of-range row, or inconsistent storage compare unequal. | |
| 66 | + | func RowsEqual(a, b Frame, y int) bool { | |
| 67 | + | return RowsEqualAt(a, y, b, y) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // RowsEqualAt is RowsEqual for logical row ya of a against logical row yb of b. | |
| 71 | + | func RowsEqualAt(a Frame, ya int, b Frame, yb int) bool { | |
| 72 | + | if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil || | |
| 73 | + | ya < 0 || ya >= a.Height || yb < 0 || yb >= b.Height || | |
| 74 | + | ya >= len(a.page.rows) || yb >= len(b.page.rows) { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | width := a.Width | |
| 78 | + | offA, offB := int(a.page.rows[ya]), int(b.page.rows[yb]) | |
| 79 | + | if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | rowA := a.page.cells[offA : offA+width] | |
| 83 | + | rowB := b.page.cells[offB : offB+width] | |
| 84 | + | ||
| 85 | + | // Remember the last style ID pair that compared equal so runs of one style | |
| 86 | + | // cost a single table lookup. | |
| 87 | + | var lastStyleA, lastStyleB uint32 | |
| 88 | + | for x := range rowA { | |
| 89 | + | ca, cb := rowA[x], rowB[x] | |
| 90 | + | if ca.rune != cb.rune || ca.flags != cb.flags { | |
| 91 | + | return false | |
| 92 | + | } | |
| 93 | + | if ca.styleID != 0 || cb.styleID != 0 { | |
| 94 | + | if ca.styleID != lastStyleA || cb.styleID != lastStyleB { | |
| 95 | + | if !a.storedStylesEqual(b, ca.styleID, cb.styleID) { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | lastStyleA, lastStyleB = ca.styleID, cb.styleID | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if ca.payloadID != 0 || cb.payloadID != 0 { | |
| 102 | + | if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) { | |
| 103 | + | return false | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return true | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool { | |
| 111 | + | if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | sa, sb := f.page.styles[id].style, other.page.styles[otherID].style | |
| 115 | + | return sa == sb || sa.Equal(sb) | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 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 | + | // The cache refers to a slot in the style table copied above, so copying it | |||
| 36 | + | // keeps it valid. Leaving the destination's old cache would point at a slot | |||
| 37 | + | // that may now hold a different style. | |||
| 38 | + | dst.styleCache, dst.styleCacheID, dst.styleCacheOK = from.styleCache, from.styleCacheID, from.styleCacheOK | |||
| 39 | + | if dst.styleIndex == nil { | |||
| 40 | + | dst.styleIndex = make(map[Style]uint32, len(from.styleIndex)) | |||
| 41 | + | } else { | |||
| 42 | + | clear(dst.styleIndex) | |||
| 43 | + | } | |||
| 44 | + | maps.Copy(dst.styleIndex, from.styleIndex) | |||
| 45 | + | dst.payloads = append(dst.payloads[:0], from.payloads...) | |||
| 46 | + | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | |||
| 47 | + | dst.payloadBytes = from.payloadBytes | |||
| 48 | + | if len(from.payloadIndex) == 0 { | |||
| 49 | + | clear(dst.payloadIndex) | |||
| 50 | + | } else { | |||
| 51 | + | if dst.payloadIndex == nil { | |||
| 52 | + | dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex)) | |||
| 53 | + | } else { | |||
| 54 | + | clear(dst.payloadIndex) | |||
| 55 | + | } | |||
| 56 | + | maps.Copy(dst.payloadIndex, from.payloadIndex) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // RowsEqual reports whether logical row y holds semantically equal cells in a | |||
| 61 | + | // and b (the same comparison as Cell.Equal for every column). It compares | |||
| 62 | + | // compact stored cells directly and resolves page-local style and payload IDs | |||
| 63 | + | // only when they are not both the default, so it does not allocate and does not | |||
| 64 | + | // require the two frames to share ID tables. Frames of different width, an | |||
| 65 | + | // out-of-range row, or inconsistent storage compare unequal. | |||
| 66 | + | func RowsEqual(a, b Frame, y int) bool { | |||
| 67 | + | return RowsEqualAt(a, y, b, y) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // RowsEqualAt is RowsEqual for logical row ya of a against logical row yb of b. | |||
| 71 | + | func RowsEqualAt(a Frame, ya int, b Frame, yb int) bool { | |||
| 72 | + | if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil || | |||
| 73 | + | ya < 0 || ya >= a.Height || yb < 0 || yb >= b.Height || | |||
| 74 | + | ya >= len(a.page.rows) || yb >= len(b.page.rows) { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | width := a.Width | |||
| 78 | + | offA, offB := int(a.page.rows[ya]), int(b.page.rows[yb]) | |||
| 79 | + | if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | rowA := a.page.cells[offA : offA+width] | |||
| 83 | + | rowB := b.page.cells[offB : offB+width] | |||
| 84 | + | ||||
| 85 | + | // Remember the last style ID pair that compared equal so runs of one style | |||
| 86 | + | // cost a single table lookup. | |||
| 87 | + | var lastStyleA, lastStyleB uint32 | |||
| 88 | + | for x := range rowA { | |||
| 89 | + | ca, cb := rowA[x], rowB[x] | |||
| 90 | + | if ca.rune != cb.rune || ca.flags != cb.flags { | |||
| 91 | + | return false | |||
| 92 | + | } | |||
| 93 | + | if ca.styleID != 0 || cb.styleID != 0 { | |||
| 94 | + | if ca.styleID != lastStyleA || cb.styleID != lastStyleB { | |||
| 95 | + | if !a.storedStylesEqual(b, ca.styleID, cb.styleID) { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | lastStyleA, lastStyleB = ca.styleID, cb.styleID | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | if ca.payloadID != 0 || cb.payloadID != 0 { | |||
| 102 | + | if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) { | |||
| 103 | + | return false | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | return true | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool { | |||
| 111 | + | if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | sa, sb := f.page.styles[id].style, other.page.styles[otherID].style | |||
| 115 | + | return sa == sb || sa.Equal(sb) | |||
| 116 | + | } | |||
A core/frame_copy_test.go +299 -0
| @@ -0,0 +1,299 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "reflect" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding | |
| 10 | + | // a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom | |
| 11 | + | // (and this count) are updated. | |
| 12 | + | const cellPageFieldCount = 13 | |
| 13 | + | ||
| 14 | + | func copyTestPayload(t *testing.T, grapheme, link string) CellPayload { | |
| 15 | + | t.Helper() | |
| 16 | + | p, err := NewCellPayload(grapheme, link) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | return p | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func copyTestFrame(t *testing.T, width, height int, seed rune) Frame { | |
| 24 | + | t.Helper() | |
| 25 | + | f := NewFrame(width, height) | |
| 26 | + | styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}} | |
| 27 | + | for y := range height { | |
| 28 | + | for x := range width { | |
| 29 | + | c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]} | |
| 30 | + | if (x+y)%5 == 0 { | |
| 31 | + | c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3))) | |
| 32 | + | } | |
| 33 | + | f.Set(x, y, c) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | if height > 2 { | |
| 37 | + | f.ScrollUp(0, height-1, 1) | |
| 38 | + | } | |
| 39 | + | return f | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func requireFramesEqual(t *testing.T, got, want Frame) { | |
| 43 | + | t.Helper() | |
| 44 | + | if got.Width != want.Width || got.Height != want.Height { | |
| 45 | + | t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height) | |
| 46 | + | } | |
| 47 | + | for y := range want.Height { | |
| 48 | + | if !reflect.DeepEqual(got.Row(y), want.Row(y)) { | |
| 49 | + | t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y)) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if err := got.CheckInvariants(); err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) { | |
| 58 | + | src := copyTestFrame(t, 9, 5, 'a') | |
| 59 | + | var dst Frame | |
| 60 | + | dst.CopyFrom(src) | |
| 61 | + | requireFramesEqual(t, dst, src) | |
| 62 | + | if dst.page == src.page { | |
| 63 | + | t.Fatal("copy aliases source page") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Mutating the source must not affect the copy. | |
| 67 | + | before := dst.Clone() | |
| 68 | + | src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}}) | |
| 69 | + | requireFramesEqual(t, dst, before) | |
| 70 | + | ||
| 71 | + | // Copy over a different, larger-state destination: the page pointer and | |
| 72 | + | // backing arrays are reused and stale styles/payloads do not leak. | |
| 73 | + | other := copyTestFrame(t, 9, 5, 'k') | |
| 74 | + | other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}) | |
| 75 | + | page := dst.page | |
| 76 | + | cells := &dst.page.cells[0] | |
| 77 | + | dst.CopyFrom(other) | |
| 78 | + | if dst.page != page || &dst.page.cells[0] != cells { | |
| 79 | + | t.Fatal("CopyFrom did not reuse destination storage") | |
| 80 | + | } | |
| 81 | + | requireFramesEqual(t, dst, other) | |
| 82 | + | if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() { | |
| 83 | + | t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // The copy stays a fully functional, independent frame. | |
| 87 | + | dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()}) | |
| 88 | + | dst.ScrollDown(0, 4, 2) | |
| 89 | + | if err := dst.CheckInvariants(); err != nil { | |
| 90 | + | t.Fatal(err) | |
| 91 | + | } | |
| 92 | + | requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f } | |
| 96 | + | ||
| 97 | + | func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) { | |
| 98 | + | dst := copyTestFrame(t, 4, 3, 'a') | |
| 99 | + | small := copyTestFrame(t, 2, 2, 'x') | |
| 100 | + | dst.CopyFrom(small) | |
| 101 | + | requireFramesEqual(t, dst, small) | |
| 102 | + | large := copyTestFrame(t, 12, 7, 'm') | |
| 103 | + | dst.CopyFrom(large) | |
| 104 | + | requireFramesEqual(t, dst, large) | |
| 105 | + | ||
| 106 | + | before := dst.Clone() | |
| 107 | + | dst.CopyFrom(Frame{}) | |
| 108 | + | dst.CopyFrom(Frame{Width: 3, Height: 3}) | |
| 109 | + | requireFramesEqual(t, dst, before) | |
| 110 | + | dst.CopyFrom(dst) | |
| 111 | + | requireFramesEqual(t, dst, before) | |
| 112 | + | var nilFrame *Frame | |
| 113 | + | nilFrame.CopyFrom(large) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) { | |
| 117 | + | src := copyTestFrame(t, 40, 10, 'a') | |
| 118 | + | dst := src.Clone() | |
| 119 | + | dst.CopyFrom(src) | |
| 120 | + | if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 { | |
| 121 | + | t.Fatalf("CopyFrom allocated %v times", n) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestRowsEqual(t *testing.T) { | |
| 126 | + | a := copyTestFrame(t, 10, 4, 'a') | |
| 127 | + | b := a.Clone() | |
| 128 | + | for y := range a.Height { | |
| 129 | + | if !RowsEqual(a, b, y) { | |
| 130 | + | t.Fatalf("clone row %d differs", y) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Equal content with different physical rotation and different ID tables. | |
| 135 | + | c := NewFrame(a.Width, a.Height) | |
| 136 | + | // Intern an unrelated style first so IDs differ from a's. | |
| 137 | + | c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}}) | |
| 138 | + | for y := range a.Height { | |
| 139 | + | for x := range a.Width { | |
| 140 | + | c.Set(x, y, a.Cell(x, y)) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | for y := range a.Height { | |
| 144 | + | if !RowsEqual(a, c, y) { | |
| 145 | + | t.Fatalf("semantically equal row %d reported different", y) | |
| 146 | + | } | |
| 147 | + | if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want { | |
| 148 | + | t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Each kind of difference is detected on the right row only. | |
| 153 | + | diffs := []Cell{ | |
| 154 | + | {Rune: 'Q', Style: a.Cell(3, 1).Style}, | |
| 155 | + | {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}}, | |
| 156 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")}, | |
| 157 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true}, | |
| 158 | + | } | |
| 159 | + | for i, d := range diffs { | |
| 160 | + | m := a.Clone() | |
| 161 | + | m.Set(3, 1, d) | |
| 162 | + | for y := range a.Height { | |
| 163 | + | want := y != 1 | |
| 164 | + | if got := RowsEqual(a, m, y); got != want { | |
| 165 | + | t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Payload present on one side only, and differing payloads. | |
| 171 | + | m := a.Clone() | |
| 172 | + | px := -1 | |
| 173 | + | for x := range a.Width { | |
| 174 | + | if a.Cell(x, 0).Payload != (CellPayload{}) { | |
| 175 | + | px = x | |
| 176 | + | break | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | if px < 0 { | |
| 180 | + | t.Fatal("test frame row 0 has no payload") | |
| 181 | + | } | |
| 182 | + | m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style}) | |
| 183 | + | if RowsEqual(a, m, 0) { | |
| 184 | + | t.Fatal("payload removal not detected") | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Inactive RGB fields and other equivalent spellings compare equal. | |
| 188 | + | e1, e2 := NewFrame(2, 1), NewFrame(2, 1) | |
| 189 | + | s1 := Style{Bold: true, Foreground: 2, Background: -1} | |
| 190 | + | s2 := s1 | |
| 191 | + | s2.ForegroundRGB = RGB{R: 9} | |
| 192 | + | e1.Set(0, 0, Cell{Rune: 'a', Style: s1}) | |
| 193 | + | e2.Set(0, 0, Cell{Rune: 'a', Style: s2}) | |
| 194 | + | if !RowsEqual(e1, e2, 0) { | |
| 195 | + | t.Fatal("equivalent styles reported different") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Mismatched shape or range is unequal and never panics. | |
| 199 | + | if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) { | |
| 200 | + | t.Fatal("invalid comparison reported equal") | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestRowsEqualDoesNotAllocate(t *testing.T) { | |
| 205 | + | a := copyTestFrame(t, 80, 4, 'a') | |
| 206 | + | b := a.Clone() | |
| 207 | + | if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 { | |
| 208 | + | t.Fatalf("RowsEqual allocated %v times", n) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestCopyFromHandlesEveryPageField(t *testing.T) { | |
| 213 | + | typ := reflect.TypeOf(cellPage{}) | |
| 214 | + | if typ.NumField() != cellPageFieldCount { | |
| 215 | + | t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Build a source that populates every field, and a destination whose every | |
| 219 | + | // field holds different, stale state. | |
| 220 | + | src := copyTestFrame(t, 9, 5, 'a') | |
| 221 | + | src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}}) | |
| 222 | + | dst := copyTestFrame(t, 9, 5, 'k') | |
| 223 | + | dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}}) | |
| 224 | + | dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot | |
| 225 | + | dst.CopyFrom(src) | |
| 226 | + | ||
| 227 | + | sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page) | |
| 228 | + | populated := 0 | |
| 229 | + | for i := range typ.NumField() { | |
| 230 | + | name := typ.Field(i).Name | |
| 231 | + | a, b := sp.Field(i), dp.Field(i) | |
| 232 | + | if !a.IsZero() { | |
| 233 | + | populated++ | |
| 234 | + | } | |
| 235 | + | if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 { | |
| 236 | + | continue // nil and empty are equivalent | |
| 237 | + | } | |
| 238 | + | // Printing works on unexported fields and treats nil and empty alike; | |
| 239 | + | // fmt sorts map keys, so the rendering is deterministic. | |
| 240 | + | if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want { | |
| 241 | + | t.Errorf("cellPage.%s = %s, want %s", name, got, want) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty | |
| 245 | + | t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField()) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestCopyFromDropsStaleStyleCache(t *testing.T) { | |
| 250 | + | styleA := Style{Bold: true, Foreground: 1, Background: -1} | |
| 251 | + | styleB := Style{Italic: true, Foreground: 2, Background: -1} | |
| 252 | + | ||
| 253 | + | // dst: style A lives in slot k and is cached by the last Set. | |
| 254 | + | dst := NewFrame(4, 2) | |
| 255 | + | dst.Set(0, 0, Cell{Rune: 'a', Style: styleA}) | |
| 256 | + | k := dst.page.styleCacheID | |
| 257 | + | if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA { | |
| 258 | + | t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // src: a different style occupies the same slot k, and no cache. | |
| 262 | + | src := NewFrame(4, 2) | |
| 263 | + | src.Set(0, 0, Cell{Rune: 'b', Style: styleB}) | |
| 264 | + | src.page.styleCacheOK = false | |
| 265 | + | if src.page.styleIndex[styleB] != k { | |
| 266 | + | t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | dst.CopyFrom(src) | |
| 270 | + | if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache { | |
| 271 | + | t.Fatal("CopyFrom left a stale style cache") | |
| 272 | + | } | |
| 273 | + | cell := Cell{Rune: 'c', Style: styleA} | |
| 274 | + | dst.Set(1, 1, cell) | |
| 275 | + | if got := dst.Cell(1, 1); !got.Equal(cell) { | |
| 276 | + | t.Fatalf("cell = %+v, want %+v", got, cell) | |
| 277 | + | } | |
| 278 | + | if got := dst.Cell(0, 0); got.Style != styleB { | |
| 279 | + | t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB) | |
| 280 | + | } | |
| 281 | + | if err := dst.CheckInvariants(); err != nil { | |
| 282 | + | t.Fatal(err) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestRowsEqualAt(t *testing.T) { | |
| 287 | + | a := copyTestFrame(t, 10, 5, 'a') | |
| 288 | + | b := a.Clone() | |
| 289 | + | // b row y+1 shifted: compare a row y+1 against b row y after scrolling b. | |
| 290 | + | b.ScrollUp(0, 4, 1) | |
| 291 | + | for y := range 4 { | |
| 292 | + | if !RowsEqualAt(a, y+1, b, y) { | |
| 293 | + | t.Fatalf("row %d of a != row %d of scrolled b", y+1, y) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) { | |
| 297 | + | t.Fatal("unequal or out-of-range rows reported equal") | |
| 298 | + | } | |
| 299 | + | } | |
| @@ -0,0 +1,299 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding | |||
| 10 | + | // a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom | |||
| 11 | + | // (and this count) are updated. | |||
| 12 | + | const cellPageFieldCount = 13 | |||
| 13 | + | ||||
| 14 | + | func copyTestPayload(t *testing.T, grapheme, link string) CellPayload { | |||
| 15 | + | t.Helper() | |||
| 16 | + | p, err := NewCellPayload(grapheme, link) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | return p | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func copyTestFrame(t *testing.T, width, height int, seed rune) Frame { | |||
| 24 | + | t.Helper() | |||
| 25 | + | f := NewFrame(width, height) | |||
| 26 | + | styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}} | |||
| 27 | + | for y := range height { | |||
| 28 | + | for x := range width { | |||
| 29 | + | c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]} | |||
| 30 | + | if (x+y)%5 == 0 { | |||
| 31 | + | c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3))) | |||
| 32 | + | } | |||
| 33 | + | f.Set(x, y, c) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | if height > 2 { | |||
| 37 | + | f.ScrollUp(0, height-1, 1) | |||
| 38 | + | } | |||
| 39 | + | return f | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func requireFramesEqual(t *testing.T, got, want Frame) { | |||
| 43 | + | t.Helper() | |||
| 44 | + | if got.Width != want.Width || got.Height != want.Height { | |||
| 45 | + | t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height) | |||
| 46 | + | } | |||
| 47 | + | for y := range want.Height { | |||
| 48 | + | if !reflect.DeepEqual(got.Row(y), want.Row(y)) { | |||
| 49 | + | t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y)) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | if err := got.CheckInvariants(); err != nil { | |||
| 53 | + | t.Fatal(err) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) { | |||
| 58 | + | src := copyTestFrame(t, 9, 5, 'a') | |||
| 59 | + | var dst Frame | |||
| 60 | + | dst.CopyFrom(src) | |||
| 61 | + | requireFramesEqual(t, dst, src) | |||
| 62 | + | if dst.page == src.page { | |||
| 63 | + | t.Fatal("copy aliases source page") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Mutating the source must not affect the copy. | |||
| 67 | + | before := dst.Clone() | |||
| 68 | + | src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}}) | |||
| 69 | + | requireFramesEqual(t, dst, before) | |||
| 70 | + | ||||
| 71 | + | // Copy over a different, larger-state destination: the page pointer and | |||
| 72 | + | // backing arrays are reused and stale styles/payloads do not leak. | |||
| 73 | + | other := copyTestFrame(t, 9, 5, 'k') | |||
| 74 | + | other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}) | |||
| 75 | + | page := dst.page | |||
| 76 | + | cells := &dst.page.cells[0] | |||
| 77 | + | dst.CopyFrom(other) | |||
| 78 | + | if dst.page != page || &dst.page.cells[0] != cells { | |||
| 79 | + | t.Fatal("CopyFrom did not reuse destination storage") | |||
| 80 | + | } | |||
| 81 | + | requireFramesEqual(t, dst, other) | |||
| 82 | + | if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() { | |||
| 83 | + | t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount()) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // The copy stays a fully functional, independent frame. | |||
| 87 | + | dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()}) | |||
| 88 | + | dst.ScrollDown(0, 4, 2) | |||
| 89 | + | if err := dst.CheckInvariants(); err != nil { | |||
| 90 | + | t.Fatal(err) | |||
| 91 | + | } | |||
| 92 | + | requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f } | |||
| 96 | + | ||||
| 97 | + | func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) { | |||
| 98 | + | dst := copyTestFrame(t, 4, 3, 'a') | |||
| 99 | + | small := copyTestFrame(t, 2, 2, 'x') | |||
| 100 | + | dst.CopyFrom(small) | |||
| 101 | + | requireFramesEqual(t, dst, small) | |||
| 102 | + | large := copyTestFrame(t, 12, 7, 'm') | |||
| 103 | + | dst.CopyFrom(large) | |||
| 104 | + | requireFramesEqual(t, dst, large) | |||
| 105 | + | ||||
| 106 | + | before := dst.Clone() | |||
| 107 | + | dst.CopyFrom(Frame{}) | |||
| 108 | + | dst.CopyFrom(Frame{Width: 3, Height: 3}) | |||
| 109 | + | requireFramesEqual(t, dst, before) | |||
| 110 | + | dst.CopyFrom(dst) | |||
| 111 | + | requireFramesEqual(t, dst, before) | |||
| 112 | + | var nilFrame *Frame | |||
| 113 | + | nilFrame.CopyFrom(large) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) { | |||
| 117 | + | src := copyTestFrame(t, 40, 10, 'a') | |||
| 118 | + | dst := src.Clone() | |||
| 119 | + | dst.CopyFrom(src) | |||
| 120 | + | if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 { | |||
| 121 | + | t.Fatalf("CopyFrom allocated %v times", n) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestRowsEqual(t *testing.T) { | |||
| 126 | + | a := copyTestFrame(t, 10, 4, 'a') | |||
| 127 | + | b := a.Clone() | |||
| 128 | + | for y := range a.Height { | |||
| 129 | + | if !RowsEqual(a, b, y) { | |||
| 130 | + | t.Fatalf("clone row %d differs", y) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Equal content with different physical rotation and different ID tables. | |||
| 135 | + | c := NewFrame(a.Width, a.Height) | |||
| 136 | + | // Intern an unrelated style first so IDs differ from a's. | |||
| 137 | + | c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}}) | |||
| 138 | + | for y := range a.Height { | |||
| 139 | + | for x := range a.Width { | |||
| 140 | + | c.Set(x, y, a.Cell(x, y)) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | for y := range a.Height { | |||
| 144 | + | if !RowsEqual(a, c, y) { | |||
| 145 | + | t.Fatalf("semantically equal row %d reported different", y) | |||
| 146 | + | } | |||
| 147 | + | if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want { | |||
| 148 | + | t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Each kind of difference is detected on the right row only. | |||
| 153 | + | diffs := []Cell{ | |||
| 154 | + | {Rune: 'Q', Style: a.Cell(3, 1).Style}, | |||
| 155 | + | {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}}, | |||
| 156 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")}, | |||
| 157 | + | {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true}, | |||
| 158 | + | } | |||
| 159 | + | for i, d := range diffs { | |||
| 160 | + | m := a.Clone() | |||
| 161 | + | m.Set(3, 1, d) | |||
| 162 | + | for y := range a.Height { | |||
| 163 | + | want := y != 1 | |||
| 164 | + | if got := RowsEqual(a, m, y); got != want { | |||
| 165 | + | t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Payload present on one side only, and differing payloads. | |||
| 171 | + | m := a.Clone() | |||
| 172 | + | px := -1 | |||
| 173 | + | for x := range a.Width { | |||
| 174 | + | if a.Cell(x, 0).Payload != (CellPayload{}) { | |||
| 175 | + | px = x | |||
| 176 | + | break | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | if px < 0 { | |||
| 180 | + | t.Fatal("test frame row 0 has no payload") | |||
| 181 | + | } | |||
| 182 | + | m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style}) | |||
| 183 | + | if RowsEqual(a, m, 0) { | |||
| 184 | + | t.Fatal("payload removal not detected") | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Inactive RGB fields and other equivalent spellings compare equal. | |||
| 188 | + | e1, e2 := NewFrame(2, 1), NewFrame(2, 1) | |||
| 189 | + | s1 := Style{Bold: true, Foreground: 2, Background: -1} | |||
| 190 | + | s2 := s1 | |||
| 191 | + | s2.ForegroundRGB = RGB{R: 9} | |||
| 192 | + | e1.Set(0, 0, Cell{Rune: 'a', Style: s1}) | |||
| 193 | + | e2.Set(0, 0, Cell{Rune: 'a', Style: s2}) | |||
| 194 | + | if !RowsEqual(e1, e2, 0) { | |||
| 195 | + | t.Fatal("equivalent styles reported different") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // Mismatched shape or range is unequal and never panics. | |||
| 199 | + | if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) { | |||
| 200 | + | t.Fatal("invalid comparison reported equal") | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestRowsEqualDoesNotAllocate(t *testing.T) { | |||
| 205 | + | a := copyTestFrame(t, 80, 4, 'a') | |||
| 206 | + | b := a.Clone() | |||
| 207 | + | if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 { | |||
| 208 | + | t.Fatalf("RowsEqual allocated %v times", n) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestCopyFromHandlesEveryPageField(t *testing.T) { | |||
| 213 | + | typ := reflect.TypeOf(cellPage{}) | |||
| 214 | + | if typ.NumField() != cellPageFieldCount { | |||
| 215 | + | t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // Build a source that populates every field, and a destination whose every | |||
| 219 | + | // field holds different, stale state. | |||
| 220 | + | src := copyTestFrame(t, 9, 5, 'a') | |||
| 221 | + | src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}}) | |||
| 222 | + | dst := copyTestFrame(t, 9, 5, 'k') | |||
| 223 | + | dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}}) | |||
| 224 | + | dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot | |||
| 225 | + | dst.CopyFrom(src) | |||
| 226 | + | ||||
| 227 | + | sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page) | |||
| 228 | + | populated := 0 | |||
| 229 | + | for i := range typ.NumField() { | |||
| 230 | + | name := typ.Field(i).Name | |||
| 231 | + | a, b := sp.Field(i), dp.Field(i) | |||
| 232 | + | if !a.IsZero() { | |||
| 233 | + | populated++ | |||
| 234 | + | } | |||
| 235 | + | if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 { | |||
| 236 | + | continue // nil and empty are equivalent | |||
| 237 | + | } | |||
| 238 | + | // Printing works on unexported fields and treats nil and empty alike; | |||
| 239 | + | // fmt sorts map keys, so the rendering is deterministic. | |||
| 240 | + | if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want { | |||
| 241 | + | t.Errorf("cellPage.%s = %s, want %s", name, got, want) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty | |||
| 245 | + | t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField()) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestCopyFromDropsStaleStyleCache(t *testing.T) { | |||
| 250 | + | styleA := Style{Bold: true, Foreground: 1, Background: -1} | |||
| 251 | + | styleB := Style{Italic: true, Foreground: 2, Background: -1} | |||
| 252 | + | ||||
| 253 | + | // dst: style A lives in slot k and is cached by the last Set. | |||
| 254 | + | dst := NewFrame(4, 2) | |||
| 255 | + | dst.Set(0, 0, Cell{Rune: 'a', Style: styleA}) | |||
| 256 | + | k := dst.page.styleCacheID | |||
| 257 | + | if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA { | |||
| 258 | + | t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // src: a different style occupies the same slot k, and no cache. | |||
| 262 | + | src := NewFrame(4, 2) | |||
| 263 | + | src.Set(0, 0, Cell{Rune: 'b', Style: styleB}) | |||
| 264 | + | src.page.styleCacheOK = false | |||
| 265 | + | if src.page.styleIndex[styleB] != k { | |||
| 266 | + | t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | dst.CopyFrom(src) | |||
| 270 | + | if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache { | |||
| 271 | + | t.Fatal("CopyFrom left a stale style cache") | |||
| 272 | + | } | |||
| 273 | + | cell := Cell{Rune: 'c', Style: styleA} | |||
| 274 | + | dst.Set(1, 1, cell) | |||
| 275 | + | if got := dst.Cell(1, 1); !got.Equal(cell) { | |||
| 276 | + | t.Fatalf("cell = %+v, want %+v", got, cell) | |||
| 277 | + | } | |||
| 278 | + | if got := dst.Cell(0, 0); got.Style != styleB { | |||
| 279 | + | t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB) | |||
| 280 | + | } | |||
| 281 | + | if err := dst.CheckInvariants(); err != nil { | |||
| 282 | + | t.Fatal(err) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestRowsEqualAt(t *testing.T) { | |||
| 287 | + | a := copyTestFrame(t, 10, 5, 'a') | |||
| 288 | + | b := a.Clone() | |||
| 289 | + | // b row y+1 shifted: compare a row y+1 against b row y after scrolling b. | |||
| 290 | + | b.ScrollUp(0, 4, 1) | |||
| 291 | + | for y := range 4 { | |||
| 292 | + | if !RowsEqualAt(a, y+1, b, y) { | |||
| 293 | + | t.Fatalf("row %d of a != row %d of scrolled b", y+1, y) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) { | |||
| 297 | + | t.Fatal("unequal or out-of-range rows reported equal") | |||
| 298 | + | } | |||
| 299 | + | } | |||