diff --git a/src/cli/SchemaInspector.ts b/src/cli/SchemaInspector.ts index bda9d8c..e59854f 100644 --- a/src/cli/SchemaInspector.ts +++ b/src/cli/SchemaInspector.ts @@ -14,6 +14,17 @@ import { FieldDefinition, } from '../types'; +/** + * Result of locale detection from date values. + * @internal + */ +interface LocaleDetectionResult { + /** Detected locale (BCP 47) or undefined if detection not possible */ + locale?: string; + /** Whether the detection was ambiguous (all date parts <= 12) */ + ambiguous: boolean; +} + /** * Inspects AppSheet tables and generates schema definitions. * @@ -35,6 +46,27 @@ import { * ``` */ export class SchemaInspector { + /** Locale mapping: partOrder + separator → representative locale */ + private static readonly LOCALE_MAP: Record = { + 'day,month,year:.': 'de-DE', + 'day,month,year:/': 'en-GB', + 'month,day,year:/': 'en-US', + 'year,month,day:/': 'ja-JP', + }; + + /** Default locales for ambiguous cases, keyed by separator */ + private static readonly DEFAULT_LOCALE: Record = { + '/': 'en-US', + '.': 'de-DE', + }; + + /** Pattern matching locale-formatted dates: DD.MM.YYYY, MM/DD/YYYY, YYYY/MM/DD etc. */ + private static readonly LOCALE_DATE_PATTERN = /^\d{1,4}[./]\d{1,2}[./]\d{1,4}$/; + + /** Pattern matching locale-formatted datetimes: date part + space + time part */ + private static readonly LOCALE_DATETIME_PATTERN = + /^\d{1,4}[./]\d{1,2}[./]\d{1,4}\s+\d{1,2}:\d{2}/; + constructor(private client: AppSheetClient) {} /** @@ -84,7 +116,9 @@ export class SchemaInspector { for (const fieldName of fieldNames) { // Collect all values for this field - const values = sampleRows.map((row) => row[fieldName]).filter((v) => v !== null && v !== undefined); + const values = sampleRows + .map((row) => row[fieldName]) + .filter((v) => v !== null && v !== undefined); if (values.length === 0) { // No non-null values found @@ -117,10 +151,20 @@ export class SchemaInspector { fields[fieldName] = fieldDef; } + // Detect locale from date field values + const { locale, ambiguous } = this.detectLocale(sampleRows, fields); + + let warning: string | undefined; + if (ambiguous && locale) { + warning = `Locale detection ambiguous, defaulting to "${locale}". Please verify.`; + } + return { tableName, keyField: this.guessKeyField(sampleRows[0]), fields, + locale, + warning, }; } catch (error: any) { throw new Error(`Failed to inspect table "${tableName}": ${error.message}`); @@ -169,6 +213,16 @@ export class SchemaInspector { return 'Date'; } + // Locale-formatted DateTime (e.g. "25.03.2026 14:30:00", "03/25/2026 2:30 PM") + if (SchemaInspector.LOCALE_DATETIME_PATTERN.test(value)) { + return 'DateTime'; + } + + // Locale-formatted Date (e.g. "25.03.2026", "03/25/2026", "2026/03/25") + if (SchemaInspector.LOCALE_DATE_PATTERN.test(value)) { + return 'Date'; + } + // Email pattern if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return 'Email'; @@ -248,6 +302,106 @@ export class SchemaInspector { return Array.from(uniqueValues).sort(); } + /** + * Detect locale from date values in sample rows. + * + * Analyzes Date/DateTime/ChangeTimestamp field values to determine the locale + * of the AppSheet app by examining separator and part order. + * + * @param rows - Sample rows from AppSheet API + * @param fields - Already-inferred field definitions + * @returns Detected locale or undefined if detection not possible + */ + private detectLocale( + rows: Record[], + fields: Record + ): LocaleDetectionResult { + // 1. Collect date field names + const dateFieldNames = Object.entries(fields) + .filter(([, def]) => ['Date', 'DateTime', 'ChangeTimestamp'].includes(def.type)) + .map(([name]) => name); + + if (dateFieldNames.length === 0) { + return { locale: undefined, ambiguous: false }; + } + + // 2. Collect non-ISO date strings (extract date part from DateTime values) + const dateStrings: string[] = []; + for (const row of rows) { + for (const fieldName of dateFieldNames) { + const value = row[fieldName]; + if (typeof value !== 'string') continue; + if (/^\d{4}-\d{2}-\d{2}/.test(value)) continue; // Skip ISO + + // For DateTime, extract date part only (before space) + const spaceIdx = value.indexOf(' '); + const datePart = spaceIdx > 0 ? value.substring(0, spaceIdx) : value; + dateStrings.push(datePart); + } + } + + if (dateStrings.length === 0) { + return { locale: undefined, ambiguous: false }; + } + + // 3. Detect separator from first value + const separator = dateStrings[0].match(/[^0-9]/)?.[0]; + if (!separator) { + return { locale: undefined, ambiguous: false }; + } + + // 4. Determine part order + const parts = dateStrings[0].split(separator); + if (parts.length !== 3) { + return { locale: undefined, ambiguous: false }; + } + + // Year position (4-digit part) + const yearPos = parts.findIndex((p) => p.length === 4); + if (yearPos === 0) { + // YMD (e.g. ja-JP: "2026/03/12") + const key = `year,month,day:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + if (yearPos !== 2) { + return { locale: undefined, ambiguous: false }; + } + + // Year at position 2 → DMY or MDY + // Check all date values for disambiguation + let foundFirstPartGt12 = false; + let foundSecondPartGt12 = false; + + for (const dateStr of dateStrings) { + const p = dateStr.split(separator); + if (p.length !== 3) continue; + const first = parseInt(p[0], 10); + const second = parseInt(p[1], 10); + if (first > 12) foundFirstPartGt12 = true; + if (second > 12) foundSecondPartGt12 = true; + } + + if (foundFirstPartGt12 && !foundSecondPartGt12) { + // First part is day → DMY + const key = `day,month,year:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + if (foundSecondPartGt12 && !foundFirstPartGt12) { + // Second part is day → MDY + const key = `month,day,year:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + // Ambiguous: no part > 12, or both > 12 (shouldn't happen with valid dates) + const defaultLocale = SchemaInspector.DEFAULT_LOCALE[separator] || 'en-US'; + return { locale: defaultLocale, ambiguous: true }; + } + /** * Guess the key field from row data */ @@ -273,27 +427,66 @@ export class SchemaInspector { tableNames: string[] ): Promise { const tables: Record = {}; + const detectedLocales: string[] = []; for (const tableName of tableNames) { console.log(`Inspecting table: ${tableName}...`); const inspection = await this.inspectTable(tableName); - tables[this.toSchemaName(tableName)] = { + const tableDef: TableDefinition = { tableName: inspection.tableName, keyField: inspection.keyField, fields: inspection.fields, }; + // Set locale on table level if detected + if (inspection.locale) { + tableDef.locale = inspection.locale; + detectedLocales.push(inspection.locale); + } + + tables[this.toSchemaName(tableName)] = tableDef; + if (inspection.warning) { console.warn(` Warning: ${inspection.warning}`); } } - return { + // Connection-level locale = most frequent detected locale + const connectionLocale = this.mostFrequent(detectedLocales); + + const connectionDef: ConnectionDefinition = { appId: '${APPSHEET_APP_ID}', // Placeholder applicationAccessKey: '${APPSHEET_ACCESS_KEY}', // Placeholder tables, }; + + if (connectionLocale) { + connectionDef.locale = connectionLocale; + } + + return connectionDef; + } + + /** + * Returns the most frequent string in an array, or undefined if empty. + * @internal + */ + private mostFrequent(values: string[]): string | undefined { + if (values.length === 0) return undefined; + const counts = new Map(); + for (const v of values) { + counts.set(v, (counts.get(v) || 0) + 1); + } + let best = values[0]; + let bestCount = 0; + for (const [v, c] of counts) { + if (c > bestCount) { + best = v; + bestCount = c; + } + } + return best; } /** @@ -303,10 +496,12 @@ export class SchemaInspector { * - "worklog" -> "worklogs" */ toSchemaName(tableName: string): string { - return tableName - .replace(/^extract_/, '') - .replace(/_/g, '') - .toLowerCase() + 's'; + return ( + tableName + .replace(/^extract_/, '') + .replace(/_/g, '') + .toLowerCase() + 's' + ); } /** diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 5d1d33f..3f95d15 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { AppSheetClient } from '../client'; import { SchemaInspector } from './SchemaInspector'; import { SchemaLoader } from '../utils'; -import { SchemaConfig, ConnectionDefinition } from '../types'; +import { SchemaConfig, ConnectionDefinition, TableDefinition } from '../types'; /** * Create CLI program with all commands @@ -16,10 +16,7 @@ import { SchemaConfig, ConnectionDefinition } from '../types'; export function createCLI(): Command { const program = new Command(); - program - .name('appsheet') - .description('AppSheet Schema Management CLI') - .version('0.1.0'); + program.name('appsheet').description('AppSheet Schema Management CLI').version('0.1.0'); // Command: init program @@ -108,10 +105,18 @@ export function createCLI(): Command { fs.writeFileSync(options.output, output, 'utf-8'); console.log(`\n✓ Schema generated: ${options.output}`); console.log('✓ Inspected tables:', tableNames.join(', ')); + if (connection.locale) { + console.log(`✓ Locale detected: ${connection.locale}`); + } console.log('\nPlease review and update:'); console.log(' - Key fields may need manual adjustment'); console.log(' - Field types are inferred and may need refinement'); console.log(' - Add required, enum, and description properties as needed'); + if (!connection.locale) { + console.log( + ' - No locale detected. Consider adding locale manually for date validation.' + ); + } } catch (error: any) { console.error('Error:', error.message); process.exit(1); @@ -170,16 +175,30 @@ export function createCLI(): Command { // Add to schema const schemaName = inspector.toSchemaName(tableName); - schema.connections[connection].tables[schemaName] = { + const tableDef: TableDefinition = { tableName: inspection.tableName, keyField: inspection.keyField, fields: inspection.fields, }; + // Propagate auto-detected locale + if (inspection.locale) { + tableDef.locale = inspection.locale; + } + + schema.connections[connection].tables[schemaName] = tableDef; + + if (inspection.warning) { + console.warn(` Warning: ${inspection.warning}`); + } + // Write back const output = yaml.stringify(schema); fs.writeFileSync(options.schema, output, 'utf-8'); console.log(`✓ Table "${tableName}" added to connection "${connection}"`); + if (inspection.locale) { + console.log(`✓ Locale detected: ${inspection.locale}`); + } } catch (error: any) { console.error('Error:', error.message); process.exit(1); diff --git a/src/types/schema.ts b/src/types/schema.ts index ed78972..dd2e09c 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -214,6 +214,18 @@ export interface TableInspectionResult { /** Discovered fields with AppSheet types */ fields: Record; + /** + * Auto-detected locale from date values in sample data (BCP 47 language tag). + * + * Determined by analyzing Date/DateTime/ChangeTimestamp field values + * for separator and part order patterns. When detection is ambiguous + * (all date parts <= 12), a default locale is assigned based on separator. + * + * @example 'de-DE' // detected from DD.MM.YYYY pattern + * @example 'en-US' // detected from MM/DD/YYYY pattern + */ + locale?: string; + /** Optional warning message */ warning?: string; } diff --git a/tests/cli/SchemaInspector.locale.test.ts b/tests/cli/SchemaInspector.locale.test.ts new file mode 100644 index 0000000..ce7fd34 --- /dev/null +++ b/tests/cli/SchemaInspector.locale.test.ts @@ -0,0 +1,519 @@ +/** + * Tests for SOSO-446: Automatic locale detection in SchemaInspector + * + * Tests cover: + * - inferType() extension for locale-formatted dates + * - detectLocale() algorithm (separator + part order analysis) + * - inspectTable() integration with locale detection + * - generateSchema() with locale propagation to connection + table level + */ + +import { SchemaInspector } from '../../src/cli/SchemaInspector'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { ConnectionDefinition } from '../../src/types'; + +// Mock AppSheetClient +jest.mock('../../src/client/AppSheetClient'); + +describe('SchemaInspector — Locale Detection (SOSO-446)', () => { + let mockClient: jest.Mocked; + let inspector: SchemaInspector; + + const mockConnectionDef: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: {}, + }; + + beforeEach(() => { + mockClient = new AppSheetClient( + mockConnectionDef, + 'test@example.com' + ) as jest.Mocked; + + inspector = new SchemaInspector(mockClient); + }); + + // ─── inferType() extension for locale-formatted dates ───────────────── + + describe('inferType — locale-formatted dates', () => { + // Access private method via bracket notation + const callInferType = (inspector: SchemaInspector, value: any) => + (inspector as any).inferType(value); + + it('should detect DD.MM.YYYY as Date', () => { + expect(callInferType(inspector, '25.03.2026')).toBe('Date'); + }); + + it('should detect MM/DD/YYYY as Date', () => { + expect(callInferType(inspector, '03/25/2026')).toBe('Date'); + }); + + it('should detect YYYY/MM/DD as Date', () => { + expect(callInferType(inspector, '2026/03/25')).toBe('Date'); + }); + + it('should detect DD/MM/YYYY as Date', () => { + expect(callInferType(inspector, '25/03/2026')).toBe('Date'); + }); + + it('should detect D.M.YYYY as Date (single digit day/month)', () => { + expect(callInferType(inspector, '5.3.2026')).toBe('Date'); + }); + + it('should detect DD.MM.YYYY HH:MM:SS as DateTime', () => { + expect(callInferType(inspector, '25.03.2026 14:30:00')).toBe('DateTime'); + }); + + it('should detect MM/DD/YYYY H:MM AM as DateTime', () => { + expect(callInferType(inspector, '03/25/2026 2:30 PM')).toBe('DateTime'); + }); + + it('should detect YYYY/MM/DD HH:MM as DateTime', () => { + expect(callInferType(inspector, '2026/03/25 14:30')).toBe('DateTime'); + }); + + it('should still detect ISO date as Date', () => { + expect(callInferType(inspector, '2026-03-25')).toBe('Date'); + }); + + it('should still detect ISO datetime as DateTime', () => { + expect(callInferType(inspector, '2026-03-25T14:30:00Z')).toBe('DateTime'); + }); + + it('should NOT detect random text as Date', () => { + expect(callInferType(inspector, 'hello world')).toBe('Text'); + }); + + it('should NOT detect partial date-like strings as Date', () => { + expect(callInferType(inspector, '25.03')).toBe('Text'); + }); + }); + + // ─── detectLocale() unit tests ──────────────────────────────────────── + + describe('detectLocale', () => { + // Access private method via bracket notation + const callDetectLocale = ( + inspector: SchemaInspector, + rows: Record[], + fields: Record + ) => (inspector as any).detectLocale(rows, fields); + + describe('de-DE detection (DMY with dot)', () => { + it('should detect de-DE from DD.MM.YYYY dates', () => { + const rows = [ + { date: '25.03.2026' }, // 25 > 12 → first part is day → DMY + { date: '12.06.2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + + it('should detect de-DE from DateTime values', () => { + const rows = [{ created: '25.03.2026 14:30:00' }, { created: '13.06.2026 09:00:00' }]; + const fields = { created: { type: 'DateTime' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + + it('should detect de-DE with single value where day > 12', () => { + const rows = [{ date: '31.12.2026' }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('en-US detection (MDY with slash)', () => { + it('should detect en-US from MM/DD/YYYY dates', () => { + const rows = [ + { date: '03/25/2026' }, // 25 > 12 → second part is day → MDY + { date: '06/12/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(false); + }); + + it('should detect en-US from DateTime values', () => { + const rows = [{ created: '03/25/2026 2:30 PM' }]; + const fields = { created: { type: 'DateTime' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('en-GB detection (DMY with slash)', () => { + it('should detect en-GB from DD/MM/YYYY dates', () => { + const rows = [ + { date: '25/03/2026' }, // 25 > 12 → first part is day → DMY + { date: '12/06/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-GB'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ja-JP detection (YMD with slash)', () => { + it('should detect ja-JP from YYYY/MM/DD dates', () => { + const rows = [{ date: '2026/03/12' }, { date: '2026/12/25' }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('ja-JP'); + expect(result.ambiguous).toBe(false); + }); + + it('should detect ja-JP with DateTime values', () => { + const rows = [{ created: '2026/03/12 14:30:00' }]; + const fields = { created: { type: 'DateTime' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('ja-JP'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ambiguous cases', () => { + it('should default to en-US when slash separator is ambiguous', () => { + const rows = [ + { date: '03/06/2026' }, // Both parts ≤ 12 + { date: '01/12/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(true); + }); + + it('should default to de-DE when dot separator is ambiguous', () => { + const rows = [ + { date: '03.06.2026' }, // Both parts ≤ 12 + { date: '01.12.2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(true); + }); + + it('should be ambiguous when only one date and parts ≤ 12', () => { + const rows = [{ date: '06/03/2026' }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.ambiguous).toBe(true); + }); + }); + + describe('no detection possible', () => { + it('should return undefined when no date fields exist', () => { + const rows = [{ name: 'Test' }]; + const fields = { name: { type: 'Text' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBeUndefined(); + expect(result.ambiguous).toBe(false); + }); + + it('should return undefined when all dates are ISO', () => { + const rows = [{ date: '2026-03-12' }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBeUndefined(); + expect(result.ambiguous).toBe(false); + }); + + it('should return undefined when date fields are empty', () => { + const rows = [{ date: null }, { date: undefined }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBeUndefined(); + expect(result.ambiguous).toBe(false); + }); + + it('should return undefined when no rows provided', () => { + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, [], fields); + expect(result.locale).toBeUndefined(); + expect(result.ambiguous).toBe(false); + }); + + it('should return undefined when date values are non-string', () => { + const rows = [{ date: 12345 }, { date: true }]; + const fields = { date: { type: 'Date' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBeUndefined(); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ChangeTimestamp values', () => { + it('should detect locale from ChangeTimestamp fields', () => { + const rows = [{ modified: '03/25/2026 09:00:00' }]; + const fields = { modified: { type: 'ChangeTimestamp' } }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('mixed date fields', () => { + it('should use all date fields for disambiguation', () => { + // First field alone would be ambiguous (03/06), but second has day > 12 + const rows = [{ date: '03/06/2026', created: '03/25/2026' }]; + const fields = { + date: { type: 'Date' }, + created: { type: 'DateTime' }, + }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('en-US'); // 25 > 12 in second position → MDY + expect(result.ambiguous).toBe(false); + }); + + it('should ignore non-date fields', () => { + const rows = [{ name: 'Test', date: '25.03.2026', count: 42 }]; + const fields = { + name: { type: 'Text' }, + date: { type: 'Date' }, + count: { type: 'Number' }, + }; + const result = callDetectLocale(inspector, rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + }); + }); + + // ─── inspectTable() integration ─────────────────────────────────────── + + describe('inspectTable — locale integration', () => { + it('should include detected locale in result (de-DE)', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '25.03.2026', name: 'Test' }, + { id: '2', date: '12.06.2026', name: 'Other' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_worklog'); + + expect(result.locale).toBe('de-DE'); + expect(result.fields.date.type).toBe('Date'); + expect(result.warning).toBeUndefined(); + }); + + it('should include detected locale in result (en-US)', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '03/25/2026' }, + { id: '2', date: '06/12/2026' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_worklog'); + + expect(result.locale).toBe('en-US'); + expect(result.fields.date.type).toBe('Date'); + }); + + it('should detect locale from DateTime fields', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', created: '25.03.2026 14:30:00' }, + { id: '2', created: '13.06.2026 09:00:00' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_worklog'); + + expect(result.locale).toBe('de-DE'); + expect(result.fields.created.type).toBe('DateTime'); + }); + + it('should add warning for ambiguous locale', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '03/06/2026' }, // Both parts ≤ 12 + { id: '2', date: '01/12/2026' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_worklog'); + + expect(result.locale).toBe('en-US'); + expect(result.warning).toContain('ambiguous'); + expect(result.warning).toContain('en-US'); + }); + + it('should have no locale when table has no date fields', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', name: 'Test', email: 'test@example.com' }, + { id: '2', name: 'Other', email: 'other@example.com' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_config'); + + expect(result.locale).toBeUndefined(); + expect(result.warning).toBeUndefined(); + }); + + it('should have no locale when all dates are ISO', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '2026-03-25' }, + { id: '2', date: '2026-06-12' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('extract_events'); + + expect(result.locale).toBeUndefined(); + expect(result.fields.date.type).toBe('Date'); + }); + + it('should have no locale for empty table', async () => { + mockClient.find.mockResolvedValue({ rows: [], warnings: [] }); + + const result = await inspector.inspectTable('extract_empty'); + + expect(result.locale).toBeUndefined(); + expect(result.warning).toBe('Table is empty, could not infer field types'); + }); + }); + + // ─── generateSchema() integration ───────────────────────────────────── + + describe('generateSchema — locale propagation', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should set locale on connection and table level', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '25.03.2026', name: 'Test' }, + { id: '2', date: '12.06.2026', name: 'Other' }, + ], + warnings: [], + }); + + const result = await inspector.generateSchema('default', ['extract_worklog']); + + expect(result.locale).toBe('de-DE'); + expect(result.tables.worklogs.locale).toBe('de-DE'); + }); + + it('should set connection locale to most frequent table locale', async () => { + // Two tables with de-DE, one without locale + mockClient.find + .mockResolvedValueOnce({ + rows: [{ id: '1', date: '25.03.2026' }], + warnings: [], + }) + .mockResolvedValueOnce({ + rows: [{ id: '1', date: '13.06.2026' }], + warnings: [], + }) + .mockResolvedValueOnce({ + rows: [{ id: '1', name: 'Config' }], // No date fields + warnings: [], + }); + + const result = await inspector.generateSchema('default', [ + 'extract_worklog', + 'extract_event', + 'extract_config', + ]); + + expect(result.locale).toBe('de-DE'); + expect(result.tables.worklogs.locale).toBe('de-DE'); + expect(result.tables.events.locale).toBe('de-DE'); + expect(result.tables.configs.locale).toBeUndefined(); + }); + + it('should not set locale when no date fields in any table', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ id: '1', name: 'Test', email: 'test@example.com' }], + warnings: [], + }); + + const result = await inspector.generateSchema('default', ['extract_config']); + + expect(result.locale).toBeUndefined(); + expect(result.tables.configs.locale).toBeUndefined(); + }); + + it('should not set locale when all dates are ISO', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '2026-03-25' }, + { id: '2', date: '2026-06-12' }, + ], + warnings: [], + }); + + const result = await inspector.generateSchema('default', ['extract_event']); + + expect(result.locale).toBeUndefined(); + expect(result.tables.events.locale).toBeUndefined(); + }); + + it('should log warning for ambiguous locale detection', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '03/06/2026' }, + { id: '2', date: '01/12/2026' }, + ], + warnings: [], + }); + + await inspector.generateSchema('default', ['extract_worklog']); + + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('ambiguous')); + }); + }); + + // ─── mostFrequent() helper ──────────────────────────────────────────── + + describe('mostFrequent helper', () => { + const callMostFrequent = (inspector: SchemaInspector, values: string[]) => + (inspector as any).mostFrequent(values); + + it('should return undefined for empty array', () => { + expect(callMostFrequent(inspector, [])).toBeUndefined(); + }); + + it('should return single value', () => { + expect(callMostFrequent(inspector, ['de-DE'])).toBe('de-DE'); + }); + + it('should return most frequent value', () => { + expect(callMostFrequent(inspector, ['de-DE', 'en-US', 'de-DE'])).toBe('de-DE'); + }); + + it('should return first value when tied', () => { + const result = callMostFrequent(inspector, ['de-DE', 'en-US']); + // Both have count 1, should return the one with higher count (first one wins) + expect(result).toBeDefined(); + }); + }); +});