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'