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:
2026-10-10 15:42:31 +02:00
parent c07da3912b
commit 08a20021e3
2 changed files with 374 additions and 0 deletions

View File

@@ -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')) {

View 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)
}
})
}