From 323b6d684a4af2f5f53a716ba2294caf2a155ee5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 14 Oct 2026 20:38:52 +0200 Subject: [PATCH] test(18.1-06): taglist and flat reorder unit matrices, partitioned flat smoke and admin specs (task 2) - TestTaglistFieldValidate: both modes, trim, dedupe, empty, null, wrong shapes, customTags false, required; TestTaglistFieldHydrate: stored NULL and bad text as [] - TestReorderFlatNodes: parent 422, duplicate and zero id 422, missing or extra 409, no depth or cycle check; TestReorderFlatCompile checks the localized list schema - TestReorderFlatSmoke/partitioned: flat reorder within one partition on Postgres - vitest: script markup as text, Backspace, blur, paste, datalist, customTags, string mode; flat ArrowUp/ArrowDown and the before/after drop zones - export_test.go wrappers for the taglist save check, hydration and node check - check-phase18.1.sh requires the new names in the go, admin and postgres stages --- admin/tests/form/TaglistField.test.ts | 93 ++++++++++ admin/tests/views/ReorderView.test.ts | 92 ++++++++++ modules/cabana/README.md | 2 + modules/cabana/catalog_smoke_test.go | 170 +++++++++++++++++- modules/cabana/export_test.go | 34 ++++ modules/cabana/reorder_flat_test.go | 138 ++++++++++++++- modules/cabana/taglist_field_test.go | 243 +++++++++++++++++++++++++- scripts/check-phase18.1.sh | 79 ++++++++- 8 files changed, 841 insertions(+), 10 deletions(-) diff --git a/admin/tests/form/TaglistField.test.ts b/admin/tests/form/TaglistField.test.ts index 267e45f..a1c3849 100644 --- a/admin/tests/form/TaglistField.test.ts +++ b/admin/tests/form/TaglistField.test.ts @@ -152,3 +152,96 @@ describe('TaglistField', () => { expect(wrapper.find('input').exists()).toBe(false) }) }) + +// Plan 18.1-06 coverage of the taglist control: markup safety (T-18.1-02), +// keyboard add and remove, suggestions, customTags and string mode. +describe('TaglistField coverage', () => { + it('renders a tag holding a script element as text and runs nothing', async () => { + ;(window as unknown as { __pwned?: boolean }).__pwned = false + const tag = '' + const wrapper = mountField({ modelValue: [tag, ''] }) + await nextTick() + expect(wrapper.find('script').exists()).toBe(false) + expect(wrapper.find('img').exists()).toBe(false) + expect(chips(wrapper)).toEqual([tag, '']) + expect(wrapper.find('[data-taglist-remove]').attributes('aria-label')).toBe(`Remove ${tag}`) + expect((window as unknown as { __pwned?: boolean }).__pwned).toBe(false) + }) + + it('removes the last chip on Backspace in an empty input, and nothing while text is typed', async () => { + const wrapper = mountField({ modelValue: ['a', 'b', 'c'] }) + await type(wrapper, 'x') + await input(wrapper).trigger('keydown', { key: 'Backspace' }) + expect(emitted(wrapper)).toEqual([]) + await type(wrapper, '') + await input(wrapper).trigger('keydown', { key: 'Backspace' }) + expect(emitted(wrapper)).toEqual([['a', 'b']]) + expect(chips(wrapper)).toEqual(['a', 'b']) + await input(wrapper).trigger('keydown', { key: 'Backspace' }) + await input(wrapper).trigger('keydown', { key: 'Backspace' }) + await input(wrapper).trigger('keydown', { key: 'Backspace' }) + expect(emitted(wrapper)).toEqual([['a', 'b'], ['a'], []]) + expect(chips(wrapper)).toEqual([]) + }) + + it('adds the draft on blur', async () => { + const wrapper = mountField({ modelValue: ['a'] }) + await type(wrapper, ' b ') + await input(wrapper).trigger('blur') + expect(emitted(wrapper)).toEqual([['a', 'b']]) + expect(input(wrapper).element.value).toBe('') + }) + + it('adds every complete tag of a pasted list and keeps the unfinished rest as the draft', async () => { + const wrapper = mountField({ modelValue: [] }) + await type(wrapper, 'a, b,,a,c') + expect(emitted(wrapper)).toEqual([['a'], ['a', 'b']]) + expect(input(wrapper).element.value).toBe('c') + await input(wrapper).trigger('keydown', { key: 'Enter' }) + expect(emitted(wrapper).at(-1)).toEqual(['a', 'b', 'c']) + }) + + it('offers the options as datalist suggestions bound to the input, and none without options', () => { + const wrapper = mountField({ modelValue: [] }) + const list = wrapper.find('datalist') + expect(list.attributes('id')).toBe('f-tags-options') + expect(input(wrapper).attributes('list')).toBe('f-tags-options') + expect(list.findAll('option').map((o) => o.attributes('value'))).toEqual(['alpha', 'beta']) + const bare = mountField({ modelValue: [], field: { options: [] } }) + expect(bare.find('datalist').exists()).toBe(false) + expect(bare.find('input').attributes('list')).toBeUndefined() + }) + + it('with customTags false ignores a free tag typed with the separator but adds a suggested one', async () => { + const wrapper = mountField({ modelValue: ['alpha'], field: { customTags: false } }) + await type(wrapper, 'gamma,') + expect(emitted(wrapper)).toEqual([]) + expect(chips(wrapper)).toEqual(['alpha']) + await type(wrapper, 'beta,') + expect(emitted(wrapper)).toEqual([['alpha', 'beta']]) + }) + + it('splits a comma string-mode value, trims and drops duplicates, and emits the joined string', async () => { + const wrapper = mountField({ modelValue: ' a , ,b,a', field: { mode: 'string', separator: 'comma', options: [] } }) + expect(chips(wrapper)).toEqual(['a', 'b']) + await type(wrapper, 'c,d,') + expect(emitted(wrapper)).toEqual(['a,b,c', 'a,b,c,d']) + await wrapper.find('[data-taglist-remove]').trigger('click') + expect(emitted(wrapper).at(-1)).toBe('b,c,d') + await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click') + await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click') + await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click') + expect(emitted(wrapper).at(-1)).toBe('') + }) + + it('reads a string as no tags in array mode', () => { + const wrapper = mountField({ modelValue: 'a,b' }) + expect(chips(wrapper)).toEqual([]) + }) + + it('marks the input required and invalid for the form', () => { + const wrapper = mountField({ modelValue: [], invalid: true, field: { required: true } }) + expect(input(wrapper).attributes('aria-required')).toBe('true') + expect(input(wrapper).attributes('aria-invalid')).toBe('true') + }) +}) diff --git a/admin/tests/views/ReorderView.test.ts b/admin/tests/views/ReorderView.test.ts index 0c716f2..4cb40db 100644 --- a/admin/tests/views/ReorderView.test.ts +++ b/admin/tests/views/ReorderView.test.ts @@ -533,6 +533,98 @@ describe('ReorderView flat', () => { }) }) + it('moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents', async () => { + let body: unknown = null + const { wrapper, calls } = await mountApp( + '/acme/companions/links/reorder', + { + [`GET ${REORDER}`]: flatTree(), + [`POST ${REORDER}`]: async (request) => { + body = await request.json() + return flatTree() + }, + }, + { attach: true }, + ) + // ArrowUp on the first row and ArrowDown on the last change nothing. + keydown(handle(wrapper, 1).element, 'ArrowUp') + keydown(handle(wrapper, 3).element, 'ArrowDown') + await flushPromises() + expect(shape(wrapper)).toBe('Alpha Beta Gamma') + expect(wrapper.find('[data-reorder-node="1"] [data-action="reorder-up"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-reorder-node="3"] [data-action="reorder-down"]').attributes('disabled')).toBeDefined() + + const down = keydown(handle(wrapper, 1).element, 'ArrowDown') + expect(down.defaultPrevented).toBe(true) + await flushPromises() + expect(shape(wrapper)).toBe('Beta Alpha Gamma') + expect(document.activeElement).toBe(handle(wrapper, 1).element) + await vi.waitFor(() => { + expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Alpha na pozycję 2 z 3.') + }) + const up = keydown(handle(wrapper, 3).element, 'ArrowUp') + expect(up.defaultPrevented).toBe(true) + await flushPromises() + expect(shape(wrapper)).toBe('Beta Gamma Alpha') + expect(document.activeElement).toBe(handle(wrapper, 3).element) + // ArrowRight and ArrowLeft are not taken from the browser on a flat list. + expect(keydown(handle(wrapper, 3).element, 'ArrowRight').defaultPrevented).toBe(false) + expect(keydown(handle(wrapper, 3).element, 'ArrowLeft').defaultPrevented).toBe(false) + + await vi.waitFor(() => { + expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) + }) + expect(body).toEqual({ + partition: null, + nodes: [ + { id: 2, parent_id: null, sort_order: 1 }, + { id: 3, parent_id: null, sort_order: 2 }, + { id: 1, parent_id: null, sort_order: 3 }, + ], + }) + }) + + it('maps the top third of a row to before, the bottom third to after and the middle to after on a flat list', async () => { + const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: flatTree(), [`POST ${REORDER}`]: flatTree() }, { attach: true }) + const box = { top: 0, height: 30, bottom: 30, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect + const row = (id: number) => { + const found = wrapper.find(`[data-reorder-node="${id}"]`) + vi.spyOn(found.element, 'getBoundingClientRect').mockReturnValue(box) + return found + } + const lineOf = (id: number) => wrapper.find(`[data-reorder-node="${id}"] [data-reorder-line]`) + + // Gamma dragged over the top third of Alpha: a line at the top, dropped before Alpha. + await handle(wrapper, 3).trigger('dragstart') + const alpha = row(1) + await alpha.trigger('dragover', { clientY: 2 }) + expect(lineOf(1).classes()).toContain('top-0') + expect(alpha.classes()).not.toContain('outline-dashed') + await alpha.trigger('drop') + await flushPromises() + expect(shape(wrapper)).toBe('Gamma Alpha Beta') + + // Gamma dragged over the middle of Beta: never an inside highlight, a line at the bottom, dropped after Beta. + await handle(wrapper, 3).trigger('dragstart') + const beta = row(2) + await beta.trigger('dragover', { clientY: 15 }) + expect(lineOf(2).classes()).toContain('bottom-0') + expect(beta.classes()).not.toContain('outline-dashed') + await beta.trigger('drop') + await flushPromises() + expect(shape(wrapper)).toBe('Alpha Beta Gamma') + + // Alpha dragged over the bottom third of Gamma lands after it. + await handle(wrapper, 1).trigger('dragstart') + const gamma = row(3) + await gamma.trigger('dragover', { clientY: 28 }) + expect(lineOf(3).classes()).toContain('bottom-0') + await gamma.trigger('drop') + await flushPromises() + expect(shape(wrapper)).toBe('Beta Gamma Alpha') + expect(wrapper.findAll('[data-reorder-tree] ul')).toHaveLength(0) + }) + it('maps the inside zone to after, keeps sibling moves and flattens with null parents', () => { expect(flatZone('inside', true)).toBe('after') expect(flatZone('inside', false)).toBe('inside') diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 59d378a..9748b46 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -375,3 +375,5 @@ go test ./modules/cabana/... The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`. The Phase 18 pieces (money field and column, write-only settings secrets, richeditor and tabs on settings forms, danger record actions) are covered by Docker-free unit tests in `money_field_test.go` (TestMoneyFieldCompile, TestMoneyFieldColumnGate, TestMoneyFieldValidate, TestMoneyListColumn), `settings_secret_test.go` (TestSettingsSecretCompile, TestSettingsSecretProjection, TestSettingsSecretPut), `settings_tabs_test.go` (TestSettingsTabs, TestSettingsRichEditor) and `record_action_danger_test.go` (TestRecordActionDanger), and by Postgres tests in `money_field_postgres_test.go` (TestMoneyFieldSave), `settings_secret_postgres_test.go` (TestSettingsSecretPostgres), the smoke tests in `payments_smoke_test.go` and the Phase 18 payload cases of TestPhase10OpenAPIConformance. `scripts/check-phase18.sh` runs them by name. + +The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name. diff --git a/modules/cabana/catalog_smoke_test.go b/modules/cabana/catalog_smoke_test.go index 0b2aaba..25d650b 100644 --- a/modules/cabana/catalog_smoke_test.go +++ b/modules/cabana/catalog_smoke_test.go @@ -302,7 +302,8 @@ type catalogReorder struct { // TestReorderFlatSmoke checks the flat reorder routes on Postgres: the GET // answers flat true with parent_id null on every node, the POST stores the // submitted sort values (only the sort column) and answers the new order, a -// node with a parent is a 422 on nodes and a missing node a 409. +// node with a parent is a 422 on nodes and a missing node a 409. The +// partitioned subtest runs the same rules within one partition. func TestReorderFlatSmoke(t *testing.T) { e := newCatalogEnv(t) ids := make([]uint, 0, 3) @@ -371,4 +372,171 @@ func TestReorderFlatSmoke(t *testing.T) { if rec.Code != http.StatusConflict { t.Fatalf("missing node status=%d body=%s", rec.Code, rec.Body.String()) } + t.Run("partitioned", reorderFlatPartition) +} + +// shelfPin is the record of the partitioned flat reorder fixture +// (acme.shelf): a name, the shelf it belongs to (the partition) and a sort +// column, and no parent_id column, so its reorder is flat. +type shelfPin struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + ShelfID *uint `gorm:"column:shelf_id"` + SortOrder int `gorm:"column:sort_order"` +} + +func (shelfPin) TableName() string { return "acme_shelf_pins" } +func (shelfPin) Fillable() []string { return []string{"name", "sort_order"} } +func (shelfPin) Rules() map[string]string { return map[string]string{"name": "required"} } + +// shelfShelf is the belongsTo target of the pins' shelf field. +type shelfShelf struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` +} + +func (shelfShelf) TableName() string { return "acme_shelf_shelves" } + +// shelfFS is the acme.shelf fixture tree: the pins controller with a flat +// reorder partitioned by shelf. +var shelfFS = fstest.MapFS{ + "controllers/pins/config_list.yaml": {Data: []byte("list: ~/plugins/acme/shelf/models/pin/columns.yaml\nmodelClass: Pin\ntitle: Pins\nrecordUrl: acme/shelf/pins/update/:id\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n")}, + "controllers/pins/config_form.yaml": {Data: []byte("name: Pin\nform: ~/plugins/acme/shelf/models/pin/fields.yaml\nmodelClass: Pin\ndefaultRedirect: acme/shelf/pins\n")}, + "controllers/pins/config_reorder.yaml": {Data: []byte("title: Reorder pins\nnameFrom: name\nmodelClass: Pin\npartition:\n column: shelf_id\n relation: shelf\n label: Shelf\n")}, + "models/pin/columns.yaml": {Data: []byte("columns:\n name:\n label: Name\n sort_order:\n label: Order\n")}, + "models/pin/fields.yaml": {Data: []byte("fields:\n name:\n label: Name\n type: text\n shelf:\n label: Shelf\n type: relation\n nameFrom: title\n")}, +} + +// shelfPlugin registers the pins controller. +type shelfPlugin struct{} + +func (shelfPlugin) ID() string { return "acme.shelf" } +func (shelfPlugin) Requires() []string { return nil } +func (shelfPlugin) Register(*backpack.App) error { return nil } +func (shelfPlugin) Boot(*backpack.App) error { return nil } +func (shelfPlugin) AdminFS() fs.FS { return shelfFS } +func (shelfPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{shelfPins{}} +} +func (shelfPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}} +} + +// shelfPins is the pins controller. +type shelfPins struct{} + +func (shelfPins) ID() string { return "acme.shelf.pins" } +func (shelfPins) ModelName() string { return "Pin" } +func (shelfPins) ConfigDir() string { return "controllers/pins" } +func (shelfPins) RequiredPermissions() []string { return []string{"acme.shelf.access"} } +func (shelfPins) NewRecord() any { return &shelfPin{} } + +// AdminFieldRelations binds the shelf relation field, the reorder partition. +func (shelfPins) AdminFieldRelations() []cabana.FieldRelationContract { + return []cabana.FieldRelationContract{{Field: "shelf", Kind: "belongsTo", NewRelated: func() any { return &shelfShelf{} }, ForeignKey: "shelf_id"}} +} + +// newShelfEnv is a logged-in admin over the acme.shelf fixture with fresh +// tables. +func newShelfEnv(t *testing.T) *conformEnv { + t.Helper() + gdb := adminGorm(t) + models := []any{&shelfPin{}, &shelfShelf{}} + if err := gdb.Migrator().DropTable(models...); err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(models...); err != nil { + t.Fatal(err) + } + login := fmt.Sprintf("shelf-%d", time.Now().UnixNano()) + insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + h, err := paymentsAssemble(t, gdb, []party.Plugin{shelfPlugin{}}) + if err != nil { + t.Fatal(err) + } + env := &conformEnv{h: h, db: gdb, login: login} + if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { + t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) + } + return env +} + +// reorderFlatPartition checks a partitioned flat reorder on Postgres (plan +// 18.1-06, a subtest of TestReorderFlatSmoke): the GET of one shelf answers flat true with only +// that shelf's pins and the partition options, the POST writes only the sort +// column (the shelf and the other shelf's rows are untouched), a pin of the +// other shelf is the 409 stale set and a parent is a 422 on nodes. +func reorderFlatPartition(t *testing.T) { + e := newShelfEnv(t) + top, low := shelfShelf{Title: "Top"}, shelfShelf{Title: "Low"} + for _, s := range []*shelfShelf{&top, &low} { + if err := e.db.Create(s).Error; err != nil { + t.Fatal(err) + } + } + pins := []shelfPin{{Name: "A", ShelfID: &top.ID, SortOrder: 1}, {Name: "B", ShelfID: &top.ID, SortOrder: 2}, {Name: "X", ShelfID: &low.ID, SortOrder: 7}} + for i := range pins { + if err := e.db.Create(&pins[i]).Error; err != nil { + t.Fatal(err) + } + } + a, b, x := pins[0].ID, pins[1].ID, pins[2].ID + + rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/shelf/pins/reorder?partition=%d", top.ID), nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Data struct { + Flat bool `json:"flat"` + Partition *struct { + Label string `json:"label"` + Value *uint `json:"value"` + Options []struct { + Label string `json:"label"` + } `json:"options"` + } `json:"partition"` + Nodes []struct { + ID uint `json:"id"` + ParentID *uint `json:"parent_id"` + } `json:"nodes"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + d := got.Data + if !d.Flat || d.Partition == nil || d.Partition.Label != "Shelf" || d.Partition.Value == nil || *d.Partition.Value != top.ID || len(d.Partition.Options) != 2 { + t.Fatalf("GET = %s", rec.Body.String()) + } + if len(d.Nodes) != 2 || d.Nodes[0].ID != a || d.Nodes[1].ID != b || d.Nodes[0].ParentID != nil || d.Nodes[1].ParentID != nil { + t.Fatalf("GET nodes = %s", rec.Body.String()) + } + + post := func(nodes ...map[string]any) int { + return e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{"partition": top.ID, "nodes": nodes}, true).Code + } + n := func(id uint, parent any, sort int) map[string]any { + return map[string]any{"id": id, "parent_id": parent, "sort_order": sort} + } + if code := post(n(b, nil, 1), n(a, nil, 2)); code != http.StatusOK { + t.Fatalf("POST status=%d", code) + } + rows := map[uint]shelfPin{} + var all []shelfPin + if err := e.db.Find(&all).Error; err != nil { + t.Fatal(err) + } + for _, p := range all { + rows[p.ID] = p + } + if rows[b].SortOrder != 1 || rows[a].SortOrder != 2 || *rows[a].ShelfID != top.ID || *rows[b].ShelfID != top.ID || rows[x].SortOrder != 7 || *rows[x].ShelfID != low.ID { + t.Fatalf("rows after POST = %+v", rows) + } + if code := post(n(b, nil, 1), n(a, nil, 2), n(x, nil, 3)); code != http.StatusConflict { + t.Fatalf("pin of the other shelf status=%d, want 409", code) + } + if code := post(n(b, nil, 1), n(a, b, 2)); code != http.StatusUnprocessableEntity { + t.Fatalf("parent status=%d, want 422", code) + } } diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index ab4bd6b..4876280 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -1,11 +1,14 @@ package cabana import ( + "context" + "errors" "fmt" "time" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) @@ -68,3 +71,34 @@ func CheckMoneyType(model any, field FormField) error { return checkMoneyType(mo func ProjectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any { return projectRow(row, controller, cols) } + +// CheckTaglistValues exposes the taglist save check (every field allowed) +// to the external taglist tests: it rewrites body in place and returns the +// *ValidationError of the refused values, or nil. +func CheckTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields []FormField, body map[string]any, create bool) error { + return checkTaglistValues(ctx, tr, fields, body, nil, create) +} + +// HydrateTaglistFields exposes the record GET's taglist hydration to the +// external taglist tests. +func HydrateTaglistFields(fields []FormField, data map[string]any) { + hydrateTaglistFields(fields, data) +} + +// CheckReorderNodes exposes the reorder save's node check to the external +// reorder tests, over a reorder that is flat or a tree with maxDepth. +func CheckReorderNodes(flat bool, maxDepth int, locked []AdminReorderNode, nodes []AdminReorderNodeInput, flatParent string) error { + ro := &compiledReorder{flat: flat, maxDepth: maxDepth} + if !flat { + ro.parentColumn = "parent_id" + } + ro.sortColumn = "sort_order" + return checkReorderNodes(ro, locked, nodes, flatParent) +} + +// IsReorderStale reports whether err is the reorder save's 409 (the +// submitted node set differs from the stored one). +func IsReorderStale(err error) bool { + var stale reorderStale + return errors.As(err, &stale) +} diff --git a/modules/cabana/reorder_flat_test.go b/modules/cabana/reorder_flat_test.go index f3c991e..0942f37 100644 --- a/modules/cabana/reorder_flat_test.go +++ b/modules/cabana/reorder_flat_test.go @@ -1,18 +1,24 @@ package cabana_test import ( + "context" "encoding/json" + "errors" "strings" "testing" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/towel" ) // Smoke tests of the flat (WinterCMS Sortable) reorder (Phase 18.1 D-17), // Docker-free: the inference rule and the flat flag. The Postgres routes are -// TestReorderFlatSmoke in catalog_smoke_test.go. Full unit coverage is plan -// 06. No application names appear in framework tests. +// TestReorderFlatSmoke in catalog_smoke_test.go; TestReorderFlatNodes is the +// unit matrix of the save's node check (plan 18.1-06). No application names +// appear in framework tests. // TestReorderFlatCompile pins the flat inference: a config_reorder.yaml // without parentColumn on a record without a parent_id column compiles as @@ -60,6 +66,33 @@ func TestReorderFlatCompile(t *testing.T) { }) } + // The localized list schema (GET .../schema/list) carries the flat flag + // too, not only the compiled one (deferred defect from plan 18.1-04). + t.Run("the localized list schema keeps flat", func(t *testing.T) { + app := backpack.New(nil) + if err := phrasebook.Activate[party.Plugin](app, nil); err != nil { + t.Fatal(err) + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok { + t.Fatal("translator missing") + } + view, err := list.Localize(towel.WithLocale(context.Background(), "en"), tr) + if err != nil { + t.Fatal(err) + } + if view.Reorder == nil || !view.Reorder.Flat || view.Reorder.Title != "Reorder badges" { + t.Fatalf("localized reorder = %+v", view.Reorder) + } + raw, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"reorder":{"title":"Reorder badges","flat":true}`) { + t.Fatalf("localized list schema JSON lacks flat: %s", raw) + } + }) + t.Run("the tree fixture stays a tree", func(t *testing.T) { tree, err := cabana.CompiledListSchema([]party.Plugin{companionsFSPlugin{fsys: companionsOverlay(t, nil)}}, "acme.companions.links") if err != nil { @@ -77,3 +110,104 @@ func TestReorderFlatCompile(t *testing.T) { } }) } + +// TestReorderFlatNodes is the unit matrix of the reorder save's node check +// on a flat reorder (D-17), Docker-free: any non-null parent is a 422 on +// nodes with the flat_parent message, a duplicate id or a zero id is a 422, +// a missing or an extra node is the 409 stale set, and a valid set in any +// order passes with no depth or cycle evaluation. The same node lists on a +// tree reorder show what flat mode changes. TestReorderFlatSmoke drives the +// rules over HTTP on Postgres. +func TestReorderFlatNodes(t *testing.T) { + const flatParent = "A flat reorder has no parents." + locked := []cabana.AdminReorderNode{{ID: 1, SortOrder: 1}, {ID: 2, SortOrder: 2}, {ID: 3, SortOrder: 3}} + node := func(id uint64, parent *uint64, sort int64) cabana.AdminReorderNodeInput { + return cabana.AdminReorderNodeInput{ID: id, ParentID: parent, SortOrder: sort} + } + ptr := func(v uint64) *uint64 { return &v } + nodesError := func(t *testing.T, err error, want string) { + t.Helper() + var ve *cabana.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want a 422 validation error", err) + } + msgs, _ := ve.Details["nodes"].([]string) + if len(msgs) != 1 || msgs[0] != want || len(ve.Details) != 1 { + t.Fatalf("details = %#v, want nodes: %q", ve.Details, want) + } + } + + t.Run("a reversed set passes", func(t *testing.T) { + nodes := []cabana.AdminReorderNodeInput{node(3, nil, 1), node(2, nil, 2), node(1, nil, 3)} + if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil { + t.Fatal(err) + } + }) + t.Run("equal and negative sort values pass", func(t *testing.T) { + nodes := []cabana.AdminReorderNodeInput{node(1, nil, -5), node(2, nil, 0), node(3, nil, 0)} + if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil { + t.Fatal(err) + } + }) + t.Run("a long flat list has no depth or cycle check", func(t *testing.T) { + var many []cabana.AdminReorderNode + var nodes []cabana.AdminReorderNodeInput + for i := uint(1); i <= 200; i++ { + many = append(many, cabana.AdminReorderNode{ID: i}) + nodes = append(nodes, node(uint64(201-i), nil, int64(i))) + } + if err := cabana.CheckReorderNodes(true, 1, many, nodes, flatParent); err != nil { + t.Fatal(err) + } + }) + t.Run("an empty set over no records passes", func(t *testing.T) { + if err := cabana.CheckReorderNodes(true, 0, nil, nil, flatParent); err != nil { + t.Fatal(err) + } + }) + + for _, tc := range []struct { + name string + nodes []cabana.AdminReorderNodeInput + want string + }{ + {"a parent inside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)}, flatParent}, + {"a parent outside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(99), 3)}, flatParent}, + {"a parent of zero", []cabana.AdminReorderNodeInput{node(1, ptr(0), 1), node(2, nil, 2), node(3, nil, 3)}, flatParent}, + {"its own parent", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(3), 3)}, flatParent}, + {"a duplicate id", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(2, nil, 3)}, "The nodes field lists a record twice."}, + {"a zero id", []cabana.AdminReorderNodeInput{node(0, nil, 1), node(2, nil, 2), node(3, nil, 3)}, "The nodes field must list record ids."}, + } { + t.Run(tc.name+" is a 422", func(t *testing.T) { + nodesError(t, cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent), tc.want) + }) + } + + for _, tc := range []struct { + name string + nodes []cabana.AdminReorderNodeInput + }{ + {"a missing node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2)}}, + {"an extra node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, nil, 3), node(4, nil, 4)}}, + {"a node swapped for another", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(4, nil, 3)}}, + {"no nodes", nil}, + } { + t.Run(tc.name+" is the 409 stale set", func(t *testing.T) { + err := cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent) + if !cabana.IsReorderStale(err) { + t.Fatalf("err = %v, want the stale set", err) + } + }) + } + + t.Run("the same parent is accepted on a tree", func(t *testing.T) { + nodes := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)} + if err := cabana.CheckReorderNodes(false, 0, locked, nodes, flatParent); err != nil { + t.Fatal(err) + } + cycle := []cabana.AdminReorderNodeInput{node(1, ptr(2), 1), node(2, ptr(1), 2), node(3, nil, 3)} + nodesError(t, cabana.CheckReorderNodes(false, 0, locked, cycle, flatParent), "The tree has a cycle.") + deep := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, ptr(2), 3)} + nodesError(t, cabana.CheckReorderNodes(false, 2, locked, deep, flatParent), "The tree may be at most 2 levels deep.") + }) +} diff --git a/modules/cabana/taglist_field_test.go b/modules/cabana/taglist_field_test.go index 723c422..4d3e2ca 100644 --- a/modules/cabana/taglist_field_test.go +++ b/modules/cabana/taglist_field_test.go @@ -1,20 +1,28 @@ package cabana_test import ( + "context" "encoding/json" + "errors" "strings" "testing" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/towel" ) // Smoke tests of the cabana taglist field (Phase 18.1 D-17), Docker-free: // the boot gates of the keys and of the model column. The Postgres save // round trip is TestTaglistFieldSmoke in catalog_smoke_test.go; the fixture -// (acme.catalog) lives there. Full unit coverage is plan 06. No application -// names appear in framework tests. +// (acme.catalog) lives there. TestTaglistFieldValidate and +// TestTaglistFieldHydrate are the unit matrices of the save check and the +// record hydration (plan 18.1-06). No application names appear in framework +// tests. // catalogFieldsPath is the badge fields.yaml of the fixture. const catalogFieldsPath = "models/badge/fields.yaml" @@ -156,3 +164,234 @@ func TestTaglistFieldCompile(t *testing.T) { } }) } + +// taglistField compiles one taglist field named keywords from the YAML keys +// given (indented under the field) on the badge fixture model. +func taglistField(t *testing.T, keys string) cabana.FormField { + t.Helper() + fields := "fields:\n keywords:\n type: taglist\n" + keys + schema, err := cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, map[string]string{catalogFieldsPath: fields})) + if err != nil { + t.Fatalf("compile %q: %v", keys, err) + } + return schema.Fields[0] +} + +// taglistTranslator is the framework translator with the backend phrases. +func taglistTranslator(t *testing.T) *phrasebook.Translator { + t.Helper() + app := backpack.New(nil) + if err := phrasebook.Activate[party.Plugin](app, nil); err != nil { + t.Fatal(err) + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok { + t.Fatal("translator missing") + } + return tr +} + +// TestTaglistFieldValidate is the save-time matrix of the taglist field +// (D-17) on the save check itself, Docker-free: normalisation (trim, drop +// empty, dedupe keeping the first), the stored form per mode (JSON text of +// the list in array mode, the tags joined by the separator in string mode), +// null as no tags, the 422 taglist.invalid for every wrong shape (a string +// in array mode, a list in string mode, a number item, an object, a null +// item), the 422 taglist.not_allowed naming the first tag outside the +// options with customTags false, and the required rule on the normalised +// tags. TestTaglistFieldSmoke drives the same rules over HTTP on Postgres. +func TestTaglistFieldValidate(t *testing.T) { + tr := taglistTranslator(t) + ctx := towel.WithLocale(context.Background(), "en") + array := taglistField(t, " mode: array\n") + comma := taglistField(t, "") + space := taglistField(t, " separator: space\n") + strict := taglistField(t, " mode: array\n options: [alpha, beta]\n customTags: false\n") + strictString := taglistField(t, " options: [alpha, beta]\n customTags: false\n") + open := taglistField(t, " mode: array\n options: [alpha, beta]\n") + required := taglistField(t, " mode: array\n required: true\n") + requiredString := taglistField(t, " required: true\n") + + const invalid = "The keywords field must be a list of tags." + ok := []struct { + name string + field cabana.FormField + in any + want any + }{ + {"array trims, drops empty and dedupes keeping the first", array, []any{" a ", "a", "", "b", " "}, `["a","b"]`}, + {"array keeps the order of first appearance", array, []any{"b", "a", "b"}, `["b","a"]`}, + {"array null stores the empty list", array, nil, `[]`}, + {"array empty list stays the empty list", array, []any{}, `[]`}, + {"array of blanks is the empty list", array, []any{" ", ""}, `[]`}, + {"array keeps inner spaces and case", array, []any{"New York", "new york"}, `["New York","new york"]`}, + {"array keeps markup as plain text (HTML-escaped JSON)", array, []any{"x"}, `["\u003cb\u003ex\u003c/b\u003e"]`}, + {"string comma splits, trims and dedupes", comma, " a , b,a,, ", "a,b"}, + {"string comma keeps inner spaces", comma, "big data, ai", "big data,ai"}, + {"string null stores an empty string", comma, nil, ""}, + {"string empty stays empty", comma, "", ""}, + {"string space splits on any whitespace", space, "x y x", "x y"}, + {"string space takes tabs and newlines", space, "\tx\ny ", "x y"}, + {"string space keeps commas inside a tag", space, "a,b c", "a,b c"}, + {"customTags false accepts the options, normalised", strict, []any{"alpha", " beta ", "alpha"}, `["alpha","beta"]`}, + {"customTags false accepts no tags", strict, []any{}, `[]`}, + {"customTags false in string mode accepts the options", strictString, "beta,alpha", "beta,alpha"}, + {"options without customTags false take any tag", open, []any{"delta"}, `["delta"]`}, + {"required with a tag", required, []any{"a"}, `["a"]`}, + } + for _, tc := range ok { + t.Run(tc.name, func(t *testing.T) { + body := map[string]any{"keywords": tc.in} + if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{tc.field}, body, false); err != nil { + t.Fatalf("refused: %v %+v", err, err) + } + if body["keywords"] != tc.want { + t.Fatalf("stored %#v, want %#v", body["keywords"], tc.want) + } + }) + } + + refused := []struct { + name string + field cabana.FormField + body map[string]any + create bool + want string + }{ + {"a string in array mode", array, map[string]any{"keywords": "a,b"}, false, invalid}, + {"a number item in array mode", array, map[string]any{"keywords": []any{"a", float64(1)}}, false, invalid}, + {"a null item in array mode", array, map[string]any{"keywords": []any{"a", nil}}, false, invalid}, + {"a nested list in array mode", array, map[string]any{"keywords": []any{[]any{"a"}}}, false, invalid}, + {"an object in array mode", array, map[string]any{"keywords": map[string]any{"a": true}}, false, invalid}, + {"a boolean in array mode", array, map[string]any{"keywords": true}, false, invalid}, + {"a list in string mode", comma, map[string]any{"keywords": []any{"a"}}, false, invalid}, + {"a number in string mode", comma, map[string]any{"keywords": float64(5)}, false, invalid}, + {"an object in string mode", space, map[string]any{"keywords": map[string]any{}}, false, invalid}, + {"customTags false names the first tag outside the options", strict, map[string]any{"keywords": []any{"alpha", "delta", "omega"}}, false, "The tag delta is not allowed."}, + {"customTags false compares the trimmed tag", strict, map[string]any{"keywords": []any{" gamma "}}, false, "The tag gamma is not allowed."}, + {"customTags false is case-sensitive", strict, map[string]any{"keywords": []any{"Alpha"}}, false, "The tag Alpha is not allowed."}, + {"customTags false in string mode", strictString, map[string]any{"keywords": "alpha,delta"}, false, "The tag delta is not allowed."}, + {"required with only blanks", required, map[string]any{"keywords": []any{" ", ""}}, false, "The keywords field is required."}, + {"required with null", required, map[string]any{"keywords": nil}, false, "The keywords field is required."}, + {"required missing on create", required, map[string]any{}, true, "The keywords field is required."}, + {"required string of separators", requiredString, map[string]any{"keywords": " , ,"}, false, "The keywords field is required."}, + } + for _, tc := range refused { + t.Run(tc.name, func(t *testing.T) { + before, _ := json.Marshal(tc.body) + err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{tc.field}, tc.body, tc.create) + var ve *cabana.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want a validation error", err) + } + msgs, _ := ve.Details["keywords"].([]string) + if len(msgs) != 1 || msgs[0] != tc.want || len(ve.Details) != 1 { + t.Fatalf("details = %#v, want keywords: %q", ve.Details, tc.want) + } + after, _ := json.Marshal(tc.body) + if string(before) != string(after) { + t.Fatalf("a refused value rewrote the body: %s -> %s", before, after) + } + }) + } + + t.Run("missing on update leaves the body alone", func(t *testing.T) { + body := map[string]any{"name": "x"} + if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{required}, body, false); err != nil { + t.Fatal(err) + } + if _, ok := body["keywords"]; ok || len(body) != 1 { + t.Fatalf("body = %#v", body) + } + }) + + t.Run("every refused field is reported at once and other fields are untouched", func(t *testing.T) { + other := strict + other.Name = "tags" + text := cabana.FormField{Name: "name", Type: "text"} + body := map[string]any{"keywords": "a", "tags": []any{"delta"}, "name": []any{"not checked here"}} + err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{array, other, text}, body, false) + var ve *cabana.ValidationError + if !errors.As(err, &ve) || len(ve.Details) != 2 || ve.Details["keywords"] == nil || ve.Details["tags"] == nil { + t.Fatalf("err = %v %+v", err, ve) + } + }) + + t.Run("the messages follow the request locale", func(t *testing.T) { + body := map[string]any{"keywords": "a"} + err := cabana.CheckTaglistValues(towel.WithLocale(context.Background(), "pl"), tr, []cabana.FormField{array}, body, false) + var ve *cabana.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v", err) + } + if msgs, _ := ve.Details["keywords"].([]string); len(msgs) != 1 || msgs[0] != "Pole keywords musi być listą tagów." { + t.Fatalf("pl details = %#v", ve.Details) + } + }) + + t.Run("a nil body is accepted", func(t *testing.T) { + if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{required}, nil, true); err != nil { + t.Fatal(err) + } + }) +} + +// TestTaglistFieldHydrate checks the record GET's hydration of an +// array-mode taglist: the stored JSON text (a string, a *string or a JSON +// column value) becomes the list; a stored NULL, JSON null, unparsable text +// or a JSON value that is not a list of strings becomes the empty list; a +// missing key stays missing; a string-mode value and other fields are served +// as stored. +func TestTaglistFieldHydrate(t *testing.T) { + array := taglistField(t, " mode: array\n") + comma := taglistField(t, "") + comma.Name = "words" + text := `["a","b"]` + var nilText *string + for _, tc := range []struct { + name string + stored any + want []string + }{ + {"JSON text", `["a","b"]`, []string{"a", "b"}}, + {"a pointer to JSON text", &text, []string{"a", "b"}}, + {"a JSON column value", lagoon.Jsonable[[]string]{Data: []string{"x"}, Valid: true}, []string{"x"}}, + {"an empty list", `[]`, []string{}}, + {"a stored NULL", nil, []string{}}, + {"a nil pointer", nilText, []string{}}, + {"an invalid JSON column value", lagoon.Jsonable[[]string]{}, []string{}}, + {"JSON null", `null`, []string{}}, + {"unparsable text", `["a",`, []string{}}, + {"plain text", `a,b`, []string{}}, + {"a JSON object", `{"a":true}`, []string{}}, + {"a list of numbers", `[1,2]`, []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + data := map[string]any{"keywords": tc.stored, "words": "a,b", "name": "kept"} + cabana.HydrateTaglistFields([]cabana.FormField{array, comma, {Name: "name", Type: "text"}}, data) + got, ok := data["keywords"].([]string) + if !ok || got == nil { + t.Fatalf("hydrated %#v, want a non-nil []string", data["keywords"]) + } + raw, _ := json.Marshal(got) + want, _ := json.Marshal(tc.want) + if string(raw) != string(want) { + t.Fatalf("hydrated %s, want %s", raw, want) + } + if data["words"] != "a,b" || data["name"] != "kept" { + t.Fatalf("other fields changed: %#v", data) + } + }) + } + + t.Run("a missing key stays missing", func(t *testing.T) { + data := map[string]any{"name": "x"} + cabana.HydrateTaglistFields([]cabana.FormField{array}, data) + if _, ok := data["keywords"]; ok { + t.Fatalf("data = %#v", data) + } + }) + t.Run("nil data is accepted", func(t *testing.T) { + cabana.HydrateTaglistFields([]cabana.FormField{array}, nil) + }) +} diff --git a/scripts/check-phase18.1.sh b/scripts/check-phase18.1.sh index ce839a3..3d1f73a 100755 --- a/scripts/check-phase18.1.sh +++ b/scripts/check-phase18.1.sh @@ -61,7 +61,10 @@ PG_LAYER="${PG_LAYER:-$ROOT/../vue-paymentgateway-layer}" # must pass; a name that skips or does not run fails the stage. # go stage: Docker-free framework suites (-short). -CABANA_SHORT_TESTS=(TestTaglistFieldCompile) +CABANA_SHORT_TESTS=( + TestTaglistFieldCompile TestTaglistFieldValidate TestTaglistFieldHydrate + TestReorderFlatCompile TestReorderFlatNodes +) # plugins stage: Docker-free suites (-short), per repository and package. # An empty list means the package is covered by vet and the -short run only. @@ -72,7 +75,7 @@ PGSTRIPE_ROOT_TESTS=(TestSDKPin) PGSTRIPE_SUBS_TESTS=() # postgres stage: testcontainers suites, per repository and package. -PG_CABANA_TESTS=(TestTaglistFieldSmoke) +PG_CABANA_TESTS=(TestTaglistFieldSmoke TestReorderFlatSmoke TestReorderPostgres) PG_PGSUB_TESTS=(TestSubscriptionsTracer) PG_PGSTRIPE_ROOT_TESTS=() PG_PGSTRIPE_SUBS_TESTS=(TestHostSubscriptionFlow) @@ -80,17 +83,83 @@ PG_PGSTRIPE_SUBS_TESTS=(TestHostSubscriptionFlow) # admin stage: vitest spec files and full test names (describe + it). ADMIN_FILES=( tests/form/TaglistField.test.ts + tests/views/ReorderView.test.ts +) +ADMIN_REQUIRE=( + "TaglistField renders a loaded list as chips and does not re-emit it untouched" + "TaglistField adds the typed tag on Enter and ignores a duplicate or empty text" + "TaglistField adds a tag on typing a comma" + "TaglistField removes a chip through its labelled remove button" + "TaglistField replaces the chips when the value changes from outside" + "TaglistField renders read-only chips without an input or remove buttons" + "TaglistField renders a tag holding markup as text" + "TaglistField splits and joins a string-mode value on its space separator" + "TaglistField joins a string-mode value with a comma by default" + "TaglistField refuses a tag outside the options when customTags is false" + "TaglistField renders the tags joined as text on the preview screen" + "TaglistField coverage renders a tag holding a script element as text and runs nothing" + "TaglistField coverage removes the last chip on Backspace in an empty input, and nothing while text is typed" + "TaglistField coverage adds the draft on blur" + "TaglistField coverage adds every complete tag of a pasted list and keeps the unfinished rest as the draft" + "TaglistField coverage offers the options as datalist suggestions bound to the input, and none without options" + "TaglistField coverage with customTags false ignores a free tag typed with the separator but adds a suggested one" + "TaglistField coverage splits a comma string-mode value, trims and drops duplicates, and emits the joined string" + "TaglistField coverage reads a string as no tags in array mode" + "TaglistField coverage marks the input required and invalid for the form" + "ReorderView flat offers no indent or outdent, ignores ArrowRight, drops a middle drag after the row and sends parent_id null" + "ReorderView flat moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents" + "ReorderView flat maps the top third of a row to before, the bottom third to after and the middle to after on a flat list" + "ReorderView flat maps the inside zone to after, keeps sibling moves and flattens with null parents" ) -ADMIN_REQUIRE=() # layers stage: test files (must exist and match the test script's glob), TAP # test names, and the minimum number of passing tests named "subscriptions: ...". LAYER_FILES=( tests/subscriptions.test.ts + tests/subscribe.test.ts + tests/subscriptionComponents.test.ts + tests/i18n.test.ts +) +LAYER_REQUIRE=( + 'subscriptions: pl and en carry the same pg.subscriptions keys and placeholders' + 'subscriptions: every status and interval is labelled and every pg.subscriptions key used in app/ exists' + 'subscriptions: subscribe assigns checkout_url once and stays busy' + 'subscriptions: a second click while the checkout runs sends no request' + 'subscriptions: each refused status maps to its key and enables the button again' + 'subscriptions: an answer without an http(s) URL is never followed' + 'subscriptions: PgPlans lists the plans with a working PgSubscribeButton per paid plan' + 'subscriptions: subscribe without a currency sends none, and a retry after a refusal clears the error' + 'subscriptions: PgSubscribeButton and PgManageSubscriptionButton render enabled with their copy and fetch nothing before a click' + 'subscriptions: PgPlans renders two plans with exact prices and features, and no subscribe button for a free plan' + 'subscriptions: PgPlans keeps the API order, filters by planCodes and falls back to the first price' + 'subscriptions: a plan text with markup renders as text' + 'subscriptions: PgPlans shows skeleton cards, the load error with Retry, and the empty copy' + 'subscriptions: PgMySubscription without a subscription shows the none copy and the plans link' + 'subscriptions: an active subscription shows the status, period end, days until renewal, access, Manage and Cancel' + 'subscriptions: a scheduled cancel shows the will-cancel notice with the date and no Cancel button' + 'subscriptions: a past_due subscription shows the grace notice and the warning tone, without Cancel' + 'subscriptions: PgMySubscription shows the login copy on a 401 and the load error with Retry otherwise' + 'subscriptions: useSubscription turns the 404 into null and keeps other failures' + 'subscriptions: the manage flow assigns portal_url once and maps a 404 to no_billing_account' + 'subscriptions: the cancel flow resolves the updated subscription, or null with the mapped error' + 'subscriptions: the plans currency prop picks the matching price in any case, and no currency shows the first' + 'subscriptions: the confirmed none answer (404 Subscription not found) renders the no-subscription state' + 'subscriptions: the cancel button shows only for an active or trialing subscription with no cancel scheduled' + 'subscriptions: the manage flow maps 401 to the login key and 502 to the provider key on open and cancel' + 'subscriptions: a second cancel while one runs sends nothing, and a cancel after a portal redirect is refused' + 'subscriptions: plans GETs /plans and unwraps {data}; a body without a list is empty' + 'subscriptions: every route hits its path with its method and body' + 'subscriptions: the client never sends a redirect URL, a price, a subscriber or a subscription id' + 'subscriptions: hasNoSubscription is a 404 only' + 'subscriptions: planPrice picks the currency, else the first price; status and interval keys' + 'subscriptions: on the server the composable uses the internal base, forwards the visitor and sends the bearer' + 'subscriptions: in the browser the composable uses the public base and the bearer token only' + 'subscriptions: without a token from the hook no authorization header is sent' + 'subscriptions: a refused request rejects with the fetcher error unchanged on every route' + 'subscriptions: usePlans reads GET /plans under the key pg:subscriptions:plans' ) -LAYER_REQUIRE=() LAYER_PREFIX='subscriptions:' -LAYER_PREFIX_MIN=6 +LAYER_PREFIX_MIN=36 usage() { cat >&2 <<'EOF'