diff --git a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md index 59156de..268fe5a 100644 --- a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md +++ b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md @@ -1,4 +1,4 @@ -# Phase 10A AI-Powered Development: Dishant Progress +# Phase 10A — AI-Powered Development: Dishant Progress **Developer:** Dishant **Branch:** `cline-dev-dishant` @@ -13,7 +13,7 @@ | STRUCT-001 | JSON Schema Definition | COMPLETE | 8 schemas + validator + tests (33/33 pass) | | STRUCT-002 | Export Engine Core | COMPLETE | ProjectExporter + 50 unit tests | | STRUCT-003 | Import Engine Core | COMPLETE | ProjectImporter + 55 unit tests incl. round-trip validation | -| STRUCT-004 | Editor Format Detection | TODO | Unblocked by STRUCT-003 | +| STRUCT-004 | Editor Format Detection | COMPLETE | ProjectFormatDetector + 30 unit tests incl. integration | | STRUCT-005 | Lazy Loading | TODO | Unblocked by STRUCT-004 | | STRUCT-006 | Save Logic | TODO | Unblocked by STRUCT-005 | | STRUCT-007 | Migration Wizard UI | TODO | Unblocked by STRUCT-006 | @@ -22,7 +22,7 @@ --- -## STRUCT-001 JSON Schema Definition COMPLETE +## STRUCT-001 — JSON Schema Definition COMPLETE **Completed:** 2026-02-18 @@ -41,20 +41,20 @@ All files under `packages/noodl-editor/src/editor/src/schemas/`: | `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants | | `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions | -- `validator.ts` SchemaValidator singleton, per-schema convenience methods, Ajv v8 + ajv-formats -- `index.ts` re-exports all schemas, validator, TypeScript interfaces -- `tests/schemas/schema-validator.test.ts` 33 test cases, all passing +- `validator.ts` — SchemaValidator singleton, per-schema convenience methods, Ajv v8 + ajv-formats +- `index.ts` — re-exports all schemas, validator, TypeScript interfaces +- `tests/schemas/schema-validator.test.ts` — 33 test cases, all passing ### Key decisions -- `additionalProperties: true` on nodes/connections open-ended by design -- Port type is `oneOf [string, object]` Noodl uses both formats -- `strict: false` on Ajv schemas use `description` in `definitions` -- `require()` for `ajv-formats` avoids TS type conflict +- `additionalProperties: true` on nodes/connections — open-ended by design +- Port type is `oneOf [string, object]` — Noodl uses both formats +- `strict: false` on Ajv — schemas use `description` in `definitions` +- `require()` for `ajv-formats` — avoids TS type conflict --- -## STRUCT-002 Export Engine Core COMPLETE +## STRUCT-002 — Export Engine Core COMPLETE **Completed:** 2026-02-19 @@ -68,7 +68,7 @@ All files under `packages/noodl-editor/src/editor/src/schemas/`: ### Architecture -`ProjectExporter` is pure no filesystem access. Takes `LegacyProject`, returns `ExportResult { files[], stats }`. +`ProjectExporter` is pure — no filesystem access. Takes `LegacyProject`, returns `ExportResult { files[], stats }`. Output layout: ``` @@ -85,17 +85,17 @@ components/ ### Key decisions -1. Pure/filesystem-agnostic caller writes files -2. Node tree flattening recursive tree to flat array with parent/children IDs -3. Styles/routes files conditional only produced when content exists +1. Pure/filesystem-agnostic — caller writes files +2. Node tree flattening — recursive tree to flat array with parent/children IDs +3. Styles/routes files conditional — only produced when content exists 4. Legacy `stateParamaters` typo normalised to `stateParameters` -5. Metadata split styles/routes extracted, rest stays in project file +5. Metadata split — styles/routes extracted, rest stays in project file 6. Original legacy path preserved in `component.json` for round-trip fidelity 7. Empty `parameters: {}` omitted from output --- -## STRUCT-003 Import Engine Core COMPLETE +## STRUCT-003 — Import Engine Core COMPLETE **Completed:** 2026-02-19 @@ -108,7 +108,7 @@ components/ ### Architecture -`ProjectImporter` is pure no filesystem access. Takes `ImportInput` (all file contents), returns `ImportResult { project, warnings }`. +`ProjectImporter` is pure — no filesystem access. Takes `ImportInput` (all file contents), returns `ImportResult { project, warnings }`. ``` ImportInput { @@ -129,30 +129,79 @@ ImportInput { ### Key decisions -1. Pure/filesystem-agnostic symmetric with ProjectExporter -2. `unflattenNodes` two-pass: build map, then wire children in order -3. `toLegacyName` prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction -4. `stateParameters` `stateParamaters` reversal restores legacy typo -5. Metadata merge routes/styles merged back into `project.metadata` -6. Non-fatal warnings component failures collected, not thrown -7. Round-trip validated 20 round-trip tests covering all data types +1. Pure/filesystem-agnostic — symmetric with ProjectExporter +2. `unflattenNodes` — two-pass: build map, then wire children in order +3. `toLegacyName` — prefers `component.path` (preserved original) for perfect round-trip; falls back to reconstruction +4. `stateParameters` → `stateParamaters` reversal — restores legacy typo +5. Metadata merge — routes/styles merged back into `project.metadata` +6. Non-fatal warnings — component failures collected, not thrown +7. Round-trip validated — 20 round-trip tests covering all data types ### Test coverage (55 tests) -- `unflattenNodes` 11 cases (empty, single, parent-child, deep nesting, order, field restoration) -- `toLegacyName` 4 cases (path field, fallback, root, cloud) -- `ProjectImporter.import()` 20 cases (basic, metadata, variants, components, nodes) -- Round-trip (export import) 20 cases (all data types, edge cases) +- `unflattenNodes` — 11 cases (empty, single, parent-child, deep nesting, order, field restoration) +- `toLegacyName` — 4 cases (path field, fallback, root, cloud) +- `ProjectImporter.import()` — 20 cases (basic, metadata, variants, components, nodes) +- Round-trip (export → import) — 20 cases (all data types, edge cases) -### Cross-branch note for Richard +--- -No shared dependencies with Phase 9/6 work. STRUCT-003 is self-contained in `packages/noodl-editor/src/editor/src/io/` and `packages/noodl-editor/tests/io/`. No cherry-pick needed. +## STRUCT-004 — Editor Format Detection COMPLETE + +**Completed:** 2026-02-19 + +### What was built + +| File | Purpose | +|------|---------| +| `packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts` | Format detection utility | +| `packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts` | 30 unit + integration tests | + +### Architecture + +`ProjectFormatDetector` accepts a `DetectorFilesystem` interface (injectable, testable). Supports both async `detect()` and sync `detectSync()`. + +Detection logic (priority order): +1. `nodegx.project.json` + `components/_registry.json` → **v2 / high confidence** +2. Either v2 indicator alone → **v2 / medium confidence** +3. `project.json` only → **legacy / high confidence** +4. Nothing found → **unknown / low confidence** +5. Both legacy + v2 → **v2 wins** (migration in progress) + +### Key exports + +| Export | Purpose | +|--------|---------| +| `ProjectFormatDetector` | Main class | +| `DetectorFilesystem` | Interface for filesystem injection | +| `V2_INDICATORS` | Sentinel filenames for v2 format | +| `LEGACY_INDICATORS` | Sentinel filenames for legacy format | +| `createNodeDetector()` | Factory using Node.js `fs.existsSync` | +| `ProjectFormat` | `'legacy' | 'v2' | 'unknown'` type | +| `FormatDetectionResult` | `{ format, confidence, indicators }` | + +### Key decisions + +1. Injectable filesystem — no platform singleton dependency, fully testable +2. Supports both sync and async `exists()` — `detectSync()` throws if async fs injected +3. `createNodeDetector()` factory for Node.js/test contexts +4. Scoring system — v2 needs score ≥ 2 (nodegx.project.json=2, registry=2, components dir=1) +5. Errors in `exists()` treated as "not found" — graceful degradation + +### Test coverage (30 tests) + +- `detect()` — 12 cases (all format combinations, async fs, error handling) +- `detectSync()` — 4 cases (v2, legacy, unknown, throws on async fs) +- Convenience methods — 7 cases (getFormat, isV2, isLegacy) +- Constants — 3 cases +- `createNodeDetector()` integration — 4 cases (real filesystem with tmp dirs) --- ## Decisions and Learnings - **[2026-02-18]** Ajv v8 + ajv-formats: use `require()` for ajv-formats to avoid TS type conflict with root-level ajv-formats package -- **[2026-02-19]** Legacy `stateParamaters` typo (missing 'e') is real must be preserved in round-trip. Exporter normalises to `stateParameters`, importer reverses it. -- **[2026-02-19]** `component.path` field is the key to perfect round-trip fidelity always preserve the original legacy name in the v2 component file -- **[2026-02-19]** `unflattenNodes` needs two passes first build the node map, then wire children in order using the children ID array +- **[2026-02-19]** Legacy `stateParamaters` typo (missing 'e') is real — must be preserved in round-trip. Exporter normalises to `stateParameters`, importer reverses it. +- **[2026-02-19]** `component.path` field is the key to perfect round-trip fidelity — always preserve the original legacy name in the v2 component file +- **[2026-02-19]** `unflattenNodes` needs two passes — first build the node map, then wire children in order using the children ID array +- **[2026-02-19]** Format detection: inject filesystem interface rather than using platform singleton — makes the utility testable without Electron context and usable in Node.js scripts/tests diff --git a/packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts b/packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts new file mode 100644 index 0000000..b5211a3 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/io/ProjectFormatDetector.ts @@ -0,0 +1,240 @@ +/** + * STRUCT-004 Project Format Detection + * + * Utility for detecting whether a project directory uses the legacy + * monolithic format (project.json) or the v2 multi-file format + * (nodegx.project.json + components/). + * + * Design goals: + * - Pure utility no side effects, no global state + * - Testable accepts a filesystem abstraction, not the platform singleton + * - Fast uses file existence checks only, no parsing + * - Explicit returns a typed result with confidence and indicators + * + * @module noodl-editor/io/ProjectFormatDetector + * @since 1.2.0 + */ + +// Types + +/** The detected project format */ +export type ProjectFormat = 'legacy' | 'v2' | 'unknown'; + +/** + * Result of a format detection check. + */ +export interface FormatDetectionResult { + /** The detected format */ + format: ProjectFormat; + /** Confidence level of the detection */ + confidence: 'high' | 'medium' | 'low'; + /** Human-readable indicators that led to this conclusion */ + indicators: string[]; +} + +/** + * Minimal filesystem interface required by ProjectFormatDetector. + * Allows injection of a test double without depending on the platform. + */ +export interface DetectorFilesystem { + /** Returns true if the path exists (file or directory) */ + exists(path: string): boolean | Promise; + /** Joins path segments */ + join(...parts: string[]): string; +} + +// Sentinel filenames + +/** Files/dirs that indicate a v2 project */ +export const V2_INDICATORS = { + projectFile: 'nodegx.project.json', + componentsDir: 'components', + registryFile: 'components/_registry.json' +} as const; + +/** Files that indicate a legacy project */ +export const LEGACY_INDICATORS = { + projectFile: 'project.json' +} as const; + +// ProjectFormatDetector + +/** + * Detects the format of a project directory. + * + * Detection logic (in priority order): + * 1. If `nodegx.project.json` exists v2 (high confidence) + * 2. If `components/_registry.json` exists v2 (high confidence) + * 3. If `project.json` exists AND no v2 indicators legacy (high confidence) + * 4. If `project.json` exists AND v2 indicators present v2 (medium confidence, mixed project) + * 5. If neither exists unknown (low confidence) + * + * @example + * ```ts + * // With platform filesystem + * import { filesystem } from '@noodl/platform'; + * const detector = new ProjectFormatDetector(filesystem); + * const result = await detector.detect('/path/to/project'); + * + * // With test double + * const mockFs = { exists: (p) => p.endsWith('project.json'), join: path.join }; + * const detector = new ProjectFormatDetector(mockFs); + * ``` + */ +export class ProjectFormatDetector { + constructor(private readonly fs: DetectorFilesystem) {} + + /** + * Detects the format of the project at the given directory path. + * + * @param projectDir - Absolute path to the project directory + * @returns FormatDetectionResult with format, confidence, and indicators + */ + async detect(projectDir: string): Promise { + const indicators: string[] = []; + + // Check for v2 indicators + const hasV2ProjectFile = await this.exists(this.fs.join(projectDir, V2_INDICATORS.projectFile)); + const hasRegistry = await this.exists(this.fs.join(projectDir, V2_INDICATORS.registryFile)); + const hasComponentsDir = await this.exists(this.fs.join(projectDir, V2_INDICATORS.componentsDir)); + + // Check for legacy indicator + const hasLegacyProjectFile = await this.exists(this.fs.join(projectDir, LEGACY_INDICATORS.projectFile)); + + // Build indicator list + if (hasV2ProjectFile) indicators.push(`Found ${V2_INDICATORS.projectFile}`); + if (hasRegistry) indicators.push(`Found ${V2_INDICATORS.registryFile}`); + if (hasComponentsDir) indicators.push(`Found ${V2_INDICATORS.componentsDir}/ directory`); + if (hasLegacyProjectFile) indicators.push(`Found ${LEGACY_INDICATORS.projectFile}`); + + // Decision logic + + const v2Score = (hasV2ProjectFile ? 2 : 0) + (hasRegistry ? 2 : 0) + (hasComponentsDir ? 1 : 0); + const isV2 = v2Score >= 2; // needs at least nodegx.project.json OR registry + + if (isV2) { + return { + format: 'v2', + confidence: hasV2ProjectFile && hasRegistry ? 'high' : 'medium', + indicators + }; + } + + if (hasLegacyProjectFile) { + return { + format: 'legacy', + confidence: 'high', + indicators + }; + } + + // Nothing found + return { + format: 'unknown', + confidence: 'low', + indicators: indicators.length > 0 ? indicators : ['No project files found in directory'] + }; + } + + /** + * Synchronous version of detect() for contexts where async is not available. + * Only works if the injected filesystem.exists() is synchronous. + * + * @param projectDir - Absolute path to the project directory + * @returns FormatDetectionResult (synchronous) + */ + detectSync(projectDir: string): FormatDetectionResult { + const indicators: string[] = []; + + const existsSync = (p: string): boolean => { + const result = this.fs.exists(p); + if (typeof result === 'boolean') return result; + throw new Error('detectSync() requires a synchronous filesystem.exists() implementation'); + }; + + const hasV2ProjectFile = existsSync(this.fs.join(projectDir, V2_INDICATORS.projectFile)); + const hasRegistry = existsSync(this.fs.join(projectDir, V2_INDICATORS.registryFile)); + const hasComponentsDir = existsSync(this.fs.join(projectDir, V2_INDICATORS.componentsDir)); + const hasLegacyProjectFile = existsSync(this.fs.join(projectDir, LEGACY_INDICATORS.projectFile)); + + if (hasV2ProjectFile) indicators.push(`Found ${V2_INDICATORS.projectFile}`); + if (hasRegistry) indicators.push(`Found ${V2_INDICATORS.registryFile}`); + if (hasComponentsDir) indicators.push(`Found ${V2_INDICATORS.componentsDir}/ directory`); + if (hasLegacyProjectFile) indicators.push(`Found ${LEGACY_INDICATORS.projectFile}`); + + const v2Score = (hasV2ProjectFile ? 2 : 0) + (hasRegistry ? 2 : 0) + (hasComponentsDir ? 1 : 0); + const isV2 = v2Score >= 2; + + if (isV2) { + return { + format: 'v2', + confidence: hasV2ProjectFile && hasRegistry ? 'high' : 'medium', + indicators + }; + } + + if (hasLegacyProjectFile) { + return { format: 'legacy', confidence: 'high', indicators }; + } + + return { + format: 'unknown', + confidence: 'low', + indicators: indicators.length > 0 ? indicators : ['No project files found in directory'] + }; + } + + /** + * Quick check returns just the format string without full result object. + * Useful for simple if/else branching. + * + * @param projectDir - Absolute path to the project directory + */ + async getFormat(projectDir: string): Promise { + const result = await this.detect(projectDir); + return result.format; + } + + /** + * Returns true if the project at the given path is in v2 format. + */ + async isV2(projectDir: string): Promise { + return (await this.getFormat(projectDir)) === 'v2'; + } + + /** + * Returns true if the project at the given path is in legacy format. + */ + async isLegacy(projectDir: string): Promise { + return (await this.getFormat(projectDir)) === 'legacy'; + } + + // Private helpers + + private async exists(path: string): Promise { + try { + const result = this.fs.exists(path); + return result instanceof Promise ? await result : result; + } catch { + return false; + } + } +} + +// Factory helpers + +/** + * Creates a ProjectFormatDetector using Node.js fs module. + * Use this in tests or Node.js scripts. + */ +export function createNodeDetector(): ProjectFormatDetector { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('fs') as typeof import('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodePath = require('path') as typeof import('path'); + + return new ProjectFormatDetector({ + exists: (p: string) => fs.existsSync(p), + join: (...parts: string[]) => nodePath.join(...parts) + }); +} diff --git a/packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts b/packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts new file mode 100644 index 0000000..b2ab775 --- /dev/null +++ b/packages/noodl-editor/tests/io/ProjectFormatDetector.test.ts @@ -0,0 +1,275 @@ +/** + * ProjectFormatDetector Tests -- STRUCT-004 + */ + +import { + ProjectFormatDetector, + DetectorFilesystem, + V2_INDICATORS, + LEGACY_INDICATORS, + createNodeDetector +} from '../../src/editor/src/io/ProjectFormatDetector'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +// ---- Mock filesystem factory ------------------------------------------------ + +function makeMockFs(existingPaths: string[]): DetectorFilesystem { + const set = new Set(existingPaths.map((p) => p.replace(/\\/g, '/'))); + return { + exists: (p: string) => set.has(p.replace(/\\/g, '/')), + join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/') + }; +} + +// ---- detect() --------------------------------------------------------------- + +describe('ProjectFormatDetector.detect()', () => { + it('returns v2/high when nodegx.project.json AND _registry.json exist', async () => { + const mockFs = makeMockFs([ + '/proj/nodegx.project.json', + '/proj/components/_registry.json', + '/proj/components' + ]); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('high'); + }); + + it('returns v2/medium when only nodegx.project.json exists', async () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('medium'); + }); + + it('returns v2/medium when only _registry.json exists', async () => { + const mockFs = makeMockFs(['/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('medium'); + }); + + it('returns legacy/high when only project.json exists', async () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('legacy'); + expect(result.confidence).toBe('high'); + }); + + it('returns unknown/low when no project files exist', async () => { + const mockFs = makeMockFs([]); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('unknown'); + expect(result.confidence).toBe('low'); + }); + + it('returns v2 when both project.json and nodegx.project.json exist (mixed)', async () => { + const mockFs = makeMockFs([ + '/proj/project.json', + '/proj/nodegx.project.json', + '/proj/components/_registry.json' + ]); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('v2'); + }); + + it('includes indicator for nodegx.project.json when found', async () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.indicators.some((i) => i.includes('nodegx.project.json'))).toBe(true); + }); + + it('includes indicator for project.json when found', async () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.indicators.some((i) => i.includes('project.json'))).toBe(true); + }); + + it('includes indicator for _registry.json when found', async () => { + const mockFs = makeMockFs(['/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.indicators.some((i) => i.includes('_registry.json'))).toBe(true); + }); + + it('includes "No project files found" indicator when nothing exists', async () => { + const mockFs = makeMockFs([]); + const detector = new ProjectFormatDetector(mockFs); + const result = await detector.detect('/proj'); + expect(result.indicators.some((i) => i.toLowerCase().includes('no project files'))).toBe(true); + }); + + it('handles async filesystem.exists()', async () => { + const asyncFs: DetectorFilesystem = { + exists: async (p: string) => p.includes('nodegx.project.json') || p.includes('_registry.json'), + join: (...parts: string[]) => parts.join('/') + }; + const detector = new ProjectFormatDetector(asyncFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('high'); + }); + + it('handles filesystem.exists() throwing (treats as not found)', async () => { + const throwingFs: DetectorFilesystem = { + exists: () => { throw new Error('permission denied'); }, + join: (...parts: string[]) => parts.join('/') + }; + const detector = new ProjectFormatDetector(throwingFs); + const result = await detector.detect('/proj'); + expect(result.format).toBe('unknown'); + }); +}); + +// ---- detectSync() ----------------------------------------------------------- + +describe('ProjectFormatDetector.detectSync()', () => { + it('returns v2/high for v2 project', () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = detector.detectSync('/proj'); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('high'); + }); + + it('returns legacy/high for legacy project', () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + const result = detector.detectSync('/proj'); + expect(result.format).toBe('legacy'); + expect(result.confidence).toBe('high'); + }); + + it('returns unknown for empty directory', () => { + const mockFs = makeMockFs([]); + const detector = new ProjectFormatDetector(mockFs); + const result = detector.detectSync('/proj'); + expect(result.format).toBe('unknown'); + }); + + it('throws when filesystem.exists() returns a Promise', () => { + const asyncFs: DetectorFilesystem = { + exists: async () => false, + join: (...parts: string[]) => parts.join('/') + }; + const detector = new ProjectFormatDetector(asyncFs); + expect(() => detector.detectSync('/proj')).toThrow('synchronous'); + }); +}); + +// ---- getFormat() / isV2() / isLegacy() -------------------------------------- + +describe('ProjectFormatDetector convenience methods', () => { + it('getFormat() returns "v2" for v2 project', async () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.getFormat('/proj')).toBe('v2'); + }); + + it('getFormat() returns "legacy" for legacy project', async () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.getFormat('/proj')).toBe('legacy'); + }); + + it('getFormat() returns "unknown" for empty dir', async () => { + const mockFs = makeMockFs([]); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.getFormat('/proj')).toBe('unknown'); + }); + + it('isV2() returns true for v2 project', async () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.isV2('/proj')).toBe(true); + }); + + it('isV2() returns false for legacy project', async () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.isV2('/proj')).toBe(false); + }); + + it('isLegacy() returns true for legacy project', async () => { + const mockFs = makeMockFs(['/proj/project.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.isLegacy('/proj')).toBe(true); + }); + + it('isLegacy() returns false for v2 project', async () => { + const mockFs = makeMockFs(['/proj/nodegx.project.json', '/proj/components/_registry.json']); + const detector = new ProjectFormatDetector(mockFs); + expect(await detector.isLegacy('/proj')).toBe(false); + }); +}); + +// ---- V2_INDICATORS / LEGACY_INDICATORS constants ---------------------------- + +describe('Sentinel constants', () => { + it('V2_INDICATORS.projectFile is nodegx.project.json', () => { + expect(V2_INDICATORS.projectFile).toBe('nodegx.project.json'); + }); + + it('V2_INDICATORS.registryFile is components/_registry.json', () => { + expect(V2_INDICATORS.registryFile).toBe('components/_registry.json'); + }); + + it('LEGACY_INDICATORS.projectFile is project.json', () => { + expect(LEGACY_INDICATORS.projectFile).toBe('project.json'); + }); +}); + +// ---- createNodeDetector() integration test ---------------------------------- + +describe('createNodeDetector() integration', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noodl-format-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects legacy project from real filesystem', async () => { + fs.writeFileSync(path.join(tmpDir, 'project.json'), '{}'); + const detector = createNodeDetector(); + const result = await detector.detect(tmpDir); + expect(result.format).toBe('legacy'); + expect(result.confidence).toBe('high'); + }); + + it('detects v2 project from real filesystem', async () => { + fs.writeFileSync(path.join(tmpDir, 'nodegx.project.json'), '{}'); + fs.mkdirSync(path.join(tmpDir, 'components')); + fs.writeFileSync(path.join(tmpDir, 'components', '_registry.json'), '{}'); + const detector = createNodeDetector(); + const result = await detector.detect(tmpDir); + expect(result.format).toBe('v2'); + expect(result.confidence).toBe('high'); + }); + + it('detects unknown for empty directory', async () => { + const detector = createNodeDetector(); + const result = await detector.detect(tmpDir); + expect(result.format).toBe('unknown'); + }); + + it('detectSync works on real filesystem', () => { + fs.writeFileSync(path.join(tmpDir, 'project.json'), '{}'); + const detector = createNodeDetector(); + const result = detector.detectSync(tmpDir); + expect(result.format).toBe('legacy'); + }); +}); diff --git a/packages/noodl-editor/tests/io/index.ts b/packages/noodl-editor/tests/io/index.ts index 7be08c7..cc3fda3 100644 --- a/packages/noodl-editor/tests/io/index.ts +++ b/packages/noodl-editor/tests/io/index.ts @@ -1,2 +1,3 @@ export * from './ProjectExporter.test'; export * from './ProjectImporter.test'; +export * from './ProjectFormatDetector.test';