192 lines
7.6 KiB
Dart
192 lines
7.6 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:motorcycle_dash/core/version/app_identity.dart';
|
|
import 'package:motorcycle_dash/core/app_info.dart';
|
|
import 'package:motorcycle_dash/database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample;
|
|
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_replay_service.dart';
|
|
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late AppDatabase db;
|
|
late FieldTestLogger logger;
|
|
late FieldTestExportService exportService;
|
|
late FieldTestReplayService replayService;
|
|
|
|
setUp(() {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
logger = FieldTestLogger(db);
|
|
exportService = FieldTestExportService(db);
|
|
replayService = FieldTestReplayService(validator: TelemetryValidator());
|
|
});
|
|
|
|
tearDown(() async {
|
|
logger.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
group('Production Versioning & Identity System Tests', () {
|
|
// 1. Semantic version parsing
|
|
test('1. Semantic version parsing conforms to MAJOR.MINOR.PATCH format', () {
|
|
final identity = AppIdentity.current();
|
|
final parts = identity.semanticVersion.split('.');
|
|
expect(parts.length, 3);
|
|
expect(int.tryParse(parts[0]), isNotNull);
|
|
expect(int.tryParse(parts[1]), isNotNull);
|
|
expect(int.tryParse(parts[2]), isNotNull);
|
|
});
|
|
|
|
// 2. Build number handling
|
|
test('2. Build number is a valid positive integer', () {
|
|
final identity = AppIdentity.current();
|
|
expect(identity.buildNumber, isPositive);
|
|
expect(identity.buildNumber, greaterThanOrEqualTo(100));
|
|
});
|
|
|
|
// 3. Git SHA handling
|
|
test('3. Git SHA is captured with valid 40-char full hash representation', () {
|
|
final identity = AppIdentity.current();
|
|
expect(identity.gitCommit, isNotEmpty);
|
|
expect(identity.displayString.contains(identity.gitCommit.substring(0, 8)), isTrue);
|
|
});
|
|
|
|
// 4. Missing Git metadata handling
|
|
test('4. Deserialization with missing Git metadata gracefully falls back to unknown', () {
|
|
final fallbackJson = {
|
|
'version': '1.0.0',
|
|
'build': 100,
|
|
};
|
|
final fallback = AppIdentity.fromJson(fallbackJson);
|
|
expect(fallback.gitCommit, 'unknown');
|
|
expect(fallback.gitBranch, 'unknown');
|
|
expect(fallback.releaseChannel, 'release');
|
|
expect(fallback.telemetrySchemaVersion, 5);
|
|
expect(fallback.exportSchemaVersion, 1);
|
|
});
|
|
|
|
// 5. Release-channel handling
|
|
test('5. Release channel is recognized from environment or runtime mode', () {
|
|
final identity = AppIdentity.current();
|
|
expect(['debug', 'profile', 'release', 'beta', 'production'].contains(identity.releaseChannel), isTrue);
|
|
});
|
|
|
|
// 6. Schema-version separation
|
|
test('6. Database schema version, telemetry schema, and export schema are independent', () {
|
|
final identity = AppIdentity.current();
|
|
expect(identity.telemetrySchemaVersion, 5);
|
|
expect(identity.exportSchemaVersion, 1);
|
|
expect(identity.semanticVersion, '1.0.0');
|
|
// Verify changing app version does not change schema versions
|
|
const customIdentity = AppIdentity(
|
|
semanticVersion: '2.0.0',
|
|
buildNumber: 200,
|
|
gitCommit: 'abc123456789',
|
|
gitBranch: 'feature',
|
|
releaseChannel: 'beta',
|
|
telemetrySchemaVersion: 5,
|
|
exportSchemaVersion: 1,
|
|
buildTimestamp: '2026-08-28T05:00:00Z',
|
|
);
|
|
expect(customIdentity.telemetrySchemaVersion, 5);
|
|
expect(customIdentity.exportSchemaVersion, 1);
|
|
});
|
|
|
|
// 7. Field-test session identity capture
|
|
test('7. Field test session captures immutable software identity on startSession', () async {
|
|
const rideId = 777;
|
|
logger.startSession(rideId, notes: 'Field test readiness session');
|
|
|
|
expect(logger.sessionIdentity.semanticVersion, '1.0.0');
|
|
expect(logger.sessionIdentity.buildNumber, 100);
|
|
expect(logger.sessionIdentity.telemetrySchemaVersion, 5);
|
|
|
|
await logger.flush();
|
|
final events = await (db.select(db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get();
|
|
final startEvent = events.firstWhere((e) => e.eventType == 'RIDE_STARTED');
|
|
expect(startEvent.message.contains('1.0.0+100'), isTrue);
|
|
final meta = jsonDecode(startEvent.metadata!);
|
|
expect(meta['software']['version'], '1.0.0');
|
|
expect(meta['software']['build'], 100);
|
|
});
|
|
|
|
// 8. Export metadata verification
|
|
test('8. Forensic export JSON contains comprehensive software identity object', () async {
|
|
const rideId = 888;
|
|
logger.startSession(rideId);
|
|
final sample = TelemetrySample(
|
|
timestamp: DateTime(2026, 8, 28, 9, 0, 0),
|
|
speed: 45.0,
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
gpsAccuracy: 3.0,
|
|
);
|
|
logger.processTelemetrySample(sample, TelemetryValidator().validate(sample));
|
|
await logger.flush();
|
|
|
|
final jsonString = await exportService.generateFieldTestSessionJson(rideId);
|
|
final Map<String, dynamic> data = jsonDecode(jsonString);
|
|
|
|
expect(data['software'], isNotNull);
|
|
expect(data['software']['version'], '1.0.0');
|
|
expect(data['software']['build'], 100);
|
|
expect(data['software']['telemetrySchemaVersion'], 5);
|
|
expect(data['software']['exportSchemaVersion'], 1);
|
|
});
|
|
|
|
// 9. Replay compatibility
|
|
test('9. Replay engine evaluates export schema compatibility safely', () {
|
|
expect(AppIdentity.checkExportCompatibility(1), ExportCompatibility.supported);
|
|
expect(AppIdentity.checkExportCompatibility(2), ExportCompatibility.unsupported);
|
|
expect(AppIdentity.checkExportCompatibility(0), ExportCompatibility.unknown);
|
|
});
|
|
|
|
// 10. Version metadata consistency
|
|
test('10. AppInfo and AppIdentity maintain consistent fallback and single source of truth', () {
|
|
final identity = AppInfo.identity;
|
|
expect(AppInfo.fallbackVersion, '1.0.0+100');
|
|
expect(identity.semanticVersion, '1.0.0');
|
|
expect(identity.buildNumber, 100);
|
|
});
|
|
|
|
// 11. Release manifest generation
|
|
test('11. Release manifest contains all required metadata attributes', () {
|
|
final manifest = AppIdentity.current().generateReleaseManifest();
|
|
expect(manifest['application'], 'MotorSignal');
|
|
expect(manifest['version'], '1.0.0');
|
|
expect(manifest['build'], 100);
|
|
expect(manifest['gitCommit'], isNotEmpty);
|
|
expect(manifest['telemetrySchema'], 5);
|
|
expect(manifest['exportSchema'], 1);
|
|
expect(manifest['buildTimestamp'], isNotEmpty);
|
|
});
|
|
|
|
// 12. Existing exports remaining readable
|
|
test('12. Legacy export payloads without explicit software block remain readable', () async {
|
|
final legacyExportJson = jsonEncode({
|
|
'exportVersion': '1.0.0',
|
|
'generatedAt': '2026-08-28T04:00:00Z',
|
|
'rideMetadata': {'id': 1},
|
|
'events': [
|
|
{
|
|
'timestamp': '2026-08-28T04:00:00.000Z',
|
|
'latitude': 37.7749,
|
|
'longitude': -122.4194,
|
|
'speed': 35.0,
|
|
'gpsAccuracy': 3.0,
|
|
}
|
|
]
|
|
});
|
|
|
|
final result = await replayService.replaySessionJson(legacyExportJson);
|
|
expect(result.totalSamples, 1);
|
|
expect(result.validSamples, 1);
|
|
expect(result.exportCompatibility, ExportCompatibility.supported);
|
|
});
|
|
});
|
|
}
|