import { toKey, blankAttribute, draftToAttributePayloads, attributeToDraft, diffAttributes, suggestRelationshipKey, DATA_TYPES, type DraftAttribute, type ExistingAttribute, type EditableDraft, } from '../schema-builder-core' describe('toKey', () => { it('slugifies a label into a stable lowercase key', () => { expect(toKey('Customer')).toBe('customer') expect(toKey('Purchase Order')).toBe('purchase_order') expect(toKey(' Invoice #2024 ')).toBe('invoice_2024') }) it('collapses runs of non-alphanumerics and trims edge underscores', () => { expect(toKey('A -- B')).toBe('a_b') expect(toKey('***weird***')).toBe('weird') expect(toKey('')).toBe('') }) }) describe('blankAttribute', () => { it('defaults to a non-required text field with no choices/bounds/regex', () => { expect(blankAttribute()).toEqual({ name: '', dataType: 'text', required: false, choices: '', min: '', max: '', regex: '', }) }) }) describe('draftToAttributePayloads', () => { const text = (over: Partial = {}): DraftAttribute => ({ ...blankAttribute(), name: 'email', ...over, }) it('drops blank-name rows and trims names', () => { const payloads = draftToAttributePayloads(7, [ text({ name: ' email ' }), text({ name: '' }), text({ name: ' ' }), ]) expect(payloads).toHaveLength(1) expect(payloads[0]).toMatchObject({ entityType: 7, name: 'email', config: {} }) }) it('builds enum config.choices from the comma-separated input (trimmed, empties removed)', () => { const [payload] = draftToAttributePayloads(3, [ text({ name: 'status', dataType: 'enum', choices: 'Open, In progress , ,Done' }), ]) expect(payload).toMatchObject({ entityType: 3, name: 'status', dataType: 'enum', config: { choices: ['Open', 'In progress', 'Done'] }, }) }) it('carries the required flag and uses an empty config for non-enum fields', () => { const [payload] = draftToAttributePayloads(1, [ text({ name: 'amount', dataType: 'number', required: true }), ]) expect(payload).toEqual({ entityType: 1, name: 'amount', dataType: 'number', required: true, config: {}, }) }) it('builds numeric config.min/max from the min/max inputs (number/integer)', () => { const [payload] = draftToAttributePayloads(5, [ text({ name: 'qty', dataType: 'integer', min: '1', max: '10' }), ]) expect(payload).toMatchObject({ name: 'qty', dataType: 'integer', config: { min: 1, max: 10 }, }) }) it('sets only the numeric bounds that are provided (blank min/max omitted)', () => { const [payload] = draftToAttributePayloads(5, [ text({ name: 'score', dataType: 'number', min: '0', max: '' }), ]) expect(payload.config).toEqual({ min: 0 }) }) it('builds text config.regex from the regex input (text/longtext)', () => { const [payload] = draftToAttributePayloads(5, [ text({ name: 'email', dataType: 'text', regex: '[^@]+@[^@]+' }), ]) expect(payload.config).toEqual({ regex: '[^@]+@[^@]+' }) }) it('keeps config keys scoped to the dataType (no cross-contamination)', () => { // text reads only regex; a stray min/max/choices must not leak into config. const [payload] = draftToAttributePayloads(5, [ text({ name: 'note', dataType: 'text', min: '1', max: '9', choices: 'a,b' }), ]) expect(payload.config).toEqual({}) }) }) describe('attributeToDraft (hydrate a saved attribute into an editable row)', () => { const existing = (over: Partial = {}): ExistingAttribute => ({ id: 1, name: 'status', dataType: 'text', required: false, config: {}, ...over, }) it('round-trips an enum: config.choices -> comma-joined string', () => { const d = attributeToDraft(existing({ dataType: 'enum', config: { choices: ['Open', 'Done'] } })) expect(d).toMatchObject({ id: 1, dataType: 'enum', choices: 'Open, Done', min: '', max: '', regex: '' }) }) it('round-trips numeric bounds and a regex into their string inputs', () => { expect(attributeToDraft(existing({ dataType: 'integer', config: { min: 1, max: 10 } }))) .toMatchObject({ min: '1', max: '10', choices: '', regex: '' }) expect(attributeToDraft(existing({ dataType: 'text', config: { regex: '\\d+' } }))) .toMatchObject({ regex: '\\d+', min: '', max: '' }) }) it('is the inverse of draftToAttributePayloads for the config payload', () => { const e = existing({ id: 7, name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } }) const draft = attributeToDraft(e) const [payload] = draftToAttributePayloads(99, [draft]) expect(payload).toMatchObject({ name: 'qty', dataType: 'number', required: true, config: { min: 0, max: 5 } }) }) }) describe('diffAttributes (create/update/delete reconciliation)', () => { const existing = (over: Partial = {}): ExistingAttribute => ({ id: 1, name: 'title', dataType: 'text', required: false, config: {}, ...over, }) const draftOf = (e: ExistingAttribute): EditableDraft => attributeToDraft(e) const newRow = (over: Partial = {}): EditableDraft => ({ ...blankAttribute(), name: 'extra', ...over, }) it('flags a brand-new (id-less) row as a create', () => { const orig = [existing()] const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0]), newRow({ name: 'note' })]) expect(creates).toHaveLength(1) expect(creates[0]).toMatchObject({ entityType: 3, name: 'note' }) expect(updates).toEqual([]) expect(deletes).toEqual([]) }) it('flags a changed existing row as an update (carrying its id)', () => { const orig = [existing({ id: 5, name: 'title', dataType: 'text' })] const edited = { ...draftOf(orig[0]), name: 'headline', dataType: 'longtext' as const } const { creates, updates, deletes } = diffAttributes(3, orig, [edited]) expect(creates).toEqual([]) expect(updates).toEqual([{ id: 5, name: 'headline', dataType: 'longtext', required: false, config: {} }]) expect(deletes).toEqual([]) }) it('does not update an unchanged existing row', () => { const orig = [existing({ id: 5, dataType: 'enum', config: { choices: ['a', 'b'] } })] const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])]) expect(creates).toEqual([]) expect(updates).toEqual([]) expect(deletes).toEqual([]) }) it('flags an original whose row was removed as a delete', () => { const orig = [existing({ id: 5, name: 'title' }), existing({ id: 6, name: 'gone' })] const { creates, updates, deletes } = diffAttributes(3, orig, [draftOf(orig[0])]) expect(creates).toEqual([]) expect(updates).toEqual([]) expect(deletes).toEqual([6]) }) it('treats a blanked-name existing row as a delete (not an update)', () => { const orig = [existing({ id: 9, name: 'title' })] const { updates, deletes } = diffAttributes(3, orig, [{ ...draftOf(orig[0]), name: ' ' }]) expect(updates).toEqual([]) expect(deletes).toEqual([9]) }) }) describe('suggestRelationshipKey', () => { it('joins source + target into a slugified edge key', () => { expect(suggestRelationshipKey('artist', 'release')).toBe('artist_release') expect(suggestRelationshipKey('Purchase Order', 'Line Item')).toBe('purchase_order_line_item') }) it('is empty until both ends are chosen', () => { expect(suggestRelationshipKey('', 'release')).toBe('') expect(suggestRelationshipKey('artist', '')).toBe('') }) }) describe('DATA_TYPES', () => { it('exposes the eight backend data types as {value,label} options', () => { expect(DATA_TYPES.map((d) => d.value)).toEqual([ 'text', 'longtext', 'number', 'integer', 'boolean', 'date', 'enum', 'json', ]) }) })