Files
Qlyra/test/chat_scroll_anchor_test.dart
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

367 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:qlyra/frontend/screens/chats/chat/chat_controller.dart';
import 'package:qlyra/frontend/screens/chats/chat/retain_offset_physics.dart';
const double _itemHeight = 60;
const double _newestHeight = 84;
const double _viewportHeight = 300;
double? _offsetInList(GlobalKey listKey, GlobalKey itemKey) {
final listBox = listKey.currentContext?.findRenderObject();
final box = itemKey.currentContext?.findRenderObject();
if (listBox is! RenderBox || box is! RenderBox || !box.attached) return null;
return box.localToGlobal(Offset.zero, ancestor: listBox).dy;
}
class _Harness {
_Harness(this.tester, {required this.physics});
final WidgetTester tester;
final ScrollPhysics? physics;
final GlobalKey listKey = GlobalKey();
final ScrollController controller = ScrollController();
final List<String> items = [for (var i = 0; i < 200; i++) 'm$i'];
final Map<String, GlobalKey> keys = {};
GlobalKey keyFor(String id) => keys.putIfAbsent(id, GlobalKey.new);
Future<void> pump() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
key: listKey,
height: _viewportHeight,
child: CustomScrollView(
controller: controller,
reverse: true,
physics: physics,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) return const SizedBox(height: 0);
final id = items[items.length - index];
return SizedBox(
key: keyFor(id),
height: id == 'newest' ? _newestHeight : _itemHeight,
child: Text(id),
);
}, childCount: items.length + 1),
),
],
),
),
),
),
);
await tester.pump();
}
String anchorId() => items.firstWhere((id) {
final dy = _offsetInList(listKey, keyFor(id));
return dy != null && dy >= 0 && dy <= _viewportHeight;
});
double dyOf(String id) => _offsetInList(listKey, keyFor(id))!;
double? dyOrNull(String id) => _offsetInList(listKey, keyFor(id));
double contentOffsetOf(String id) => dyOf(id) - controller.position.pixels;
double alignmentOf(String id) => dyOf(id) / _viewportHeight;
void insertAt(int index, int count) {
items.insertAll(index, [for (var i = 0; i < count; i++) 'gap$index-$i']);
}
bool restore(String id, double before) {
final dy = dyOrNull(id);
if (dy == null) return false;
final delta = before - (dy - controller.position.pixels);
if (delta.abs() <= 0.5) return true;
final pos = controller.position;
final target = (pos.pixels + delta).clamp(
pos.minScrollExtent,
pos.maxScrollExtent,
);
if ((target - pos.pixels).abs() <= 0.5) return true;
controller.jumpTo(target);
return true;
}
int visibleOldestIndex() {
for (var i = 0; i < items.length; i++) {
final dy = dyOrNull(items[i]);
if (dy != null && dy + _itemHeight > 0 && dy < _viewportHeight) return i;
}
return -1;
}
bool jumpNear(String id) {
final index = items.indexOf(id);
final oldest = visibleOldestIndex();
if (index == -1 || oldest == -1) return false;
final perScreen = (_viewportHeight / _itemHeight).floor();
final away = (oldest - index).abs();
final screens = (away / perScreen).clamp(1.0, 4.0);
final pos = controller.position;
final step = pos.viewportDimension * screens;
final next = index < oldest ? pos.pixels + step : pos.pixels - step;
final clamped = next.clamp(pos.minScrollExtent, pos.maxScrollExtent);
if ((clamped - pos.pixels).abs() < 0.5) return false;
controller.jumpTo(clamped);
return true;
}
Future<int> align(String id, double alignment) async {
for (var frame = 0; frame < 40; frame++) {
final dy = dyOrNull(id);
if (dy == null) {
if (!jumpNear(id)) return frame;
await tester.pump();
continue;
}
final delta = alignment * _viewportHeight - dy;
if (delta.abs() <= 0.5) return frame;
final pos = controller.position;
final target = (pos.pixels + delta).clamp(
pos.minScrollExtent,
pos.maxScrollExtent,
);
if ((target - pos.pixels).abs() <= 0.5) return frame;
controller.jumpTo(target);
await tester.pump();
}
return -1;
}
}
void main() {
testWidgets('appending to a reversed list drags the view toward the newest '
'message', (tester) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final beforeDy = h.dyOf(anchor);
h.items.add('newest');
await h.pump();
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
expect(h.controller.position.pixels, 600);
});
testWidgets('RetainOffsetScrollPhysics holds the view in place when a '
'message is appended', (tester) async {
var retainOnce = false;
final h = _Harness(
tester,
physics: RetainOffsetScrollPhysics(
retain: () {
if (!retainOnce) return false;
retainOnce = false;
return true;
},
),
);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final beforeDy = h.dyOf(anchor);
retainOnce = true;
h.items.add('newest');
await h.pump();
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
expect(h.controller.position.pixels, greaterThan(600));
});
testWidgets('RetainOffsetScrollPhysics stays inert while the flag is unset', (
tester,
) async {
final h = _Harness(
tester,
physics: RetainOffsetScrollPhysics(retain: () => false),
);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final beforeDy = h.dyOf(anchor);
h.items.add('newest');
await h.pump();
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
expect(h.controller.position.pixels, 600);
});
testWidgets('вставка старее вьюпорта не двигает его вообще', (tester) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final beforeDy = h.dyOf(anchor);
h.insertAt(10, 60);
await h.pump();
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
expect(h.controller.position.pixels, 600);
});
testWidgets('заполнение дыры не двигает то, что новее её', (tester) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final before = h.contentOffsetOf(anchor);
final beforeDy = h.dyOf(anchor);
h.insertAt(10, 5);
await h.pump();
h.restore(anchor, before);
await tester.pump();
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
expect(h.controller.position.pixels, 600);
});
testWidgets('заполнение дыры удерживает то, что старее её', (tester) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final before = h.contentOffsetOf(anchor);
final beforeDy = h.dyOf(anchor);
h.insertAt(195, 5);
await h.pump();
expect(h.dyOf(anchor), lessThan(beforeDy - 1));
h.restore(anchor, before);
await tester.pump();
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
expect(h.controller.position.pixels, 600 + 5 * _itemHeight);
});
testWidgets('скролл пользователя во время дозагрузки не отменяется', (
tester,
) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final before = h.contentOffsetOf(anchor);
final beforeDy = h.dyOf(anchor);
h.insertAt(195, 5);
await h.pump();
h.controller.jumpTo(h.controller.position.pixels + 120);
await tester.pump();
h.restore(anchor, before);
await tester.pump();
expect(h.dyOf(anchor), closeTo(beforeDy + 120, 0.5));
expect(h.controller.position.pixels, 600 + 120 + 5 * _itemHeight);
});
testWidgets('большой блок уносит якорь за пределы отрисованного окна', (
tester,
) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final before = h.contentOffsetOf(anchor);
h.insertAt(195, 60);
await h.pump();
expect(h.dyOrNull(anchor), isNull);
expect(h.restore(anchor, before), isFalse);
expect(h.controller.position.pixels, 600);
});
testWidgets('после большого блока выравнивание возвращает якорь на место', (
tester,
) async {
final h = _Harness(tester, physics: null);
addTearDown(h.controller.dispose);
await h.pump();
h.controller.jumpTo(600);
await tester.pump();
final anchor = h.anchorId();
final before = h.contentOffsetOf(anchor);
final beforeDy = h.dyOf(anchor);
final alignment = h.alignmentOf(anchor);
h.insertAt(195, 60);
await h.pump();
expect(h.restore(anchor, before), isFalse);
final frames = await h.align(anchor, alignment);
expect(frames, greaterThanOrEqualTo(0));
expect(frames, lessThan(10));
expect(h.dyOf(anchor), closeTo(beforeDy, 0.5));
expect(h.controller.position.pixels, 600 + 60 * _itemHeight);
});
group('дыру можно заполнять только с новой стороны', () {
final gap = HistoryGap(edgeId: 'e', edgeTime: 1100, tailTime: 9000);
test('пользователь в хвосте — вставка ляжет выше вьюпорта', () {
expect(ChatController.gapFillLeavesViewportInPlace(gap, 9000), isTrue);
expect(ChatController.gapFillLeavesViewportInPlace(gap, 9050), isTrue);
});
test('пользователь у закрепа — вставка утащила бы вьюпорт', () {
expect(ChatController.gapFillLeavesViewportInPlace(gap, 1050), isFalse);
expect(ChatController.gapFillLeavesViewportInPlace(gap, 8999), isFalse);
});
test('без отрисованных сообщений заполнять нечего', () {
expect(ChatController.gapFillLeavesViewportInPlace(gap, null), isFalse);
});
});
}