test(16.2-06): pin the untouched-value skip in cabana and PluginField
- TestFieldTypeUntouched: served values skip Decode, writes and notices; one changed locale writes and reports only that locale; canonical JSON comparison; a create never skips - PluginField: an element that echoes its value on connect is never written a second time
This commit is contained in:
@@ -245,6 +245,81 @@ describe('stored value reload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** A stub element that re-emits its current value when it connects, as an
|
||||
* element that normalizes nothing but announces its value on mount does. */
|
||||
class AcmeDemoEcho extends AcmeDemoNotes {
|
||||
connectedCallback(): void {
|
||||
this.dispatchEvent(new CustomEvent(FIELD_CHANGE_EVENT, { bubbles: true, detail: { value: this.value } }))
|
||||
}
|
||||
}
|
||||
if (!customElements.get('acme-demo-echo')) {
|
||||
customElements.define('acme-demo-echo', AcmeDemoEcho)
|
||||
}
|
||||
|
||||
describe('untouched value (D-03)', () => {
|
||||
// The form: every update:modelValue becomes the next modelValue, as
|
||||
// FormView's v-model does.
|
||||
function mountEcho(modelValue: unknown) {
|
||||
const wrapper: VueWrapper = mount(PluginField, {
|
||||
props: {
|
||||
field: notesField({ element: 'acme-demo-echo' }),
|
||||
modelValue,
|
||||
controlId: 'f-notes',
|
||||
recordId: 7,
|
||||
source: SOURCE,
|
||||
'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
|
||||
},
|
||||
global: { provide: { [FORM_LOCALE as symbol]: ref('pl') } },
|
||||
// Attached, so the element's connectedCallback runs.
|
||||
attachTo: document.body,
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function echoElement(wrapper: VueWrapper): AcmeDemoEcho {
|
||||
const found = wrapper.find('acme-demo-echo').element
|
||||
if (!(found instanceof AcmeDemoEcho)) {
|
||||
throw new Error('echo element not mounted')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
it('keeps the served HTML identical when the element echoes it on connect', async () => {
|
||||
const served = '\n<!-- imported -->\n<p>Grzyby <b>rosną</b></p>\n<footer class="author-footer"><p>Autor</p></footer>\n'
|
||||
const wrapper = mountEcho(served)
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[served]])
|
||||
expect((wrapper.props() as { modelValue: unknown }).modelValue).toBe(served)
|
||||
expect(el.value).toBe(served)
|
||||
// The property was written once, before connect; the echo wrote nothing.
|
||||
expect(el.writes).toEqual([served])
|
||||
})
|
||||
|
||||
it('does not write an equal object value a second time', async () => {
|
||||
const served = { blocks: [{ type: 'p', text: 'a' }], version: 1 }
|
||||
const wrapper = mountEcho(served)
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
expect((wrapper.props() as { modelValue: unknown }).modelValue).toEqual(served)
|
||||
// An equal copy from the server (a reload) is not written either.
|
||||
await wrapper.setProps({ modelValue: JSON.parse(JSON.stringify(served)) })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual([served])
|
||||
})
|
||||
|
||||
it('writes a value that really differs exactly once', async () => {
|
||||
const wrapper = mountEcho('<p>a</p>')
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual(['<p>a</p>', '<p>b</p>'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MLPluginField', () => {
|
||||
class AcmeDemoNotesml extends AcmeDemoNotes {}
|
||||
if (!customElements.get('acme-demo-notesml')) {
|
||||
|
||||
299
modules/cabana/field_type_test.go
Normal file
299
modules/cabana/field_type_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Unit tests of the plugin field type framework (Phase 16.2 plan 06). They
|
||||
// use no database: the compile path runs on fstest trees, saves run the
|
||||
// decode half of CRUDService.save (ftSave) with the recording translation
|
||||
// writer, and routes run through httptest.
|
||||
|
||||
// utCounter counts Decode calls per field and locale.
|
||||
type utCounter map[string]int
|
||||
|
||||
// utTypes are the untouched-skip stub types: blob (plain, Load parses a JSON
|
||||
// object and serves anything else as stored) and notesml (translatable, Load
|
||||
// prefixes "served:"). Both Decodes always report a notice, so a skipped
|
||||
// value is visible as a missing notice.
|
||||
func utTypes(calls utCounter) []pact.FormFieldType {
|
||||
return []pact.FormFieldType{
|
||||
{
|
||||
Name: "blob",
|
||||
Element: "acme-demo-blob",
|
||||
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
calls[in.Field+":"+in.Locale]++
|
||||
return pact.FormFieldDecoded{Value: in.Value, Notices: []pact.FormFieldNotice{{Message: "decoded"}}}, nil
|
||||
},
|
||||
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text, _ := in.Value.(string)
|
||||
if strings.HasPrefix(text, "{") {
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return text, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "notesml",
|
||||
Element: "acme-demo-notesml",
|
||||
Translatable: true,
|
||||
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
calls[in.Field+":"+in.Locale]++
|
||||
text, _ := in.Value.(string)
|
||||
return pact.FormFieldDecoded{Value: text, Notices: []pact.FormFieldNotice{{Message: "decoded " + in.Locale}}}, nil
|
||||
},
|
||||
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text, _ := in.Value.(string)
|
||||
return "served:" + text, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// utController compiles the notes controller with notes as blob and body as
|
||||
// notesml.
|
||||
func utController(t *testing.T, calls utCounter) *CompiledController {
|
||||
t.Helper()
|
||||
plugin := ftPlugin{
|
||||
id: "acme.demo",
|
||||
fsys: ftFS(" type: blob\n label: Notes\n", ftBodyField),
|
||||
types: utTypes(calls),
|
||||
}
|
||||
reg, err := ftCompile(plugin)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
cc, ok := reg.Get("acme.demo.notes")
|
||||
if !ok {
|
||||
t.Fatal("no notes controller")
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// utJSON decodes a JSON save body value the way the request decoder does.
|
||||
func utJSON(t *testing.T, raw string) any {
|
||||
t.Helper()
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFieldTypeUntouched(t *testing.T) {
|
||||
stored := func() *ftNote {
|
||||
return &ftNote{ID: 1, Body: "P", Notes: `{"a":2,"b":1}`}
|
||||
}
|
||||
storedWriter := func() *recordingWriter {
|
||||
w := ftWriter()
|
||||
w.attrs = map[string]map[string]string{"en": {"body": "E"}}
|
||||
return w
|
||||
}
|
||||
|
||||
t.Run("an update equal to the served values writes and reports nothing", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := storedWriter()
|
||||
target := stored()
|
||||
body := map[string]any{
|
||||
"body": map[string]any{"pl": "served:P", "en": "served:E"},
|
||||
"notes": utJSON(t, `{"b":1,"a":2}`),
|
||||
}
|
||||
res, projected, translations, err := ftSave(t, cc, writer, target, body, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := projected["body"]; ok {
|
||||
t.Fatalf("untouched host value projected: %v", projected)
|
||||
}
|
||||
if _, ok := projected["notes"]; ok {
|
||||
t.Fatalf("untouched plain value projected: %v", projected)
|
||||
}
|
||||
writer.attrs = nil
|
||||
if err := applyMLTranslations(context.Background(), nil, writer, target, translations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.ids) != 0 || len(writer.attrs) != 0 {
|
||||
t.Fatalf("WriteTranslated called: ids=%v attrs=%v", writer.ids, writer.attrs)
|
||||
}
|
||||
if len(res.writes) != 0 {
|
||||
t.Fatalf("writes=%v", res.writes)
|
||||
}
|
||||
if len(res.notices) != 0 || localizeNotices(context.Background(), nil, res.notices) != nil {
|
||||
t.Fatalf("notices=%v", res.notices)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("Decode ran for untouched values: %v", calls)
|
||||
}
|
||||
raw, _ := json.Marshal(RecordMeta{Labels: map[string][]RelationOption{}, Notices: localizeNotices(context.Background(), nil, res.notices)})
|
||||
if strings.Contains(string(raw), "notices") {
|
||||
t.Fatalf("meta=%s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the stored value is not the served value", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := storedWriter()
|
||||
// "P" is the stored host text; the element received "served:P".
|
||||
res, projected, _, err := ftSave(t, cc, writer, stored(), map[string]any{"body": map[string]any{"pl": "P", "en": "served:E"}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projected["body"] != "P" || calls["body:pl"] != 1 || calls["body:en"] != 0 {
|
||||
t.Fatalf("projected=%v calls=%v", projected, calls)
|
||||
}
|
||||
if len(res.writes["body"]) != 1 || res.writes["body"][0] != (FieldWrite{Locale: "pl", Default: true, Value: "P"}) {
|
||||
t.Fatalf("writes=%v", res.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one changed non-default locale writes and reports only that locale", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := storedWriter()
|
||||
target := stored()
|
||||
res, projected, translations, err := ftSave(t, cc, writer, target, map[string]any{"body": map[string]any{"pl": "served:P", "en": "E2"}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := projected["body"]; ok {
|
||||
t.Fatalf("untouched host value projected: %v", projected)
|
||||
}
|
||||
writer.attrs = nil
|
||||
if err := applyMLTranslations(context.Background(), nil, writer, target, translations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(writer.attrs) != fmt.Sprint(map[string]map[string]string{"en": {"body": "E2"}}) || len(writer.ids) != 1 {
|
||||
t.Fatalf("attrs=%v ids=%v", writer.attrs, writer.ids)
|
||||
}
|
||||
if fmt.Sprint(res.writes) != fmt.Sprint(map[string][]FieldWrite{"body": {{Locale: "en", Value: "E2"}}}) {
|
||||
t.Fatalf("writes=%v", res.writes)
|
||||
}
|
||||
got := localizeNotices(context.Background(), nil, res.notices)
|
||||
if len(got) != 1 || got[0] != (FieldNotice{Field: "body", Locale: "en", Message: "decoded en"}) {
|
||||
t.Fatalf("notices=%+v", got)
|
||||
}
|
||||
if fmt.Sprint(calls) != fmt.Sprint(utCounter{"body:en": 1}) {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one changed default locale writes the host value only", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := storedWriter()
|
||||
target := stored()
|
||||
res, projected, translations, err := ftSave(t, cc, writer, target, map[string]any{"body": map[string]any{"pl": "P2", "en": "served:E"}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projected["body"] != "P2" {
|
||||
t.Fatalf("projected=%v", projected)
|
||||
}
|
||||
writer.attrs = nil
|
||||
if err := applyMLTranslations(context.Background(), nil, writer, target, translations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.ids) != 0 {
|
||||
t.Fatalf("translation written for an untouched locale: %v", writer.attrs)
|
||||
}
|
||||
got := localizeNotices(context.Background(), nil, res.notices)
|
||||
if len(got) != 1 || got[0].Locale != "pl" {
|
||||
t.Fatalf("notices=%+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an untouched missing translation is the empty string", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := ftWriter() // no en translation stored
|
||||
res, _, translations, err := ftSave(t, cc, writer, stored(), map[string]any{"body": map[string]any{"pl": "served:P", "en": "served:"}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.writes) != 0 || len(res.notices) != 0 {
|
||||
t.Fatalf("writes=%v notices=%v", res.writes, res.notices)
|
||||
}
|
||||
for _, item := range translations {
|
||||
if _, ok := item.values["en"]; ok && item.field == "body" {
|
||||
t.Fatalf("an untouched empty en is still written: %v", translations)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical comparison: key order is equal, a string is not a number", func(t *testing.T) {
|
||||
if !sameJSON(utJSON(t, `{"b":1,"a":{"y":[1,2],"x":null}}`), utJSON(t, `{"a":{"x":null,"y":[1,2]},"b":1}`)) {
|
||||
t.Fatal("equal objects with different key order compare different")
|
||||
}
|
||||
if sameJSON("1", float64(1)) || sameJSON(float64(1), "1") {
|
||||
t.Fatal(`"1" and 1 compare equal`)
|
||||
}
|
||||
if sameJSON(utJSON(t, `[1,2]`), utJSON(t, `[2,1]`)) {
|
||||
t.Fatal("array order ignored")
|
||||
}
|
||||
if sameJSON(func() {}, func() {}) {
|
||||
t.Fatal("unencodable values compare equal")
|
||||
}
|
||||
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
target := stored()
|
||||
target.Notes = "1"
|
||||
res, projected, _, err := ftSave(t, cc, ftWriter(), target, map[string]any{"notes": utJSON(t, `1`)}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projected["notes"] != float64(1) || calls["notes:"] != 1 || len(res.notices) != 1 {
|
||||
t.Fatalf("number against a stored string was skipped: projected=%v calls=%v", projected, calls)
|
||||
}
|
||||
_, projected, _, err = ftSave(t, cc, ftWriter(), target, map[string]any{"notes": "1"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := projected["notes"]; ok {
|
||||
t.Fatalf("an equal string was decoded: %v", projected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a create never skips", func(t *testing.T) {
|
||||
calls := utCounter{}
|
||||
cc := utController(t, calls)
|
||||
writer := storedWriter()
|
||||
target := stored()
|
||||
body := map[string]any{
|
||||
"body": map[string]any{"pl": "served:P", "en": "served:E"},
|
||||
"notes": utJSON(t, `{"b":1,"a":2}`),
|
||||
}
|
||||
res, projected, translations, err := ftSave(t, cc, writer, target, body, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projected["body"] != "served:P" || projected["notes"] == nil {
|
||||
t.Fatalf("projected=%v", projected)
|
||||
}
|
||||
writer.attrs = nil
|
||||
if err := applyMLTranslations(context.Background(), nil, writer, target, translations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writer.attrs["en"]["body"] != "served:E" {
|
||||
t.Fatalf("attrs=%v", writer.attrs)
|
||||
}
|
||||
if len(res.writes["body"]) != 2 || len(res.writes["notes"]) != 1 || len(res.notices) != 3 {
|
||||
t.Fatalf("writes=%v notices=%v", res.writes, res.notices)
|
||||
}
|
||||
if fmt.Sprint(calls) != fmt.Sprint(utCounter{"body:pl": 1, "body:en": 1, "notes:": 1}) {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user