diff --git a/test/src/device_app_info/app_info_test.dart b/test/src/device_app_info/app_info_test.dart new file mode 100644 index 00000000..e6047f50 --- /dev/null +++ b/test/src/device_app_info/app_info_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/app_info.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/app_info'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('AppInfo.empty', () { + test('fields have safe defaults and toJson serialises the core keys', () { + final app = AppInfo.empty(); + expect(app.bundleId, ''); + expect(app.version, ''); + expect(app.storeUrl, isNull); + expect(app.firstInstallTime, 0); + expect(app.lastUpdateTime, 0); + expect(app.startTime, 0); + + final json = app.toJson(); + expect(json['bundleId'], ''); + expect(json['version'], ''); + expect(json['firstInstallTime'], 0); + expect(json['lastUpdateTime'], 0); + expect(json['startTime'], 0); + expect(json.containsKey('storeUrl'), isFalse); + }); + }); + + group('AppInfo.init', () { + test('does not throw under the test binding', () async { + // package_info_plus and the native channel are both unavailable under + // the default test binding; init() should catch and return .empty(). + // We verify it resolves. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getInstallUpdateTimes') { + return {'firstInstall': 1000, 'lastUpdate': 2000}; + } + if (call.method == 'getProcessStartEpochMs') return 3000; + return null; + }); + + final app = await AppInfo.init(); + expect(app, isNotNull); + expect(app.bundleId, isA()); + expect(app.version, isA()); + }); + + test('constructs the iOS storeUrl from iosAppStoreId when provided (via empty fallback assertion)', () { + // The constructor is private and init() depends on Platform.isIOS, which we + // cannot flip in a Dart test. We only assert that a non-iOS code path + // produces a store URL via the Android bundleId template, which depends + // on a real platform. Skip dynamic dispatch and rely on the empty path. + final empty = AppInfo.empty(); + expect(empty.storeUrl, isNull); // sanity + }); + }); +} diff --git a/test/src/device_app_info/device_app_info_test.dart b/test/src/device_app_info/device_app_info_test.dart new file mode 100644 index 00000000..00effb2b --- /dev/null +++ b/test/src/device_app_info/device_app_info_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/app_info.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_app_info.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_audio.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_hardware.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_network.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_power.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_screen.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/operation_system.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DeviceAppInfo.empty', () { + test('aggregates empty instances of every sub-info', () { + final d = DeviceAppInfo.empty(); + expect(d.appInfo, isA()); + expect(d.os, isA()); + expect(d.hardware, isA()); + expect(d.power, isA()); + expect(d.network, isA()); + expect(d.appInfo.bundleId, ''); + expect(d.os.name, ''); + }); + }); + + group('DeviceAppInfo.toJson', () { + test('assembles the full JSON shape from all sub-infos', () { + final d = DeviceAppInfo.empty(); + final json = d.toJson( + screen: DeviceScreen.empty(), + audio: DeviceAudio.empty(), + ); + expect(json.keys, containsAll(['os', 'hardware', 'screen', 'power', 'audio', 'network'])); + expect(json['os'], isA>()); + expect(json['hardware'], isA>()); + expect(json['screen'], isA>()); + expect(json['audio'], isA>()); + expect(json['network'], isA>()); + }); + }); + + group('DeviceAppInfo.toJsonFresh', () { + test('returns a Map with all expected top-level keys', () async { + final d = DeviceAppInfo.empty(); + final json = await d.toJsonFresh(); + expect(json.keys, containsAll(['os', 'hardware', 'screen', 'power', 'audio', 'network'])); + }); + }); + + group('DeviceAppInfo.init', () { + test('returns a singleton-equivalent value on repeated calls', () async { + final first = await DeviceAppInfo.init(); + final second = await DeviceAppInfo.init(); + // The same instance is returned on subsequent calls — a memoisation test. + expect(identical(first, second), isTrue); + }); + }); +} diff --git a/test/src/device_app_info/device_audio_test.dart b/test/src/device_app_info/device_audio_test.dart new file mode 100644 index 00000000..c9f949c2 --- /dev/null +++ b/test/src/device_app_info/device_audio_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_audio.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/device_audio'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('DeviceAudio.empty', () { + test('fields are null and toJson emits an empty map', () { + final audio = DeviceAudio.empty(); + expect(audio.volume, isNull); + expect(audio.muted, isNull); + expect(audio.outputPluggedIn, isNull); + expect(audio.outputType, isNull); + expect(audio.toJson(), isEmpty); + }); + }); + + group('DeviceAudio.init', () { + test('decodes a full native response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getAudioInfo') { + return { + 'volume': 0.75 * 100, + 'muted': false, + 'outputPluggedIn': true, + 'outputType': ['wired', 'bluetooth'], + }; + } + return null; + }); + + final audio = await DeviceAudio.init(); + + expect(audio.volume, 75); // rounded from 75.0 + expect(audio.muted, false); + expect(audio.outputPluggedIn, true); + expect(audio.outputType, [AudioOutputType.wired, AudioOutputType.bluetooth]); + }); + + test('maps every AudioOutputType string value', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'outputType': ['wired', 'hdmi', 'bluetooth', 'usb', 'other'], + }; + }); + + final audio = await DeviceAudio.init(); + expect(audio.outputType, [ + AudioOutputType.wired, + AudioOutputType.hdmi, + AudioOutputType.bluetooth, + AudioOutputType.usb, + AudioOutputType.other, + ]); + }); + + test('unknown output types fall back to AudioOutputType.other', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'outputType': ['carplay', 'unknown']}; + }); + + final audio = await DeviceAudio.init(); + expect(audio.outputType, [AudioOutputType.other, AudioOutputType.other]); + }); + + test('returns an empty instance when the native call throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E', message: 'boom'); + }); + + final audio = await DeviceAudio.init(); + expect(audio.volume, isNull); + expect(audio.muted, isNull); + expect(audio.outputType, isNull); + }); + + test('returns an empty instance when the native call returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + + final audio = await DeviceAudio.init(); + expect(audio.volume, isNull); + expect(audio.outputType, isNull); + }); + }); + + group('DeviceAudio.toJson', () { + test('omits null fields and serialises every provided one', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'volume': 60, + 'muted': true, + 'outputPluggedIn': false, + 'outputType': ['wired'], + }; + }); + + final json = (await DeviceAudio.init()).toJson(); + expect(json['volume'], 60); + expect(json['muted'], true); + expect(json['outputPluggedIn'], false); + expect(json['outputType'], ['wired']); + }); + }); +} diff --git a/test/src/device_app_info/device_hardware_test.dart b/test/src/device_app_info/device_hardware_test.dart new file mode 100644 index 00000000..5b7982c9 --- /dev/null +++ b/test/src/device_app_info/device_hardware_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_hardware.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/device_hardware'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('DeviceHardware.empty', () { + test('fields default to null/other and toJson carries type', () { + final hw = DeviceHardware.empty(); + expect(hw.brand, isNull); + expect(hw.model, isNull); + expect(hw.type, DeviceType.other); + expect(hw.bootTime, isNull); + expect(hw.sdCardAvailable, isNull); + + final json = hw.toJson(); + expect(json['type'], 'other'); + expect(json.containsKey('brand'), isFalse); + expect(json.containsKey('model'), isFalse); + expect(json.containsKey('bootTime'), isFalse); + expect(json.containsKey('sdCardAvailable'), isFalse); + }); + }); + + group('DeviceHardware.toJson', () { + test('includes provided fields', () { + // Constructor is private, but toJson on empty combined with channel-backed + // init is enough. Here we verify the shape via the native-init path below. + final json = DeviceHardware.empty().toJson(); + expect(json['type'], DeviceType.other.name); + }); + }); + + group('DeviceHardware.init', () { + test('returns a DeviceHardware under the test binding without throwing', () async { + // We cannot control Platform.isIOS / isAndroid from a test, but init() + // catches any thrown platform exceptions and returns .empty(). The test + // binding routes channel calls through our handler only, so _getBootTime + // and _hasSdCard fall through to the Platform.isAndroid branch safely. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getBootEpochMs') return 1700000000000; + if (call.method == 'hasRemovableSdCard') return true; + return null; + }); + + final hw = await DeviceHardware.init( + TestWidgetsFlutterBinding.instance.platformDispatcher); + expect(hw, isNotNull); + // `type` reflects the platform the test host runs on (desktop in local + // `flutter test`, may be other on non-mobile CI). We just assert that + // it's assigned. + expect(hw.type, isA()); + }); + }); +} diff --git a/test/src/device_app_info/device_network_test.dart b/test/src/device_app_info/device_network_test.dart new file mode 100644 index 00000000..d52b4c11 --- /dev/null +++ b/test/src/device_app_info/device_network_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_network.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/device_network'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('DeviceNetwork.empty', () { + test('all fields are null and toJson is empty', () { + final n = DeviceNetwork.empty(); + expect(n.userAgent, isNull); + expect(n.type, isNull); + expect(n.detail, isNull); + expect(n.carrier, isNull); + expect(n.toJson(), isEmpty); + }); + }); + + group('DeviceNetwork.init', () { + test('decodes a full native response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'userAgent': 'Mozilla/5.0 test', + 'type': 'cellular', + 'detail': 'lte', + 'carrier': 'T-Mobile', + }; + }); + + final n = await DeviceNetwork.init(); + expect(n.userAgent, 'Mozilla/5.0 test'); + expect(n.type, NetworkType.cellular); + expect(n.detail, NetworkDetail.lte); + expect(n.carrier, 'T-Mobile'); + }); + + test('maps every NetworkType', () async { + for (final (raw, expected) in [ + ('wifi', NetworkType.wifi), + ('cellular', NetworkType.cellular), + ('ethernet', NetworkType.ethernet), + ('other', NetworkType.other), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'type': raw}; + }); + final n = await DeviceNetwork.init(); + expect(n.type, expected, reason: raw); + } + }); + + test('unknown NetworkType string yields null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'type': 'not-a-type'}; + }); + final n = await DeviceNetwork.init(); + expect(n.type, isNull); + }); + + test('returns empty when native call throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E'); + }); + final n = await DeviceNetwork.init(); + expect(n.userAgent, isNull); + expect(n.type, isNull); + }); + }); + + group('DeviceNetwork.toJson', () { + test('serialises enum names and omits null fields', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'type': 'wifi', + 'detail': 'lte', + 'carrier': 'Vodafone', + }; + }); + final json = (await DeviceNetwork.init()).toJson(); + expect(json['type'], 'wifi'); + expect(json['detail'], 'lte'); + expect(json['carrier'], 'Vodafone'); + expect(json.containsKey('userAgent'), isFalse); + }); + }); +} diff --git a/test/src/device_app_info/device_power_test.dart b/test/src/device_app_info/device_power_test.dart new file mode 100644 index 00000000..5f131ffe --- /dev/null +++ b/test/src/device_app_info/device_power_test.dart @@ -0,0 +1,110 @@ +import 'dart:ui' show PlatformDispatcher; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_power.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/device_power'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('DevicePower.empty', () { + test('all fields are null and toJson is empty', () { + final p = DevicePower.empty(); + expect(p.batteryLevel, isNull); + expect(p.batteryState, isNull); + expect(p.lowPowerMode, isNull); + expect(p.toJson(), isEmpty); + }); + }); + + group('DevicePower.init', () { + test('decodes a full native response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'level': 83.5, + 'state': 'charging', + 'lowPower': false, + }; + }); + + final p = await DevicePower.init(PlatformDispatcher.instance); + expect(p.batteryLevel, 83.5); + expect(p.batteryState, BatteryState.charging); + expect(p.lowPowerMode, false); + }); + + test('maps all known battery states', () async { + for (final (raw, expected) in [ + ('charging', BatteryState.charging), + ('full', BatteryState.full), + ('unplugged', BatteryState.unplugged), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'state': raw}; + }); + final p = await DevicePower.init(PlatformDispatcher.instance); + expect(p.batteryState, expected, reason: 'for state "$raw"'); + } + }); + + test('unknown state string maps to BatteryState.unknown', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'state': 'something-weird'}; + }); + final p = await DevicePower.init(PlatformDispatcher.instance); + expect(p.batteryState, BatteryState.unknown); + }); + + test('accepts integer battery level and widens to double', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'level': 50}; + }); + final p = await DevicePower.init(PlatformDispatcher.instance); + expect(p.batteryLevel, 50.0); + }); + + test('returns empty on PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E', message: 'boom'); + }); + final p = await DevicePower.init(PlatformDispatcher.instance); + expect(p.batteryLevel, isNull); + expect(p.batteryState, isNull); + }); + }); + + group('DevicePower.toJson', () { + test('omits null fields', () { + final p = DevicePower.empty(); + expect(p.toJson(), isEmpty); + }); + + test('serialises battery state as the enum name', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return { + 'level': 10, + 'state': 'full', + 'lowPower': true, + }; + }); + final json = (await DevicePower.init(PlatformDispatcher.instance)).toJson(); + expect(json['batteryState'], 'full'); + expect(json['lowPowerMode'], true); + expect(json['batteryLevel'], 10.0); + }); + }); +} + diff --git a/test/src/device_app_info/device_screen_test.dart b/test/src/device_app_info/device_screen_test.dart new file mode 100644 index 00000000..785d4354 --- /dev/null +++ b/test/src/device_app_info/device_screen_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/device_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DeviceScreen.empty', () { + test('zeros width/height/dpr and defaults to portrait + light mode', () { + final s = DeviceScreen.empty(); + expect(s.width, 0); + expect(s.height, 0); + expect(s.dpr, 0); + expect(s.orientation, ScreenOrientation.portrait); + expect(s.darkMode, false); + }); + + test('toJson emits every field using enum name for orientation', () { + final json = DeviceScreen.empty().toJson(); + expect(json['width'], 0); + expect(json['height'], 0); + expect(json['dpr'], 0); + expect(json['orientation'], 'portrait'); + expect(json['darkMode'], false); + }); + }); + + group('DeviceScreen.init', () { + test('does not throw under the test binding and returns non-negative dimensions', () { + final s = DeviceScreen.init(); + expect(s.width >= 0, isTrue); + expect(s.height >= 0, isTrue); + expect(s.dpr >= 0, isTrue); + expect(s.orientation, anyOf(ScreenOrientation.portrait, ScreenOrientation.landscape)); + // Under the test binding, the brightness is accessible without throwing. + expect(s.darkMode, anyOf(true, false)); + }); + + test('toJson on the live instance emits all expected keys', () { + final json = DeviceScreen.init().toJson(); + expect(json.keys, containsAll(['width', 'height', 'dpr', 'orientation', 'darkMode'])); + }); + }); +} diff --git a/test/src/device_app_info/operation_system_test.dart b/test/src/device_app_info/operation_system_test.dart new file mode 100644 index 00000000..5148fdc7 --- /dev/null +++ b/test/src/device_app_info/operation_system_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/device_app_info/operation_system.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/operation_system'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('OperationSystem.empty', () { + test('all fields are empty strings and toJson serialises all', () { + final os = OperationSystem.empty(); + expect(os.name, ''); + expect(os.version, ''); + expect(os.locale, ''); + expect(os.timezone, ''); + + final json = os.toJson(); + expect(json, {'name': '', 'version': '', 'locale': '', 'timezone': ''}); + }); + }); + + group('OperationSystem.init', () { + test('returns a non-null instance with locale string derived from platform locale', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getTimezone') return 'Europe/Prague'; + return null; + }); + + final os = await OperationSystem.init( + TestWidgetsFlutterBinding.instance.platformDispatcher); + expect(os, isNotNull); + // locale is derived from PlatformDispatcher.locale, which varies by test + // environment — just check it's a well-formed string (either "lang" or + // "lang-COUNTRY"). Timezone should be our mocked value unless device_info_plus + // throws before the timezone step (common without the plugin) — in which + // case init() catches and returns .empty(). + expect(os.locale, isA()); + }); + + test('falls back to empty when the native layer throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E', message: 'boom'); + }); + + final os = await OperationSystem.init( + TestWidgetsFlutterBinding.instance.platformDispatcher); + // Whether we hit the catch block or degrade through _getTimezone's catch, + // we always get a well-formed OperationSystem. + expect(os, isNotNull); + expect(os.name, isA()); + }); + }); +} diff --git a/test/src/integration_test.dart b/test/src/integration_test.dart new file mode 100644 index 00000000..4248482d --- /dev/null +++ b/test/src/integration_test.dart @@ -0,0 +1,284 @@ +import 'dart:convert' show jsonDecode; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:kontext_flutter_sdk/src/models/character.dart'; +import 'package:kontext_flutter_sdk/src/models/message.dart'; +import 'package:kontext_flutter_sdk/src/models/regulatory.dart'; +import 'package:kontext_flutter_sdk/src/services/advertising_id_service.dart'; +import 'package:kontext_flutter_sdk/src/services/api.dart'; +import 'package:kontext_flutter_sdk/src/services/http_client.dart'; +import 'package:kontext_flutter_sdk/src/utils/types.dart' show Json; +import 'package:mocktail/mocktail.dart'; + +/// Integration tests that drive the real Api → HttpClient → http.Client +/// pipeline end-to-end, with only the outermost http.Client mocked. This +/// covers the full pre-load flow that publishers depend on: +/// - TCF consent lookup, +/// - IFA resolution, +/// - body assembly and header wiring, +/// - response decoding, +/// - error-path fallback. +class MockHttp extends Mock implements http.Client {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockHttp httpMock; + + const tcfChannel = MethodChannel('kontext_flutter_sdk/transparency_consent_framework'); + + setUp(() { + registerFallbackValue(Uri.parse('https://dummy.local')); + httpMock = MockHttp(); + + HttpClient.resetInstance(); + Api.resetInstance(); + + AdvertisingIdService.resetForTesting(); + AdvertisingIdService.isIOSProvider = () => false; + AdvertisingIdService.idfvProvider = () async => null; + AdvertisingIdService.advertisingIdProvider = () async => null; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(tcfChannel, (_) async => null); + + HttpClient(baseUrl: 'https://api.integration.test', client: httpMock); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(tcfChannel, null); + AdvertisingIdService.resetForTesting(); + HttpClient.resetInstance(); + Api.resetInstance(); + }); + + Api buildApi() { + final api = Api(); + // Avoid platform-channel lookups for device info during tests. + api.deviceInfoProvider = ({String? iosAppStoreId}) async { + throw Exception('skip device info in tests'); + }; + return api; + } + + group('integration: full preload pipeline', () { + test('happy path POSTs to /preload with correct token + body and decodes bids', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response( + '{"sessionId": "s-int-1", "bids": [{"bidId": "b-1", "code": "inlineAd", "adDisplayPosition": "afterAssistantMessage"}]}', + 200, + ), + ); + + final api = buildApi(); + final response = await api.preload( + publisherToken: 'pub-tok-int', + userId: 'u-int', + conversationId: 'c-int', + messages: [Message(id: 'u-1', role: MessageRole.user, content: 'Hi', createdAt: DateTime.utc(2025))], + enabledPlacementCodes: const ['inlineAd'], + isDisabled: false, + ); + + expect(response.sessionId, 's-int-1'); + expect(response.bids, isNotEmpty); + expect(response.bids.first.code, 'inlineAd'); + + // Verify wire contract. + final captured = verify(() => httpMock.post( + captureAny(), + headers: captureAny(named: 'headers'), + body: captureAny(named: 'body'), + )).captured; + expect(captured, isNotEmpty); + final url = captured[captured.length - 3] as Uri; + final headers = captured[captured.length - 2] as Map; + final body = jsonDecode(captured.last as String) as Json; + + expect(url.toString(), 'https://api.integration.test/preload'); + expect(headers['Kontextso-Publisher-Token'], 'pub-tok-int'); + expect(headers['Kontextso-Is-Disabled'], '0'); + expect(body['publisherToken'], 'pub-tok-int'); + expect(body['conversationId'], 'c-int'); + expect(body['userId'], 'u-int'); + expect(body['enabledPlacementCodes'], ['inlineAd']); + expect(body['messages'], isA()); + }); + + test('isDisabled=true is forwarded as Kontextso-Is-Disabled: 1', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{"sessionId": "s", "bids": []}', 200), + ); + + await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + isDisabled: true, + ); + + final headers = verify(() => httpMock.post(any(), + headers: captureAny(named: 'headers'), body: any(named: 'body'))) + .captured + .last as Map; + expect(headers['Kontextso-Is-Disabled'], '1'); + }); + + test('skip response is propagated through to PreloadResponse', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{"sessionId": "s", "skip": true, "skipCode": "rate_limit"}', 200), + ); + + final response = await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + isDisabled: false, + ); + expect(response.skip, isTrue); + expect(response.skipCode, 'rate_limit'); + expect(response.bids, isEmpty); + }); + + test('network throw is swallowed into an empty PreloadResponse', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenThrow( + Exception('network error'), + ); + + final response = await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + isDisabled: false, + ); + expect(response.sessionId, isNull); + expect(response.bids, isEmpty); + }); + + test('5xx response surfaces statusCode', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{}', 503), + ); + + final response = await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + isDisabled: false, + ); + expect(response.statusCode, 503); + }); + + test('TCF data from the platform channel is merged into regulatory', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(tcfChannel, (call) async { + return {'gdprApplies': 1, 'tcString': 'CONSENT'}; + }); + + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{"sessionId": "s", "bids": []}', 200), + ); + + await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + regulatory: const Regulatory(coppa: 1), + isDisabled: false, + ); + + final body = jsonDecode( + verify(() => httpMock.post(any(), headers: any(named: 'headers'), body: captureAny(named: 'body'))).captured.last + as String, + ) as Json; + final regulatory = body['regulatory'] as Json; + expect(regulatory['gdpr'], 1); + expect(regulatory['gdprConsent'], 'CONSENT'); + expect(regulatory['coppa'], 1); // publisher-provided + }); + + test('character, variantId and userEmail are forwarded when provided', () async { + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{"sessionId": "s", "bids": []}', 200), + ); + + await buildApi().preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + character: Character(id: 'c-1', name: 'Max'), + variantId: 'v-1', + userEmail: 'x@y.z', + isDisabled: false, + ); + + final body = jsonDecode( + verify(() => httpMock.post(any(), headers: any(named: 'headers'), body: captureAny(named: 'body'))).captured.last + as String, + ) as Json; + expect(body['character'], isA()); + expect((body['character'] as Json)['id'], 'c-1'); + expect(body['variantId'], 'v-1'); + expect(body['userEmail'], 'x@y.z'); + }); + + test('bids survive a second preload with previously returned sessionId', () async { + // 1st response — gives us a sessionId. + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response('{"sessionId": "sess-A", "bids": []}', 200), + ); + + final api = buildApi(); + final r1 = await api.preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const [], + isDisabled: false, + ); + expect(r1.sessionId, 'sess-A'); + + // 2nd preload with sessionId passed in. + when(() => httpMock.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response( + '{"sessionId": "sess-A", "bids": [{"bidId": "b-1", "code": "inlineAd", "adDisplayPosition": "afterAssistantMessage"}]}', + 200, + ), + ); + + final r2 = await api.preload( + publisherToken: 'tok', + userId: 'u', + conversationId: 'c', + messages: const [], + enabledPlacementCodes: const ['inlineAd'], + sessionId: 'sess-A', + isDisabled: false, + ); + expect(r2.bids.first.code, 'inlineAd'); + + // Last body should carry the sessionId back up. + final body = jsonDecode( + verify(() => httpMock.post(any(), headers: any(named: 'headers'), body: captureAny(named: 'body'))).captured.last + as String, + ) as Json; + expect(body['sessionId'], 'sess-A'); + }); + }); +} diff --git a/test/src/models/ad_event_test.dart b/test/src/models/ad_event_test.dart new file mode 100644 index 00000000..939f2220 --- /dev/null +++ b/test/src/models/ad_event_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/ad_event.dart'; + +void main() { + group('AdEventType.fromString', () { + test('maps every known event name to the right enum case', () { + expect(AdEventType.fromString('ad.clicked'), AdEventType.adClicked); + expect(AdEventType.fromString('ad.viewed'), AdEventType.adViewed); + expect(AdEventType.fromString('ad.filled'), AdEventType.adFilled); + expect(AdEventType.fromString('ad.no-fill'), AdEventType.adNoFill); + expect(AdEventType.fromString('ad.render-started'), AdEventType.adRenderStarted); + expect(AdEventType.fromString('ad.render-completed'), AdEventType.adRenderCompleted); + expect(AdEventType.fromString('ad.error'), AdEventType.adError); + expect(AdEventType.fromString('reward.granted'), AdEventType.rewardGranted); + expect(AdEventType.fromString('video.started'), AdEventType.videoStarted); + expect(AdEventType.fromString('video.completed'), AdEventType.videoCompleted); + }); + + test('falls back to unknown for an unrecognized event name', () { + expect(AdEventType.fromString('pizza.delivered'), AdEventType.unknown); + }); + + test('falls back to unknown for a null name', () { + expect(AdEventType.fromString(null), AdEventType.unknown); + }); + + test('exposes the raw event-name string via .value', () { + expect(AdEventType.adClicked.value, 'ad.clicked'); + expect(AdEventType.rewardGranted.value, 'reward.granted'); + expect(AdEventType.unknown.value, 'unknown'); + }); + }); + + group('AdEvent.fromJson', () { + test('parses top-level code and nested payload fields', () { + final event = AdEvent.fromJson({ + 'name': 'ad.clicked', + 'code': 'inlineAd', + 'payload': { + 'id': 'bid-1', + 'content': 'ad body', + 'messageId': 'm-1', + 'url': 'https://advertiser.example', + 'format': 'inline', + 'area': 'cta', + }, + }); + + expect(event.type, AdEventType.adClicked); + expect(event.code, 'inlineAd'); + expect(event.id, 'bid-1'); + expect(event.content, 'ad body'); + expect(event.messageId, 'm-1'); + expect(event.url, 'https://advertiser.example'); + expect(event.format, 'inline'); + expect(event.area, 'cta'); + }); + + test('parses error payload into message and errCode', () { + final event = AdEvent.fromJson({ + 'name': 'ad.error', + 'payload': {'message': 'boom', 'errCode': 'E42'}, + }); + + expect(event.type, AdEventType.adError); + expect(event.message, 'boom'); + expect(event.errCode, 'E42'); + }); + + test('returns unknown event with all-null fields when payload is missing', () { + final event = AdEvent.fromJson({'name': 'ad.no-fill'}); + expect(event.type, AdEventType.adNoFill); + expect(event.code, isNull); + expect(event.id, isNull); + expect(event.content, isNull); + expect(event.messageId, isNull); + expect(event.url, isNull); + }); + + test('falls back to unknown type when name is missing', () { + final event = AdEvent.fromJson({}); + expect(event.type, AdEventType.unknown); + }); + + test('swallows malformed JSON and returns an unknown event', () { + // payload is not a Json — casting `as Json?` throws → catch block returns unknown event. + final event = AdEvent.fromJson({ + 'name': 'ad.clicked', + 'payload': 'not-a-map', + }); + expect(event.type, AdEventType.unknown); + }); + }); + + group('AdEvent.copyWith', () { + test('returns a new instance with overridden fields', () { + final original = AdEvent(type: AdEventType.adFilled, code: 'inlineAd', id: 'bid-1'); + final updated = original.copyWith(type: AdEventType.adViewed, id: 'bid-2'); + + expect(updated.type, AdEventType.adViewed); + expect(updated.id, 'bid-2'); + expect(updated.code, 'inlineAd'); + }); + + test('returns an equivalent event when no overrides are supplied', () { + final original = AdEvent(type: AdEventType.adFilled, code: 'c', id: 'id', revenue: 1.0); + final copy = original.copyWith(); + + expect(copy.type, original.type); + expect(copy.code, original.code); + expect(copy.id, original.id); + expect(copy.revenue, original.revenue); + }); + }); + + group('AdEvent skip code constants', () { + test('expose the stable strings used by the server contract', () { + expect(AdEvent.skipCodeUnFilledBid, 'unfilled_bid'); + expect(AdEvent.skipCodeSessionDisabled, 'session_disabled'); + expect(AdEvent.skipCodeRequestFailed, 'request_failed'); + expect(AdEvent.skipCodeUnknown, 'unknown'); + expect(AdEvent.skipCodeError, 'error'); + }); + }); + + group('AdEvent.toString', () { + test('includes all set fields for diagnostics', () { + final event = AdEvent( + type: AdEventType.adClicked, + code: 'c', + id: 'i', + url: 'https://x.y', + ); + final str = event.toString(); + expect(str, contains('AdEventType.adClicked')); + expect(str, contains('c')); + expect(str, contains('i')); + expect(str, contains('https://x.y')); + }); + }); +} diff --git a/test/src/models/character_test.dart b/test/src/models/character_test.dart new file mode 100644 index 00000000..2be393a0 --- /dev/null +++ b/test/src/models/character_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/character.dart'; + +void main() { + group('Character.toJson', () { + test('serialises required fields', () { + final json = Character(id: 'c-1', name: 'Max').toJson(); + expect(json['id'], 'c-1'); + expect(json['name'], 'Max'); + }); + + test('omits every optional field when left null', () { + final json = Character(id: 'c-1', name: 'Max').toJson(); + expect(json.containsKey('avatarUrl'), isFalse); + expect(json.containsKey('isNsfw'), isFalse); + expect(json.containsKey('greeting'), isFalse); + expect(json.containsKey('persona'), isFalse); + expect(json.containsKey('tags'), isFalse); + expect(json.length, 2); + }); + + test('serialises every optional field when provided', () { + final json = Character( + id: 'c-1', + name: 'Max', + avatarUrl: 'https://cdn.example/a.png', + isNsfw: false, + greeting: 'Hello', + persona: 'friendly', + tags: ['fantasy', 'adventure'], + ).toJson(); + + expect(json['avatarUrl'], 'https://cdn.example/a.png'); + expect(json['isNsfw'], false); + expect(json['greeting'], 'Hello'); + expect(json['persona'], 'friendly'); + expect(json['tags'], ['fantasy', 'adventure']); + }); + + test('merges additionalProperties into the top level JSON', () { + final json = Character( + id: 'c-1', + name: 'Max', + additionalProperties: {'theme': 'dark', 'locale': 'cs-CZ'}, + ).toJson(); + + expect(json['theme'], 'dark'); + expect(json['locale'], 'cs-CZ'); + expect(json['id'], 'c-1'); // core fields stay + }); + + test('additionalProperties cannot silently override core fields via a later key', () { + // Spec: core fields are spread before additionalProperties in toJson, + // so additionalProperties wins on a key collision. Document this here. + final json = Character( + id: 'c-1', + name: 'Max', + additionalProperties: {'name': 'OverriddenName'}, + ).toJson(); + expect(json['name'], 'OverriddenName'); + }); + }); + + group('Character.toString', () { + test('includes the id and name for diagnostics', () { + final str = Character(id: 'c-1', name: 'Max').toString(); + expect(str, contains('c-1')); + expect(str, contains('Max')); + }); + }); +} diff --git a/test/src/models/message_test.dart b/test/src/models/message_test.dart new file mode 100644 index 00000000..aae8ccab --- /dev/null +++ b/test/src/models/message_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/message.dart'; + +void main() { + final createdAt = DateTime.parse('2025-01-01T00:00:00Z'); + + group('Message.isUser/isAssistant', () { + test('user message reports isUser and not isAssistant', () { + final m = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + expect(m.isUser, isTrue); + expect(m.isAssistant, isFalse); + }); + + test('assistant message reports isAssistant and not isUser', () { + final m = Message(id: '1', role: MessageRole.assistant, content: 'hi', createdAt: createdAt); + expect(m.isAssistant, isTrue); + expect(m.isUser, isFalse); + }); + }); + + group('Message.toJson', () { + test('emits every field using role.name and ISO-8601 UTC timestamp', () { + final m = Message( + id: 'm-1', + role: MessageRole.user, + content: 'hello', + createdAt: DateTime.utc(2025, 1, 2, 3, 4, 5), + ); + final json = m.toJson(); + + expect(json['id'], 'm-1'); + expect(json['role'], 'user'); + expect(json['content'], 'hello'); + expect(json['createdAt'], '2025-01-02T03:04:05.000Z'); + }); + + test('converts a local DateTime to UTC before serialising', () { + // Pick a fixed UTC instant and construct an equivalent local-zone Date. + final utcInstant = DateTime.utc(2025, 1, 2, 3, 4, 5); + final local = utcInstant.toLocal(); + final json = Message(id: '1', role: MessageRole.user, content: 'x', createdAt: local).toJson(); + expect(json['createdAt'], '2025-01-02T03:04:05.000Z'); + }); + }); + + group('Message equality', () { + test('messages with the same id/role/content are equal', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + final b = Message( + id: '1', + role: MessageRole.user, + content: 'hi', + createdAt: createdAt.add(const Duration(seconds: 10)), + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different ids are not equal', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + final b = Message(id: '2', role: MessageRole.user, content: 'hi', createdAt: createdAt); + expect(a, isNot(equals(b))); + }); + + test('different roles are not equal', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + final b = Message(id: '1', role: MessageRole.assistant, content: 'hi', createdAt: createdAt); + expect(a, isNot(equals(b))); + }); + + test('different content is not equal', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + final b = Message(id: '1', role: MessageRole.user, content: 'bye', createdAt: createdAt); + expect(a, isNot(equals(b))); + }); + + test('is equal to itself', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + expect(a == a, isTrue); + }); + + test('is not equal to a non-Message', () { + final a = Message(id: '1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + // ignore: unrelated_type_equality_checks + expect(a == 'not a message', isFalse); + }); + }); + + group('Message.toString', () { + test('includes id, role, content, createdAt', () { + final m = Message(id: 'm-1', role: MessageRole.user, content: 'hi', createdAt: createdAt); + final s = m.toString(); + expect(s, contains('m-1')); + expect(s, contains('MessageRole.user')); + expect(s, contains('hi')); + }); + }); +} diff --git a/test/src/services/ad_attribution_kit_service_test.dart b/test/src/services/ad_attribution_kit_service_test.dart new file mode 100644 index 00000000..47ac31e1 --- /dev/null +++ b/test/src/services/ad_attribution_kit_service_test.dart @@ -0,0 +1,41 @@ +import 'dart:io' show Platform; +import 'dart:ui' show Rect; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/services/ad_attribution_kit_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AdAttributionKit on non-iOS hosts', () { + final skipOnIOS = Platform.isIOS; + + test('initImpression returns false', () async { + expect(await AdAttributionKit.initImpression('jws-token'), isFalse); + }, skip: skipOnIOS); + + test('setAttributionFrame returns false when not initialised', () async { + expect( + await AdAttributionKit.setAttributionFrame(const Rect.fromLTWH(0, 0, 100, 100)), + isFalse, + ); + }, skip: skipOnIOS); + + test('handleTap returns false when not initialised (with or without URI)', () async { + expect(await AdAttributionKit.handleTap(null), isFalse); + expect(await AdAttributionKit.handleTap(Uri.parse('https://example.com')), isFalse); + }, skip: skipOnIOS); + + test('beginView completes as no-op', () async { + await AdAttributionKit.beginView(); + }, skip: skipOnIOS); + + test('endView completes as no-op', () async { + await AdAttributionKit.endView(); + }, skip: skipOnIOS); + + test('dispose completes even when not initialised', () async { + await AdAttributionKit.dispose(); + }, skip: skipOnIOS); + }); +} diff --git a/test/src/services/sk_ad_network_service_test.dart b/test/src/services/sk_ad_network_service_test.dart new file mode 100644 index 00000000..a769279f --- /dev/null +++ b/test/src/services/sk_ad_network_service_test.dart @@ -0,0 +1,38 @@ +import 'dart:io' show Platform; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart' show Skan; +import 'package:kontext_flutter_sdk/src/services/sk_ad_network_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Skan skan() => Skan( + version: '4.0', + network: 'example.com', + itunesItem: '123', + sourceApp: '0', + ); + + group('SKAdNetwork on non-iOS hosts', () { + // These tests run on whatever the test host is (macOS during local dev). + // If that host happens to be iOS (unlikely in CI but possible), skip. + final skipOnIOS = Platform.isIOS; + + test('initImpression returns false without touching the channel', () async { + expect(await SKAdNetwork.initImpression(skan()), isFalse); + }, skip: skipOnIOS); + + test('startImpression completes (no-op) without side effects', () async { + await SKAdNetwork.startImpression(); + }, skip: skipOnIOS); + + test('endImpression completes (no-op) without side effects', () async { + await SKAdNetwork.endImpression(); + }, skip: skipOnIOS); + + test('dispose completes even when not initialised', () async { + await SKAdNetwork.dispose(); + }, skip: skipOnIOS); + }); +} diff --git a/test/src/services/sk_overlay_service_test.dart b/test/src/services/sk_overlay_service_test.dart new file mode 100644 index 00000000..00266385 --- /dev/null +++ b/test/src/services/sk_overlay_service_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart' show Skan; +import 'package:kontext_flutter_sdk/src/services/sk_overlay_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/sk_overlay'); + + Skan skan({String itunesItem = '123456'}) => Skan( + version: '4.0', + network: 'example.com', + itunesItem: itunesItem, + sourceApp: '0', + ); + + setUp(() { + SKOverlayService.isIOS = () => true; + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + SKOverlayService.isIOS = () => true; + }); + + group('present', () { + test('returns false on non-iOS without touching the channel', () async { + SKOverlayService.isIOS = () => false; + var channelCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + channelCalls++; + return true; + }); + + final ok = await SKOverlayService.present( + skan: skan(), + position: SKOverlayPosition.bottom, + ); + expect(ok, isFalse); + expect(channelCalls, 0); + }); + + test('returns false when itunesItem is empty', () async { + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return true; + }); + final ok = await SKOverlayService.present( + skan: skan(itunesItem: ''), + position: SKOverlayPosition.bottom, + ); + expect(ok, isFalse); + expect(called, isFalse); + }); + + test('forwards skan, position and dismissible to native', () async { + MethodCall? capturedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + capturedCall = call; + return true; + }); + + final ok = await SKOverlayService.present( + skan: skan(), + position: SKOverlayPosition.bottomRaised, + dismissible: false, + ); + + expect(ok, isTrue); + expect(capturedCall?.method, 'present'); + final args = capturedCall!.arguments as Map; + expect(args['position'], 'bottomRaised'); + expect(args['dismissible'], false); + expect(args['skan'], isA()); + }); + + test('returns false when native result is not true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => false); + expect( + await SKOverlayService.present(skan: skan(), position: SKOverlayPosition.bottom), + isFalse, + ); + }); + + test('swallows UNSUPPORTED_IOS platform error and returns false', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'UNSUPPORTED_IOS', message: 'iOS < 16'); + }); + expect( + await SKOverlayService.present(skan: skan(), position: SKOverlayPosition.bottom), + isFalse, + ); + }); + + test('swallows any platform exception and returns false', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'OTHER', message: 'boom'); + }); + expect( + await SKOverlayService.present(skan: skan(), position: SKOverlayPosition.bottom), + isFalse, + ); + }); + }); + + group('dismiss', () { + test('returns false on non-iOS without touching the channel', () async { + SKOverlayService.isIOS = () => false; + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return true; + }); + expect(await SKOverlayService.dismiss(), isFalse); + expect(called, isFalse); + }); + + test('calls native dismiss and returns true when native returns true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'dismiss'); + return true; + }); + expect(await SKOverlayService.dismiss(), isTrue); + }); + + test('returns false on platform exception', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E'); + }); + expect(await SKOverlayService.dismiss(), isFalse); + }); + }); +} diff --git a/test/src/services/sk_store_product_service_test.dart b/test/src/services/sk_store_product_service_test.dart new file mode 100644 index 00000000..e4a08b0e --- /dev/null +++ b/test/src/services/sk_store_product_service_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart' show Skan; +import 'package:kontext_flutter_sdk/src/services/sk_store_product_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/sk_store_product'); + + Skan skan() => Skan( + version: '4.0', + network: 'example.com', + itunesItem: '123', + sourceApp: '0', + ); + + setUp(() { + SKStoreProductService.isIOS = () => true; + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + SKStoreProductService.isIOS = () => true; + }); + + group('present', () { + test('returns false on non-iOS without touching the channel', () async { + SKStoreProductService.isIOS = () => false; + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return true; + }); + expect(await SKStoreProductService.present(skan()), isFalse); + expect(called, isFalse); + }); + + test('forwards skan JSON and returns true when native returns true', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + + expect(await SKStoreProductService.present(skan()), isTrue); + expect(captured?.method, 'present'); + expect(captured?.arguments, isA()); + }); + + test('returns false when native throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E', message: 'boom'); + }); + expect(await SKStoreProductService.present(skan()), isFalse); + }); + + test('returns false when native result is not true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + expect(await SKStoreProductService.present(skan()), isFalse); + }); + }); + + group('dismiss', () { + test('returns false on non-iOS without touching the channel', () async { + SKStoreProductService.isIOS = () => false; + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return true; + }); + expect(await SKStoreProductService.dismiss(), isFalse); + expect(called, isFalse); + }); + + test('invokes dismiss and returns true on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'dismiss'); + return true; + }); + expect(await SKStoreProductService.dismiss(), isTrue); + }); + + test('returns false on platform exception', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E'); + }); + expect(await SKStoreProductService.dismiss(), isFalse); + }); + }); +} diff --git a/test/src/services/tracking_authorization_service_test.dart b/test/src/services/tracking_authorization_service_test.dart new file mode 100644 index 00000000..f106d917 --- /dev/null +++ b/test/src/services/tracking_authorization_service_test.dart @@ -0,0 +1,60 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/services/tracking_authorization_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/tracking_authorization'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('trackingAuthorizationStatus', () { + test('returns notSupported on non-iOS hosts', () async { + // Running the test on macOS/linux means Platform.isIOS == false. + if (!Platform.isIOS) { + expect(await TrackingAuthorizationService.trackingAuthorizationStatus, + TrackingStatus.notSupported); + } + }); + + test('maps raw status ints to enum cases when channel responds (iOS path)', () async { + // We cannot change Platform.isIOS from a Dart test, but we can at least + // ensure the mapping helper would produce the expected values. On + // non-iOS hosts, trackingAuthorizationStatus short-circuits before the + // channel is touched, so this test documents the expected contract via + // the enum index ordering instead. + const cases = [ + (0, TrackingStatus.notDetermined), + (1, TrackingStatus.restricted), + (2, TrackingStatus.denied), + (3, TrackingStatus.authorized), + ]; + for (final (i, expected) in cases) { + expect(TrackingStatus.values[i], expected); + } + }); + + test('every TrackingStatus case is reachable from the values array', () { + expect(TrackingStatus.values, contains(TrackingStatus.notDetermined)); + expect(TrackingStatus.values, contains(TrackingStatus.restricted)); + expect(TrackingStatus.values, contains(TrackingStatus.denied)); + expect(TrackingStatus.values, contains(TrackingStatus.authorized)); + expect(TrackingStatus.values, contains(TrackingStatus.notSupported)); + }); + }); + + group('requestTrackingAuthorization', () { + test('returns notSupported on non-iOS hosts', () async { + if (!Platform.isIOS) { + expect(await TrackingAuthorizationService.requestTrackingAuthorization(), + TrackingStatus.notSupported); + } + }); + }); +} diff --git a/test/src/services/transparency_consent_framework_service_test.dart b/test/src/services/transparency_consent_framework_service_test.dart new file mode 100644 index 00000000..d21e4146 --- /dev/null +++ b/test/src/services/transparency_consent_framework_service_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/services/transparency_consent_framework_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('kontext_flutter_sdk/transparency_consent_framework'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('TransparencyConsentFrameworkService.getTCFData', () { + test('returns both fields when native layer provides valid data', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getTCFData') { + return { + 'gdprApplies': 1, + 'tcString': 'CONSENT-STRING', + }; + } + return null; + }); + + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, 1); + expect(data.gdprConsent, 'CONSENT-STRING'); + }); + + test('returns gdpr=0 when native reports no GDPR applicability', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'gdprApplies': 0, 'tcString': 'CS'}; + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, 0); + }); + + test('treats non-0/1 gdprApplies as null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'gdprApplies': 2, 'tcString': 'CS'}; + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, isNull); + }); + + test('treats non-int gdprApplies as null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'gdprApplies': '1', 'tcString': 'CS'}; + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, isNull); + }); + + test('treats empty tcString as null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'gdprApplies': 1, 'tcString': ''}; + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdprConsent, isNull); + }); + + test('treats non-string tcString as null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return {'gdprApplies': 1, 'tcString': 42}; + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdprConsent, isNull); + }); + + test('returns both null when native returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, isNull); + expect(data.gdprConsent, isNull); + }); + + test('returns both null when native throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'E', message: 'boom'); + }); + final data = await TransparencyConsentFrameworkService.getTCFData(); + expect(data.gdpr, isNull); + expect(data.gdprConsent, isNull); + }); + }); +} diff --git a/test/src/widgets/ads_provider_data_test.dart b/test/src/widgets/ads_provider_data_test.dart new file mode 100644 index 00000000..2a13b255 --- /dev/null +++ b/test/src/widgets/ads_provider_data_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; +import 'package:kontext_flutter_sdk/src/models/message.dart'; +import 'package:kontext_flutter_sdk/src/widgets/ads_provider_data.dart'; + +void main() { + Bid bid(String id) => Bid.fromJson({ + 'bidId': id, + 'code': 'inlineAd', + 'adDisplayPosition': 'afterAssistantMessage', + }); + + Message msg(String id, {MessageRole role = MessageRole.user}) => + Message(id: id, role: role, content: 'c', createdAt: DateTime.utc(2025)); + + AdsProviderData build({ + String adServerUrl = 'https://a.test', + List? messages, + List? bids, + bool isDisabled = false, + List? placements, + Map? otherParams, + bool readyForStreamingAssistant = false, + bool readyForStreamingUser = false, + String? lastAssistantMessageId, + String? lastUserMessageId, + String? relevantAssistantMessageId, + }) { + return AdsProviderData( + adServerUrl: adServerUrl, + messages: messages ?? const [], + bids: bids ?? const [], + isDisabled: isDisabled, + enabledPlacementCodes: placements ?? const ['inlineAd'], + otherParams: otherParams, + readyForStreamingAssistant: readyForStreamingAssistant, + readyForStreamingUser: readyForStreamingUser, + lastAssistantMessageId: lastAssistantMessageId, + lastUserMessageId: lastUserMessageId, + relevantAssistantMessageId: relevantAssistantMessageId, + setRelevantAssistantMessageId: (_) {}, + getCachedContent: (_) => null, + setCachedContent: (_, __) {}, + resetAll: () {}, + onEvent: null, + child: const SizedBox.shrink(), + ); + } + + group('AdsProviderData.of', () { + testWidgets('returns the nearest ancestor instance', (tester) async { + AdsProviderData? captured; + final data = build(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AdsProviderData( + adServerUrl: data.adServerUrl, + messages: data.messages, + bids: data.bids, + isDisabled: data.isDisabled, + enabledPlacementCodes: data.enabledPlacementCodes, + otherParams: data.otherParams, + readyForStreamingAssistant: data.readyForStreamingAssistant, + readyForStreamingUser: data.readyForStreamingUser, + lastAssistantMessageId: data.lastAssistantMessageId, + lastUserMessageId: data.lastUserMessageId, + relevantAssistantMessageId: data.relevantAssistantMessageId, + setRelevantAssistantMessageId: data.setRelevantAssistantMessageId, + getCachedContent: data.getCachedContent, + setCachedContent: data.setCachedContent, + resetAll: data.resetAll, + onEvent: data.onEvent, + child: Builder(builder: (context) { + captured = AdsProviderData.of(context); + return const SizedBox.shrink(); + }), + ), + ), + ); + + expect(captured, isNotNull); + expect(captured!.adServerUrl, 'https://a.test'); + }); + + testWidgets('returns null when no AdsProviderData ancestor exists', (tester) async { + AdsProviderData? captured; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + captured = AdsProviderData.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(captured, isNull); + }); + }); + + group('updateShouldNotify', () { + test('no-op when all fields equal', () { + final a = build(); + final b = build(); + expect(a.updateShouldNotify(b), isFalse); + }); + + test('changes to adServerUrl, isDisabled, lastUserMessageId and each flag retrigger', () { + final base = build(); + expect(base.updateShouldNotify(build(adServerUrl: 'https://other')), isTrue); + expect(base.updateShouldNotify(build(isDisabled: true)), isTrue); + expect(base.updateShouldNotify(build(readyForStreamingAssistant: true)), isTrue); + expect(base.updateShouldNotify(build(readyForStreamingUser: true)), isTrue); + expect(base.updateShouldNotify(build(lastAssistantMessageId: 'a-1')), isTrue); + expect(base.updateShouldNotify(build(relevantAssistantMessageId: 'r-1')), isTrue); + expect(base.updateShouldNotify(build(lastUserMessageId: 'u-1')), isTrue); + }); + + test('messages list change retriggers', () { + final base = build(); + final withMessage = build(messages: [msg('m-1')]); + expect(base.updateShouldNotify(withMessage), isTrue); + }); + + test('bids list change retriggers', () { + final base = build(); + final withBid = build(bids: [bid('b-1')]); + expect(base.updateShouldNotify(withBid), isTrue); + }); + + test('placementCodes list change retriggers', () { + final base = build(); + final other = build(placements: ['boxAd']); + expect(base.updateShouldNotify(other), isTrue); + }); + + test('otherParams change retriggers', () { + final base = build(otherParams: const {'theme': 'dark'}); + final changed = build(otherParams: const {'theme': 'light'}); + expect(base.updateShouldNotify(changed), isTrue); + }); + + test('deep-equal otherParams does NOT retrigger', () { + final a = build(otherParams: const {'theme': 'dark'}); + final b = build(otherParams: const {'theme': 'dark'}); + expect(a.updateShouldNotify(b), isFalse); + }); + + test('deep-equal messages list does NOT retrigger', () { + final a = build(messages: [msg('m-1')]); + final b = build(messages: [msg('m-1')]); + expect(a.updateShouldNotify(b), isFalse); + }); + }); +} diff --git a/test/src/widgets/utils/select_bid_test.dart b/test/src/widgets/utils/select_bid_test.dart new file mode 100644 index 00000000..1f493bad --- /dev/null +++ b/test/src/widgets/utils/select_bid_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/bid.dart'; +import 'package:kontext_flutter_sdk/src/models/message.dart'; +import 'package:kontext_flutter_sdk/src/widgets/ads_provider_data.dart'; +import 'package:kontext_flutter_sdk/src/widgets/utils/select_bid.dart'; + +void main() { + Bid makeBid(String code, AdDisplayPosition position) => Bid.fromJson({ + 'bidId': 'bid-$code', + 'code': code, + 'adDisplayPosition': position == AdDisplayPosition.afterAssistantMessage + ? 'afterAssistantMessage' + : 'afterUserMessage', + }); + + AdsProviderData makeData({ + required List bids, + List placementCodes = const [], + String? lastAssistantMessageId, + String? relevantAssistantMessageId, + String? lastUserMessageId, + bool readyForStreamingAssistant = false, + bool readyForStreamingUser = false, + }) { + return AdsProviderData( + adServerUrl: 'https://ads.example', + messages: const [], + bids: bids, + isDisabled: false, + enabledPlacementCodes: placementCodes, + readyForStreamingAssistant: readyForStreamingAssistant, + readyForStreamingUser: readyForStreamingUser, + lastAssistantMessageId: lastAssistantMessageId, + lastUserMessageId: lastUserMessageId, + relevantAssistantMessageId: relevantAssistantMessageId, + setRelevantAssistantMessageId: (_) {}, + getCachedContent: (_) => null, + setCachedContent: (_, __) {}, + resetAll: () {}, + onEvent: null, + child: const SizedBox.shrink(), + ); + } + + group('selectBid', () { + test('returns null when placement code is not enabled', () { + final data = makeData( + bids: [makeBid('inlineAd', AdDisplayPosition.afterAssistantMessage)], + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), isNull); + }); + + test('returns null when no bid matches the code', () { + final data = makeData( + bids: [makeBid('boxAd', AdDisplayPosition.afterAssistantMessage)], + placementCodes: const ['inlineAd'], + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), isNull); + }); + + test('returns the matching bid when the afterAssistant conditions align', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterAssistantMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastAssistantMessageId: 'm-1', + readyForStreamingAssistant: true, + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), bid); + }); + + test('prefers relevantAssistantMessageId over lastAssistantMessageId', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterAssistantMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastAssistantMessageId: 'm-2', + relevantAssistantMessageId: 'm-1', + readyForStreamingAssistant: true, + ); + // Only m-1 matches — m-2 should not because relevant overrides last. + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), bid); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-2'), isNull); + }); + + test('returns null when assistant streaming is not ready', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterAssistantMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastAssistantMessageId: 'm-1', + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), isNull); + }); + + test('returns null for a mismatched assistant messageId', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterAssistantMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastAssistantMessageId: 'm-1', + readyForStreamingAssistant: true, + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'other'), isNull); + }); + + test('afterUser bid requires lastUserMessageId + readyForStreamingUser', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterUserMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastUserMessageId: 'u-1', + readyForStreamingUser: true, + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'u-1'), bid); + }); + + test('afterUser bid with readyForStreamingUser=false returns null', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterUserMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastUserMessageId: 'u-1', + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'u-1'), isNull); + }); + + test('afterUser bid with mismatched user messageId returns null', () { + final bid = makeBid('inlineAd', AdDisplayPosition.afterUserMessage); + final data = makeData( + bids: [bid], + placementCodes: const ['inlineAd'], + lastUserMessageId: 'u-1', + readyForStreamingUser: true, + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'u-2'), isNull); + }); + + test('picks the first bid for a code when multiple exist', () { + final first = Bid.fromJson({ + 'bidId': 'bid-1', + 'code': 'inlineAd', + 'adDisplayPosition': 'afterAssistantMessage', + }); + final second = Bid.fromJson({ + 'bidId': 'bid-2', + 'code': 'inlineAd', + 'adDisplayPosition': 'afterAssistantMessage', + }); + final data = makeData( + bids: [first, second], + placementCodes: const ['inlineAd'], + lastAssistantMessageId: 'm-1', + readyForStreamingAssistant: true, + ); + expect(selectBid(data, code: 'inlineAd', messageId: 'm-1'), first); + }); + }); +} diff --git a/test/src/widgets/utils/use_last_messages_test.dart b/test/src/widgets/utils/use_last_messages_test.dart new file mode 100644 index 00000000..965098be --- /dev/null +++ b/test/src/widgets/utils/use_last_messages_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kontext_flutter_sdk/src/models/message.dart'; +import 'package:kontext_flutter_sdk/src/widgets/utils/use_last_messages.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Message user(String id) => Message(id: id, role: MessageRole.user, content: 'u', createdAt: DateTime.utc(2025)); + Message assistant(String id) => + Message(id: id, role: MessageRole.assistant, content: 'a', createdAt: DateTime.utc(2025)); + + testWidgets('empty messages list resets every setter to null/false', (tester) async { + final readyCalls = []; + final assistantCalls = []; + final userCalls = []; + final relevantCalls = []; + + await tester.pumpWidget(HookBuilder(builder: (context) { + useLastMessages( + const [], + lastUserMessageId: null, + setReadyForStreamingAssistant: readyCalls.add, + setLastAssistantMessageId: assistantCalls.add, + setLastUserMessageId: userCalls.add, + setRelevantAssistantMessageId: relevantCalls.add, + ); + return const SizedBox.shrink(); + })); + + expect(readyCalls, [false]); + expect(assistantCalls, [null]); + expect(userCalls, [null]); + expect(relevantCalls, [null]); + }); + + testWidgets('emits last user and last assistant ids, readyForStreaming=true for assistant-last', (tester) async { + var ready = false; + String? lastA, lastU, relevant; + + await tester.pumpWidget(HookBuilder(builder: (context) { + useLastMessages( + [user('u-1'), assistant('a-1')], + lastUserMessageId: null, + setReadyForStreamingAssistant: (v) => ready = v, + setLastAssistantMessageId: (v) => lastA = v, + setLastUserMessageId: (v) => lastU = v, + setRelevantAssistantMessageId: (v) => relevant = v, + ); + return const SizedBox.shrink(); + })); + + expect(ready, isTrue); // last message is assistant + expect(lastA, 'a-1'); + expect(lastU, 'u-1'); + // last message is assistant → relevant unaffected (not reset). + expect(relevant, isNull); + }); + + testWidgets('readyForStreaming=false when last message is user', (tester) async { + var ready = true; // start true to observe it flipping to false + + await tester.pumpWidget(HookBuilder(builder: (context) { + useLastMessages( + [assistant('a-1'), user('u-1')], + lastUserMessageId: null, + setReadyForStreamingAssistant: (v) => ready = v, + setLastAssistantMessageId: (_) {}, + setLastUserMessageId: (_) {}, + setRelevantAssistantMessageId: (_) {}, + ); + return const SizedBox.shrink(); + })); + + expect(ready, isFalse); + }); + + testWidgets('resets relevantAssistantMessageId when a new user message appears', (tester) async { + final relevantCalls = []; + + await tester.pumpWidget(HookBuilder(builder: (context) { + useLastMessages( + [user('u-1')], + // Simulate the previous-iteration id being different from the new one. + lastUserMessageId: 'u-0', + setReadyForStreamingAssistant: (_) {}, + setLastAssistantMessageId: (_) {}, + setLastUserMessageId: (_) {}, + setRelevantAssistantMessageId: relevantCalls.add, + ); + return const SizedBox.shrink(); + })); + + // The reset only fires when last is a user AND the id changed. + expect(relevantCalls, contains(null)); + }); + + testWidgets('does NOT reset relevantAssistantMessageId when the last user id is the same', (tester) async { + final relevantCalls = []; + + await tester.pumpWidget(HookBuilder(builder: (context) { + useLastMessages( + [user('u-1')], + lastUserMessageId: 'u-1', // same as current last user + setReadyForStreamingAssistant: (_) {}, + setLastAssistantMessageId: (_) {}, + setLastUserMessageId: (_) {}, + setRelevantAssistantMessageId: relevantCalls.add, + ); + return const SizedBox.shrink(); + })); + + // Called 0 times — reset gate is gated on id change. + expect(relevantCalls, isEmpty); + }); +}