new home screen
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/home_target.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
|
||||
ContainerData _container(String id) =>
|
||||
ContainerData(id: id, color: const Color(0xFF000000), orderKey: 'a');
|
||||
|
||||
void main() {
|
||||
group('resolveHomeTargetContainer', () {
|
||||
final selected = _container('selected');
|
||||
final scoped = _container('scoped');
|
||||
|
||||
test('unscoped follows the selected container', () {
|
||||
expect(
|
||||
resolveHomeTargetContainer(
|
||||
scopeToContainer: false,
|
||||
scopedContainer: null,
|
||||
selectedContainer: selected,
|
||||
),
|
||||
isA<SpecificContainerTabSelection>().having(
|
||||
(s) => s.container.id,
|
||||
'container',
|
||||
'selected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('unscoped with no selection is unassigned', () {
|
||||
expect(
|
||||
resolveHomeTargetContainer(
|
||||
scopeToContainer: false,
|
||||
scopedContainer: null,
|
||||
selectedContainer: null,
|
||||
),
|
||||
isA<UnassignedContainerTabSelection>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('scoped uses its own container, not the selected one', () {
|
||||
expect(
|
||||
resolveHomeTargetContainer(
|
||||
scopeToContainer: true,
|
||||
scopedContainer: scoped,
|
||||
selectedContainer: selected,
|
||||
),
|
||||
isA<SpecificContainerTabSelection>().having(
|
||||
(s) => s.container.id,
|
||||
'container',
|
||||
'scoped',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('scoped to the unassigned container stays unassigned', () {
|
||||
// The case a plain null-check gets wrong: closing the last unassigned tab
|
||||
// scopes to "unassigned", which is a real container, not the absence of
|
||||
// a scope — falling back to the selected container would move the user.
|
||||
expect(
|
||||
resolveHomeTargetContainer(
|
||||
scopeToContainer: true,
|
||||
scopedContainer: null,
|
||||
selectedContainer: selected,
|
||||
),
|
||||
isA<UnassignedContainerTabSelection>(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('default', () {
|
||||
test('is home, so startup is unchanged for existing users', () {
|
||||
// Any other default would alter startup behaviour for everyone on
|
||||
// upgrade. Changing this needs a deliberate decision, not a drive-by.
|
||||
expect(GeneralSettings.withDefaults().homeTarget, HomeTarget.home);
|
||||
expect(GeneralSettings.withDefaults().homeTargetUrl, isNull);
|
||||
expect(GeneralSettings.withDefaults().homeTargetOnLastTabClosed, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveHomeTarget', () {
|
||||
test('home stays home', () {
|
||||
expect(
|
||||
resolveHomeTarget(target: HomeTarget.home, customUrl: null),
|
||||
HomeTarget.home,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'resume is returned; the caller decides if there is anything to resume',
|
||||
() {
|
||||
expect(
|
||||
resolveHomeTarget(target: HomeTarget.resumeLastTab, customUrl: null),
|
||||
HomeTarget.resumeLastTab,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a configured address is used', () {
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://example.com',
|
||||
),
|
||||
HomeTarget.customUrl,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unset address falls back to home', () {
|
||||
expect(
|
||||
resolveHomeTarget(target: HomeTarget.customUrl, customUrl: null),
|
||||
HomeTarget.home,
|
||||
);
|
||||
expect(
|
||||
resolveHomeTarget(target: HomeTarget.customUrl, customUrl: ' '),
|
||||
HomeTarget.home,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unparseable address falls back to home', () {
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'not a url at all',
|
||||
),
|
||||
HomeTarget.home,
|
||||
);
|
||||
});
|
||||
|
||||
group('custom-URL reopen loop', () {
|
||||
test('closing the configured page does not reopen it', () {
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://example.com/start',
|
||||
closingTabUrl: Uri.parse('https://example.com/start'),
|
||||
),
|
||||
HomeTarget.home,
|
||||
);
|
||||
});
|
||||
|
||||
test('the URL guard ignores scheme and host case', () {
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://Example.com/start',
|
||||
closingTabUrl: Uri.parse('http://example.com/start'),
|
||||
),
|
||||
HomeTarget.home,
|
||||
);
|
||||
});
|
||||
|
||||
test('closing a different page still opens the configured one', () {
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://example.com/start',
|
||||
closingTabUrl: Uri.parse('https://example.com/other'),
|
||||
),
|
||||
HomeTarget.customUrl,
|
||||
);
|
||||
});
|
||||
|
||||
test('reopening within the guard window is suppressed', () {
|
||||
final now = DateTime(2026, 8, 1, 12);
|
||||
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://example.com',
|
||||
lastCustomUrlOpenedAt: now.subtract(const Duration(seconds: 1)),
|
||||
now: now,
|
||||
),
|
||||
HomeTarget.home,
|
||||
reason:
|
||||
'a redirect away from the configured page would otherwise '
|
||||
'defeat the URL guard and loop',
|
||||
);
|
||||
});
|
||||
|
||||
test('reopening after the window is allowed', () {
|
||||
final now = DateTime(2026, 8, 1, 12);
|
||||
|
||||
expect(
|
||||
resolveHomeTarget(
|
||||
target: HomeTarget.customUrl,
|
||||
customUrl: 'https://example.com',
|
||||
lastCustomUrlOpenedAt: now.subtract(const Duration(seconds: 30)),
|
||||
now: now,
|
||||
),
|
||||
HomeTarget.customUrl,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_module_order.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
|
||||
ModuleOrderEntry _entry(SearchModuleType type, {bool visible = true}) =>
|
||||
ModuleOrderEntry(type: type, visible: visible);
|
||||
|
||||
List<SearchModuleType> _types(List<ModuleOrderEntry> entries) =>
|
||||
entries.map((e) => e.type).toList();
|
||||
|
||||
void main() {
|
||||
group('mergeModuleOrderWithDefaults', () {
|
||||
test('uses the defaults verbatim when nothing is persisted', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(null, defaults);
|
||||
|
||||
expect(_types(merged), defaults.map((d) => d.type).toList());
|
||||
expect(merged.every((e) => e.visible), isTrue);
|
||||
});
|
||||
|
||||
test('preserves a reordered persisted list', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(type: SearchModuleType.frequentBangs, visible: true),
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
final persisted = [
|
||||
_entry(SearchModuleType.topSites),
|
||||
_entry(SearchModuleType.recentSearches),
|
||||
_entry(SearchModuleType.frequentBangs),
|
||||
];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(_types(merged), _types(persisted));
|
||||
});
|
||||
|
||||
test('preserves persisted visibility', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
final persisted = [
|
||||
_entry(SearchModuleType.recentSearches, visible: false),
|
||||
_entry(SearchModuleType.topSites),
|
||||
];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(merged[0].visible, isFalse);
|
||||
expect(merged[1].visible, isTrue);
|
||||
});
|
||||
|
||||
test('drops persisted modules that are no longer offered', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
final persisted = [
|
||||
_entry(SearchModuleType.recentSearches),
|
||||
_entry(SearchModuleType.topSites),
|
||||
];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(_types(merged), [SearchModuleType.topSites]);
|
||||
});
|
||||
|
||||
test('inserts a new default at its position, not at the tail', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(
|
||||
type: SearchModuleType.frequentBangs,
|
||||
visible: true,
|
||||
), // newly introduced, in the middle
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
final persisted = [
|
||||
_entry(SearchModuleType.recentSearches),
|
||||
_entry(SearchModuleType.topSites),
|
||||
];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(_types(merged), [
|
||||
SearchModuleType.recentSearches,
|
||||
SearchModuleType.frequentBangs,
|
||||
SearchModuleType.topSites,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a new default keeps its own visibility instead of forcing on', () {
|
||||
// This is what lets a module be offered on a surface without switching it
|
||||
// on for everyone who already customised that surface.
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
(type: SearchModuleType.quote, visible: false),
|
||||
];
|
||||
final persisted = [_entry(SearchModuleType.topSites)];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(
|
||||
merged.firstWhere((e) => e.type == SearchModuleType.quote).visible,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('clamps the insert position when the persisted list is shorter', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(type: SearchModuleType.frequentBangs, visible: true),
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
(
|
||||
type: SearchModuleType.containers,
|
||||
visible: true,
|
||||
), // index 3, beyond the persisted length
|
||||
];
|
||||
final persisted = [_entry(SearchModuleType.recentSearches)];
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
|
||||
expect(
|
||||
merged.map((e) => e.type).toSet(),
|
||||
defaults.map((d) => d.type).toSet(),
|
||||
);
|
||||
expect(merged, hasLength(defaults.length));
|
||||
});
|
||||
|
||||
test('is idempotent', () {
|
||||
const defaults = <ModuleSurfaceDefault>[
|
||||
(type: SearchModuleType.recentSearches, visible: true),
|
||||
(type: SearchModuleType.frequentBangs, visible: true),
|
||||
(type: SearchModuleType.topSites, visible: true),
|
||||
];
|
||||
final persisted = [
|
||||
_entry(SearchModuleType.topSites, visible: false),
|
||||
_entry(SearchModuleType.recentSearches),
|
||||
];
|
||||
|
||||
final once = mergeModuleOrderWithDefaults(persisted, defaults);
|
||||
final twice = mergeModuleOrderWithDefaults(once, defaults);
|
||||
|
||||
expect(twice, once);
|
||||
});
|
||||
});
|
||||
|
||||
group('persisted payload compatibility', () {
|
||||
// The storage key and the on-disk shape are a compatibility contract: the
|
||||
// empty-state order has shipped to users under this exact key, encoded by
|
||||
// ModuleOrderEntry.toJson. Changing either silently resets their layout.
|
||||
test('the empty-state order keeps its shipped storage key', () {
|
||||
expect(ModuleSurface.newTab.key, 'EmptyStateModuleOrder');
|
||||
});
|
||||
|
||||
test('a real shipped payload round-trips unchanged', () {
|
||||
// Captured from the shape SearchModuleOrder.build writes today: a user
|
||||
// who moved Shortcuts to the top and hid History Highlights.
|
||||
const payload =
|
||||
'[{"type":"topSites","visible":true},'
|
||||
'{"type":"recentSearches","visible":true},'
|
||||
'{"type":"frequentBangs","visible":true},'
|
||||
'{"type":"recentArticles","visible":true},'
|
||||
'{"type":"recentTabs","visible":true},'
|
||||
'{"type":"recentHistory","visible":true},'
|
||||
'{"type":"historyHighlights","visible":false},'
|
||||
'{"type":"containers","visible":true}]';
|
||||
|
||||
final decoded = (jsonDecode(payload) as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(ModuleOrderEntry.fromJson)
|
||||
.toList();
|
||||
|
||||
final merged = mergeModuleOrderWithDefaults(
|
||||
decoded,
|
||||
ModuleSurface.newTab.defaultModules,
|
||||
);
|
||||
|
||||
// Everything the user saved survives, in their order, untouched...
|
||||
expect(
|
||||
merged.where((e) => decoded.any((d) => d.type == e.type)).toList(),
|
||||
decoded,
|
||||
reason: 'a saved layout must survive the surface rename untouched',
|
||||
);
|
||||
expect(_types(merged).first, SearchModuleType.topSites);
|
||||
expect(
|
||||
merged
|
||||
.firstWhere((e) => e.type == SearchModuleType.historyHighlights)
|
||||
.visible,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
// ...and modules added since then appear without switching themselves on.
|
||||
final added = merged.where((e) => !decoded.any((d) => d.type == e.type));
|
||||
expect(
|
||||
added.every((e) => !e.visible),
|
||||
isTrue,
|
||||
reason: 'a module added to a shipped surface must default to off',
|
||||
);
|
||||
});
|
||||
|
||||
test('unparseable entries are skipped rather than poisoning the list', () {
|
||||
// Mirrors the try/catch in SearchModuleOrder.build's decode: an entry
|
||||
// naming a module that no longer exists must not discard the whole order.
|
||||
const payload =
|
||||
'[{"type":"topSites","visible":true},'
|
||||
'{"type":"aModuleThatWasRemoved","visible":true}]';
|
||||
|
||||
final decoded = (jsonDecode(payload) as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) {
|
||||
try {
|
||||
return ModuleOrderEntry.fromJson(e);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ModuleOrderEntry>()
|
||||
.toList();
|
||||
|
||||
expect(_types(decoded), [SearchModuleType.topSites]);
|
||||
});
|
||||
});
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_host.dart';
|
||||
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
||||
|
||||
TopFrecentSiteInfo _site(String url, {String? title}) =>
|
||||
TopFrecentSiteInfo(url: url, title: title);
|
||||
|
||||
void main() {
|
||||
group('canonicalTopSiteHost', () {
|
||||
test('lowercases the host', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('https://EXAMPLE.com/x')),
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('strips a leading www.', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('https://www.example.com')),
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('strips only one leading www.', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('https://www.www.example.com')),
|
||||
'www.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('drops the port', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('https://example.com:8443/x')),
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps subdomains distinct', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('https://app.discord.com')),
|
||||
isNot(canonicalTopSiteHost(Uri.parse('https://discord.com'))),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles IP literals', () {
|
||||
expect(
|
||||
canonicalTopSiteHost(Uri.parse('http://127.0.0.1:8080')),
|
||||
'127.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty for authority-less URLs so it never matches', () {
|
||||
expect(canonicalTopSiteHost(Uri.parse('about:blank')), isEmpty);
|
||||
expect(canonicalTopSiteHost(Uri.parse('data:text/plain,hi')), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('filterFrecentTopSites', () {
|
||||
test('maps frecent sites to history-sourced shortcuts', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('https://example.com', title: 'Example')],
|
||||
limit: 5,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: const {},
|
||||
);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.title, 'Example');
|
||||
expect(items.single.url, Uri.parse('https://example.com'));
|
||||
});
|
||||
|
||||
test('falls back to the host when a site has no title', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('https://example.com/page')],
|
||||
limit: 5,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: const {},
|
||||
);
|
||||
|
||||
expect(items.single.title, 'example.com');
|
||||
});
|
||||
|
||||
test('a hidden URL suppresses the matching history entry', () {
|
||||
// Regression test: the hidden list was only ever applied to the bundled
|
||||
// defaults, so removing a frecency-ranked shortcut looked like it worked
|
||||
// and then the site reappeared on the next refresh.
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('https://example.com'), _site('https://other.com')],
|
||||
limit: 5,
|
||||
excludeUrls: {Uri.parse('https://example.com').normalized.toString()},
|
||||
excludeHosts: const {},
|
||||
);
|
||||
|
||||
expect(items.map((i) => i.url.host), ['other.com']);
|
||||
});
|
||||
|
||||
test('a hidden host suppresses every URL on it (issue #267)', () {
|
||||
// The reported case: a PWA occupying 19 of 25 slots with distinct URLs.
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [
|
||||
for (var i = 0; i < 19; i++) _site('https://discord.com/channels/$i'),
|
||||
_site('https://example.com'),
|
||||
_site('https://other.com'),
|
||||
],
|
||||
limit: 25,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: {'discord.com'},
|
||||
);
|
||||
|
||||
expect(
|
||||
items.any((i) => i.url.host == 'discord.com'),
|
||||
isFalse,
|
||||
reason: 'hiding the domain must clear every one of its URLs',
|
||||
);
|
||||
expect(items.map((i) => i.url.host), ['example.com', 'other.com']);
|
||||
});
|
||||
|
||||
test('host exclusion ignores www. and case', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('https://WWW.Discord.com/app')],
|
||||
limit: 5,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: {'discord.com'},
|
||||
);
|
||||
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('still fills up to the limit once exclusions are applied', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [
|
||||
for (var i = 0; i < 10; i++) _site('https://blocked.com/$i'),
|
||||
for (var i = 0; i < 5; i++) _site('https://site$i.com'),
|
||||
],
|
||||
limit: 3,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: {'blocked.com'},
|
||||
);
|
||||
|
||||
expect(items, hasLength(3));
|
||||
});
|
||||
|
||||
test('never returns more than the limit', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [for (var i = 0; i < 20; i++) _site('https://site$i.com')],
|
||||
limit: 4,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: const {},
|
||||
);
|
||||
|
||||
expect(items, hasLength(4));
|
||||
});
|
||||
|
||||
test('a pinned site is unaffected by its host being hidden', () {
|
||||
// Regression guard for the fix to addPinnedSite: pinned entries are
|
||||
// returned ahead of these filters, so pinning one URL never needs to
|
||||
// lift a domain-wide hide — doing so would restore every other page on
|
||||
// that domain the user had just removed.
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('https://discord.com/a'), _site('https://discord.com/b')],
|
||||
limit: 25,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: {'discord.com'},
|
||||
);
|
||||
|
||||
expect(
|
||||
items,
|
||||
isEmpty,
|
||||
reason: 'the host stays hidden for everything that is not pinned',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips unparseable URLs instead of throwing', () {
|
||||
final items = filterFrecentTopSites(
|
||||
sites: [_site('::::not a url'), _site('https://example.com')],
|
||||
limit: 5,
|
||||
excludeUrls: const {},
|
||||
excludeHosts: const {},
|
||||
);
|
||||
|
||||
expect(items.map((i) => i.url.host), ['example.com']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
void main() {
|
||||
group('GeneralSettings deserialization coverage', () {
|
||||
// The failure this guards against is silent: a field added to
|
||||
// GeneralSettings without a matching read in the deserializer saves to the
|
||||
// database correctly and then reverts to its default on the next launch,
|
||||
// because nothing ever reads it back out.
|
||||
test('every serialized field is read back by the deserializer', () {
|
||||
final serializedKeys = GeneralSettings.withDefaults()
|
||||
.toJson()
|
||||
.keys
|
||||
.toSet();
|
||||
final readKeys = {
|
||||
...generalSettingColumnTypes.keys,
|
||||
...generalSettingJsonKeys,
|
||||
};
|
||||
|
||||
expect(
|
||||
serializedKeys.difference(readKeys),
|
||||
isEmpty,
|
||||
reason:
|
||||
'These GeneralSettings fields are written but never read back. '
|
||||
'Add each one to generalSettingColumnTypes (with its DriftSqlType) '
|
||||
'or, for JSON documents, to generalSettingJsonKeys.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a key is never both a plain column and a JSON document', () {
|
||||
expect(
|
||||
generalSettingColumnTypes.keys.toSet().intersection(
|
||||
generalSettingJsonKeys,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('JSON-backed settings are absent from the plain column types', () {
|
||||
// They are read as strings and decoded, so listing them in the column map
|
||||
// as well would hand fromJson the raw encoded string.
|
||||
for (final key in generalSettingJsonKeys) {
|
||||
expect(generalSettingColumnTypes.containsKey(key), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy keys are retained so fromJson migrations keep working', () {
|
||||
// These no longer exist on GeneralSettings but are still consumed by the
|
||||
// migrations in GeneralSettings.fromJson, so they must stay readable.
|
||||
for (final legacyKey in const [
|
||||
'newTabPosition',
|
||||
'tabBarShowQuickTabSwitcherBar',
|
||||
'quickTabSwitcherMode',
|
||||
]) {
|
||||
expect(
|
||||
generalSettingColumnTypes.containsKey(legacyKey),
|
||||
isTrue,
|
||||
reason: '$legacyKey is a legacy key consumed by a fromJson migration',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('column types are limited to the kinds the setting table stores', () {
|
||||
const supported = {
|
||||
DriftSqlType.string,
|
||||
DriftSqlType.bool,
|
||||
DriftSqlType.int,
|
||||
DriftSqlType.double,
|
||||
};
|
||||
|
||||
for (final MapEntry(key: key, value: type)
|
||||
in generalSettingColumnTypes.entries) {
|
||||
expect(supported, contains(type), reason: '$key has type $type');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user