|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #5164 — the `_views` bundle key a container's DEFAULT `list` gets from the |
| 4 | +// i18n extractor must be the SAME STRING the runtime registry assigns it. |
| 5 | +// |
| 6 | +// Three surfaces used to derive that key independently and disagree: the |
| 7 | +// composer (`expandViewContainer`) named a nameless default list |
| 8 | +// `<object>.default`, this extractor wrote `_views.list`, and the lint accepted |
| 9 | +// both. An app declaring only a default `list` therefore got a bundle skeleton |
| 10 | +// keyed `list`, a runtime view keyed `default`, and an English label on screen |
| 11 | +// forever. Maintainer ruling 2026-08-06: canonical = the runtime identity's |
| 12 | +// bare key; the extractor converges on the composer. |
| 13 | +// |
| 14 | +// These tests use the COMPOSER ITSELF as the oracle rather than hard-coding the |
| 15 | +// expected spelling, so they pin the invariant ("same key") and not one side's |
| 16 | +// current opinion of it. Hard-coded spellings are asserted too, but only where |
| 17 | +// the string is the thing under discussion. |
| 18 | +import { describe, it, expect } from 'vitest'; |
| 19 | +import { expandViewContainer } from '@objectstack/spec/ui'; |
| 20 | +import { collectExpectedEntries } from '../src/utils/i18n-extract'; |
| 21 | + |
| 22 | +/** Every `_views` key the extractor scaffolds for `object`, in emit order. */ |
| 23 | +function extractorViewKeys(object: string, container: any): string[] { |
| 24 | + const prefix = `objects.${object}._views.`; |
| 25 | + const keys: string[] = []; |
| 26 | + for (const entry of collectExpectedEntries({ views: [container] })) { |
| 27 | + const path = entry.path.join('.'); |
| 28 | + if (!path.startsWith(prefix) || !path.endsWith('.label')) continue; |
| 29 | + keys.push(path.slice(prefix.length, -'.label'.length)); |
| 30 | + } |
| 31 | + return keys; |
| 32 | +} |
| 33 | + |
| 34 | +/** Every LIST-family view key the runtime registry assigns for `object`. */ |
| 35 | +function runtimeListViewKeys(object: string, container: any): string[] { |
| 36 | + return expandViewContainer(object, container) |
| 37 | + .filter((item) => item.viewKind === 'list') |
| 38 | + .map((item) => item.name.slice(`${object}.`.length)); |
| 39 | +} |
| 40 | + |
| 41 | +const data = { provider: 'object', object: 'task' } as const; |
| 42 | + |
| 43 | +describe('_views keys the extractor scaffolds == the keys the runtime registry assigns (#5164)', () => { |
| 44 | + it('a default-only `list` container is keyed `default`, never `list`', () => { |
| 45 | + // The exact shape measured in the issue body. |
| 46 | + const container = { |
| 47 | + list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] }, |
| 48 | + }; |
| 49 | + |
| 50 | + expect(runtimeListViewKeys('task', container)).toEqual(['default']); |
| 51 | + expect(extractorViewKeys('task', container)).toEqual(['default']); |
| 52 | + |
| 53 | + const paths = collectExpectedEntries({ views: [container] }).map((e) => e.path.join('.')); |
| 54 | + expect(paths).toContain('objects.task._views.default.label'); |
| 55 | + expect(paths).not.toContain('objects.task._views.list.label'); |
| 56 | + }); |
| 57 | + |
| 58 | + it('carries the default list\'s own label onto the `default` key', () => { |
| 59 | + const container = { |
| 60 | + list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] }, |
| 61 | + }; |
| 62 | + const byPath = Object.fromEntries( |
| 63 | + collectExpectedEntries({ views: [container] }).map((e) => [e.path.join('.'), e.sourceValue]), |
| 64 | + ); |
| 65 | + expect(byPath['objects.task._views.default.label']).toBe('All Tasks'); |
| 66 | + }); |
| 67 | + |
| 68 | + it('a default list that STRUCTURALLY restates a `listViews` entry gets no key of its own', () => { |
| 69 | + // The `examples/app-crm` shape: the composer collapses the two by signature, |
| 70 | + // so `crm_*.all` is the only registry entry and a second scaffolded key |
| 71 | + // would be a translation no lookup can reach — the same defect one shape over. |
| 72 | + const view = { label: 'All', type: 'grid', data, columns: ['title'] }; |
| 73 | + const container = { list: { ...view }, listViews: { all: { ...view } } }; |
| 74 | + |
| 75 | + expect(runtimeListViewKeys('task', container)).toEqual(['all']); |
| 76 | + expect(extractorViewKeys('task', container)).toEqual(['all']); |
| 77 | + }); |
| 78 | + |
| 79 | + it('a default list carrying an explicit `name` keeps that name on both sides', () => { |
| 80 | + const container = { |
| 81 | + list: { name: 'recent', label: 'Recent', type: 'grid', data, columns: ['title'] }, |
| 82 | + }; |
| 83 | + |
| 84 | + expect(runtimeListViewKeys('task', container)).toEqual(['recent']); |
| 85 | + expect(extractorViewKeys('task', container)).toEqual(['recent']); |
| 86 | + }); |
| 87 | + |
| 88 | + it('a default list alongside a distinct `listViews` entry keys both, same set as the registry', () => { |
| 89 | + const container = { |
| 90 | + list: { label: 'All', type: 'grid', data, columns: ['title'] }, |
| 91 | + listViews: { |
| 92 | + open: { label: 'Open', type: 'grid', data, columns: ['title', 'status'] }, |
| 93 | + }, |
| 94 | + }; |
| 95 | + |
| 96 | + expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'open']); |
| 97 | + expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'open']); |
| 98 | + }); |
| 99 | + |
| 100 | + it('a `listViews.default` collision renames the default list, and the skeleton follows the rename', () => { |
| 101 | + // The composer keeps the registry key unique (`default` → `default_2`) and |
| 102 | + // stamps a rename warning. The bundle key IS the registry key, so the |
| 103 | + // scaffold has to follow it rather than keep pointing at the taken name. |
| 104 | + const container = { |
| 105 | + list: { label: 'All', type: 'grid', data, columns: ['title'] }, |
| 106 | + listViews: { |
| 107 | + default: { label: 'Curated', type: 'grid', data, columns: ['title', 'status'] }, |
| 108 | + }, |
| 109 | + }; |
| 110 | + |
| 111 | + expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'default_2']); |
| 112 | + expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'default_2']); |
| 113 | + }); |
| 114 | + |
| 115 | + it('the container\'s default FORM contributes no `_views` key on either side of the comparison', () => { |
| 116 | + // The extractor deliberately leaves form views out of `_views` (they have |
| 117 | + // no counterpart in the `viewLabel` resolver convention), so there is no |
| 118 | + // sibling `'list'`-style defaulting site to converge for the form face — |
| 119 | + // the composer's `<object>.form` identity is simply not scaffolded. |
| 120 | + const container = { |
| 121 | + list: { label: 'All', type: 'grid', data, columns: ['title'] }, |
| 122 | + form: { label: 'Edit', type: 'simple', data, sections: [] }, |
| 123 | + }; |
| 124 | + |
| 125 | + expect(runtimeListViewKeys('task', container)).toEqual(['default']); |
| 126 | + expect(extractorViewKeys('task', container)).toEqual(['default']); |
| 127 | + expect(expandViewContainer('task', container).map((i) => i.name)).toContain('task.form'); |
| 128 | + }); |
| 129 | + |
| 130 | + it('a container with no default `list` at all scaffolds only its `listViews` keys', () => { |
| 131 | + const container = { |
| 132 | + objectName: 'task', |
| 133 | + listViews: { open: { label: 'Open', type: 'grid', data, columns: ['title'] } }, |
| 134 | + }; |
| 135 | + |
| 136 | + expect(extractorViewKeys('task', container)).toEqual(['open']); |
| 137 | + }); |
| 138 | +}); |
0 commit comments