diff --git a/test/src/core/utils/did_utils_test.dart b/test/src/core/utils/did_utils_test.dart new file mode 100644 index 00000000..9fe1d836 --- /dev/null +++ b/test/src/core/utils/did_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/utils/did_utils.dart'; + +void main() { + group('DidUtils', () { + group('buildDidDocumentUrl', () { + test('builds plc directory URL for did:plc', () { + final uri = DidUtils.buildDidDocumentUrl('did:plc:abc123'); + expect(uri.toString(), 'https://plc.directory/did:plc:abc123'); + }); + + test('builds well-known URL for did:web with domain only', () { + final uri = DidUtils.buildDidDocumentUrl('did:web:example.com'); + expect(uri.toString(), 'https://example.com/.well-known/did.json'); + }); + + test('builds path-based URL for did:web with path segments', () { + final uri = DidUtils.buildDidDocumentUrl( + 'did:web:example.com:user:alice', + ); + expect(uri.toString(), 'https://example.com/user/alice/did.json'); + }); + + test('handles did:web with percent-encoded domain (port number)', () { + final uri = DidUtils.buildDidDocumentUrl('did:web:example%3A8080'); + expect(uri.toString(), 'https://example:8080/.well-known/did.json'); + }); + + test('builds path-based URL for did:web with deep path', () { + final uri = DidUtils.buildDidDocumentUrl( + 'did:web:example.com:org:department:team', + ); + expect( + uri.toString(), + 'https://example.com/org/department/team/did.json', + ); + }); + + test('defaults to plc directory for unknown DID methods', () { + final uri = DidUtils.buildDidDocumentUrl('did:key:z6MkhaXg...'); + expect(uri.toString(), 'https://plc.directory/did:key:z6MkhaXg...'); + }); + + test('throws ArgumentError for empty did:web domain', () { + expect( + () => DidUtils.buildDidDocumentUrl('did:web:'), + throwsArgumentError, + ); + }); + + test('throws ArgumentError for did:web with only colons', () { + expect( + () => DidUtils.buildDidDocumentUrl('did:web:::'), + throwsArgumentError, + ); + }); + }); + }); +} diff --git a/test/src/core/utils/label_utils_test.dart b/test/src/core/utils/label_utils_test.dart new file mode 100644 index 00000000..d39c7f81 --- /dev/null +++ b/test/src/core/utils/label_utils_test.dart @@ -0,0 +1,259 @@ +import 'package:atproto/com_atproto_label_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; +import 'package:spark/src/core/utils/label_utils.dart'; + +void main() { + group('LabelUtils', () { + group('getLabelPreferenceFromPrefs', () { + test('maps visibility to correct blurs/severity/setting/adultOnly', () { + final prefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:labeler', + label: 'porn', + visibility: 'warn', + ), + ContentLabelPref( + labelerDid: 'did:plc:labeler', + label: 'gore', + visibility: 'hide', + ), + ContentLabelPref( + labelerDid: 'did:plc:labeler', + label: 'nudity', + visibility: 'ignore', + ), + ], + ); + + final warn = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'porn')!; + expect(warn.blurs, Blurs.media); + expect(warn.severity, Severity.alert); + expect(warn.setting, Setting.warn); + expect(warn.adultOnly, isTrue); + + final hide = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'gore')!; + expect(hide.blurs, Blurs.content); + expect(hide.severity, Severity.alert); + expect(hide.setting, Setting.hide); + expect(hide.adultOnly, isFalse); + + final ignore = LabelUtils.getLabelPreferenceFromPrefs(prefs, 'nudity')!; + expect(ignore.blurs, Blurs.none); + expect(ignore.severity, Severity.none); + expect(ignore.setting, Setting.ignore); + }); + + test('returns null when label not found', () { + final prefs = Preferences.internal(preferences: []); + expect( + LabelUtils.getLabelPreferenceFromPrefs(prefs, 'unknown'), + isNull, + ); + }); + + test('returns null when contentLabelPrefs is null', () { + final prefs = Preferences.internal(preferences: []); + expect(LabelUtils.getLabelPreferenceFromPrefs(prefs, 'porn'), isNull); + }); + }); + + group('shouldShowWarning', () { + test('returns true only when severity=alert and setting=warn', () { + final warnPrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'porn', + visibility: 'warn', + ), + ], + ); + final hidePrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'porn', + visibility: 'hide', + ), + ], + ); + final noMatchPrefs = Preferences.internal(preferences: []); + + final label = Label( + src: 'did:plc:l', + uri: 'at://test', + val: 'porn', + cts: DateTime.now(), + ); + + expect(LabelUtils.shouldShowWarning(warnPrefs, [label]), isTrue); + expect(LabelUtils.shouldShowWarning(hidePrefs, [label]), isFalse); + expect(LabelUtils.shouldShowWarning(noMatchPrefs, [label]), isFalse); + expect(LabelUtils.shouldShowWarning(warnPrefs, []), isFalse); + }); + }); + + group('shouldBlurContent', () { + test('blurs on hide (content) and warn (media)', () { + final hidePrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'porn', + visibility: 'hide', + ), + ], + ); + final warnPrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'sexual', + visibility: 'warn', + ), + ], + ); + final ignorePrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'nudity', + visibility: 'ignore', + ), + ], + ); + + final label = (String val) => Label( + src: 'did:plc:l', + uri: 'at://test', + val: val, + cts: DateTime.now(), + ); + + expect( + LabelUtils.shouldBlurContent(hidePrefs, [label('porn')]), + isTrue, + ); + expect( + LabelUtils.shouldBlurContent(warnPrefs, [label('sexual')]), + isTrue, + ); + expect( + LabelUtils.shouldBlurContent(ignorePrefs, [label('nudity')]), + isFalse, + ); + }); + }); + + group('shouldHideContent', () { + test('hides when setting=hide or adultOnly=true', () { + final hidePrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'gore', + visibility: 'hide', + ), + ], + ); + final warnAdultPrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'porn', + visibility: 'warn', + ), + ], + ); + final warnNonAdultPrefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'gore', + visibility: 'warn', + ), + ], + ); + + final label = (String val) => Label( + src: 'did:plc:l', + uri: 'at://test', + val: val, + cts: DateTime.now(), + ); + + expect( + LabelUtils.shouldHideContent(hidePrefs, [label('gore')]), + isTrue, + ); + expect( + LabelUtils.shouldHideContent(warnAdultPrefs, [label('porn')]), + isTrue, + ); + expect( + LabelUtils.shouldHideContent(warnNonAdultPrefs, [label('gore')]), + isFalse, + ); + expect( + LabelUtils.shouldHideContent( + Preferences.internal(preferences: []), + [], + ), + isFalse, + ); + }); + }); + + group('getWarningLabels', () { + test('returns only labels with severity=alert and setting=warn', () { + final prefs = Preferences.internal( + preferences: [], + contentLabelPrefs: [ + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'gore', + visibility: 'warn', + ), + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'porn', + visibility: 'warn', + ), + ContentLabelPref( + labelerDid: 'did:plc:l', + label: 'nudity', + visibility: 'ignore', + ), + ], + ); + + final labels = ['gore', 'porn', 'nudity'] + .map( + (v) => Label( + src: 'did:plc:l', + uri: 'at://test', + val: v, + cts: DateTime.now(), + ), + ) + .toList(); + + final result = LabelUtils.getWarningLabels(prefs, labels); + expect(result, containsAll(['gore', 'porn'])); + expect(result, isNot(contains('nudity'))); + }); + }); + }); +} diff --git a/test/src/core/utils/text_formatter_test.dart b/test/src/core/utils/text_formatter_test.dart new file mode 100644 index 00000000..c835aa50 --- /dev/null +++ b/test/src/core/utils/text_formatter_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/utils/text_formatter.dart'; + +void main() { + group('TextFormatter', () { + group('formatCount', () { + test('returns 0 for null and unexpected types', () { + expect(TextFormatter.formatCount(null), '0'); + expect(TextFormatter.formatCount(3.14), '0'); + expect(TextFormatter.formatCount('abc'), '0'); + }); + + test('formats plain numbers as-is', () { + expect(TextFormatter.formatCount(0), '0'); + expect(TextFormatter.formatCount(42), '42'); + expect(TextFormatter.formatCount(999), '999'); + }); + + test('formats thousands with K suffix', () { + expect(TextFormatter.formatCount(1000), '1K'); + expect(TextFormatter.formatCount(1500), '1.5K'); + expect(TextFormatter.formatCount(999000), '999K'); + }); + + test('formats millions with M suffix', () { + expect(TextFormatter.formatCount(1000000), '1M'); + expect(TextFormatter.formatCount(1500000), '1.5M'); + expect(TextFormatter.formatCount(2300000), '2.3M'); + }); + + test('handles string input', () { + expect(TextFormatter.formatCount('42'), '42'); + expect(TextFormatter.formatCount('1000'), '1K'); + }); + }); + + group('findUsernameMatches', () { + test('finds @username matches', () { + final matches = TextFormatter.findUsernameMatches('hello @alice world'); + expect(matches.length, 1); + expect(matches.first.group(1), 'alice'); + }); + + test('finds @handle.domain matches', () { + final matches = TextFormatter.findUsernameMatches( + 'check @alice.bsky.social', + ); + expect(matches.length, 1); + expect(matches.first.group(0), '@alice.bsky.social'); + }); + + test('finds multiple @mentions', () { + final matches = TextFormatter.findUsernameMatches('@alice and @bob'); + expect(matches.length, 2); + }); + + test('returns empty list when no mentions', () { + expect(TextFormatter.findUsernameMatches('no mentions here'), isEmpty); + }); + + test('does not match @ alone', () { + expect(TextFormatter.findUsernameMatches('hello @ world'), isEmpty); + }); + }); + + group('extractUrls', () { + test('extracts https URLs', () { + final urls = TextFormatter.extractUrls( + 'visit https://example.com for more', + ); + expect(urls, contains('https://example.com')); + }); + + test('extracts http URLs', () { + final urls = TextFormatter.extractUrls( + 'visit http://example.com for more', + ); + expect(urls, contains('http://example.com')); + }); + + test('does not extract emails as URLs', () { + expect(TextFormatter.extractUrls('contact user@example.com'), isEmpty); + }); + + test('returns empty list for text without URLs', () { + expect(TextFormatter.extractUrls('no urls here'), isEmpty); + }); + + test('extracts known-TLD domains without http prefix', () { + final urls = TextFormatter.extractUrls('visit example.com today'); + expect(urls, contains('example.com')); + }); + }); + + group('charIndexToByteIndex / byteIndexToCharIndex', () { + test('round-trips correctly for ASCII', () { + const text = 'hello world'; + for (int i = 0; i <= text.length; i++) { + final byteIndex = TextFormatter.charIndexToByteIndex(text, i); + final charIndex = TextFormatter.byteIndexToCharIndex(text, byteIndex); + expect(charIndex, i); + } + }); + + test('round-trips correctly for multi-byte text', () { + const text = 'café'; + // c=0, a=1, f=2, é=3 (char indices) → 0,1,2,4 (byte indices) + for (final i in [0, 1, 2, 3, 4]) { + final byteIndex = TextFormatter.charIndexToByteIndex(text, i); + final charIndex = TextFormatter.byteIndexToCharIndex(text, byteIndex); + expect(charIndex, i); + } + }); + + test('handles boundary conditions', () { + expect(TextFormatter.charIndexToByteIndex('hello', -1), 0); + expect(TextFormatter.charIndexToByteIndex('hello', 100), 5); + expect(TextFormatter.byteIndexToCharIndex('hello', -1), 0); + expect(TextFormatter.byteIndexToCharIndex('hello', 0), 0); + expect(TextFormatter.byteIndexToCharIndex('hello', 100), 5); + expect(TextFormatter.charIndexToByteIndex('', 0), 0); + }); + }); + + group('byteLength', () { + test('returns correct byte length for ASCII, multi-byte, and empty', () { + expect(TextFormatter.byteLength('hello'), 5); + expect(TextFormatter.byteLength('café'), 5); + expect(TextFormatter.byteLength('🎉'), 4); + expect(TextFormatter.byteLength(''), 0); + }); + }); + }); +} diff --git a/test/src/features/notifications/models/grouped_notification_test.dart b/test/src/features/notifications/models/grouped_notification_test.dart new file mode 100644 index 00000000..879711e8 --- /dev/null +++ b/test/src/features/notifications/models/grouped_notification_test.dart @@ -0,0 +1,216 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; +import 'package:spark/src/features/notifications/models/grouped_notification.dart'; + +Notification _makeNotification({ + required String did, + required String reason, + bool isRead = false, + DateTime? indexedAt, + AtUri? reasonSubject, + AtUri? following, +}) { + return Notification( + uri: reasonSubject ?? AtUri('at://did:plc:test/so.sprk.feed.post/test'), + cid: 'test-cid', + author: ProfileViewBasic( + did: did, + handle: '$did.handle', + viewer: ActorViewer(following: following), + ), + reason: reason, + record: {}, + isRead: isRead, + indexedAt: indexedAt ?? DateTime(2026, 1, 1), + reasonSubject: reasonSubject, + ); +} + +void main() { + group('groupNotifications', () { + test('returns empty list for empty input', () { + expect(groupNotifications([]), isEmpty); + }); + + test('groups follows together', () { + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'follow', + indexedAt: DateTime(2026, 1, 3), + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'follow', + indexedAt: DateTime(2026, 1, 2), + ), + ]); + + expect(results, hasLength(1)); + expect(results.first.reason, 'follow'); + expect(results.first.actorCount, 2); + expect(results.first.primaryNotification.author.did, 'did:plc:alice'); + }); + + test('separates follow-backs from regular follows', () { + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'follow', + indexedAt: DateTime(2026, 1, 3), + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'follow', + following: AtUri('at://did:plc:bob/app.bsky.graph.follow/xyz'), + indexedAt: DateTime(2026, 1, 2), + ), + ]); + + expect(results, hasLength(2)); + }); + + test('groups likes by reasonSubject', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'like', + reasonSubject: subject, + indexedAt: DateTime(2026, 1, 3), + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'like', + reasonSubject: subject, + indexedAt: DateTime(2026, 1, 2), + ), + ]); + + expect(results, hasLength(1)); + expect(results.first.actorCount, 2); + }); + + test('separates likes on different posts', () { + final subject1 = AtUri('at://did:plc:author/so.sprk.feed.post/1'); + final subject2 = AtUri('at://did:plc:author/so.sprk.feed.post/2'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'like', + reasonSubject: subject1, + indexedAt: DateTime(2026, 1, 3), + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'like', + reasonSubject: subject2, + indexedAt: DateTime(2026, 1, 2), + ), + ]); + + expect(results, hasLength(2)); + }); + + test('does not group likes without reasonSubject', () { + final results = groupNotifications([ + _makeNotification(did: 'did:plc:alice', reason: 'like'), + _makeNotification(did: 'did:plc:bob', reason: 'like'), + ]); + + expect(results, hasLength(2)); + }); + + test('groups reposts by reasonSubject', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'repost', + reasonSubject: subject, + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'repost', + reasonSubject: subject, + ), + ]); + + expect(results, hasLength(1)); + expect(results.first.actorCount, 2); + }); + + test('does not group replies', () { + final results = groupNotifications([ + _makeNotification(did: 'did:plc:alice', reason: 'reply'), + _makeNotification(did: 'did:plc:bob', reason: 'reply'), + ]); + + expect(results, hasLength(2)); + }); + + test('does not group mentions', () { + final results = groupNotifications([ + _makeNotification(did: 'did:plc:alice', reason: 'mention'), + _makeNotification(did: 'did:plc:bob', reason: 'mention'), + ]); + + expect(results, hasLength(2)); + }); + + test('sorts groups by most recent notification', () { + final subject = AtUri('at://did:plc:author/so.sprk.feed.post/123'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:bob', + reason: 'like', + reasonSubject: subject, + indexedAt: DateTime(2026, 1, 1), + ), + _makeNotification( + did: 'did:plc:alice', + reason: 'follow', + indexedAt: DateTime(2026, 1, 5), + ), + _makeNotification( + did: 'did:plc:carol', + reason: 'reply', + indexedAt: DateTime(2026, 1, 3), + ), + ]); + + expect(results.first.reason, 'follow'); + }); + + test('handles mixed notification types', () { + final likeSubject = AtUri('at://did:plc:author/so.sprk.feed.post/1'); + final results = groupNotifications([ + _makeNotification( + did: 'did:plc:alice', + reason: 'follow', + indexedAt: DateTime(2026, 1, 5), + ), + _makeNotification( + did: 'did:plc:bob', + reason: 'follow', + indexedAt: DateTime(2026, 1, 4), + ), + _makeNotification( + did: 'did:plc:carol', + reason: 'like', + reasonSubject: likeSubject, + indexedAt: DateTime(2026, 1, 3), + ), + _makeNotification( + did: 'did:plc:dave', + reason: 'reply', + indexedAt: DateTime(2026, 1, 2), + ), + ]); + + expect(results, hasLength(3)); + }); + }); +}