This commit is contained in:
Nathan Anderson 2026-06-03 05:12:02 -05:00
parent 0c4c72f9c9
commit e64d21dc2e
9 changed files with 608 additions and 177 deletions

View File

@ -0,0 +1,15 @@
import 'guess_score_summary.dart';
import 'incoming_question.dart';
/// Outcome of resetting guess score and clearing guess-game progress.
class GuessScoreResetResult {
const GuessScoreResetResult({
required this.score,
required this.unansweredCount,
this.question,
});
final GuessScoreSummary score;
final int unansweredCount;
final IncomingQuestion? question;
}

View File

@ -40,7 +40,9 @@ class HomeScreen extends StatelessWidget {
ProfileSyncStatus.idle => AppColors.textSecondary,
};
return Scaffold(
return Stack(
children: <Widget>[
Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
centerTitle: true,
@ -258,6 +260,28 @@ class HomeScreen extends StatelessWidget {
),
),
),
),
ListenableBuilder(
listenable: QuestionsHubService.instance.scoreResetBusy,
builder: (BuildContext context, Widget? child) {
if (!QuestionsHubService.instance.scoreResetBusy.value) {
return const SizedBox.shrink();
}
return Stack(
key: const Key('guess-score-reset-blocking-overlay'),
children: <Widget>[
ModalBarrier(
dismissible: false,
color: Colors.black.withValues(alpha: 0.54),
),
const Center(
child: CircularProgressIndicator(color: AppColors.accent),
),
],
);
},
),
],
);
}
}
@ -403,14 +427,8 @@ Future<void> _confirmResetGuessScore(BuildContext context) async {
return;
}
final bool ok =
await QuestionsHubService.instance.resetGuessScoreSummary();
if (!context.mounted) {
return;
}
if (ok) {
Navigator.of(context).pop();
}
await QuestionsHubService.instance.resetGuessScoreSummary();
}
class _ScoreStatRow extends StatelessWidget {

View File

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config/api_config.dart';
import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart';
import '../models/question_defer_result.dart';
@ -177,7 +178,7 @@ class QuestionsApiService {
return GuessScoreSummary.fromJson(scoreJson);
}
Future<GuessScoreSummary?> resetGuessScoreSummary() async {
Future<GuessScoreResetResult?> resetGuessScoreSummary() async {
final String? token = await AuthService.instance.getIdToken();
if (token == null) {
return null;
@ -198,10 +199,17 @@ class QuestionsApiService {
jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? scoreJson =
body['score'] as Map<String, dynamic>?;
if (scoreJson == null) {
return GuessScoreSummary.empty;
}
return GuessScoreSummary.fromJson(scoreJson);
final Map<String, dynamic>? questionJson =
body['question'] as Map<String, dynamic>?;
return GuessScoreResetResult(
score: scoreJson == null
? GuessScoreSummary.empty
: GuessScoreSummary.fromJson(scoreJson),
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
question: questionJson == null
? null
: IncomingQuestion.fromJson(questionJson),
);
}
Map<String, String> _authHeaders(String token) {

View File

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:signalr_netcore/signalr_client.dart';
import '../config/api_config.dart';
import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart';
import '../models/question_defer_result.dart';
@ -27,6 +28,7 @@ class QuestionsHubService {
final ValueNotifier<List<IncomingQuestion>> questionQueue =
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
@ -300,12 +302,21 @@ class QuestionsHubService {
/// Clears cumulative guess score and answer statistics on the server.
Future<bool> resetGuessScoreSummary() async {
final GuessScoreSummary? score = await _api.resetGuessScoreSummary();
if (score == null) {
scoreResetBusy.value = true;
try {
final GuessScoreResetResult? result = await _api.resetGuessScoreSummary();
if (result == null) {
return false;
}
guessScoreSummary.value = score;
guessScoreSummary.value = result.score;
_clearPendingUi();
if (result.question != null) {
_applyIncoming(result.question!);
}
return true;
} finally {
scoreResetBusy.value = false;
}
}
/// Clears pending question UI; keeps the SignalR connection alive.

View File

@ -13,9 +13,14 @@ const double _holdEdgeButtonWidth = 58;
/// Equal left/right margin for the slider; edge buttons sit in the gutters.
const double _centerSideInset = _holdEdgeButtonWidth + 8;
enum _LockedDragAxis { horizontal, vertical, rejected }
/// Swipeable question card: right submits, left defers (does not resolve).
///
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
///
/// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
/// cardinal axis; diagonal swipes are ignored.
class SwipeQuestionTile extends StatefulWidget {
const SwipeQuestionTile({
super.key,
@ -23,6 +28,7 @@ class SwipeQuestionTile extends StatefulWidget {
required this.onSwipeRight,
required this.onSwipeLeft,
this.busy = false,
this.swipeAxisToleranceDegrees = 25,
});
final String questionId;
@ -30,6 +36,9 @@ class SwipeQuestionTile extends StatefulWidget {
final Future<void> Function() onSwipeLeft;
final bool busy;
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
final double swipeAxisToleranceDegrees;
@override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
}
@ -38,6 +47,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
with TickerProviderStateMixin {
double _dragOffset = 0;
double _verticalOffset = 0;
double _panTotalDx = 0;
double _panTotalDy = 0;
double _scrollAccumulatedDy = 0;
_LockedDragAxis? _lockedDragAxis;
bool _acting = false;
bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false;
@ -47,6 +60,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
late final AnimationController _holdDeferController;
static const double _swipeThreshold = 96;
static const double _axisLockSlop = 14;
static const double _scrollStepPixels = 100;
static const Duration _holdEdgeDuration = Duration(seconds: 1);
static const double _glyphSize = 80;
static const double _trackEdgeInset = 8;
@ -70,8 +85,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
bool get _horizontalSwipeEnabled =>
_holdSaveEnabled && !_holdEdgeActive;
bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive;
@override
void initState() {
@ -80,15 +94,13 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
vsync: this,
duration: const Duration(milliseconds: 140),
);
_holdSaveController = AnimationController(
vsync: this,
duration: _holdEdgeDuration,
)..addListener(_syncHoldSaveDrag)
_holdSaveController =
AnimationController(vsync: this, duration: _holdEdgeDuration)
..addListener(_syncHoldSaveDrag)
..addStatusListener(_onHoldSaveStatus);
_holdDeferController = AnimationController(
vsync: this,
duration: _holdEdgeDuration,
)..addListener(_syncHoldDeferDrag)
_holdDeferController =
AnimationController(vsync: this, duration: _holdEdgeDuration)
..addListener(_syncHoldDeferDrag)
..addStatusListener(_onHoldDeferStatus);
}
@ -236,9 +248,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Swipe up +10, swipe down -10; snaps to whole numbers.
int get _snappedSliderValue =>
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
.round()
.clamp(_sliderMin, _sliderMax);
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax).round().clamp(
_sliderMin,
_sliderMax,
);
double get _clampedVerticalOffset =>
_verticalOffset.clamp(-_maxVerticalDrag, _maxVerticalDrag);
@ -260,13 +273,142 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
});
}
void _applyHorizontalDelta(double dx, double maxOffset) {
setState(() {
_dragOffset += dx;
_dragOffset = _dragOffset.clamp(-maxOffset, maxOffset);
});
}
bool _angleNearCardinal(double angleDeg, double targetDeg) {
var diff = angleDeg - targetDeg;
while (diff > 180) {
diff -= 360;
}
while (diff < -180) {
diff += 360;
}
return diff.abs() <= widget.swipeAxisToleranceDegrees;
}
bool _isHorizontalAngle(double angleDeg) =>
_angleNearCardinal(angleDeg, 0) ||
_angleNearCardinal(angleDeg, 180) ||
_angleNearCardinal(angleDeg, -180);
bool _isVerticalAngle(double angleDeg) =>
_angleNearCardinal(angleDeg, 90) || _angleNearCardinal(angleDeg, -90);
_LockedDragAxis _resolveDragAxis(double totalDx, double totalDy) {
final double angleDeg = math.atan2(totalDy, totalDx) * 180 / math.pi;
final bool horizontal = _isHorizontalAngle(angleDeg);
final bool vertical = _isVerticalAngle(angleDeg);
if (horizontal && vertical) {
return totalDx.abs() >= totalDy.abs()
? _LockedDragAxis.horizontal
: _LockedDragAxis.vertical;
}
if (horizontal) {
return _LockedDragAxis.horizontal;
}
if (vertical) {
return _LockedDragAxis.vertical;
}
return _LockedDragAxis.rejected;
}
void _resetPanGesture() {
_panTotalDx = 0;
_panTotalDy = 0;
_lockedDragAxis = null;
}
void _onPanStart(DragStartDetails details) {
if (widget.busy || _acting || _holdEdgeActive) {
return;
}
_resetPanGesture();
}
void _onPanUpdate(DragUpdateDetails details) {
if (widget.busy || _acting || _holdEdgeActive) {
return;
}
_panTotalDx += details.delta.dx;
_panTotalDy += details.delta.dy;
if (_lockedDragAxis == null) {
final double distance = math.sqrt(
_panTotalDx * _panTotalDx + _panTotalDy * _panTotalDy,
);
if (distance < _axisLockSlop) {
return;
}
var axis = _resolveDragAxis(_panTotalDx, _panTotalDy);
if (axis == _LockedDragAxis.horizontal && !_horizontalSwipeEnabled) {
axis = _LockedDragAxis.rejected;
}
_lockedDragAxis = axis;
final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55;
switch (axis) {
case _LockedDragAxis.horizontal:
_applyHorizontalDelta(_panTotalDx, maxHorizontal);
case _LockedDragAxis.vertical:
_applyVerticalInputDelta(_panTotalDy);
case _LockedDragAxis.rejected:
break;
}
return;
}
if (_lockedDragAxis == _LockedDragAxis.rejected) {
return;
}
final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55;
switch (_lockedDragAxis!) {
case _LockedDragAxis.horizontal:
_applyHorizontalDelta(details.delta.dx, maxHorizontal);
case _LockedDragAxis.vertical:
_applyVerticalInputDelta(details.delta.dy);
case _LockedDragAxis.rejected:
break;
}
}
void _onPanEnd(DragEndDetails details) {
final _LockedDragAxis? axis = _lockedDragAxis;
_resetPanGesture();
if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) {
unawaited(_releaseDrag());
} else if (axis == _LockedDragAxis.rejected && mounted) {
setState(() => _dragOffset = 0);
}
}
void _onPanCancel() {
final _LockedDragAxis? axis = _lockedDragAxis;
_resetPanGesture();
if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) {
unawaited(_releaseDrag());
} else if (mounted) {
setState(() => _dragOffset = 0);
}
}
/// One wheel notch / trackpad tick one integer on the [-10, 10] scale.
void _stepSliderFromScroll(double scrollDy) {
if (scrollDy == 0) {
return;
}
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1))
.clamp(_sliderMin, _sliderMax);
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
_sliderMin,
_sliderMax,
);
if (next == _snappedSliderValue) {
return;
}
@ -279,6 +421,30 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
});
}
void _accumulateScrollDelta(double dy) {
if (widget.busy || _acting || dy == 0) {
return;
}
_scrollAccumulatedDy += dy;
while (_scrollAccumulatedDy.abs() >= _scrollStepPixels) {
final double step = _scrollAccumulatedDy > 0
? _scrollStepPixels
: -_scrollStepPixels;
_stepSliderFromScroll(step);
_scrollAccumulatedDy -= step;
}
}
void _onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
_accumulateScrollDelta(event.scrollDelta.dy);
}
}
void _onPointerPanZoomUpdate(PointerPanZoomUpdateEvent event) {
_accumulateScrollDelta(event.panDelta.dy);
}
void _maybeTriggerSnapFeedback(int snapped) {
if (snapped == _lastSnappedValue) {
return;
@ -327,16 +493,18 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override
Widget build(BuildContext context) {
final double width = MediaQuery.sizeOf(context).width;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
const double outerVerticalPadding = 48;
const double trackVerticalInset = 16;
final double innerHeight =
math.max(constraints.maxHeight - outerVerticalPadding, 172);
final double trackHeight =
math.max(innerHeight - trackVerticalInset, _glyphSize);
final double innerHeight = math.max(
constraints.maxHeight - outerVerticalPadding,
172,
);
final double trackHeight = math.max(
innerHeight - trackVerticalInset,
_glyphSize,
);
_maxVerticalDrag = math.max(
(trackHeight / 2) - (_glyphSize / 2) - _trackEdgeInset,
40,
@ -351,29 +519,33 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
child: AnimatedBuilder(
animation: _snapController,
builder: (BuildContext context, Widget? child) {
final ({double shakeX, double scale}) motion =
_snapMotion(_snapController.value);
final ({double shakeX, double scale}) motion = _snapMotion(
_snapController.value,
);
return Transform.translate(
offset: Offset(motion.shakeX, 0),
child: Transform.scale(
scale: motion.scale,
child: child,
),
child: Transform.scale(scale: motion.scale, child: child),
);
},
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerSignal: widget.busy || _acting
? null
: _onPointerSignal,
onPointerPanZoomUpdate: widget.busy || _acting
? null
: _onPointerPanZoomUpdate,
child: GestureDetector(
onHorizontalDragUpdate: _horizontalSwipeEnabled
? (DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta.dx;
_dragOffset =
_dragOffset.clamp(-width * 0.55, width * 0.55);
});
}
: null,
onHorizontalDragEnd: _horizontalSwipeEnabled
? (_) => unawaited(_releaseDrag())
: null,
supportedDevices: const <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
onPanStart: widget.busy || _acting ? null : _onPanStart,
onPanUpdate: widget.busy || _acting ? null : _onPanUpdate,
onPanEnd: widget.busy || _acting ? null : _onPanEnd,
onPanCancel: widget.busy || _acting ? null : _onPanCancel,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
@ -419,29 +591,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bottom: 8,
left: _centerSideInset,
right: _centerSideInset,
child: Listener(
onPointerSignal: (PointerSignalEvent event) {
if (widget.busy || _acting) {
return;
}
if (event is PointerScrollEvent) {
_stepSliderFromScroll(
event.scrollDelta.dy,
);
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: widget.busy || _acting
? null
: (DragUpdateDetails details) =>
_applyVerticalInputDelta(
details.delta.dy,
),
child: Center(
child: Transform.translate(
offset:
Offset(0, -_snappedVerticalOffset),
offset: Offset(0, -_snappedVerticalOffset),
child: QuestionGuidGlyph(
guid: widget.questionId,
size: _glyphSize,
@ -450,8 +602,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
),
),
),
Positioned(
left: 0,
top: 10,
@ -499,6 +649,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
),
),
if (widget.busy)
Positioned.fill(
child: ClipRRect(
@ -616,11 +767,7 @@ class _HoldSwipeEdgeButton extends StatelessWidget {
backgroundColor: progressColor.withValues(alpha: 0.2),
color: progressColor,
),
Icon(
icon,
size: 20,
color: iconColor,
),
Icon(icon, size: 20, color: iconColor),
],
),
),

View File

@ -94,8 +94,14 @@ Handler questionsHandler({
try {
final Map<String, dynamic> score =
await questionsDb.resetGuessScoreSummary(firebaseUid);
final Map<String, dynamic>? question =
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
final int unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid);
return _jsonResponse(200, <String, dynamic>{
'score': score,
'unansweredCount': unansweredCount,
if (question != null) 'question': question,
});
} catch (e, st) {
stderr.writeln('Reset questions score error: $e\n$st');

View File

@ -105,6 +105,24 @@ class QuestionsDb {
return rows.first;
}
/// Deletes every guess-game question row for [firebaseUid].
///
/// Assignments and answer snapshots referencing these rows cascade away.
Future<void> deleteAllProspectiveGuessQuestionsForUser(
String firebaseUid,
) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM questions
WHERE assigned_user_id = @uid
AND source_tag = 'market_history:prospective'
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
/// Removes an unanswered question owned by [assignedUserId].
Future<void> deleteUnansweredQuestion({
required String questionId,
@ -373,7 +391,7 @@ class QuestionsDb {
return GuessScoreStore.loadSummary(_connection, firebaseUid);
}
/// Resets guess score counters and slot progression to the earliest window slot.
/// Resets guess score to a blank slate and clears all guess questions/links.
Future<Map<String, dynamic>> resetGuessScoreSummary(
String firebaseUid, {
DateTime? now,
@ -395,6 +413,7 @@ class QuestionsDb {
await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser(
firebaseUid,
);
await deleteAllProspectiveGuessQuestionsForUser(firebaseUid);
await UserTradingStateDb(_connection).resetGuessScore(
firebaseUid,
slotStart: earliest,

View File

@ -471,6 +471,31 @@ void main() {
parameters: <String, dynamic>{'uid': uid},
);
expect((assignmentCount.first[0]! as num).toInt(), 0);
final Result questionCount = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM questions
WHERE assigned_user_id = @uid
AND source_tag = 'market_history:prospective'
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect((questionCount.first[0]! as num).toInt(), 0);
final Result answerCount = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM market_history_prospective_answers
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect((answerCount.first[0]! as num).toInt(), 0);
});
test('assignments issue every top-half asset before advancing slot pair',
@ -661,6 +686,153 @@ void main() {
expect(allAssignments.elementAt(2)[0], 'HIGH');
});
test('resetGuessScoreSummary clears questions then re-bootstrap has no duplicates',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'prospective-reset-blank-slate-uid';
final DateTime now = DateTime.now().toUtc();
final DateTime olderSlot =
ProspectiveGuessSelection.earliestPlayableSlotStart(now, 5);
final DateTime? newerSlotRaw =
MarketHistorySessionSlot.nextSlotStart(olderSlot);
if (newerSlotRaw == null) {
markTestSkipped('No newer slot after earliest window slot');
return;
}
final DateTime newerSlot =
MarketHistorySessionSlot.slotStartContaining(newerSlotRaw);
await testDb!.seedUser(uid);
await testDb!.userTradingStateDb.setGuessSlotStart(uid, olderSlot);
await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at)
VALUES
('HIGH', 'us_equity', 'active', true, @refreshed_at),
('MID', 'us_equity', 'active', true, @refreshed_at),
('LOW', 'us_equity', 'active', true, @refreshed_at)
''',
),
parameters: <String, dynamic>{'refreshed_at': now},
);
Future<void> insertBar({
required String symbol,
required DateTime asOf,
required num volume,
}) async {
await testDb!.connection.execute(
Sql.named(
'''
INSERT INTO market_data_snapshots (
symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw
) VALUES (
@symbol, 'us_equity', 'iex', 'bar', 'sessionHalf', 10, @volume, @as_of, @raw::jsonb
)
''',
),
parameters: <String, dynamic>{
'symbol': symbol,
'as_of': asOf,
'volume': volume,
'raw': jsonEncode(<String, dynamic>{
'o': 10,
'h': 10,
'l': 10,
'c': 10,
'v': volume,
'slot_start': MarketHistorySessionSlot.slotStartWire(asOf),
}),
},
);
}
await insertBar(symbol: 'HIGH', asOf: olderSlot, volume: 500);
await insertBar(symbol: 'HIGH', asOf: newerSlot, volume: 500);
await insertBar(symbol: 'MID', asOf: olderSlot, volume: 150);
await insertBar(symbol: 'MID', asOf: newerSlot, volume: 150);
await insertBar(symbol: 'LOW', asOf: olderSlot, volume: 100);
await insertBar(symbol: 'LOW', asOf: newerSlot, volume: 100);
final QuestionService service = testDb!.questionService();
final Map<String, dynamic>? firstPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(firstPayload, isNotNull);
expect(firstPayload!['unansweredCount'], 2);
final List<Map<String, dynamic>> queued =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(queued, hasLength(2));
await testDb!.questionsDb.submitAnswer(
questionId: queued.first['id']! as String,
assignedUserId: uid,
userResponse: 0,
);
await testDb!.questionsDb.resetGuessScoreSummary(uid, now: now);
final Result questionCountAfterReset = await testDb!.connection.execute(
Sql.named(
'''
SELECT COUNT(*)::int
FROM questions
WHERE assigned_user_id = @uid
AND source_tag = 'market_history:prospective'
''',
),
parameters: <String, dynamic>{'uid': uid},
);
expect((questionCountAfterReset.first[0]! as num).toInt(), 0);
final Map<String, dynamic>? afterResetPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(afterResetPayload, isNotNull);
expect(afterResetPayload!['unansweredCount'], 2);
final List<Map<String, dynamic>> afterResetQueued =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(afterResetQueued, hasLength(2));
final Set<String> symbols = afterResetQueued
.map((Map<String, dynamic> row) {
final Map<String, dynamic>? metadata =
row['metadata'] as Map<String, dynamic>?;
return metadata?['symbol'] as String? ??
metadata?['prospective_question_id']?.toString() ??
row['id']! as String;
})
.toSet();
expect(symbols, hasLength(2));
final Result assignmentSymbols = await testDb!.connection.execute(
Sql.named(
'''
SELECT symbol
FROM market_history_prospective_assignments
WHERE assigned_user_id = @uid
AND status = @status
ORDER BY symbol
''',
),
parameters: <String, dynamic>{
'uid': uid,
'status': ProspectiveGuessAssignmentsDb.statusPending,
},
);
expect(assignmentSymbols, hasLength(2));
expect(
assignmentSymbols.map((ResultRow r) => r[0]).toSet(),
hasLength(2),
);
});
test('blocks duplicate assignment for same user symbol and slot pair', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');

View File

@ -26,6 +26,7 @@ void main() {
hub.hasPendingQuestion.value = false;
hub.pendingQuestionCount.value = 0;
hub.guessScoreSummary.value = GuessScoreSummary.empty;
hub.scoreResetBusy.value = false;
});
await tester.pumpWidget(
@ -67,6 +68,7 @@ void main() {
addTearDown(() {
hub.guessScoreSummary.value = GuessScoreSummary.empty;
hub.scoreResetBusy.value = false;
});
await tester.pumpWidget(
@ -98,4 +100,37 @@ void main() {
);
expect(find.byKey(const Key('guess-score-stats-dialog')), findsOneWidget);
});
testWidgets('score reset shows blocking overlay while processing', (
WidgetTester tester,
) async {
final QuestionsHubService hub = QuestionsHubService.instance;
hub.guessScoreSummary.value = const GuessScoreSummary(
total: 5,
answersTotal: 2,
answersCorrect: 1,
percentCorrect: 50,
);
hub.scoreResetBusy.value = true;
addTearDown(() {
hub.guessScoreSummary.value = GuessScoreSummary.empty;
hub.scoreResetBusy.value = false;
});
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const HomeScreen(
user: AppUser(uid: 'u1', displayName: 'Test User'),
),
),
);
expect(
find.byKey(const Key('guess-score-reset-blocking-overlay')),
findsOneWidget,
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
}