new home screen

This commit is contained in:
Fabian Freund
2026-08-04 16:03:30 +02:00
parent 3def036b3b
commit dbfcd1daaa
62 changed files with 6302 additions and 1496 deletions
@@ -0,0 +1,164 @@
/*
* 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' show Value;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
Future<void> _addContainer(TabDatabase db, String id) {
return db.containerDao.addContainer(
ContainerData(
id: id,
name: id,
color: Colors.blue,
orderKey: id,
metadata: ContainerMetadata.withDefaults(contextualIdentity: id),
),
);
}
/// Inserts [id] and stamps it, so "most recently used" ordering is explicit
/// rather than dependent on insertion timing.
Future<void> _addTab(
TabDatabase db,
String id, {
String? containerId,
required int minuteOfUse,
}) async {
await db.tabDao.insertTab(
id,
source: TabSource.manual,
parentId: const Value(null),
containerId: Value(containerId),
);
await db.tabDao.touchTab(
id,
timestamp: DateTime(2026, 8, 1, 12, minuteOfUse),
);
}
void main() {
late TabDatabase db;
setUp(() {
db = TabDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
registerUrlFunctions(database);
},
),
);
});
tearDown(() async {
await db.close();
});
group('getTabsFifo', () {
test('returns the most recently used tab first', () async {
await _addTab(db, 'older', minuteOfUse: 1);
await _addTab(db, 'newer', minuteOfUse: 5);
final tabs = await db.tabDao.getTabsFifo(limit: 1).get();
expect(tabs.single.id, 'newer');
});
test('skips excluded tabs', () async {
// The regression this guards: tab rows are deleted only after the next
// selection is made, so the tab being closed is still here — and having
// just been active it sorts first, so an unfiltered query resumes the
// very tab that is about to disappear.
await _addTab(db, 'closing', minuteOfUse: 9);
await _addTab(db, 'survivor', minuteOfUse: 1);
final tabs = await db.tabDao
.getTabsFifo(limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs.single.id, 'survivor');
});
test('returns nothing when every candidate is excluded', () async {
await _addTab(db, 'closing', minuteOfUse: 1);
final tabs = await db.tabDao
.getTabsFifo(limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs, isEmpty);
});
});
group('getContainerTabsFifo', () {
test('stays within the requested container', () async {
await _addContainer(db, 'a');
await _addContainer(db, 'b');
await _addTab(db, 'other-container', containerId: 'b', minuteOfUse: 9);
await _addTab(db, 'wanted', containerId: 'a', minuteOfUse: 1);
final tabs = await db.tabDao.getContainerTabsFifo('a', limit: 1).get();
expect(tabs.single.id, 'wanted');
});
test('skips the closing tab within a container', () async {
await _addContainer(db, 'a');
await _addTab(db, 'closing', containerId: 'a', minuteOfUse: 9);
await _addTab(db, 'survivor', containerId: 'a', minuteOfUse: 1);
final tabs = await db.tabDao
.getContainerTabsFifo('a', limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs.single.id, 'survivor');
});
test('a null container means unassigned, not any container', () async {
await _addContainer(db, 'a');
await _addTab(db, 'in-container', containerId: 'a', minuteOfUse: 9);
await _addTab(db, 'unassigned', minuteOfUse: 1);
final tabs = await db.tabDao.getContainerTabsFifo(null, limit: 1).get();
expect(tabs.single.id, 'unassigned');
});
test('skips the closing tab in the unassigned container', () async {
// Closing the last unassigned tab must not resume that same tab, nor
// fall through into a container.
await _addContainer(db, 'a');
await _addTab(db, 'in-container', containerId: 'a', minuteOfUse: 5);
await _addTab(db, 'closing', minuteOfUse: 9);
final tabs = await db.tabDao
.getContainerTabsFifo(null, limit: 1, excludedTabIds: {'closing'})
.get();
expect(tabs, isEmpty);
});
});
}
@@ -5,6 +5,7 @@
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -12,10 +13,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
switch (version) {
case 1:
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1];
static const versions = const [1, 2];
}
@@ -0,0 +1,500 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo<TopSite, TopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
TopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TopSiteData(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
orderKey: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}order_key'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteData extends DataClass implements Insertable<TopSiteData> {
final String id;
final String title;
final String url;
final int source;
final String orderKey;
final int createdAt;
const TopSiteData({
required this.id,
required this.title,
required this.url,
required this.source,
required this.orderKey,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['title'] = Variable<String>(title);
map['url'] = Variable<String>(url);
map['source'] = Variable<int>(source);
map['order_key'] = Variable<String>(orderKey);
map['created_at'] = Variable<int>(createdAt);
return map;
}
factory TopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TopSiteData(
id: serializer.fromJson<String>(json['id']),
title: serializer.fromJson<String>(json['title']),
url: serializer.fromJson<String>(json['url']),
source: serializer.fromJson<int>(json['source']),
orderKey: serializer.fromJson<String>(json['orderKey']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'title': serializer.toJson<String>(title),
'url': serializer.toJson<String>(url),
'source': serializer.toJson<int>(source),
'orderKey': serializer.toJson<String>(orderKey),
'createdAt': serializer.toJson<int>(createdAt),
};
}
TopSiteData copyWith({
String? id,
String? title,
String? url,
int? source,
String? orderKey,
int? createdAt,
}) => TopSiteData(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
);
TopSiteData copyWithCompanion(TopSiteCompanion data) {
return TopSiteData(
id: data.id.present ? data.id.value : this.id,
title: data.title.present ? data.title.value : this.title,
url: data.url.present ? data.url.value : this.url,
source: data.source.present ? data.source.value : this.source,
orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('TopSiteData(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, title, url, source, orderKey, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TopSiteData &&
other.id == this.id &&
other.title == this.title &&
other.url == this.url &&
other.source == this.source &&
other.orderKey == this.orderKey &&
other.createdAt == this.createdAt);
}
class TopSiteCompanion extends UpdateCompanion<TopSiteData> {
final Value<String> id;
final Value<String> title;
final Value<String> url;
final Value<int> source;
final Value<String> orderKey;
final Value<int> createdAt;
final Value<int> rowid;
const TopSiteCompanion({
this.id = const Value.absent(),
this.title = const Value.absent(),
this.url = const Value.absent(),
this.source = const Value.absent(),
this.orderKey = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
TopSiteCompanion.insert({
required String id,
required String title,
required String url,
required int source,
required String orderKey,
required int createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
title = Value(title),
url = Value(url),
source = Value(source),
orderKey = Value(orderKey),
createdAt = Value(createdAt);
static Insertable<TopSiteData> custom({
Expression<String>? id,
Expression<String>? title,
Expression<String>? url,
Expression<int>? source,
Expression<String>? orderKey,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (title != null) 'title': title,
if (url != null) 'url': url,
if (source != null) 'source': source,
if (orderKey != null) 'order_key': orderKey,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
TopSiteCompanion copyWith({
Value<String>? id,
Value<String>? title,
Value<String>? url,
Value<int>? source,
Value<String>? orderKey,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return TopSiteCompanion(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
if (orderKey.present) {
map['order_key'] = Variable<String>(orderKey.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TopSiteCompanion(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSite extends Table
with TableInfo<HiddenTopSite, HiddenTopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [url];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site';
@override
Set<GeneratedColumn> get $primaryKey => {url};
@override
HiddenTopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteData(
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
);
}
@override
HiddenTopSite createAlias(String alias) {
return HiddenTopSite(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteData extends DataClass
implements Insertable<HiddenTopSiteData> {
final String url;
const HiddenTopSiteData({required this.url});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['url'] = Variable<String>(url);
return map;
}
factory HiddenTopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteData(url: serializer.fromJson<String>(json['url']));
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'url': serializer.toJson<String>(url)};
}
HiddenTopSiteData copyWith({String? url}) =>
HiddenTopSiteData(url: url ?? this.url);
HiddenTopSiteData copyWithCompanion(HiddenTopSiteCompanion data) {
return HiddenTopSiteData(url: data.url.present ? data.url.value : this.url);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteData(')
..write('url: $url')
..write(')'))
.toString();
}
@override
int get hashCode => url.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is HiddenTopSiteData && other.url == this.url);
}
class HiddenTopSiteCompanion extends UpdateCompanion<HiddenTopSiteData> {
final Value<String> url;
final Value<int> rowid;
const HiddenTopSiteCompanion({
this.url = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteCompanion.insert({
required String url,
this.rowid = const Value.absent(),
}) : url = Value(url);
static Insertable<HiddenTopSiteData> custom({
Expression<String>? url,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (url != null) 'url': url,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteCompanion copyWith({Value<String>? url, Value<int>? rowid}) {
return HiddenTopSiteCompanion(
url: url ?? this.url,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteCompanion(')
..write('url: $url, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final HiddenTopSite hiddenTopSite = HiddenTopSite(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
hiddenTopSite,
];
@override
int get schemaVersion => 1;
}
@@ -0,0 +1,649 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo<TopSite, TopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
TopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TopSiteData(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
orderKey: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}order_key'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteData extends DataClass implements Insertable<TopSiteData> {
final String id;
final String title;
final String url;
final int source;
final String orderKey;
final int createdAt;
const TopSiteData({
required this.id,
required this.title,
required this.url,
required this.source,
required this.orderKey,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['title'] = Variable<String>(title);
map['url'] = Variable<String>(url);
map['source'] = Variable<int>(source);
map['order_key'] = Variable<String>(orderKey);
map['created_at'] = Variable<int>(createdAt);
return map;
}
factory TopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TopSiteData(
id: serializer.fromJson<String>(json['id']),
title: serializer.fromJson<String>(json['title']),
url: serializer.fromJson<String>(json['url']),
source: serializer.fromJson<int>(json['source']),
orderKey: serializer.fromJson<String>(json['orderKey']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'title': serializer.toJson<String>(title),
'url': serializer.toJson<String>(url),
'source': serializer.toJson<int>(source),
'orderKey': serializer.toJson<String>(orderKey),
'createdAt': serializer.toJson<int>(createdAt),
};
}
TopSiteData copyWith({
String? id,
String? title,
String? url,
int? source,
String? orderKey,
int? createdAt,
}) => TopSiteData(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
);
TopSiteData copyWithCompanion(TopSiteCompanion data) {
return TopSiteData(
id: data.id.present ? data.id.value : this.id,
title: data.title.present ? data.title.value : this.title,
url: data.url.present ? data.url.value : this.url,
source: data.source.present ? data.source.value : this.source,
orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('TopSiteData(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, title, url, source, orderKey, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TopSiteData &&
other.id == this.id &&
other.title == this.title &&
other.url == this.url &&
other.source == this.source &&
other.orderKey == this.orderKey &&
other.createdAt == this.createdAt);
}
class TopSiteCompanion extends UpdateCompanion<TopSiteData> {
final Value<String> id;
final Value<String> title;
final Value<String> url;
final Value<int> source;
final Value<String> orderKey;
final Value<int> createdAt;
final Value<int> rowid;
const TopSiteCompanion({
this.id = const Value.absent(),
this.title = const Value.absent(),
this.url = const Value.absent(),
this.source = const Value.absent(),
this.orderKey = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
TopSiteCompanion.insert({
required String id,
required String title,
required String url,
required int source,
required String orderKey,
required int createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
title = Value(title),
url = Value(url),
source = Value(source),
orderKey = Value(orderKey),
createdAt = Value(createdAt);
static Insertable<TopSiteData> custom({
Expression<String>? id,
Expression<String>? title,
Expression<String>? url,
Expression<int>? source,
Expression<String>? orderKey,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (title != null) 'title': title,
if (url != null) 'url': url,
if (source != null) 'source': source,
if (orderKey != null) 'order_key': orderKey,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
TopSiteCompanion copyWith({
Value<String>? id,
Value<String>? title,
Value<String>? url,
Value<int>? source,
Value<String>? orderKey,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return TopSiteCompanion(
id: id ?? this.id,
title: title ?? this.title,
url: url ?? this.url,
source: source ?? this.source,
orderKey: orderKey ?? this.orderKey,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
if (orderKey.present) {
map['order_key'] = Variable<String>(orderKey.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TopSiteCompanion(')
..write('id: $id, ')
..write('title: $title, ')
..write('url: $url, ')
..write('source: $source, ')
..write('orderKey: $orderKey, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSite extends Table
with TableInfo<HiddenTopSite, HiddenTopSiteData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [url];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site';
@override
Set<GeneratedColumn> get $primaryKey => {url};
@override
HiddenTopSiteData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteData(
url: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}url'],
)!,
);
}
@override
HiddenTopSite createAlias(String alias) {
return HiddenTopSite(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteData extends DataClass
implements Insertable<HiddenTopSiteData> {
final String url;
const HiddenTopSiteData({required this.url});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['url'] = Variable<String>(url);
return map;
}
factory HiddenTopSiteData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteData(url: serializer.fromJson<String>(json['url']));
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'url': serializer.toJson<String>(url)};
}
HiddenTopSiteData copyWith({String? url}) =>
HiddenTopSiteData(url: url ?? this.url);
HiddenTopSiteData copyWithCompanion(HiddenTopSiteCompanion data) {
return HiddenTopSiteData(url: data.url.present ? data.url.value : this.url);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteData(')
..write('url: $url')
..write(')'))
.toString();
}
@override
int get hashCode => url.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is HiddenTopSiteData && other.url == this.url);
}
class HiddenTopSiteCompanion extends UpdateCompanion<HiddenTopSiteData> {
final Value<String> url;
final Value<int> rowid;
const HiddenTopSiteCompanion({
this.url = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteCompanion.insert({
required String url,
this.rowid = const Value.absent(),
}) : url = Value(url);
static Insertable<HiddenTopSiteData> custom({
Expression<String>? url,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (url != null) 'url': url,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteCompanion copyWith({Value<String>? url, Value<int>? rowid}) {
return HiddenTopSiteCompanion(
url: url ?? this.url,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (url.present) {
map['url'] = Variable<String>(url.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteCompanion(')
..write('url: $url, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class HiddenTopSiteHost extends Table
with TableInfo<HiddenTopSiteHost, HiddenTopSiteHostData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
HiddenTopSiteHost(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> host = GeneratedColumn<String>(
'host',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [host];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'hidden_top_site_host';
@override
Set<GeneratedColumn> get $primaryKey => {host};
@override
HiddenTopSiteHostData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return HiddenTopSiteHostData(
host: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}host'],
)!,
);
}
@override
HiddenTopSiteHost createAlias(String alias) {
return HiddenTopSiteHost(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class HiddenTopSiteHostData extends DataClass
implements Insertable<HiddenTopSiteHostData> {
final String host;
const HiddenTopSiteHostData({required this.host});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['host'] = Variable<String>(host);
return map;
}
factory HiddenTopSiteHostData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HiddenTopSiteHostData(
host: serializer.fromJson<String>(json['host']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{'host': serializer.toJson<String>(host)};
}
HiddenTopSiteHostData copyWith({String? host}) =>
HiddenTopSiteHostData(host: host ?? this.host);
HiddenTopSiteHostData copyWithCompanion(HiddenTopSiteHostCompanion data) {
return HiddenTopSiteHostData(
host: data.host.present ? data.host.value : this.host,
);
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteHostData(')
..write('host: $host')
..write(')'))
.toString();
}
@override
int get hashCode => host.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is HiddenTopSiteHostData && other.host == this.host);
}
class HiddenTopSiteHostCompanion
extends UpdateCompanion<HiddenTopSiteHostData> {
final Value<String> host;
final Value<int> rowid;
const HiddenTopSiteHostCompanion({
this.host = const Value.absent(),
this.rowid = const Value.absent(),
});
HiddenTopSiteHostCompanion.insert({
required String host,
this.rowid = const Value.absent(),
}) : host = Value(host);
static Insertable<HiddenTopSiteHostData> custom({
Expression<String>? host,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (host != null) 'host': host,
if (rowid != null) 'rowid': rowid,
});
}
HiddenTopSiteHostCompanion copyWith({
Value<String>? host,
Value<int>? rowid,
}) {
return HiddenTopSiteHostCompanion(
host: host ?? this.host,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (host.present) {
map['host'] = Variable<String>(host.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('HiddenTopSiteHostCompanion(')
..write('host: $host, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DatabaseAtV2 extends GeneratedDatabase {
DatabaseAtV2(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final HiddenTopSite hiddenTopSite = HiddenTopSite(this);
late final HiddenTopSiteHost hiddenTopSiteHost = HiddenTopSiteHost(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
hiddenTopSite,
hiddenTopSiteHost,
];
@override
int get schemaVersion => 2;
}
@@ -0,0 +1,76 @@
// dart format width=80
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart';
import 'package:flutter_test/flutter_test.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
import 'generated/schema_v2.dart' as v2;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
group('simple database migrations', () {
// These simple tests verify all possible schema updates with a simple (no
// data) migration. This is a quick way to ensure that written database
// migrations properly alter the schema.
const versions = GeneratedHelper.versions;
for (final (i, fromVersion) in versions.indexed) {
group('from $fromVersion', () {
for (final toVersion in versions.skip(i + 1)) {
test('to $toVersion', () async {
final schema = await verifier.schemaAt(fromVersion);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, toVersion);
await db.close();
});
}
});
}
});
// The following template shows how to write tests ensuring your migrations
// preserve existing data.
// Testing this can be useful for migrations that change existing columns
// (e.g. by alterating their type or constraints). Migrations that only add
// tables or columns typically don't need these advanced tests. For more
// information, see https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity
// TODO: This generated template shows how these tests could be written. Adopt
// it to your own needs when testing migrations with data integrity.
test('migration from v1 to v2 does not corrupt data', () async {
// Add data to insert into the old database, and the expected rows after the
// migration.
// TODO: Fill these lists
final oldTopSiteData = <v1.TopSiteData>[];
final expectedNewTopSiteData = <v2.TopSiteData>[];
final oldHiddenTopSiteData = <v1.HiddenTopSiteData>[];
final expectedNewHiddenTopSiteData = <v2.HiddenTopSiteData>[];
await verifier.testWithDataIntegrity(
oldVersion: 1,
newVersion: 2,
createOld: v1.DatabaseAtV1.new,
createNew: v2.DatabaseAtV2.new,
openTestedDatabase: TopSiteDatabase.new,
createItems: (batch, oldDb) {
batch.insertAll(oldDb.topSite, oldTopSiteData);
batch.insertAll(oldDb.hiddenTopSite, oldHiddenTopSiteData);
},
validateItems: (newDb) async {
expect(expectedNewTopSiteData, await newDb.select(newDb.topSite).get());
expect(
expectedNewHiddenTopSiteData,
await newDb.select(newDb.hiddenTopSite).get(),
);
},
);
});
}
@@ -1,181 +0,0 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.3.0"
},
"options": {
"store_date_time_values_as_text": false
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "top_site",
"was_declared_in_moor": true,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
"primary-key"
]
},
{
"name": "title",
"getter_name": "title",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const UriConverter()",
"dart_type_name": "Uri"
}
},
{
"name": "source",
"getter_name": "source",
"moor_type": "int",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumIndexConverter<StoredTopSiteSource>(StoredTopSiteSource.values)",
"dart_type_name": "StoredTopSiteSource"
}
},
{
"name": "order_key",
"getter_name": "orderKey",
"moor_type": "string",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"getter_name": "createdAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [
"UNIQUE(url)"
],
"unique_keys": [
[
"url"
]
]
}
},
{
"id": 1,
"references": [
0
],
"type": "index",
"data": {
"on": 0,
"name": "idx_top_site_order_key",
"sql": "CREATE INDEX idx_top_site_order_key ON top_site(order_key);",
"unique": false,
"columns": []
}
},
{
"id": 2,
"references": [],
"type": "table",
"data": {
"name": "top_site_seed_state",
"was_declared_in_moor": true,
"columns": [
{
"name": "seed_id",
"getter_name": "seedId",
"moor_type": "string",
"nullable": false,
"customConstraints": "PRIMARY KEY NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
"primary-key"
]
},
{
"name": "applied_at",
"getter_name": "appliedAt",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": "NOT NULL",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": []
}
}
],
"fixed_sql": [
{
"name": "top_site",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"top_site\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"title\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"source\" INTEGER NOT NULL, \"order_key\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, UNIQUE(url));"
}
]
},
{
"name": "idx_top_site_order_key",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE INDEX idx_top_site_order_key ON top_site (order_key)"
}
]
},
{
"name": "top_site_seed_state",
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"top_site_seed_state\" (\"seed_id\" TEXT PRIMARY KEY NOT NULL, \"applied_at\" INTEGER NOT NULL);"
}
]
}
]
}
@@ -1,159 +0,0 @@
// dart format width=80
// GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
import 'package:drift/drift.dart';
class TopSite extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSite(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
url,
source,
orderKey,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{url},
];
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
TopSite createAlias(String alias) {
return TopSite(attachedDatabase, alias);
}
@override
List<String> get customConstraints => const ['UNIQUE(url)'];
@override
bool get dontWriteConstraints => true;
}
class TopSiteSeedState extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
TopSiteSeedState(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> seedId = GeneratedColumn<String>(
'seed_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL',
);
late final GeneratedColumn<int> appliedAt = GeneratedColumn<int>(
'applied_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<GeneratedColumn> get $columns => [seedId, appliedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'top_site_seed_state';
@override
Set<GeneratedColumn> get $primaryKey => {seedId};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
TopSiteSeedState createAlias(String alias) {
return TopSiteSeedState(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final TopSite topSite = TopSite(this);
late final Index idxTopSiteOrderKey = Index(
'idx_top_site_order_key',
'CREATE INDEX idx_top_site_order_key ON top_site (order_key)',
);
late final TopSiteSeedState topSiteSeedState = TopSiteSeedState(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
topSite,
idxTopSiteOrderKey,
topSiteSeedState,
];
@override
int get schemaVersion => 1;
}
@@ -1,61 +0,0 @@
/*
* 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/>.
*/
// dart format width=80
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart';
import 'package:flutter_test/flutter_test.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
group('simple database migrations', () {
const versions = GeneratedHelper.versions;
for (final (i, fromVersion) in versions.indexed) {
group('from $fromVersion', () {
for (final toVersion in versions.skip(i + 1)) {
test('to $toVersion', () async {
final schema = await verifier.schemaAt(fromVersion);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, toVersion);
await db.close();
});
}
});
}
});
test('v1 schema creation works', () async {
final schema = await verifier.schemaAt(1);
final db = TopSiteDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 1);
await db.close();
});
}
@@ -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,
);
});
});
});
}
@@ -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]);
});
});
}
@@ -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');
}
});
});
}