|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | + |
| 3 | +// --- Metadata imports --- |
| 4 | +import { AccountObject } from '../objects/account.object'; |
| 5 | +import { ContactObject } from '../objects/contact.object'; |
| 6 | +import { OpportunityObject } from '../objects/opportunity.object'; |
| 7 | +import { ProductObject } from '../objects/product.object'; |
| 8 | +import { OrderObject } from '../objects/order.object'; |
| 9 | +import { OrderItemObject } from '../objects/order_item.object'; |
| 10 | +import { UserObject } from '../objects/user.object'; |
| 11 | +import { ProjectObject } from '../objects/project.object'; |
| 12 | +import { EventObject } from '../objects/event.object'; |
| 13 | +import { OpportunityContactObject } from '../objects/opportunity_contact.object'; |
| 14 | + |
| 15 | +import { AccountView } from '../views/account.view'; |
| 16 | +import { ContactView } from '../views/contact.view'; |
| 17 | +import { OpportunityView } from '../views/opportunity.view'; |
| 18 | +import { ProductView } from '../views/product.view'; |
| 19 | +import { OrderView } from '../views/order.view'; |
| 20 | +import { OrderItemView } from '../views/order_item.view'; |
| 21 | +import { UserView } from '../views/user.view'; |
| 22 | +import { EventView } from '../views/event.view'; |
| 23 | +import { ProjectView } from '../views/project.view'; |
| 24 | +import { OpportunityContactView } from '../views/opportunity_contact.view'; |
| 25 | + |
| 26 | +import { AccountActions } from '../actions/account.actions'; |
| 27 | +import { ContactActions } from '../actions/contact.actions'; |
| 28 | +import { OpportunityActions } from '../actions/opportunity.actions'; |
| 29 | +import { ProductActions } from '../actions/product.actions'; |
| 30 | +import { OrderActions } from '../actions/order.actions'; |
| 31 | +import { OrderItemActions } from '../actions/order_item.actions'; |
| 32 | +import { UserActions } from '../actions/user.actions'; |
| 33 | +import { ProjectActions } from '../actions/project.actions'; |
| 34 | +import { EventActions } from '../actions/event.actions'; |
| 35 | +import { OpportunityContactActions } from '../actions/opportunity_contact.actions'; |
| 36 | + |
| 37 | +import { CrmDashboard } from '../dashboards/crm.dashboard'; |
| 38 | +import { SalesReport } from '../reports/sales.report'; |
| 39 | +import { PipelineReport } from '../reports/pipeline.report'; |
| 40 | +import { GettingStartedPage } from '../pages/getting_started.page'; |
| 41 | +import { SettingsPage } from '../pages/settings.page'; |
| 42 | +import { HelpPage } from '../pages/help.page'; |
| 43 | +import { CrmApp } from '../apps/crm.app'; |
| 44 | + |
| 45 | +// --- i18n imports --- |
| 46 | +import { crmLocales, type CrmTranslationKeys } from '../i18n'; |
| 47 | + |
| 48 | +// ==================================================================== |
| 49 | +// 1. Metadata Spec Compliance Tests |
| 50 | +// ==================================================================== |
| 51 | + |
| 52 | +const allObjects = [ |
| 53 | + AccountObject, ContactObject, OpportunityObject, ProductObject, |
| 54 | + OrderObject, OrderItemObject, UserObject, ProjectObject, |
| 55 | + EventObject, OpportunityContactObject, |
| 56 | +]; |
| 57 | + |
| 58 | +const allViews = [ |
| 59 | + AccountView, ContactView, OpportunityView, ProductView, |
| 60 | + OrderView, OrderItemView, UserView, EventView, |
| 61 | + ProjectView, OpportunityContactView, |
| 62 | +]; |
| 63 | + |
| 64 | +const allActions = [ |
| 65 | + ...AccountActions, ...ContactActions, ...OpportunityActions, |
| 66 | + ...ProductActions, ...OrderActions, ...OrderItemActions, |
| 67 | + ...UserActions, ...ProjectActions, ...EventActions, |
| 68 | + ...OpportunityContactActions, |
| 69 | +]; |
| 70 | + |
| 71 | +describe('CRM Metadata Spec Compliance', () => { |
| 72 | + |
| 73 | + describe('Objects', () => { |
| 74 | + it('all objects have name, label, and fields', () => { |
| 75 | + for (const obj of allObjects) { |
| 76 | + expect(obj).toHaveProperty('name'); |
| 77 | + expect(obj).toHaveProperty('label'); |
| 78 | + expect(obj).toHaveProperty('fields'); |
| 79 | + expect(typeof obj.name).toBe('string'); |
| 80 | + expect(typeof obj.label).toBe('string'); |
| 81 | + expect(typeof obj.fields).toBe('object'); |
| 82 | + } |
| 83 | + }); |
| 84 | + |
| 85 | + it('all objects have at least one required field', () => { |
| 86 | + for (const obj of allObjects) { |
| 87 | + const fields = Object.values(obj.fields) as Array<{ required?: boolean }>; |
| 88 | + const hasRequired = fields.some((f) => f.required === true); |
| 89 | + expect(hasRequired).toBe(true); |
| 90 | + } |
| 91 | + }); |
| 92 | + }); |
| 93 | + |
| 94 | + describe('Views', () => { |
| 95 | + it('all views have listViews and form sections', () => { |
| 96 | + for (const view of allViews) { |
| 97 | + expect(view).toHaveProperty('listViews'); |
| 98 | + expect(view).toHaveProperty('form'); |
| 99 | + expect(view.form).toHaveProperty('sections'); |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + it('form section columns are numbers, not strings', () => { |
| 104 | + for (const view of allViews) { |
| 105 | + for (const section of view.form.sections) { |
| 106 | + if (section.columns !== undefined) { |
| 107 | + expect(typeof section.columns).toBe('number'); |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + }); |
| 112 | + |
| 113 | + it('list views have required fields (name, type, data, columns)', () => { |
| 114 | + for (const view of allViews) { |
| 115 | + for (const lv of Object.values(view.listViews) as Array<Record<string, unknown>>) { |
| 116 | + expect(lv).toHaveProperty('name'); |
| 117 | + expect(lv).toHaveProperty('type'); |
| 118 | + expect(lv).toHaveProperty('data'); |
| 119 | + expect(lv).toHaveProperty('columns'); |
| 120 | + } |
| 121 | + } |
| 122 | + }); |
| 123 | + }); |
| 124 | + |
| 125 | + describe('Actions', () => { |
| 126 | + it('all actions have name, label, type, and locations', () => { |
| 127 | + for (const action of allActions) { |
| 128 | + expect(action).toHaveProperty('name'); |
| 129 | + expect(action).toHaveProperty('label'); |
| 130 | + expect(action).toHaveProperty('type'); |
| 131 | + expect(action).toHaveProperty('locations'); |
| 132 | + expect(action.type).toBe('api'); |
| 133 | + } |
| 134 | + }); |
| 135 | + |
| 136 | + it('no action uses variant: "danger" (must use "destructive")', () => { |
| 137 | + for (const action of allActions) { |
| 138 | + if ('variant' in action) { |
| 139 | + expect(action.variant).not.toBe('danger'); |
| 140 | + expect(['default', 'primary', 'secondary', 'destructive', 'outline', 'ghost']).toContain(action.variant); |
| 141 | + } |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + it('destructive actions have confirmText', () => { |
| 146 | + for (const action of allActions) { |
| 147 | + if ('variant' in action && action.variant === 'destructive') { |
| 148 | + expect(action).toHaveProperty('confirmText'); |
| 149 | + } |
| 150 | + } |
| 151 | + }); |
| 152 | + }); |
| 153 | + |
| 154 | + describe('Dashboard', () => { |
| 155 | + it('has type: "dashboard"', () => { |
| 156 | + expect(CrmDashboard.type).toBe('dashboard'); |
| 157 | + }); |
| 158 | + |
| 159 | + it('has name and label', () => { |
| 160 | + expect(CrmDashboard.name).toBe('crm_dashboard'); |
| 161 | + expect(CrmDashboard.label).toBeDefined(); |
| 162 | + }); |
| 163 | + |
| 164 | + it('has widgets array', () => { |
| 165 | + expect(Array.isArray(CrmDashboard.widgets)).toBe(true); |
| 166 | + expect(CrmDashboard.widgets.length).toBeGreaterThan(0); |
| 167 | + }); |
| 168 | + |
| 169 | + it('all widgets have type and layout', () => { |
| 170 | + for (const widget of CrmDashboard.widgets) { |
| 171 | + expect(widget).toHaveProperty('type'); |
| 172 | + expect(widget).toHaveProperty('layout'); |
| 173 | + expect(widget.layout).toHaveProperty('x'); |
| 174 | + expect(widget.layout).toHaveProperty('y'); |
| 175 | + expect(widget.layout).toHaveProperty('w'); |
| 176 | + expect(widget.layout).toHaveProperty('h'); |
| 177 | + } |
| 178 | + }); |
| 179 | + }); |
| 180 | + |
| 181 | + describe('Reports', () => { |
| 182 | + it('reports have name, label, and columns', () => { |
| 183 | + for (const report of [SalesReport, PipelineReport]) { |
| 184 | + expect(report).toHaveProperty('name'); |
| 185 | + expect(report).toHaveProperty('label'); |
| 186 | + expect(report).toHaveProperty('columns'); |
| 187 | + expect(Array.isArray(report.columns)).toBe(true); |
| 188 | + } |
| 189 | + }); |
| 190 | + }); |
| 191 | + |
| 192 | + describe('Pages', () => { |
| 193 | + it('all pages have name, label, type, and regions', () => { |
| 194 | + for (const page of [GettingStartedPage, SettingsPage, HelpPage]) { |
| 195 | + expect(page).toHaveProperty('name'); |
| 196 | + expect(page).toHaveProperty('label'); |
| 197 | + expect(page).toHaveProperty('type'); |
| 198 | + expect(page).toHaveProperty('regions'); |
| 199 | + expect(['app', 'utility', 'record', 'home', 'dashboard']).toContain(page.type); |
| 200 | + } |
| 201 | + }); |
| 202 | + }); |
| 203 | + |
| 204 | + describe('App', () => { |
| 205 | + it('has name, label, description, and navigation', () => { |
| 206 | + expect(CrmApp).toHaveProperty('name'); |
| 207 | + expect(CrmApp).toHaveProperty('label'); |
| 208 | + expect(CrmApp).toHaveProperty('description'); |
| 209 | + expect(CrmApp).toHaveProperty('navigation'); |
| 210 | + expect(Array.isArray(CrmApp.navigation)).toBe(true); |
| 211 | + }); |
| 212 | + |
| 213 | + it('navigation items have id, type, and label', () => { |
| 214 | + const items = CrmApp.navigation as Array<Record<string, unknown>>; |
| 215 | + for (const item of items) { |
| 216 | + expect(item).toHaveProperty('id'); |
| 217 | + expect(item).toHaveProperty('type'); |
| 218 | + expect(item).toHaveProperty('label'); |
| 219 | + } |
| 220 | + }); |
| 221 | + }); |
| 222 | +}); |
| 223 | + |
| 224 | +// ==================================================================== |
| 225 | +// 2. i18n Completeness Tests |
| 226 | +// ==================================================================== |
| 227 | + |
| 228 | +const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar'] as const; |
| 229 | +const enLocale = crmLocales.en; |
| 230 | + |
| 231 | +/** Collect all leaf keys from a nested object as dot-separated paths */ |
| 232 | +function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] { |
| 233 | + const keys: string[] = []; |
| 234 | + for (const [key, value] of Object.entries(obj)) { |
| 235 | + const path = prefix ? `${prefix}.${key}` : key; |
| 236 | + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { |
| 237 | + keys.push(...collectKeys(value as Record<string, unknown>, path)); |
| 238 | + } else { |
| 239 | + keys.push(path); |
| 240 | + } |
| 241 | + } |
| 242 | + return keys; |
| 243 | +} |
| 244 | + |
| 245 | +/** Resolve a dot-path into a nested object */ |
| 246 | +function resolvePath(obj: Record<string, unknown>, path: string): unknown { |
| 247 | + const parts = path.split('.'); |
| 248 | + let current: unknown = obj; |
| 249 | + for (const part of parts) { |
| 250 | + if (current === null || typeof current !== 'object') return undefined; |
| 251 | + current = (current as Record<string, unknown>)[part]; |
| 252 | + } |
| 253 | + return current; |
| 254 | +} |
| 255 | + |
| 256 | +describe('CRM i18n Completeness', () => { |
| 257 | + it('all 10 locales are exported', () => { |
| 258 | + for (const code of SUPPORTED_LOCALES) { |
| 259 | + expect(crmLocales[code]).toBeDefined(); |
| 260 | + } |
| 261 | + }); |
| 262 | + |
| 263 | + it('all locales have top-level sections matching English', () => { |
| 264 | + const enTopKeys = Object.keys(enLocale).sort(); |
| 265 | + for (const code of SUPPORTED_LOCALES) { |
| 266 | + const locale = crmLocales[code]; |
| 267 | + const localeTopKeys = Object.keys(locale).sort(); |
| 268 | + expect(localeTopKeys).toEqual(enTopKeys); |
| 269 | + } |
| 270 | + }); |
| 271 | + |
| 272 | + it('all locales cover every key defined in the English locale', () => { |
| 273 | + const enKeys = collectKeys(enLocale as unknown as Record<string, unknown>); |
| 274 | + expect(enKeys.length).toBeGreaterThan(100); |
| 275 | + |
| 276 | + for (const code of SUPPORTED_LOCALES) { |
| 277 | + if (code === 'en') continue; |
| 278 | + const locale = crmLocales[code] as unknown as Record<string, unknown>; |
| 279 | + const missingKeys: string[] = []; |
| 280 | + for (const key of enKeys) { |
| 281 | + const val = resolvePath(locale, key); |
| 282 | + if (val === undefined) { |
| 283 | + missingKeys.push(key); |
| 284 | + } |
| 285 | + } |
| 286 | + expect(missingKeys).toEqual([]); |
| 287 | + } |
| 288 | + }); |
| 289 | + |
| 290 | + it('all locales have non-empty string values for leaf keys', () => { |
| 291 | + for (const code of SUPPORTED_LOCALES) { |
| 292 | + const locale = crmLocales[code] as unknown as Record<string, unknown>; |
| 293 | + const leafKeys = collectKeys(locale); |
| 294 | + for (const key of leafKeys) { |
| 295 | + const val = resolvePath(locale, key); |
| 296 | + expect(typeof val).toBe('string'); |
| 297 | + expect((val as string).length).toBeGreaterThan(0); |
| 298 | + } |
| 299 | + } |
| 300 | + }); |
| 301 | + |
| 302 | + describe('Object labels coverage', () => { |
| 303 | + const objectNames = allObjects.map((o) => o.name); |
| 304 | + |
| 305 | + it('English locale has a label for every CRM object', () => { |
| 306 | + for (const name of objectNames) { |
| 307 | + const objectKey = name as keyof typeof enLocale.objects; |
| 308 | + expect(enLocale.objects[objectKey]).toBeDefined(); |
| 309 | + expect(enLocale.objects[objectKey].label).toBeDefined(); |
| 310 | + } |
| 311 | + }); |
| 312 | + }); |
| 313 | + |
| 314 | + describe('Navigation labels coverage', () => { |
| 315 | + it('English locale has all navigation labels', () => { |
| 316 | + const navKeys = Object.keys(enLocale.navigation); |
| 317 | + expect(navKeys.length).toBeGreaterThanOrEqual(17); |
| 318 | + expect(navKeys).toContain('dashboard'); |
| 319 | + expect(navKeys).toContain('contacts'); |
| 320 | + expect(navKeys).toContain('accounts'); |
| 321 | + expect(navKeys).toContain('opportunities'); |
| 322 | + expect(navKeys).toContain('pipeline'); |
| 323 | + expect(navKeys).toContain('settings'); |
| 324 | + expect(navKeys).toContain('help'); |
| 325 | + }); |
| 326 | + }); |
| 327 | + |
| 328 | + describe('Action labels coverage', () => { |
| 329 | + it('English locale has a label for every CRM action', () => { |
| 330 | + for (const action of allActions) { |
| 331 | + const actionKey = action.name as keyof typeof enLocale.actions; |
| 332 | + expect(enLocale.actions[actionKey]).toBeDefined(); |
| 333 | + expect(enLocale.actions[actionKey].label).toBeDefined(); |
| 334 | + } |
| 335 | + }); |
| 336 | + }); |
| 337 | + |
| 338 | + describe('Dashboard widget labels coverage', () => { |
| 339 | + it('English locale has widget labels for all dashboard KPIs', () => { |
| 340 | + expect(enLocale.dashboard.widgets.totalRevenue).toBeDefined(); |
| 341 | + expect(enLocale.dashboard.widgets.activeDeals).toBeDefined(); |
| 342 | + expect(enLocale.dashboard.widgets.winRate).toBeDefined(); |
| 343 | + expect(enLocale.dashboard.widgets.avgDealSize).toBeDefined(); |
| 344 | + expect(enLocale.dashboard.widgets.revenueTrends).toBeDefined(); |
| 345 | + expect(enLocale.dashboard.widgets.leadSource).toBeDefined(); |
| 346 | + expect(enLocale.dashboard.widgets.pipelineByStage).toBeDefined(); |
| 347 | + expect(enLocale.dashboard.widgets.topProducts).toBeDefined(); |
| 348 | + expect(enLocale.dashboard.widgets.recentOpportunities).toBeDefined(); |
| 349 | + expect(enLocale.dashboard.widgets.revenueByAccount).toBeDefined(); |
| 350 | + }); |
| 351 | + }); |
| 352 | +}); |
0 commit comments