Initial commit of the Flutter Cursor Generator project, including the core generator tool, project brief schema, example project setup, and CI configuration. Added README documentation outlining repository structure, quick start guide, and detailed descriptions of features and architecture pillars.

This commit is contained in:
2026-05-12 22:29:55 +05:30
commit 6dfb9a8aa5
72 changed files with 4542 additions and 0 deletions
@@ -0,0 +1,372 @@
// generator_test.dart — Pillar 3: Generator test suite with golden files
// Run: dart test test/generator_test.dart
import 'dart:io';
import 'package:test/test.dart';
import '../src/brief_loader.dart';
import '../src/resolver.dart';
import '../src/renderer.dart';
import '../src/validator.dart';
import '../src/models.dart';
// ─── Test fixtures ─────────────────────────────────────────────────────────
final _blocCleanBrief = ProjectBrief(
projectName: 'TestApp',
packageId: 'com.test.testapp',
description: 'Test app for golden tests',
scale: 'medium',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['firebase'],
auth: 'firebase_auth',
platforms: ['ios', 'android'],
codegenTools: ['freezed'],
flavors: ['dev', 'prod'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'home', 'products'],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
final _riverpodFFBrief = ProjectBrief(
projectName: 'TaskFlow',
packageId: 'com.test.taskflow',
description: 'Task management app',
scale: 'small',
stateManagement: 'riverpod',
routing: 'gorouter',
architecture: 'feature_first',
backends: ['supabase'],
auth: 'supabase_auth',
platforms: ['ios', 'android', 'web'],
codegenTools: ['freezed', 'json_serializable'],
flavors: ['dev', 'prod'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'tasks', 'profile'],
specialFeatures: [],
i18nEnabled: true,
locales: ['en', 'fr'],
);
final _getxMvcBrief = ProjectBrief(
projectName: 'LegacyApp',
packageId: 'com.test.legacy',
description: 'Legacy GetX app',
scale: 'medium',
stateManagement: 'getx',
routing: 'getx_nav',
architecture: 'mvc',
backends: ['rest'],
auth: 'jwt_rest',
platforms: ['ios', 'android'],
codegenTools: [],
flavors: ['dev', 'prod'],
cicd: 'codemagic',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'openapi',
apiDocsPath: 'docs/api.yaml',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'dashboard'],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
// ─── Resolver tests ─────────────────────────────────────────────────────────
group('Resolver', () {
test('BLoC + Clean resolves correct files', () {
final files = Resolver.resolve(_blocCleanBrief);
// Universal — always present
expect(files, contains('rules/universal/flutter-core'));
expect(files, contains('rules/universal/ui-ux-standards'));
expect(files, contains('rules/universal/project-context'));
// Security — always present (Pillar 5)
expect(files, contains('rules/security/security-standards'));
// Error handling — always present
expect(files, contains('rules/error-handling/error-handling'));
// Stack-specific
expect(files, contains('rules/state-management/bloc'));
expect(files, contains('rules/architecture/clean'));
expect(files, contains('rules/routing/gorouter'));
expect(files, contains('rules/backend/firebase'));
// Testing — SM-matched
expect(files, contains('rules/testing/testing-bloc'));
// Platforms (Pillar 4)
expect(files, contains('rules/platform/platform-ios'));
expect(files, contains('rules/platform/platform-android'));
expect(files, containsNot('rules/platform/platform-web'));
// Codegen (Pillar 4)
expect(files, contains('rules/codegen/codegen-freezed'));
expect(files, containsNot('rules/codegen/codegen-injectable'));
// Agents
expect(files, contains('agents/code-reviewer'));
expect(files, containsNot('agents/migration-agent')); // not GetX
expect(files, contains('agents/security-agent')); // firebase_auth triggers it
// NOT included
expect(files, containsNot('rules/state-management/riverpod'));
expect(files, containsNot('rules/state-management/getx'));
expect(files, containsNot('rules/architecture/feature_first'));
expect(files, containsNot('rules/routing/getx_nav'));
});
test('Riverpod + Feature-First + Web resolves web platform template', () {
final files = Resolver.resolve(_riverpodFFBrief);
expect(files, contains('rules/platform/platform-web'));
expect(files, contains('rules/state-management/riverpod'));
expect(files, contains('rules/architecture/feature_first'));
expect(files, contains('rules/codegen/codegen-freezed'));
expect(files, contains('rules/codegen/codegen-json_serializable'));
expect(files, contains('rules/i18n/localization')); // i18nEnabled: true
expect(files, containsNot('agents/migration-agent'));
});
test('GetX + MVC includes migration-agent', () {
final files = Resolver.resolve(_getxMvcBrief);
expect(files, contains('agents/migration-agent'));
expect(files, contains('rules/state-management/getx'));
expect(files, contains('rules/architecture/mvc'));
expect(files, contains('agents/api-client-gen')); // rest backend
expect(files, contains('skills/generate-api-client')); // apiDocsFormat != none
});
test('Realtime special feature includes realtime template', () {
final brief = ProjectBrief(
projectName: 'RealtimeApp', packageId: 'com.test.rt', description: '',
scale: 'medium', stateManagement: 'riverpod', routing: 'gorouter',
architecture: 'feature_first', backends: ['supabase'], auth: 'supabase_auth',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: ['realtime'], i18nEnabled: false, locales: ['en'],
);
final files = Resolver.resolve(brief);
expect(files, contains('rules/backend/realtime'));
});
test('E2E testing depth includes e2e template', () {
final brief = ProjectBrief(
projectName: 'E2EApp', packageId: 'com.test.e2e', description: '',
scale: 'small', stateManagement: 'bloc', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'full', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
);
final files = Resolver.resolve(brief);
expect(files, contains('rules/testing/testing-e2e-patrol'));
});
test('No duplicate files in resolved list', () {
final files = Resolver.resolve(_blocCleanBrief);
final unique = files.toSet();
expect(files.length, equals(unique.length), reason: 'Duplicate template files detected');
});
});
// ─── Renderer / placeholder tests ───────────────────────────────────────────
group('Renderer — placeholder substitution', () {
test('buildContext produces all required keys', () {
// Access via Renderer._buildContext (white-box test)
// Instead, verify rendered output has no unreplaced {{VAR}} patterns
// by checking a known template snippet
final context = {
'PROJECT_NAME': 'TestApp',
'PACKAGE_ID': 'com.test.testapp',
};
final template = 'Project: {{PROJECT_NAME}} ({{PACKAGE_ID}})';
final result = template
.replaceAll('{{PROJECT_NAME}}', 'TestApp')
.replaceAll('{{PACKAGE_ID}}', 'com.test.testapp');
expect(result, equals('Project: TestApp (com.test.testapp)'));
expect(result, isNot(contains('{{')));
});
test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template directory not found at $templateDir — run from generator/');
return;
}
final rendered = await Renderer.render(
brief: _blocCleanBrief,
templateFiles: Resolver.resolve(_blocCleanBrief),
templateSrc: templateDir,
);
final unresolved = <String>[];
for (final entry in rendered.entries) {
final matches = RegExp(r'\{\{[A-Z_]+\}\}').allMatches(entry.value);
for (final m in matches) {
unresolved.add('${entry.key}: ${m.group(0)}');
}
}
expect(unresolved, isEmpty, reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}');
});
});
// ─── Validator tests ─────────────────────────────────────────────────────────
group('Validator', () {
test('Valid brief passes validation', () async {
final result = await Validator.validate(_blocCleanBrief);
expect(result.isValid, isTrue);
expect(result.errors, isEmpty);
});
test('Invalid state_management fails validation', () async {
final brief = ProjectBrief(
projectName: 'X', packageId: 'com.x.x', description: '',
scale: 'small', stateManagement: 'invalid_sm', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
);
final result = await Validator.validate(brief);
expect(result.isValid, isFalse);
expect(result.errors, anyElement(contains('invalid_sm')));
});
test('Missing project name fails validation', () async {
final brief = ProjectBrief(
projectName: '', packageId: 'com.x.x', description: '',
scale: 'small', stateManagement: 'riverpod', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
);
final result = await Validator.validate(brief);
expect(result.isValid, isFalse);
expect(result.errors, anyElement(contains('project.name')));
});
});
// ─── Golden file tests (Pillar 3) ───────────────────────────────────────────
group('Golden file tests', () {
test('BLoC + Clean renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
}
final rendered = await Renderer.render(
brief: _blocCleanBrief,
templateFiles: Resolver.resolve(_blocCleanBrief),
templateSrc: templateDir,
);
for (final entry in rendered.entries) {
final goldenPath = 'test/golden/bloc-clean-firebase/${entry.key}';
final goldenFile = File(goldenPath);
if (!goldenFile.existsSync()) {
// Create golden file on first run
await goldenFile.parent.create(recursive: true);
await goldenFile.writeAsString(entry.value);
print('Created golden: $goldenPath');
} else {
final golden = await goldenFile.readAsString();
expect(entry.value, equals(golden),
reason: 'Golden mismatch for ${entry.key}.\n'
'Update goldens: dart test --update-goldens');
}
}
});
test('Riverpod + FF renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
}
final rendered = await Renderer.render(
brief: _riverpodFFBrief,
templateFiles: Resolver.resolve(_riverpodFFBrief),
templateSrc: templateDir,
);
_compareGoldens('test/golden/riverpod-ff-supabase', rendered);
});
test('GetX + MVC renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
}
final rendered = await Renderer.render(
brief: _getxMvcBrief,
templateFiles: Resolver.resolve(_getxMvcBrief),
templateSrc: templateDir,
);
_compareGoldens('test/golden/getx-mvc-rest', rendered);
});
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
Future<void> _compareGoldens(String goldenDir, Map<String, String> rendered) async {
for (final entry in rendered.entries) {
final goldenFile = File('$goldenDir/${entry.key}');
if (!goldenFile.existsSync()) {
await goldenFile.parent.create(recursive: true);
await goldenFile.writeAsString(entry.value);
} else {
expect(entry.value, await goldenFile.readAsString(),
reason: 'Golden mismatch: $goldenDir/${entry.key}');
}
}
}
String _templateDir() {
// When running from generator/, go up one level to find templates/
final script = Platform.script.toFilePath();
return script.replaceAll(RegExp(r'test[/\\][^/\\]+$'), '../templates');
}
// Custom matcher
Matcher get containsNot => isNot(contains);
extension on Matcher {
Matcher call(dynamic value) => isNot(contains(value));
}