[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: suites for core utils and notif grouping

+668
+59
test/src/core/utils/did_utils_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:spark/src/core/utils/did_utils.dart'; 3 + 4 + void main() { 5 + group('DidUtils', () { 6 + group('buildDidDocumentUrl', () { 7 + test('builds plc directory URL for did:plc', () { 8 + final uri = DidUtils.buildDidDocumentUrl('did:plc:abc123'); 9 + expect(uri.toString(), 'https://plc.directory/did:plc:abc123'); 10 + }); 11 + 12 + test('builds well-known URL for did:web with domain only', () { 13 + final uri = DidUtils.buildDidDocumentUrl('did:web:example.com'); 14 + expect(uri.toString(), 'https://example.com/.well-known/did.json'); 15 + }); 16 + 17 + test('builds path-based URL for did:web with path segments', () { 18 + final uri = DidUtils.buildDidDocumentUrl( 19 + 'did:web:example.com:user:alice', 20 + ); 21 + expect(uri.toString(), 'https://example.com/user/alice/did.json'); 22 + }); 23 + 24 + test('handles did:web with percent-encoded domain (port number)', () { 25 + final uri = DidUtils.buildDidDocumentUrl('did:web:example%3A8080'); 26 + expect(uri.toString(), 'https://example:8080/.well-known/did.json'); 27 + }); 28 + 29 + test('builds path-based URL for did:web with deep path', () { 30 + final uri = DidUtils.buildDidDocumentUrl( 31 + 'did:web:example.com:org:department:team', 32 + ); 33 + expect( 34 + uri.toString(), 35 + 'https://example.com/org/department/team/did.json', 36 + ); 37 + }); 38 + 39 + test('defaults to plc directory for unknown DID methods', () { 40 + final uri = DidUtils.buildDidDocumentUrl('did:key:z6MkhaXg...'); 41 + expect(uri.toString(), 'https://plc.directory/did:key:z6MkhaXg...'); 42 + }); 43 + 44 + test('throws ArgumentError for empty did:web domain', () { 45 + expect( 46 + () => DidUtils.buildDidDocumentUrl('did:web:'), 47 + throwsArgumentError, 48 + ); 49 + }); 50 + 51 + test('throws ArgumentError for did:web with only colons', () { 52 + expect( 53 + () => DidUtils.buildDidDocumentUrl('did:web:::'), 54 + throwsArgumentError, 55 + ); 56 + }); 57 + }); 58 + }); 59 + }
+259
test/src/core/utils/label_utils_test.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; 5 + import 'package:spark/src/core/utils/label_utils.dart'; 6 + 7 + void main() { 8 + group('LabelUtils', () { 9 + group('getLabelPreferenceFromPrefs', () { 10 + test('maps visibility to correct blurs/severity/setting/adultOnly', () { 11 + final prefs = Preferences.internal( 12 + preferences: [], 13 + contentLabelPrefs: [ 14 + ContentLabelPref( 15 + labelerDid: 'did:plc:labeler', 16 + label: 'porn', 17 + visibility: 'warn', 18 + ), 19 + ContentLabelPref( 20 + labelerDid: 'did:plc:labeler', 21 + label: 'gore', 22 + visibility: 'hide', 23 + ), 24 + ContentLabelPref( 25 + labelerDid: 'did:plc:labeler', 26 + label: 'nudity', 27 + visibility: 'ignore', 28 + ), 29 + ], 30 + ); 31 + 32 + final warn = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'porn')!; 33 + expect(warn.blurs, Blurs.media); 34 + expect(warn.severity, Severity.alert); 35 + expect(warn.setting, Setting.warn); 36 + expect(warn.adultOnly, isTrue); 37 + 38 + final hide = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'gore')!; 39 + expect(hide.blurs, Blurs.content); 40 + expect(hide.severity, Severity.alert); 41 + expect(hide.setting, Setting.hide); 42 + expect(hide.adultOnly, isFalse); 43 + 44 + final ignore = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'nudity')!; 45 + expect(ignore.blurs, Blurs.none); 46 + expect(ignore.severity, Severity.none); 47 + expect(ignore.setting, Setting.ignore); 48 + }); 49 + 50 + test('returns null when label not found', () { 51 + final prefs = Preferences.internal(preferences: []); 52 + expect( 53 + LabelUtils.getLabelPreferenceFromPrefs(prefs, 'unknown'), 54 + isNull, 55 + ); 56 + }); 57 + 58 + test('returns null when contentLabelPrefs is null', () { 59 + final prefs = Preferences.internal(preferences: []); 60 + expect(LabelUtils.getLabelPreferenceFromPrefs(prefs, 'porn'), isNull); 61 + }); 62 + }); 63 + 64 + group('shouldShowWarning', () { 65 + test('returns true only when severity=alert and setting=warn', () { 66 + final warnPrefs = Preferences.internal( 67 + preferences: [], 68 + contentLabelPrefs: [ 69 + ContentLabelPref( 70 + labelerDid: 'did:plc:l', 71 + label: 'porn', 72 + visibility: 'warn', 73 + ), 74 + ], 75 + ); 76 + final hidePrefs = Preferences.internal( 77 + preferences: [], 78 + contentLabelPrefs: [ 79 + ContentLabelPref( 80 + labelerDid: 'did:plc:l', 81 + label: 'porn', 82 + visibility: 'hide', 83 + ), 84 + ], 85 + ); 86 + final noMatchPrefs = Preferences.internal(preferences: []); 87 + 88 + final label = Label( 89 + src: 'did:plc:l', 90 + uri: 'at://test', 91 + val: 'porn', 92 + cts: DateTime.now(), 93 + ); 94 + 95 + expect(LabelUtils.shouldShowWarning(warnPrefs, [label]), isTrue); 96 + expect(LabelUtils.shouldShowWarning(hidePrefs, [label]), isFalse); 97 + expect(LabelUtils.shouldShowWarning(noMatchPrefs, [label]), isFalse); 98 + expect(LabelUtils.shouldShowWarning(warnPrefs, []), isFalse); 99 + }); 100 + }); 101 + 102 + group('shouldBlurContent', () { 103 + test('blurs on hide (content) and warn (media)', () { 104 + final hidePrefs = Preferences.internal( 105 + preferences: [], 106 + contentLabelPrefs: [ 107 + ContentLabelPref( 108 + labelerDid: 'did:plc:l', 109 + label: 'porn', 110 + visibility: 'hide', 111 + ), 112 + ], 113 + ); 114 + final warnPrefs = Preferences.internal( 115 + preferences: [], 116 + contentLabelPrefs: [ 117 + ContentLabelPref( 118 + labelerDid: 'did:plc:l', 119 + label: 'sexual', 120 + visibility: 'warn', 121 + ), 122 + ], 123 + ); 124 + final ignorePrefs = Preferences.internal( 125 + preferences: [], 126 + contentLabelPrefs: [ 127 + ContentLabelPref( 128 + labelerDid: 'did:plc:l', 129 + label: 'nudity', 130 + visibility: 'ignore', 131 + ), 132 + ], 133 + ); 134 + 135 + final label = (String val) => Label( 136 + src: 'did:plc:l', 137 + uri: 'at://test', 138 + val: val, 139 + cts: DateTime.now(), 140 + ); 141 + 142 + expect( 143 + LabelUtils.shouldBlurContent(hidePrefs, [label('porn')]), 144 + isTrue, 145 + ); 146 + expect( 147 + LabelUtils.shouldBlurContent(warnPrefs, [label('sexual')]), 148 + isTrue, 149 + ); 150 + expect( 151 + LabelUtils.shouldBlurContent(ignorePrefs, [label('nudity')]), 152 + isFalse, 153 + ); 154 + }); 155 + }); 156 + 157 + group('shouldHideContent', () { 158 + test('hides when setting=hide or adultOnly=true', () { 159 + final hidePrefs = Preferences.internal( 160 + preferences: [], 161 + contentLabelPrefs: [ 162 + ContentLabelPref( 163 + labelerDid: 'did:plc:l', 164 + label: 'gore', 165 + visibility: 'hide', 166 + ), 167 + ], 168 + ); 169 + final warnAdultPrefs = Preferences.internal( 170 + preferences: [], 171 + contentLabelPrefs: [ 172 + ContentLabelPref( 173 + labelerDid: 'did:plc:l', 174 + label: 'porn', 175 + visibility: 'warn', 176 + ), 177 + ], 178 + ); 179 + final warnNonAdultPrefs = Preferences.internal( 180 + preferences: [], 181 + contentLabelPrefs: [ 182 + ContentLabelPref( 183 + labelerDid: 'did:plc:l', 184 + label: 'gore', 185 + visibility: 'warn', 186 + ), 187 + ], 188 + ); 189 + 190 + final label = (String val) => Label( 191 + src: 'did:plc:l', 192 + uri: 'at://test', 193 + val: val, 194 + cts: DateTime.now(), 195 + ); 196 + 197 + expect( 198 + LabelUtils.shouldHideContent(hidePrefs, [label('gore')]), 199 + isTrue, 200 + ); 201 + expect( 202 + LabelUtils.shouldHideContent(warnAdultPrefs, [label('porn')]), 203 + isTrue, 204 + ); 205 + expect( 206 + LabelUtils.shouldHideContent(warnNonAdultPrefs, [label('gore')]), 207 + isFalse, 208 + ); 209 + expect( 210 + LabelUtils.shouldHideContent( 211 + Preferences.internal(preferences: []), 212 + [], 213 + ), 214 + isFalse, 215 + ); 216 + }); 217 + }); 218 + 219 + group('getWarningLabels', () { 220 + test('returns only labels with severity=alert and setting=warn', () { 221 + final prefs = Preferences.internal( 222 + preferences: [], 223 + contentLabelPrefs: [ 224 + ContentLabelPref( 225 + labelerDid: 'did:plc:l', 226 + label: 'gore', 227 + visibility: 'warn', 228 + ), 229 + ContentLabelPref( 230 + labelerDid: 'did:plc:l', 231 + label: 'porn', 232 + visibility: 'warn', 233 + ), 234 + ContentLabelPref( 235 + labelerDid: 'did:plc:l', 236 + label: 'nudity', 237 + visibility: 'ignore', 238 + ), 239 + ], 240 + ); 241 + 242 + final labels = ['gore', 'porn', 'nudity'] 243 + .map( 244 + (v) => Label( 245 + src: 'did:plc:l', 246 + uri: 'at://test', 247 + val: v, 248 + cts: DateTime.now(), 249 + ), 250 + ) 251 + .toList(); 252 + 253 + final result = LabelUtils.getWarningLabels(prefs, labels); 254 + expect(result, containsAll(['gore', 'porn'])); 255 + expect(result, isNot(contains('nudity'))); 256 + }); 257 + }); 258 + }); 259 + }
+134
test/src/core/utils/text_formatter_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:spark/src/core/utils/text_formatter.dart'; 3 + 4 + void main() { 5 + group('TextFormatter', () { 6 + group('formatCount', () { 7 + test('returns 0 for null and unexpected types', () { 8 + expect(TextFormatter.formatCount(null), '0'); 9 + expect(TextFormatter.formatCount(3.14), '0'); 10 + expect(TextFormatter.formatCount('abc'), '0'); 11 + }); 12 + 13 + test('formats plain numbers as-is', () { 14 + expect(TextFormatter.formatCount(0), '0'); 15 + expect(TextFormatter.formatCount(42), '42'); 16 + expect(TextFormatter.formatCount(999), '999'); 17 + }); 18 + 19 + test('formats thousands with K suffix', () { 20 + expect(TextFormatter.formatCount(1000), '1K'); 21 + expect(TextFormatter.formatCount(1500), '1.5K'); 22 + expect(TextFormatter.formatCount(999000), '999K'); 23 + }); 24 + 25 + test('formats millions with M suffix', () { 26 + expect(TextFormatter.formatCount(1000000), '1M'); 27 + expect(TextFormatter.formatCount(1500000), '1.5M'); 28 + expect(TextFormatter.formatCount(2300000), '2.3M'); 29 + }); 30 + 31 + test('handles string input', () { 32 + expect(TextFormatter.formatCount('42'), '42'); 33 + expect(TextFormatter.formatCount('1000'), '1K'); 34 + }); 35 + }); 36 + 37 + group('findUsernameMatches', () { 38 + test('finds @username matches', () { 39 + final matches = TextFormatter.findUsernameMatches('hello @alice world'); 40 + expect(matches.length, 1); 41 + expect(matches.first.group(1), 'alice'); 42 + }); 43 + 44 + test('finds @handle.domain matches', () { 45 + final matches = TextFormatter.findUsernameMatches( 46 + 'check @alice.bsky.social', 47 + ); 48 + expect(matches.length, 1); 49 + expect(matches.first.group(0), '@alice.bsky.social'); 50 + }); 51 + 52 + test('finds multiple @mentions', () { 53 + final matches = TextFormatter.findUsernameMatches('@alice and @bob'); 54 + expect(matches.length, 2); 55 + }); 56 + 57 + test('returns empty list when no mentions', () { 58 + expect(TextFormatter.findUsernameMatches('no mentions here'), isEmpty); 59 + }); 60 + 61 + test('does not match @ alone', () { 62 + expect(TextFormatter.findUsernameMatches('hello @ world'), isEmpty); 63 + }); 64 + }); 65 + 66 + group('extractUrls', () { 67 + test('extracts https URLs', () { 68 + final urls = TextFormatter.extractUrls( 69 + 'visit https://example.com for more', 70 + ); 71 + expect(urls, contains('https://example.com')); 72 + }); 73 + 74 + test('extracts http URLs', () { 75 + final urls = TextFormatter.extractUrls( 76 + 'visit http://example.com for more', 77 + ); 78 + expect(urls, contains('http://example.com')); 79 + }); 80 + 81 + test('does not extract emails as URLs', () { 82 + expect(TextFormatter.extractUrls('contact user@example.com'), isEmpty); 83 + }); 84 + 85 + test('returns empty list for text without URLs', () { 86 + expect(TextFormatter.extractUrls('no urls here'), isEmpty); 87 + }); 88 + 89 + test('extracts known-TLD domains without http prefix', () { 90 + final urls = TextFormatter.extractUrls('visit example.com today'); 91 + expect(urls, contains('example.com')); 92 + }); 93 + }); 94 + 95 + group('charIndexToByteIndex / byteIndexToCharIndex', () { 96 + test('round-trips correctly for ASCII', () { 97 + const text = 'hello world'; 98 + for (int i = 0; i <= text.length; i++) { 99 + final byteIndex = TextFormatter.charIndexToByteIndex(text, i); 100 + final charIndex = TextFormatter.byteIndexToCharIndex(text, byteIndex); 101 + expect(charIndex, i); 102 + } 103 + }); 104 + 105 + test('round-trips correctly for multi-byte text', () { 106 + const text = 'café'; 107 + // c=0, a=1, f=2, é=3 (char indices) → 0,1,2,4 (byte indices) 108 + for (final i in [0, 1, 2, 3, 4]) { 109 + final byteIndex = TextFormatter.charIndexToByteIndex(text, i); 110 + final charIndex = TextFormatter.byteIndexToCharIndex(text, byteIndex); 111 + expect(charIndex, i); 112 + } 113 + }); 114 + 115 + test('handles boundary conditions', () { 116 + expect(TextFormatter.charIndexToByteIndex('hello', -1), 0); 117 + expect(TextFormatter.charIndexToByteIndex('hello', 100), 5); 118 + expect(TextFormatter.byteIndexToCharIndex('hello', -1), 0); 119 + expect(TextFormatter.byteIndexToCharIndex('hello', 0), 0); 120 + expect(TextFormatter.byteIndexToCharIndex('hello', 100), 5); 121 + expect(TextFormatter.charIndexToByteIndex('', 0), 0); 122 + }); 123 + }); 124 + 125 + group('byteLength', () { 126 + test('returns correct byte length for ASCII, multi-byte, and empty', () { 127 + expect(TextFormatter.byteLength('hello'), 5); 128 + expect(TextFormatter.byteLength('café'), 5); 129 + expect(TextFormatter.byteLength('🎉'), 4); 130 + expect(TextFormatter.byteLength(''), 0); 131 + }); 132 + }); 133 + }); 134 + }
+216
test/src/features/notifications/models/grouped_notification_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 5 + import 'package:spark/src/features/notifications/models/grouped_notification.dart'; 6 + 7 + Notification _makeNotification({ 8 + required String did, 9 + required String reason, 10 + bool isRead = false, 11 + DateTime? indexedAt, 12 + AtUri? reasonSubject, 13 + AtUri? following, 14 + }) { 15 + return Notification( 16 + uri: reasonSubject ?? AtUri('at://did:plc:test/so.sprk.feed.post/test'), 17 + cid: 'test-cid', 18 + author: ProfileViewBasic( 19 + did: did, 20 + handle: '$did.handle', 21 + viewer: ActorViewer(following: following), 22 + ), 23 + reason: reason, 24 + record: {}, 25 + isRead: isRead, 26 + indexedAt: indexedAt ?? DateTime(2026, 1, 1), 27 + reasonSubject: reasonSubject, 28 + ); 29 + } 30 + 31 + void main() { 32 + group('groupNotifications', () { 33 + test('returns empty list for empty input', () { 34 + expect(groupNotifications([]), isEmpty); 35 + }); 36 + 37 + test('groups follows together', () { 38 + final results = groupNotifications([ 39 + _makeNotification( 40 + did: 'did:plc:alice', 41 + reason: 'follow', 42 + indexedAt: DateTime(2026, 1, 3), 43 + ), 44 + _makeNotification( 45 + did: 'did:plc:bob', 46 + reason: 'follow', 47 + indexedAt: DateTime(2026, 1, 2), 48 + ), 49 + ]); 50 + 51 + expect(results, hasLength(1)); 52 + expect(results.first.reason, 'follow'); 53 + expect(results.first.actorCount, 2); 54 + expect(results.first.primaryNotification.author.did, 'did:plc:alice'); 55 + }); 56 + 57 + test('separates follow-backs from regular follows', () { 58 + final results = groupNotifications([ 59 + _makeNotification( 60 + did: 'did:plc:alice', 61 + reason: 'follow', 62 + indexedAt: DateTime(2026, 1, 3), 63 + ), 64 + _makeNotification( 65 + did: 'did:plc:bob', 66 + reason: 'follow', 67 + following: AtUri('at://did:plc:bob/app.bsky.graph.follow/xyz'), 68 + indexedAt: DateTime(2026, 1, 2), 69 + ), 70 + ]); 71 + 72 + expect(results, hasLength(2)); 73 + }); 74 + 75 + test('groups likes by reasonSubject', () { 76 + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); 77 + final results = groupNotifications([ 78 + _makeNotification( 79 + did: 'did:plc:alice', 80 + reason: 'like', 81 + reasonSubject: subject, 82 + indexedAt: DateTime(2026, 1, 3), 83 + ), 84 + _makeNotification( 85 + did: 'did:plc:bob', 86 + reason: 'like', 87 + reasonSubject: subject, 88 + indexedAt: DateTime(2026, 1, 2), 89 + ), 90 + ]); 91 + 92 + expect(results, hasLength(1)); 93 + expect(results.first.actorCount, 2); 94 + }); 95 + 96 + test('separates likes on different posts', () { 97 + final subject1 = AtUri('at://did:plc:author/so.sprk.feed.post/1'); 98 + final subject2 = AtUri('at://did:plc:author/so.sprk.feed.post/2'); 99 + final results = groupNotifications([ 100 + _makeNotification( 101 + did: 'did:plc:alice', 102 + reason: 'like', 103 + reasonSubject: subject1, 104 + indexedAt: DateTime(2026, 1, 3), 105 + ), 106 + _makeNotification( 107 + did: 'did:plc:bob', 108 + reason: 'like', 109 + reasonSubject: subject2, 110 + indexedAt: DateTime(2026, 1, 2), 111 + ), 112 + ]); 113 + 114 + expect(results, hasLength(2)); 115 + }); 116 + 117 + test('does not group likes without reasonSubject', () { 118 + final results = groupNotifications([ 119 + _makeNotification(did: 'did:plc:alice', reason: 'like'), 120 + _makeNotification(did: 'did:plc:bob', reason: 'like'), 121 + ]); 122 + 123 + expect(results, hasLength(2)); 124 + }); 125 + 126 + test('groups reposts by reasonSubject', () { 127 + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); 128 + final results = groupNotifications([ 129 + _makeNotification( 130 + did: 'did:plc:alice', 131 + reason: 'repost', 132 + reasonSubject: subject, 133 + ), 134 + _makeNotification( 135 + did: 'did:plc:bob', 136 + reason: 'repost', 137 + reasonSubject: subject, 138 + ), 139 + ]); 140 + 141 + expect(results, hasLength(1)); 142 + expect(results.first.actorCount, 2); 143 + }); 144 + 145 + test('does not group replies', () { 146 + final results = groupNotifications([ 147 + _makeNotification(did: 'did:plc:alice', reason: 'reply'), 148 + _makeNotification(did: 'did:plc:bob', reason: 'reply'), 149 + ]); 150 + 151 + expect(results, hasLength(2)); 152 + }); 153 + 154 + test('does not group mentions', () { 155 + final results = groupNotifications([ 156 + _makeNotification(did: 'did:plc:alice', reason: 'mention'), 157 + _makeNotification(did: 'did:plc:bob', reason: 'mention'), 158 + ]); 159 + 160 + expect(results, hasLength(2)); 161 + }); 162 + 163 + test('sorts groups by most recent notification', () { 164 + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); 165 + final results = groupNotifications([ 166 + _makeNotification( 167 + did: 'did:plc:bob', 168 + reason: 'like', 169 + reasonSubject: subject, 170 + indexedAt: DateTime(2026, 1, 1), 171 + ), 172 + _makeNotification( 173 + did: 'did:plc:alice', 174 + reason: 'follow', 175 + indexedAt: DateTime(2026, 1, 5), 176 + ), 177 + _makeNotification( 178 + did: 'did:plc:carol', 179 + reason: 'reply', 180 + indexedAt: DateTime(2026, 1, 3), 181 + ), 182 + ]); 183 + 184 + expect(results.first.reason, 'follow'); 185 + }); 186 + 187 + test('handles mixed notification types', () { 188 + final likeSubject = AtUri('at://did:plc:author/so.sprk.feed.post/1'); 189 + final results = groupNotifications([ 190 + _makeNotification( 191 + did: 'did:plc:alice', 192 + reason: 'follow', 193 + indexedAt: DateTime(2026, 1, 5), 194 + ), 195 + _makeNotification( 196 + did: 'did:plc:bob', 197 + reason: 'follow', 198 + indexedAt: DateTime(2026, 1, 4), 199 + ), 200 + _makeNotification( 201 + did: 'did:plc:carol', 202 + reason: 'like', 203 + reasonSubject: likeSubject, 204 + indexedAt: DateTime(2026, 1, 3), 205 + ), 206 + _makeNotification( 207 + did: 'did:plc:dave', 208 + reason: 'reply', 209 + indexedAt: DateTime(2026, 1, 2), 210 + ), 211 + ]); 212 + 213 + expect(results, hasLength(3)); 214 + }); 215 + }); 216 + }