diff --git a/admin/tests/form/PluginField.test.ts b/admin/tests/form/PluginField.test.ts index b50ebe8..1834c5b 100644 --- a/admin/tests/form/PluginField.test.ts +++ b/admin/tests/form/PluginField.test.ts @@ -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\n

Grzyby rosnÄ…

\n\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('

a

') + await flushPromises() + const el = echoElement(wrapper) + await wrapper.setProps({ modelValue: '

b

' }) + await flushPromises() + await wrapper.setProps({ modelValue: '

b

' }) + await flushPromises() + expect(el.writes).toEqual(['

a

', '

b

']) + }) +}) + describe('MLPluginField', () => { class AcmeDemoNotesml extends AcmeDemoNotes {} if (!customElements.get('acme-demo-notesml')) { diff --git a/modules/cabana/field_type_test.go b/modules/cabana/field_type_test.go new file mode 100644 index 0000000..f8b442c --- /dev/null +++ b/modules/cabana/field_type_test.go @@ -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) + } + }) +}