318 lines
12 KiB
Go
318 lines
12 KiB
Go
package democheck
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func fixture() (Component, Contract) {
|
|
component := Component{ID: "test.Counter", DocumentID: "test.Counter", Scene: "test.Counter/try", Identity: "exact-runtime-payload", Fields: []string{"test.Counter.Value"}}
|
|
contract := Contract{Version: 1, ComponentID: component.ID, DemoID: "counter-increment", DocumentID: component.ID, Scene: component.Scene, Identity: component.Identity,
|
|
Dependencies: []string{"counter-script"}, EditableFields: component.Fields,
|
|
Actions: []Action{{ID: "increment", Field: component.Fields[0], Value: json.RawMessage("2"), Expected: []Expectation{{Observable: "count", Equals: json.RawMessage("2"), Changes: true}}}}, Reset: "reset-counter",
|
|
Languages: map[string]Text{"en": {"Counter", "Changes its counter", "Press increment"}, "ru": {"Счётчик", "Изменяет счётчик", "Нажмите «Увеличить»"}},
|
|
Environments: map[string]Environment{"native": {Mode: "runtime"}, "chromium": {Mode: "runtime"}}}
|
|
return component, contract
|
|
}
|
|
|
|
type fakeRunner struct {
|
|
fault string
|
|
closed int
|
|
}
|
|
|
|
func (r *fakeRunner) Open(_ context.Context, c Contract, env string) (Instance, error) {
|
|
// The gate must reset before observing its settled baseline.
|
|
return &fakeInstance{r: r, c: c, env: env, value: -1}, nil
|
|
}
|
|
|
|
type fakeInstance struct {
|
|
r *fakeRunner
|
|
c Contract
|
|
env string
|
|
value int
|
|
actions int
|
|
}
|
|
|
|
func (p *fakeInstance) Observe(context.Context) (Observation, error) {
|
|
o := Observation{Identity: p.c.Identity, Components: []string{p.c.ComponentID}, Dependencies: p.c.Dependencies, RuntimeExecuting: p.c.Environments[p.env].Mode == "runtime", Observables: map[string]json.RawMessage{}}
|
|
b, _ := json.Marshal(p.value)
|
|
o.Observables["count"] = b
|
|
switch p.r.fault {
|
|
case "identity":
|
|
o.Identity = "old-build"
|
|
case "component":
|
|
o.Components = nil
|
|
case "dependency":
|
|
o.Dependencies = nil
|
|
case "fake-runtime":
|
|
o.RuntimeExecuting = true
|
|
case "stopped":
|
|
o.RuntimeExecuting = false
|
|
case "no-observable":
|
|
o.Observables = nil
|
|
}
|
|
return o, nil
|
|
}
|
|
func (p *fakeInstance) Action(context.Context, Action) error {
|
|
p.actions++
|
|
if p.r.fault != "no-op" {
|
|
p.value = 2
|
|
}
|
|
return nil
|
|
}
|
|
func (p *fakeInstance) Reset(context.Context, string) error {
|
|
if p.r.fault != "reset" || p.actions == 0 {
|
|
p.value = 0
|
|
}
|
|
return nil
|
|
}
|
|
func (p *fakeInstance) Close() error {
|
|
p.r.closed++
|
|
if p.r.fault == "close" {
|
|
return errors.New("close failed")
|
|
}
|
|
return nil
|
|
}
|
|
func TestBehavioralCoverageRequiresActionsAndReset(t *testing.T) {
|
|
c, d := fixture()
|
|
runner := &fakeRunner{}
|
|
report := Check(context.Background(), []Component{c}, []Contract{d}, runner)
|
|
if !report.Passed() || report.Error() != nil || runner.closed != 2 {
|
|
t.Fatalf("%+v closed=%d", report, runner.closed)
|
|
}
|
|
for _, fault := range []string{"identity", "component", "dependency", "no-op", "reset", "stopped", "no-observable", "close"} {
|
|
t.Run(fault, func(t *testing.T) {
|
|
runner := &fakeRunner{fault: fault}
|
|
r := Check(context.Background(), []Component{c}, []Contract{d}, runner)
|
|
if r.Passed() || r.Error() == nil || runner.closed != 2 {
|
|
t.Fatalf("%+v, closed=%d", r, runner.closed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestCoverageCannotPassWithOnlyFilesOrNoComponents(t *testing.T) {
|
|
c, d := fixture()
|
|
for _, tc := range []struct {
|
|
name string
|
|
components []Component
|
|
contracts []Contract
|
|
runner Runner
|
|
}{
|
|
{"no runner", []Component{c}, []Contract{d}, nil}, {"no contract", []Component{c}, nil, &fakeRunner{}},
|
|
{"empty registry", nil, nil, &fakeRunner{}}, {"duplicate contract", []Component{c}, []Contract{d, d}, &fakeRunner{}},
|
|
{"duplicate component", []Component{c, c}, []Contract{d}, &fakeRunner{}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := Check(context.Background(), tc.components, tc.contracts, tc.runner)
|
|
if r.Passed() {
|
|
t.Fatalf("false coverage: %+v", r)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestCoverageRejectsStaleSchemaAndNoChangeAssertions(t *testing.T) {
|
|
for _, fault := range []string{"field", "translation", "no-change", "unused-field", "identity", "only-native"} {
|
|
t.Run(fault, func(t *testing.T) {
|
|
c, d := fixture()
|
|
switch fault {
|
|
case "field":
|
|
c.Fields = nil
|
|
case "translation":
|
|
delete(d.Languages, "ru")
|
|
case "no-change":
|
|
d.Actions[0].Expected[0].Changes = false
|
|
case "unused-field":
|
|
c.Fields = append(c.Fields, "test.Counter.Extra")
|
|
d.EditableFields = append(d.EditableFields, "test.Counter.Extra")
|
|
case "identity":
|
|
d.Identity = "old"
|
|
case "only-native":
|
|
delete(d.Environments, "chromium")
|
|
}
|
|
if r := Check(context.Background(), []Component{c}, []Contract{d}, &fakeRunner{}); r.Passed() {
|
|
t.Fatalf("false coverage: %+v", r)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestAuthoringOnlyScenarioIsExplicitAndDoesNotExecuteRuntime(t *testing.T) {
|
|
c, d := fixture()
|
|
d.Environments["chromium"] = Environment{Mode: "authoring", Reason: map[string]string{"en": "Native-only output", "ru": "Вывод доступен только в нативном приложении"}}
|
|
if r := Check(context.Background(), []Component{c}, []Contract{d}, &fakeRunner{}); !r.Passed() {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r := Check(context.Background(), []Component{c}, []Contract{d}, &fakeRunner{fault: "fake-runtime"})
|
|
if r.Passed() || !strings.Contains(strings.Join(r.Results[0].Errors, " "), "falsely") {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
}
|
|
|
|
func TestScriptAttachmentActionIsExplicitAndWellFormed(t *testing.T) {
|
|
c, d := fixture()
|
|
c.ID, c.DocumentID, c.Scene, c.Fields = "engine.Script", "engine.Script", "engine.Script/try", nil
|
|
d.ComponentID, d.DocumentID, d.Scene, d.EditableFields = c.ID, c.DocumentID, c.Scene, nil
|
|
on := true
|
|
d.Actions[0].Field, d.Actions[0].Value = "", nil
|
|
d.Actions[0].ScriptToggle = &ScriptToggle{Script: "game.demo.Move", Enabled: &on}
|
|
if r := Check(context.Background(), []Component{c}, []Contract{d}, &fakeRunner{}); !r.Passed() {
|
|
t.Fatalf("valid attachment action rejected: %+v", r)
|
|
}
|
|
// Other component demos may toggle a script on their selected node, but
|
|
// the real runner must still observe that component and its changed state.
|
|
other, otherContract := c, d
|
|
other.ID, other.DocumentID, other.Scene = "test.Counter", "test.Counter", "test.Counter/try"
|
|
otherContract.ComponentID, otherContract.DocumentID, otherContract.Scene = other.ID, other.DocumentID, other.Scene
|
|
if r := Check(context.Background(), []Component{other}, []Contract{otherContract}, &fakeRunner{}); !r.Passed() {
|
|
t.Fatalf("valid selected-node script action rejected: %+v", r)
|
|
}
|
|
for _, fault := range []string{"no-operation", "empty-script", "missing-enabled", "mixed-field", "extra-value"} {
|
|
t.Run(fault, func(t *testing.T) {
|
|
component, contract := c, d
|
|
contract.Actions = append([]Action(nil), d.Actions...)
|
|
toggle := *d.Actions[0].ScriptToggle
|
|
contract.Actions[0].ScriptToggle = &toggle
|
|
switch fault {
|
|
case "no-operation":
|
|
contract.Actions[0].ScriptToggle = nil
|
|
case "empty-script":
|
|
toggle.Script = ""
|
|
case "missing-enabled":
|
|
toggle.Enabled = nil
|
|
case "mixed-field":
|
|
contract.Actions[0].Field = "engine.Script.fake"
|
|
case "extra-value":
|
|
contract.Actions[0].Value = json.RawMessage("1")
|
|
}
|
|
if r := Check(context.Background(), []Component{component}, []Contract{contract}, &fakeRunner{}); r.Passed() {
|
|
t.Fatalf("accepted invalid attachment action: %+v", r)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestJSONComparisonDoesNotRoundIntegerEvidence(t *testing.T) {
|
|
if equalJSON(json.RawMessage("9007199254740992"), json.RawMessage("9007199254740993")) {
|
|
t.Fatal("rounded integer equality")
|
|
}
|
|
if !equalJSON(json.RawMessage(`{"b":2,"a":1}`), json.RawMessage(`{"a":1,"b":2}`)) {
|
|
t.Fatal("object order changes semantics")
|
|
}
|
|
}
|
|
func TestCancelledCoverageDoesNotRunProbe(t *testing.T) {
|
|
c, d := fixture()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
runner := &fakeRunner{}
|
|
if r := Check(ctx, []Component{c}, []Contract{d}, runner); r.Passed() || runner.closed != 0 {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
}
|
|
|
|
type visualRunner struct{ fault string }
|
|
type visualInstance struct {
|
|
fault, env string
|
|
contract Contract
|
|
value int
|
|
actions int
|
|
}
|
|
|
|
func (r visualRunner) Open(_ context.Context, c Contract, env string) (Instance, error) {
|
|
return &visualInstance{contract: c, env: env, fault: r.fault}, nil
|
|
}
|
|
func (p *visualInstance) Observe(context.Context) (Observation, error) {
|
|
raw, _ := json.Marshal(p.value)
|
|
o := Observation{Identity: p.contract.Identity, Components: []string{p.contract.ComponentID}, Dependencies: p.contract.Dependencies, RuntimeExecuting: true, Observables: map[string]json.RawMessage{"count": raw}}
|
|
if p.fault == "missing" {
|
|
return o, nil
|
|
}
|
|
n := 1
|
|
if p.env == "chromium" {
|
|
n = 2
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
pixel := byte(0)
|
|
if p.value == 2 || p.fault == "reset" && p.actions > 0 {
|
|
pixel = 255
|
|
}
|
|
if p.fault == "static" || p.fault == "one-language" && i == 1 {
|
|
pixel = 0
|
|
}
|
|
frame := Frame{Width: 1, Height: 1, Pixels: []byte{pixel, 0, 0, 255}}
|
|
if p.fault == "wrong-size" && p.value == 2 {
|
|
frame.Width = 2
|
|
}
|
|
o.Frames = append(o.Frames, frame)
|
|
}
|
|
return o, nil
|
|
}
|
|
func (p *visualInstance) Action(context.Context, Action) error { p.value = 2; p.actions++; return nil }
|
|
func (p *visualInstance) Reset(context.Context, string) error { p.value = 0; return nil }
|
|
func (p *visualInstance) Close() error { return nil }
|
|
|
|
func TestVisualEvidenceRequiresChangedAndResetGameViews(t *testing.T) {
|
|
c, d := fixture()
|
|
d.Actions[0].Visual = &VisualExpectation{MinChangedPixels: 1, MaxResetDifferencePixels: 0}
|
|
if r := Check(context.Background(), []Component{c}, []Contract{d}, visualRunner{}); !r.Passed() {
|
|
t.Fatalf("real changes in all frames should pass: %+v", r)
|
|
}
|
|
for _, fault := range []string{"missing", "static", "one-language", "wrong-size", "reset"} {
|
|
t.Run(fault, func(t *testing.T) {
|
|
r := Check(context.Background(), []Component{c}, []Contract{d}, visualRunner{fault})
|
|
if r.Passed() {
|
|
t.Fatalf("accepted bad Game View evidence: %+v", r)
|
|
}
|
|
})
|
|
}
|
|
d.Actions[0].Visual.MinChangedPixels = 0
|
|
if r := Check(context.Background(), []Component{c}, []Contract{d}, visualRunner{}); r.Passed() {
|
|
t.Fatalf("accepted a zero-pixel visual threshold: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestGameplayInputContractsAreBoundedAndCanDemonstrateFieldlessComponents(t *testing.T) {
|
|
c, d := fixture()
|
|
c.Fields, d.EditableFields = nil, nil
|
|
d.Actions[0].Field, d.Actions[0].Value = "", nil
|
|
d.Actions[0].Inputs = []InputStep{{Keys: []string{"D"}, Frames: 30}, {Keys: []string{"J"}, Frames: 4}}
|
|
if err := validate(c, d); err != nil {
|
|
t.Fatalf("fieldless input demonstration rejected: %v", err)
|
|
}
|
|
for _, inputs := range [][]InputStep{
|
|
{{Frames: 0}}, {{Frames: 601}}, {{Frames: 400}, {Frames: 201}},
|
|
{{Frames: 1, Keys: []string{"D", "D"}}},
|
|
} {
|
|
d.Actions[0].Inputs = inputs
|
|
if err := validate(c, d); err == nil {
|
|
t.Fatalf("unbounded or invalid input accepted: %+v", inputs)
|
|
}
|
|
}
|
|
d.Actions[0].Inputs = []InputStep{{Frames: 600}}
|
|
d.Actions[0].Keys = []string{"D"}
|
|
if err := validate(c, d); err == nil {
|
|
t.Fatal("implicit key warmup exceeded the frame budget")
|
|
}
|
|
d.Actions[0].Inputs, d.Actions[0].Keys = nil, nil
|
|
if err := validate(c, d); err == nil {
|
|
t.Fatal("fieldless component accepted without any input action")
|
|
}
|
|
}
|
|
|
|
func TestSimulationToleranceRemainsAnExplicitFiniteNumericBound(t *testing.T) {
|
|
if !matches(json.RawMessage("0.699999988"), json.RawMessage("0.7"), 0.0001) {
|
|
t.Fatal("32-bit simulation precision rejected")
|
|
}
|
|
if matches(json.RawMessage("0.6"), json.RawMessage("0.7"), 0.0001) ||
|
|
matches(json.RawMessage(`"Run"`), json.RawMessage(`"Idle"`), 1) ||
|
|
matches(json.RawMessage("null"), json.RawMessage("0"), 1) ||
|
|
matches(json.RawMessage("0 trailing"), json.RawMessage("0"), 1) {
|
|
t.Fatal("tolerance accepted a materially different or nonnumeric value")
|
|
}
|
|
c, d := fixture()
|
|
d.Actions[0].Expected[0].Tolerance = -1
|
|
if err := validate(c, d); err == nil {
|
|
t.Fatal("negative tolerance accepted")
|
|
}
|
|
}
|