diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b4e9d097..6b804ee4 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1826,7 +1826,10 @@ class PlatformBridge { 'options': optionsJson, 'request_id': requestId, }); - final decoded = _decodeMapListResult(result, 'customSearchWithExtension'); + final decoded = await _decodeMapListResultAsync( + result, + 'customSearchWithExtension', + ); if (generation == _lookupCacheGeneration && _customSearchInFlight[cacheKey]?.requestId == requestId) { _putMemoryCachedMapList( diff --git a/test/platform_bridge_custom_search_test.dart b/test/platform_bridge_custom_search_test.dart new file mode 100644 index 00000000..611d0b29 --- /dev/null +++ b/test/platform_bridge_custom_search_test.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + for (final format in ['small-json', 'large-json', 'native-list']) { + test('custom search decodes and caches $format results', () async { + final rows = List.generate( + format == 'large-json' ? 3000 : 2, + (index) => { + 'id': '$index', + 'name': 'Lagu $index — 日本語', + 'artist_name': 'Artist', + 'album_name': 'Album', + 'duration': 123000, + }, + ); + final json = jsonEncode(rows); + if (format == 'large-json') { + expect(json.length, greaterThan(128 * 1024)); + } + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'customSearchWithExtension'); + calls++; + return format == 'native-list' ? rows : json; + }); + + final result = await PlatformBridge.customSearchWithExtension( + 'decode-test-$format', + 'query', + ); + expect(result, rows); + result.first['name'] = 'Changed by caller'; + final cached = await PlatformBridge.customSearchWithExtension( + 'decode-test-$format', + 'query', + ); + expect(cached, rows); + expect(calls, 1); + }); + } + + test( + 'large cancelled response cannot replace the latest cached search', + () async { + final pending = >[]; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'cancelExtensionRequest') return null; + expect(call.method, 'customSearchWithExtension'); + final completer = Completer(); + pending.add(completer); + return completer.future; + }); + Future>> search(String query) => + PlatformBridge.customSearchWithExtension( + 'large-cancel-test', + query, + cancelPrevious: true, + ); + final oldA = search('A'); + await Future.delayed(Duration.zero); + final b = search('B'); + await Future.delayed(Duration.zero); + final latestA = search('A'); + await Future.delayed(Duration.zero); + expect(pending, hasLength(3)); + + pending[2].complete('[{"id":"latest"}]'); + expect((await latestA).single['id'], 'latest'); + final oldRows = List.generate( + 3000, + (index) => { + 'id': 'old-$index', + 'name': 'Previous search result that must not replace the new cache', + }, + ); + final oldJson = jsonEncode(oldRows); + expect(oldJson.length, greaterThan(128 * 1024)); + pending[0].complete(oldJson); + pending[1].complete('[]'); + await Future.wait([oldA, b]); + + expect((await search('A')).single['id'], 'latest'); + expect(pending, hasLength(3)); + }, + ); + + test('invalid results fail without poisoning the retry cache', () async { + var calls = 0; + messenger.setMockMethodCallHandler(channel, (_) async { + return ++calls == 1 ? '[42]' : '[{"id":"valid"}]'; + }); + Future>> search() => + PlatformBridge.customSearchWithExtension( + 'invalid-decode-test', + 'query', + ); + await expectLater(search(), throwsFormatException); + expect((await search()).single['id'], 'valid'); + expect(calls, 2); + }); +}