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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user