mirror of
https://github.com/hajimehoshi/ebiten.git
synced 2026-04-22 15:57:15 +08:00
1a4c38c37d
ChangedAt returns the time of the most recent observable state change — text, selection, focus, or composition. Useful for cache invalidation, autosave throttling, idle detection, and similar change-detection tasks. The value is strictly monotonically increasing: back-to-back mutations on coarse-clock platforms are clamped forward by 1ns so each mutation yields a unique timestamp usable as a cache key. No-op mutations (SetSelection to the current selection, empty ReplaceText over a zero-width range, Focus/Blur that don't change focus, Undo/Redo with nothing to apply) do not advance ChangedAt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
285 lines
6.3 KiB
Go
285 lines
6.3 KiB
Go
// Copyright 2026 The Ebitengine Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package textinput_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2/exp/textinput"
|
|
)
|
|
|
|
func TestFieldChangedAtZeroValue(t *testing.T) {
|
|
var f textinput.Field
|
|
if got := f.ChangedAt(); !got.IsZero() {
|
|
t.Errorf("fresh Field: ChangedAt() = %v, want zero value", got)
|
|
}
|
|
}
|
|
|
|
func TestFieldChangedAtAdvancesOnMutations(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
setup func(*textinput.Field)
|
|
mutate func(*textinput.Field)
|
|
}{
|
|
{
|
|
name: "ResetText",
|
|
setup: func(f *textinput.Field) {},
|
|
mutate: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
},
|
|
},
|
|
{
|
|
name: "SetTextAndSelection",
|
|
setup: func(f *textinput.Field) {},
|
|
mutate: func(f *textinput.Field) {
|
|
f.SetTextAndSelection("abc", 1, 2)
|
|
},
|
|
},
|
|
{
|
|
name: "ReplaceText",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.ReplaceText("X", 0, 5)
|
|
},
|
|
},
|
|
{
|
|
name: "ReplaceTextAtSelection",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
f.SetSelection(0, 5)
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.ReplaceTextAtSelection("Y")
|
|
},
|
|
},
|
|
{
|
|
name: "SetSelection",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.SetSelection(1, 3)
|
|
},
|
|
},
|
|
{
|
|
name: "Focus",
|
|
setup: func(f *textinput.Field) {},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Focus()
|
|
},
|
|
},
|
|
{
|
|
name: "Blur",
|
|
setup: func(f *textinput.Field) {
|
|
f.Focus()
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Blur()
|
|
},
|
|
},
|
|
{
|
|
name: "Undo",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
f.ReplaceText("X", 0, 5)
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Undo()
|
|
},
|
|
},
|
|
{
|
|
name: "Redo",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
f.ReplaceText("X", 0, 5)
|
|
f.Undo()
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Redo()
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var f textinput.Field
|
|
t.Cleanup(func() { f.Blur() })
|
|
tc.setup(&f)
|
|
before := f.ChangedAt()
|
|
tc.mutate(&f)
|
|
after := f.ChangedAt()
|
|
if !after.After(before) {
|
|
t.Errorf("%s: ChangedAt did not advance: before=%v after=%v", tc.name, before, after)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldChangedAtNoOpDoesNotAdvance(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
setup func(*textinput.Field)
|
|
mutate func(*textinput.Field)
|
|
}{
|
|
{
|
|
name: "SetSelectionWithCurrentSelection",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
f.SetSelection(2, 4)
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.SetSelection(2, 4)
|
|
},
|
|
},
|
|
{
|
|
name: "SetSelectionClampingToCurrent",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
// (0,0) is already the selection; negatives clamp to (0,0).
|
|
f.SetSelection(-5, -5)
|
|
},
|
|
},
|
|
{
|
|
name: "ReplaceTextEmptyZeroWidth",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
f.SetSelection(3, 3)
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.ReplaceText("", 3, 3)
|
|
},
|
|
},
|
|
{
|
|
name: "FocusWhenAlreadyFocused",
|
|
setup: func(f *textinput.Field) {
|
|
f.Focus()
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Focus()
|
|
},
|
|
},
|
|
{
|
|
name: "BlurWhenNotFocused",
|
|
setup: func(f *textinput.Field) {},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Blur()
|
|
},
|
|
},
|
|
{
|
|
name: "UndoWithEmptyHistory",
|
|
setup: func(f *textinput.Field) {},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Undo()
|
|
},
|
|
},
|
|
{
|
|
name: "RedoWithNothingToRedo",
|
|
setup: func(f *textinput.Field) {
|
|
f.ResetText("hello")
|
|
},
|
|
mutate: func(f *textinput.Field) {
|
|
f.Redo()
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var f textinput.Field
|
|
t.Cleanup(func() { f.Blur() })
|
|
tc.setup(&f)
|
|
before := f.ChangedAt()
|
|
tc.mutate(&f)
|
|
after := f.ChangedAt()
|
|
if !after.Equal(before) {
|
|
t.Errorf("%s: ChangedAt advanced on no-op: before=%v after=%v", tc.name, before, after)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldChangedAtReadOnlyMethodsDoNotAdvance(t *testing.T) {
|
|
var f textinput.Field
|
|
t.Cleanup(func() { f.Blur() })
|
|
f.ResetText("hello")
|
|
f.SetSelection(1, 3)
|
|
f.Focus()
|
|
|
|
prior := f.ChangedAt()
|
|
|
|
_ = f.Text()
|
|
_ = f.TextForRendering()
|
|
_ = f.HasText()
|
|
_ = f.TextLengthInBytes()
|
|
_ = f.IsFocused()
|
|
_, _ = f.Selection()
|
|
_, _, _ = f.CompositionSelection()
|
|
_ = f.CanUndo()
|
|
_ = f.CanRedo()
|
|
_ = f.UncommittedTextLengthInBytes()
|
|
_ = f.Handled()
|
|
var b strings.Builder
|
|
_ = f.WriteText(&b)
|
|
b.Reset()
|
|
_ = f.WriteTextForRendering(&b)
|
|
|
|
if got := f.ChangedAt(); !got.Equal(prior) {
|
|
t.Errorf("read-only methods advanced ChangedAt: before=%v after=%v", prior, got)
|
|
}
|
|
}
|
|
|
|
func TestFieldChangedAtStolenFocusAdvancesPreviousField(t *testing.T) {
|
|
var a, b textinput.Field
|
|
t.Cleanup(func() {
|
|
a.Blur()
|
|
b.Blur()
|
|
})
|
|
a.Focus()
|
|
before := a.ChangedAt()
|
|
b.Focus() // steals focus from a
|
|
if !a.ChangedAt().After(before) {
|
|
t.Errorf("a.ChangedAt did not advance when b stole focus: before=%v after=%v", before, a.ChangedAt())
|
|
}
|
|
if a.IsFocused() {
|
|
t.Errorf("a should no longer be focused")
|
|
}
|
|
if !b.IsFocused() {
|
|
t.Errorf("b should be focused")
|
|
}
|
|
}
|
|
|
|
func TestFieldChangedAtStrictlyMonotonicUnderTightLoop(t *testing.T) {
|
|
var f textinput.Field
|
|
t.Cleanup(func() { f.Blur() })
|
|
|
|
const n = 1000
|
|
times := make([]time.Time, n)
|
|
for i := range n {
|
|
// Insert a character at the current end so each call is a real mutation.
|
|
f.ReplaceText("a", i, i)
|
|
times[i] = f.ChangedAt()
|
|
}
|
|
// Strict .After() across the whole sequence implies both ordering and uniqueness.
|
|
for i := range n - 1 {
|
|
if !times[i+1].After(times[i]) {
|
|
t.Errorf("index %d: timestamps not strictly increasing: %v <= %v", i+1, times[i+1], times[i])
|
|
}
|
|
}
|
|
}
|